Skip to content
掌握度

CodeAgent CLI 项目深挖 · 单期模块。总览见 需求分析 与 整体设计方案。

Memory 模块解决三个问题——Agent 怎么记住这一轮对话(短期)、怎么记住跨会话的稳定事实(长期)、上下文窗口满了怎么办(压缩)

Memory 模块设计 ​

1. 短期记忆(ConversationMemory)。 它是一个按插入顺序维护的、带 ID 查询和删除能力的有界记忆容器,淘汰策略近似 FIFO。有个关键设计:超预算淘汰时不是直接丢,而是先把最旧条目挪进一个 compressedSummaries 待压缩区,等真正压缩时再清掉——保证信息「先缓存、后丢失」。

2. 长期记忆(LongTermMemory)。 四个特点:自动去重(按 content 完全相等跳过)、即时持久化(每次 store 立即写盘 JSON)、启动加载(构造器里从磁盘读回)、以及作用域——每条记忆分 global(跨项目通用)还是 project(绑定当前项目),检索时按作用域过滤。

3. 检索(MemoryRetriever)。 不是简单关键词命中,而是打相关度分:关键词匹配比例 × 时间衰减(24 小时内从 1.0 线性衰减到 0.5,之后兜底 0.5)× 长期记忆权重 1.2,这里的关键词通过Jieba进行分词,取 top 相关的长期记忆,buildContextForQuery 拼成「## 相关长期记忆」注入 system prompt。

4. 两道压缩(重点,最容易混淆)。 这是我最想强调的——项目里有两个压缩,对象和算法完全不同:

  • 短期记忆压缩(ContextCompressor):用 Map-Reduce。保留最近 3 条,更早的每 5 条分片各摘要一次(Map),再把多个分片摘要合并成总摘要(Reduce),回注记忆。
  • 对话历史压缩(ConversationHistoryCompactor):压的是真正发给 LLM 的消息列表。保留最近 3 条,前面所有内容一次性摘要成 1-3 段,原地重建成「system + 摘要 + 最近 3 轮」。触发阈值是 window - 摘要预留 - 安全缓冲,比如 200k 窗口约 167k 触发。

5. token 估算(MemoryEntry)。 粗略估算:中文 1.5 字符折 1 token、英文 4 字符折 1 token,向上取整。因为估算不精确,压缩阈值才要预留安全缓冲;要精准可以换真 tokenizer。

【主动抛深度点 1:两道压缩为什么必须分开】 这其实是一个「补丁」故事:早期设计假设「LLM 调用从短期记忆重建消息」,但实际 Agent 直接维护 conversationHistory,和短期记忆是并行的——导致旧版压缩从没真正缩短发给 LLM 的 token。所以后来在 Agent.run 主循环里补了「对话历史压缩」这道,专门在调 LLM 前评估、压缩真正决定 input token 的那个列表。区分它们的一个口诀:短期记忆压缩管「记忆系统内部」,对话历史压缩管「真正发给 LLM 的 token」。

【主动抛深度点 2:长期记忆为什么不自动提取事实】 代码里其实写了 extractFacts(用 prompt 从对话里提「跨会话仍成立的稳定事实」),但故意不接入主流程。因为自动提取会误伤:模型容易把「这一轮让你做的临时任务」当成「稳定事实」存下来,污染长期记忆。所以设计成只靠 save_memory 工具和 /save 命令显式保存,让「什么值得记」由人和模型有意识决定,而不是无差别扫描。

短期记忆『淘汰先不丢』具体怎么衔接压缩的? ​

淘汰时(evictOldest)把最旧条目从 entries 移除、放进 compressedSummaries,token 计数相应扣减,但数据还在内存里。等压缩真正触发(compressIfNeeded),ContextCompressor.compress 才把这些旧条目拿去 Map-Reduce 成摘要,然后 memory.clear() 清空、注入摘要、再把保留的近期条目重新存回来。所以「淘汰」和「压缩」是两段:淘汰是廉价的挪位,压缩是贵的 LLM 调用,两者解耦。

两道压缩分别什么时候触发?阈值怎么算? ​

短期记忆压缩:每次 addUserMessage / addAssistantMessage / addToolResult 之后都会 compressIfNeeded,触发条件看占用率是否到 compressionTriggerRatio(下限 50%)。对话历史压缩:在 Agent.run 循环里、每次调 LLM 之前 maybeCompactHistory,触发阈值是 window - 摘要预留 - 安全缓冲(window - 20k - 13k 量级)。两道都会在超阈值时触发,只是对象和时机不同。

长期记忆怎么去重和检索?作用域有什么用? ​

去重是精确去重——store 时检查有没有 content 完全相同的条目,有就跳过(不是向量相似度)。检索走 MemoryRetriever:关键词匹配比例 × 时间衰减 × 长期记忆权重 1.2,排序取 top。作用域是为了多项目隔离:global 的记忆在所有项目可见,project 的只在绑定项目可见,避免 A 项目的约定污染 B 项目。

为什么压缩用 Map-Reduce 而不是一次全量摘要? ​

因为上下文窗口有限:如果旧消息很多,一次全量摘要会因为超长而截断,丢信息。Map-Reduce 把「超长输入」拆成「多个小摘要 + 一次合并」,每一段都在窗口内,信息损失更可控。这也是大模型长文本处理的经典套路。注意这只针对短期记忆压缩;对话历史压缩因为保留了最近 3 轮、要压的通常没那么长,才用一次性整体摘要。

token 估算为什么用字符数,不用真 tokenizer? ​

成本和准确性的权衡:真 tokenizer 对中英混排更准,但要引入依赖、每条消息都跑一遍分词也有开销。用「中文 1.5 / 英文 4 字符」这个启发式,零依赖、O(n) 扫描、误差可控,配合压缩阈值预留安全缓冲就够用了。如果模型对 token 极其敏感(比如要卡到 99% 占用),再上 tokenizer 校准。

两层记忆 ​

  • 短期:ConversationMemory,token 预算制(ContextProfile 按模型窗口派生:短期预算 = window × 0.45),工具结果默认截 500 字符;
  • 长期:LongTermMemory,~/.code-agent-cli/memory/long_term_memory.json 存 FACT 条目(scope = project | global)。只存稳定事实:入口仅两个——用户 /save <事实> 或明确说「记一下/记住」触发 save_memory 工具,禁止自动提取临时对话进长期记忆(CODEAGENT.md 明文约束)。

双轨压缩:一次真实的上下文工程踩坑 ​

压缩有两条独立轨道,这是踩坑后的产物:

  1. ContextCompressor 压短期记忆条目——早期唯一实现;
  2. ConversationHistoryCompactor(phase-12 补丁)压 conversationHistory——文档注释明说:旧压缩压不到「真正发给 LLM 的消息列表」,导致预算看似降了、请求却没变。

第二条轨道的两个关键细节:

  • 切割点强制落在 user message 边界——否则会切断 tool_call 与 tool_result 的配对,下一轮请求直接协议违约;
  • 保留最近 3 个 user 轮,其余拼成摘要请求 LLM,重建为 [system, user(摘要), assistant(确认), 尾部]。

压缩触发阈值由窗口派生:window − min(20k, window/4) − min(13k, window/8)——预留输出与安全余量,不是拍脑袋的固定值。

CODEAGENT.md 分层注入 ​

ProjectMemoryLoader 按序加载:~/.code-agent-cli/CODEAGENT.md(用户全局)→ 项目 CODEAGENT.md / .code-agent-cli/CODEAGENT.md(团队共享、可提交仓库)→ CODEAGENT.local.md(本地覆盖,支持 @path 导入)。作为 project_context 段进 system prompt;检索命中的长期记忆按 memoryContextTokens(window/200,封顶 5000)走独立段。MemoryRetriever 用关键词重合度评分 × 时间衰减,长期记忆命中 ×1.2 加权——纯内存实现,规模不需要向量库。

上下文压缩是怎么做的? ​

CodeAgent CLI 的上下文控制分为三层。

第一层是工具输出控制。不同工具都有独立的返回预算,例如代码搜索限制结果数量和字符数,命令输出限制在约 8KB,网页抓取默认限制在 8,000 字符。超过限制后会截断并返回 partial 标记,引导 Agent 缩小范围或者分页读取,避免一次工具调用就占满上下文。

第二层是短期记忆压缩。系统会把用户消息、助手回答和工具结果记录到 MemoryManager。其中工具结果写入短期记忆时只保留核心片段;当短期记忆超过预算后,再通过 LLM 将较早内容压缩为摘要。

第三层是主对话历史压缩。因为真正发送给模型的是 conversationHistory,只压缩短期记忆并不能减少实际请求的 Token。所以每次调用模型前,我都会估算主对话历史长度,达到阈值后保留 System Prompt 和最近三个用户轮次,将更早的消息交给 LLM 总结成摘要,触发阈值不是固定比例,而是根据模型窗口计算的:从总窗口中预留摘要输出空间和安全缓冲。例如 200K 窗口会在约 167K Token 时触发,也就是大约 83.5%。。

摘要重点保留用户目标、已经完成的操作、已确认的结论和未完成事项。压缩时只在 User 消息边界切割,避免拆散 tool_call 和 tool_result。此外,用户也可以通过 /compact 主动触发压缩。

为什么采用三层策略?每层内容一致吗? ​

因为上下文膨胀的来源不同,不能用一种方式统一处理。我采用的是从局部到全局、从低成本到高成本的分层策略。

第一层处理单次工具调用产生的大块原始数据,例如搜索结果、日志和网页正文。它通过确定性的数量或字符限制完成,不需要调用模型,成本最低。

第二层处理的是 Memory 模块保存的短期经验。它的目标是保留对后续任务有价值的信息,而不是保存每一条原始工具输出。

第三层处理的是真正传给模型的完整消息历史,包括 User、Assistant、Tool Call 和 Tool Result。它直接决定模型请求是否会超过上下文窗口,因此属于最后一道核心保障,需要调用 LLM 生成语义摘要,成本也最高。

三层处理的内容并不一致:第一层控制工具原始输出,第二层压缩内部短期记忆,第三层压缩模型实际接收的对话历史。这样可以先消除局部冗余,只有整体上下文接近上限时才进行有成本的语义压缩。

压缩过度怎么发现,怎么处理? ​

压缩过度本质上是摘要的信息召回率不足,主要可以从行为信号和结果信号两方面发现。

行为信号包括 Agent 重复读取已经分析过的文件、重复执行已经完成的操作、忘记用户确认过的技术方案,或者生成的代码突然偏离前面约定的项目规范。

结果信号包括任务完成率下降、修改与原需求不一致、压缩后重新引入已经解决的问题。工程上还可以记录压缩前后的 Token 数、摘要覆盖率和重复工具调用率,用于评估压缩质量。

当前 CodeAgent CLI 已经保留最近三个用户轮次,并要求摘要明确保存目标、操作、结论和待办;同时在 User 边界切割,保护工具调用协议。压缩失败或摘要为空时,系统会放弃本次压缩,继续保留原历史,不会用无效摘要覆盖它。

当前系统还没有自动识别“压缩后语义丢失”,这是可以继续完善的地方。我会从三个方面改进:

第一,压缩前保存历史快照。如果摘要质量不合格,可以恢复原历史并采用更保守的策略重新压缩。

第二,给用户需求、已确认方案、文件修改记录和待办设置更高优先级,摘要时强制保留,而不是把所有消息同等处理。

第三,在摘要后增加一次结构化校验,检查目标、关键决策、已修改文件和未完成事项是否仍然存在;如果缺失,就重新摘要或增加最近完整轮次的保留数量。

八股延伸|短期记忆和长期记忆是怎么做的 ​

短期记忆和长期记忆是怎么做的

“短期记忆就是会话内的消息历史,token 预算制,配合上下文压缩工作。”

“压缩的触发阈值由模型窗口派生:窗口减去摘要输出预留和安全缓冲。保留最近 3 个用户轮次原样不动,更早的内容做一次性摘要。分割边界落在用户消息上,保证工具调用和结果成对出现;摘要失败就放弃本次压缩,保留原历史。”

“长期记忆是一个 JSON 文件存在用户目录下,跨会话生效,按 project/global 作用域隔离。几个设计细节可以展开:”

  • 去重:content 完全相等直接跳过(精确去重,不算哈希也不做相似度)
  • 防膨胀:不设条目上限也不做配额淘汰——存储入口只有显式保存(/save 或 save_memory 工具)加精确去重,天然增长缓慢
  • 召回:整句包含直接满分,否则 jieba 分词命中率 × 时间衰减(24 小时线性 1.0→0.5,之后兜底 0.5),长期记忆整体再乘 1.2,取 top 10 按 token 预算截断注入

“还有一条边界要守住。压缩生成的摘要只服务当前会话,它是模型生成的二手信息,不会被晋升成长期记忆。”

“长期记忆只收用户明确要求保存的事实。这条线一旦松了,记忆库很快会被模型自己的转述污染。”

业界主流的三层记忆系统 ​

“主流分法是按作用域切三层:”

层次存什么在 CodeAgent CLI(Java) 里
会话层当前对话的消息内存里的消息列表,token 预算制
项目层仓库的规范、构建命令项目根的 CODEAGENT.md,跟着 Git 走
全局层跨项目的个人偏好用户目录的记忆库和全局配置

“文件记忆还有一个本地覆盖层 CODEAGENT.local.md,存只属于本机的配置,不进版本库。加载也有预算,总预算 24000 字符,导入深度上限 3 层,防止记忆把窗口吃穷。”

这张表建议存下来,答记忆类的题基本都能套。

八股延伸|三层记忆架构为什么这么设计? ​

三层记忆架构为什么这么设计?

“三层。第一层是短期记忆,存当前对话的上下文,包括用户说了什么、助手回了什么、工具调用了什么结果。”

它的底层是一个 LinkedHashMap,按插入顺序维护,有一个 token 预算上限。

当 token 超出预算,自动淘汰最早的条目,淘汰的条目会被暂存到一个压缩摘要队列里,等待后续被 LLM 摘要。

“第二层是长期记忆,跨会话持久化。”

用户偏好、项目事实、关键决策这些跨会话仍然有价值的信息存在这里。底层用 ConcurrentHashMap 维护,同步持久化到磁盘的 JSON 文件里。每次写入都会做内容去重,如果已有条目的内容完全一致,直接跳过。

“第三层是记忆检索层,它不存数据,而是负责从前两层中检索与当前查询最相关的记忆。”

为什么要这么分? ​

“因为 LLM 的上下文窗口是有限资源。短期记忆解决‘当前对话不丢’的问题,长期记忆解决‘跨会话能想起来’的问题,检索层解决‘该想起哪些’的问题。三层各管各的,互不干扰。”

八股延伸|文件作为事实源,为什么不直接用 RAG / 向量数据库存? ​

文件作为事实源,为什么不直接用 RAG / 向量数据库存?

“因为长期记忆存的是精炼的事实,不是大段文本。一条典型的长期记忆就是‘用户偏好用 zsh 而不是 bash’这种。”

数据量小,几十条到几百条,用关键词匹配就够了。

“更关键的是,长期记忆需要高频读写和即时一致性。用户说‘记一下,以后访问语雀优先复用 Chrome 登录态’,我必须立刻存进去,下一轮对话就能用上。”

JSON 文件的写入是同步的,每次 store 操作完成后数据就在磁盘上了。如果用向量数据库,还得等 embedding 计算、索引更新,延迟和复杂度都上来了。

八股延伸|文件作为事实源,怎么保证时效性和一致性? ​

文件作为事实源,怎么保证时效性和一致性?

“时效性靠两个机制。”

第一,写入时即持久化。ConcurrentHashMap 保证并发安全,每次 store 之后立刻把整个条目列表序列化写入磁盘。

第二,检索时有时间衰减。记忆的 timestamp 会参与相关度计算,越旧的记忆在检索排序中越靠后。一条 24 小时前存入的事实,它的分数会自动降到新事实的一半。

“一致性靠去重。长期记忆在 store 时会遍历已有条目,如果新条目的 content 和已有条目完全一致,直接跳过不存。防止 LLM 反复提取同一个事实导致记忆膨胀。”

“另外,长期记忆要区分作用域。”

每条记忆有一个 scope 字段,分 project 和 global 两种。项目级别的事实只在对应项目路径下可见,全局偏好才跨项目共享。检索时会按项目路径做可见性过滤,避免 A 项目的事实污染 B 项目的上下文。

八股延伸|文件太大时,索引不能每次读全文,怎么办? ​

文件太大时,索引不能每次读全文,怎么办?

两个层面。

第一,长期记忆本身的体量控制。长期记忆只存精炼事实,不存对话原文和工具输出。工具结果在短期记忆中就被截断到 500 字符以内,不会流入长期记忆。加上内容去重机制,长期记忆条目数通常在几十到几百这个量级,JSON 文件撑死也就几百 KB,全量加载毫无压力。

第二,注入到 LLM 上下文时的预算控制。每次调用 LLM 前,记忆检索层从长期记忆中捞最相关的几条注入到 system prompt 里,注入量有硬上限,context window 的千分之五,最多不超过 5000 token。

这意味着即使长期记忆有 500 条,注入到 LLM 上下文的也就十来条。

八股延伸|有没有做防止反复存储无意义记忆的预防机制? ​

有没有做防止反复存储无意义记忆的预防机制?

“做了两层过滤。”

“第一层是入口约束。长期记忆的存储只有两个入口:一是用户通过对话明确说‘记一下’‘记住’‘以后记得’,触发 save_memory 工具;二是用户执行 /save 命令。没有自动提取——代码里其实写过 extractFacts(用 prompt 从对话提稳定事实),但故意不接入主流程,因为自动提取容易把‘这一轮的临时任务’当成长期事实存下来,污染记忆库。”

“第二层是存储时的精确去重。长期记忆写入前检查已有条目,content 完全相同的直接跳过,防止用户反复要求记同一件事导致膨胀。”

八股延伸|Agent 的上下文窗口满了怎么办? ​

Agent 的上下文窗口满了怎么办?

”自动压缩。Agent 里有一个对话历史压缩模块,在每轮 ReAct 循环调 LLM 之前做一次检查。它先估算当前对话历史的总 token 数,如果超过压缩阈值就触发压缩。”

“压缩阈值是根据模型的 context window 动态计算的:context window 减去摘要输出预留(最多 20000 token)再减去自动压缩缓冲(最多 13000 token),就是触发阈值。比如 128K 的模型,阈值大概在 95K 左右。”

如何去进行压缩,有哪几种方式? ​

“两套压缩机制并行工作,各管各的。”

“第一套压缩的是 Agent 实际发给 LLM 的消息列表。”

算法是这样的:先找出所有 user 消息的位置索引,保留最近 3 轮的完整消息不动,把 system 之后、分割点之前的所有旧消息喂给 LLM 做摘要。

摘要完成后,重建消息列表:system prompt + 一条包含摘要的 user 消息 + 一条确认了解上下文的 assistant 消息 + 最近 3 轮原始消息。

分割点必须落在 user 消息的边界上,这是为了避免切断 tool_call 和 tool_result 的成对协议。”

“第二套压缩的是短期记忆。”

它用的是 Map-Reduce 模式:先把旧的记忆条目按 5 条一组分片,每片单独让 LLM 摘要,然后再把所有分片摘要合并成一个总摘要。

总摘要以 SUMMARY 类型回注到短期记忆中。

八股延伸|Agent 的 Memory 是如何进行管理的?都存在哪些地方? ​

Agent 的 Memory 是如何进行管理的?都存在哪些地方?

“四个地方。”

  • 对话历史:Agent 发给 LLM 的消息列表,运行时在 JVM 堆内,同时以 JSONL 持久化到 ~/.code-agent-cli/history/,跨会话可恢复。这是 LLM 真正看到的上下文
  • 短期记忆:也在 JVM 堆内存里,但有独立的 token 预算和淘汰策略。它和对话历史并行维护——前者是 LLM 的输入,后者是记忆系统的内部状态
  • 长期记忆:存在 ~/.code-agent-cli/memory/long_term_memory.json 文件里,跨会话持久化。启动时全量加载到 ConcurrentHashMap,运行中每次写入同步刷盘
  • 向量索引(RAG):代码向量索引,存在 ~/.code-agent-cli/rag/codebase.db 的 SQLite 数据库里。这个不属于对话记忆,是代码库的离线索引

“记忆管理模块是这套系统的门面,统一管理短期记忆、长期记忆、上下文压缩器和记忆检索器。Agent 只和它交互,不直接操作底层的存储。”

八股延伸|长短期记忆怎么设计的? ​

长短期记忆怎么设计的?

“三层。”

“短期记忆就是当前对话的消息历史。用户说了什么、LLM 回了什么、工具返回了什么,每轮 LLM 调用都带上。本质是一个不断增长的消息列表。”

“长期记忆是一个 JSON 文件(~/.code-agent-cli/memory/long_term_memory.json),整个条目列表序列化存储,启动时全量加载。”

每条记忆绑定了 scope(project | global,实现项目隔离)、type(条目类型)、content(记忆内容)、timestamp(写入时间,参与检索时间衰减)。

“长期静态记忆就是 CODEAGENT.md 文件。项目根目录或 .codeagent 目录下放一个 CODEAGENT.md,启动时自动加载到系统提示词里。功能类似 Claude Code 的 CLAUDE.md。”

八股延伸|为什么要分静态长期记忆和动态长期记忆? ​

为什么要分静态长期记忆和动态长期记忆?

“用途不同。”

“静态记忆存的是团队规范和项目约定,比如代码风格、分支策略、部署流程。”

变化频率低,可以提交到代码仓库,团队所有成员共享同一份。CODEAGENT.md 还支持 @filename 导入其他文件,导入深度最大 3 层,总预算 24000 字符,避免撑爆上下文。”

“动态记忆存的是用户特定的事实和偏好,比如‘这个项目用的是 PostgreSQL 而不是 MySQL’。这些信息在交互过程中学习积累,按项目隔离存储在 JSON 文件里。”

“分开存的好处是职责清晰:静态记忆由开发者维护,走版本控制;动态记忆由 Agent 自动管理,不会污染代码仓库。”

两者在 Prompt 组装时合并注入系统提示词,静态的先加载、动态的后加载。

八股延伸|什么时候触发记忆存储?会不会越存越多? ​

什么时候触发记忆存储?会不会越存越多?

“触发有两种方式。用户执行 /save 命令主动存储,或者对话中说‘记住这个’‘帮我记一下’,LLM 会调用 save_memory 这个内置工具自动存。”

“去重是精确去重:已有条目的 content 和新条目完全一致就直接跳过,不创建新记录。”

“防膨胀不靠配额淘汰,靠入口约束加去重:只有显式保存能进长期记忆,重复内容又存不进来,条目数天然保持在几十到几百的量级。”

大模型怎么决定长期记忆是否需要召回? ​

“按相关性评分召回。每次 LLM 请求前,用当前的用户消息作为查询,对所有记忆逐条打分,取分数最高的 10 条,再按 token 预算截断注入 system prompt。”

“评分是两个因子相乘:关键词命中率 × 时间衰减。查询先经 jieba 分词,整句被记忆包含直接给满分,否则算命中词占比;时间衰减是 24 小时内从 1.0 线性降到 0.5、之后兜底 0.5,保证老记忆不至于完全沉底。长期记忆整体再乘 1.2,因为它比短期对话更精炼可信。”

“没有设召回阈值——分数大于 0 才入榜,排序和 token 预算自然截掉不相关的。记忆条目量级小,这个简化够用;查询很长时命中率会被分母稀释,这是已知取舍。”

追问补强|摘要失败为什么跳过,而不是重试或硬截断? ​

三选一完整论证:重试——摘要失败多为网络/超时/限流等瞬时问题,重试大概率继续失败,且压缩发生在"马上要调 LLM"的热路径上,重试是拿用户的等待时间去赌一个大概率还失败的结果;硬截断——机械截断保不住工具对完整,也保不住目标、约束这类关键信息,半截历史比完整但略超限的历史更危险,而且超限是下一次请求才爆,当前请求大概率能过;跳过——失败零代价,原历史保留,下一轮循环重新评估再试。触发阈值本来就预留了摘要输出空间和安全缓冲,跳过一两轮不会立刻撑爆窗口。三者中只有跳过是"失败无害、下次还有机会"的选项。

本站仅供个人学习使用,请勿外传