通过源码学习思路: CLaude Code 如何实现Agent Memory (上)
阅读源码: CLaude Code 如何实现Agent Memory (上)
一、数据模型:封闭四类,一文件一记忆
封闭的四类分类法
记忆被约束在一个四值枚举里(memoryTypes.ts:14-19):user、feedback、project、reference。这不是随意的标签,而是一道内容闸门——WHAT_NOT_TO_SAVE_SECTION 明确排除代码模式、架构、git 历史、debug 方案、CLAUDE.md 已有内容、临时任务状态,即使用户明确要求保存也适用。设计意图很清楚:只存“读代码、跑 git 都推导不出来”的东西。这是整个系统控制记忆数量的第一道、也是最重要的一道防线。
其中user类型记忆:
<名称>用户</名称>
<说明>存放有关用户身份岗位、目标、工作职责以及知识储备的相关信息。完善的用户记忆,可以让你后续的交互行为适配用户的使用习惯与思考角度。读写该类记忆,目的是充分了解用户本身,明确如何针对性地为用户提供帮助。举个例子,你和资深软件工程师、初次接触编程的学生开展协作时,沟通方式应当有所区分。谨记,所有记录都应当以便于服务用户为宗旨;不要记录带有负面主观评价、或是和双方工作无关的用户信息。
<存储时机>当你获知用户的岗位、使用偏好、工作职责、知识水平等任意细节时</存储时机>
<使用方式>在工作需要结合用户个人情况、思维习惯时启用。例如用户需要你讲解某段代码,你的回答需要贴合用户的实际情况,输出对他最有用的内容,依托用户已经掌握的专业知识,帮助他搭建知识框架。</使用方式>
<示例>
用户:我是一名数据科学家,正在调研项目现有的日志方案
助手:[保存用户记忆:用户为数据科学家,当下正在研究可观测性、日志相关内容]
用户:我已经有十年Go语言开发经验,但我第一次接触该仓库的React前端部分
助手:[保存用户记忆:用户精通Go语言;初次接触React以及本项目前端,讲解前端知识时可以多用后端相关类比进行说明]
</示例>
feedback类型记忆:
<名称>反馈</名称>
<描述>用户针对你的工作方式给出的指导,包含需要规避的操作以及需要沿用的习惯。读写该类记忆十分关键,能够让你后续的工作风格保持统一,适配项目的协作要求。
失误经验与成功经验都需要记录:倘若你只保存用户的纠正意见,虽然可以规避过往错误,但会淡忘用户已经认可的处理方案,行事也容易变得过度保守。</描述>
<存储时机>当用户纠正你的处理思路(例如“不要这样”“别做X”),或是认可你非常规的方案可行(例如“正是如此”“很好,继续这么做”、默许你的特殊选择)。
纠正指令十分显眼,而肯定类提示往往较为隐晦,需要多加留意。
以上两种场景,都要记录适用于后续对话的信息,尤其是那些出乎意料、无法从代码直接看出的要求;同时记下背后缘由,方便之后处理边界场景。</存储时机>
<使用方式>依靠该类记忆规范自身工作习惯,无需用户反复重申相同要求。</使用方式>
<内容格式>开头写明行为准则,随后书写**原因:**(用户给出的理由,大多是过往事故或是个人偏好),再写明**适用方式:**(该条规范的生效场景)。
知晓背后动机,可以灵活处理各类边界情况,而非死板遵守规则。</内容格式>
<示例>
用户:测试代码不要使用数据库模拟环境。上个季度我们吃过亏,模拟测试运行正常,但线上数据库迁移却失败了
助手:【保存反馈记忆:集成测试必须连接真实数据库,禁止使用模拟库。原因:此前模拟环境和线上环境存在差异,掩盖了迁移故障】
用户:不要每次回复末尾总结操作,我可以自行查看代码变更记录
助手:【保存反馈记忆:用户偏好简洁回答,禁止在文末添加总结】
用户:没错,本次采用合并式PR是正确的,拆分多个PR只会造成无效的重复工作
助手:【保存反馈记忆:该模块的代码重构,用户倾向使用单个合并PR,而非拆分大量小型PR;该方案得到用户认可,属于通过的决策而非纠错】
</示例>
一条记忆 = 一个文件
每条记忆是一个带 YAML frontmatter 的 .md 文件,文件名称约定为{类型}_{主题}.md:
---
name: <语义名>
description: <一句话描述,用于判断相关性>
type: <user | feedback | project | reference>
---
<正文;feedback/project 要求写成 规则 + **Why:** + **How to apply:**>
MEMORY.md 是索引,不是记忆
MEMORY.md 是入口索引,每条一行指针 - [Title](file.md) - hook,无 frontmatter,指令反复强调“Never write memory content directly into MEMORY.md”。它和主题文件是两个东西。
二、存储:一个扁平的、不分类型的池子
四种类型共用同一个目录,没有 user/、feedback/ 子目录。scanMemoryFiles 过滤只看 .md 和非 MEMORY.md,不看 type;formatMemoryManifest 把 type 当成行首标签 [type] 拼进一个混编清单;召回选择器在所有类型里一起挑,不按类型配额。
type 真正起作用只有两处:写入侧的正文结构指令(feedback/project 要写 Why/How to apply)和触发时机(user 学到角色时、feedback 用户纠正/确认时)。这是给模型的写作指导,不是流程上的分区。
唯一的“物理分开”出现在 team 模式:按 private/team 两个目录路由,且 user/feedback 都偏 private、project/reference 偏 team。但即便如此,user 和 feedback 仍在一起——分开的是“它俩”和“偏 team 的另两类”,不是四种类型各自独立。
这个“扁平不分类型”的选择是后面盲区的根源之一。
三、写入:两个互斥的写手,“改还是建”由模型定
两个写手
- 写手 A:主 agent 自己。 它的系统提示里始终带着完整的 save 指令,任何时候它觉得该记就能直接用 Write/Edit 写。当用户说“记住 X”时会直接调用tools指令要求立刻存储这个记忆。
- 写手 B:后台 extractMemories。 它在每个 query loop 结束(模型给出最终回复、不再调工具)时由
handleStopHooks触发,分叉主对话(共享 prompt cache),从这轮对话里提炼持久事实。有门控:只有在主线程(agentId检查跳过子 agent)、feature flag、auto memory 开着的情况下才存储。
const howToSave = skipIndex
? [
'## How to save memories',
'',
'Write each memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
'',
...MEMORY_FRONTMATTER_EXAMPLE,
'',
'- Keep the name, description, and type fields in memory files up-to-date with the content',
'- Organize memory semantically by topic, not chronologically',
'- Update or remove memories that turn out to be wrong or outdated',
'- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.',
]
: [
'## How to save memories',
'',
'Saving a memory is a two-step process:',
'',
'**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:',
'',
...MEMORY_FRONTMATTER_EXAMPLE,
'',
`**Step 2** — add a pointer to that file in \`${ENTRYPOINT_NAME}\`. \`${ENTRYPOINT_NAME}\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`${ENTRYPOINT_NAME}\`.`,
'',
`- \`${ENTRYPOINT_NAME}\` is always loaded into your conversation context — lines after ${MAX_ENTRYPOINT_LINES} will be truncated, so keep the index concise`,
'- Keep the name, description, and type fields in memory files up-to-date with the content',
'- Organize memory semantically by topic, not chronologically',
'- Update or remove memories that turn out to be wrong or outdated',
'- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.',
]
互斥:防重复
两者互斥。hasMemoryWritesSince(extractMemories.ts:121-148)检查主 agent 这轮是否已往 auto-mem 路径写过文件;写过就跳过 extract、只推进游标。注释说得很直白:主 agent 提示词本来就有 save 指令,它自己写了,再 fork 一遍就冗余了。
“改还是建”——代码不做判断,模型做
这是“模型即档案员”信念最集中的体现。代码里没有“if 文件存在则 Edit else Write”的硬规则。代码只做两件事:
- 喂数据:extract 进来时,
scanMemoryFiles扫现有记忆,formatMemoryManifest拼成一行一个的清单喂给 fork agent。 - 给指令:Check this list before writing - update an existing file rather than creating a duplicate + Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.
然后模型拿这轮对话提炼出的事实,去对照清单里每条的 [类型] 文件名 (时间): 描述,自己判断:有相关主题文件就 Edit,没有就 Write。hasMemoryWritesSince 只判断“主 agent 写没写过”用于互斥,不判断改 vs 建,至于怎么找,下方(四)会讲。
fork 的两轮策略
fork:复制一份当前对话的上下文,另起一个独立的 agent 去跑子任务,但这份副本和原对话"共享缓存"
因为 Edit 工具要求先 Read 过同一文件,extract agent 的执行模式被设计成两轮(prompts.ts:39):turn 1 并行 Read 所有“可能要改”的候选文件(不是全部,是它看清单挑的子集),turn 2 并行 Write/Edit。先批量读、再批量写,且只读候选。
四、召回:没有向量,只有一次小模型的判断
两条召回路径
- 路径 A:MEMORY.md 始终在系统提示里。
loadMemoryPrompt构建系统提示时同步读MEMORY.md,截断到 200 行/25KB 后拼进去。这是“始终在场”的部分。 - 路径 B:相关记忆动态注入。 每次用户请求(且超过一个词)触发
findRelevantMemories,选 ≤5 条相关记忆作为<system-reminder>附件注入。这是“按需召回”。
扫描原语:scanMemoryFiles
两条路径的下游、以及 extract 的清单,都依赖同一个扫描原语 scanMemoryFiles(memoryScan.ts:35-77)。它做四件事:递归 readdir → 过滤 .md 排除 MEMORY.md → 并行读每个文件前 30 行 frontmatter → 按 mtimeMs 降序截取前 200 个。
一个设计精髓值得学习:
- 单遍优化。
readFileInRange内部本来就要读取文件内容(才知道文件大小按行读),顺带把元信息里的mtimeMs一起返回。于是“读内容(顺带拿 mtime)→ 排序 → 截断”一轮搞定,省掉一次单独的查询文件元信息轮。
清单:formatMemoryManifest
每个 MemoryHeader 被压成一行(memoryScan.ts:84-94):
- [feedback] feedback_testing.md (2026-02-10T08:13:22.000Z): 测试别 mock 数据库
注意这里只有 filename + description + type + 时间,没有 frontmatter 的 name。200 个文件 × 一行,本身就是个紧凑清单。
选择器:一次 Sonnet 判断
selectRelevantMemories(findRelevantMemories.ts:77-141)是召回的核心,也是“模型即档案员”信念的另一半。没有向量检索、没有词频打分、没有 embedding。 它做的是:
拿用户 query + 清单 + 最近用过的工具列表,调一次 sideQuery,用比主循环更小更快的 Sonnet 模型,系统提示要求“up to 5,确信有用才选,不确定就别选,可以返空”,输出强制 JSON schema { selected_memories: [filename...] },max_tokens: 256。最后用 validFilenames 过滤掉小模型幻觉出来的不存在文件名。
“最多 5”不是代码截断,是系统提示里写“up to 5”让模型自己控制,代码只在 attachments.ts:2231-2234 有个 .slice(0, 5) 兜底。
一个精妙的细节:recentTools 一并传给选择器,让它避开正在用的工具的参考文档(那是噪音),但保留那些工具的“坑/警告”类记忆——正在用时恰恰是坑最要紧的时候。
mtimeMs:贯穿排序与新鲜度
mtimeMs 是文件系统给的“内容最后修改时间(毫秒戳)”,读文件时顺带拿到。它干四件事:排序(新在前)、截 200、清单里显示 ISO 时间、算新鲜度提示(“47 days ago”、给老记忆加“引用前请核实”警告)。新鲜度文案在附件创建时算好固化,不在渲染时现算,避免 Date.now() 让“3 天前”变“4 天前”打碎 prompt cache。
五、如何防止记忆爆炸:七层截断,且压缩会重置
“动态注入”听起来危险——岂不是会把记忆越塞越多直到提示词爆炸?系统用七层防护卡死,从索引到单条到累计:
| 层 | 限制 | 代码位置 |
|---|---|---|
| 1. 索引截断 | MEMORY.md 最多 200 行 / 25KB | memdir.ts:35-38 |
| 2. 扫描上限 | 最多扫 200 个文件,每个只读前 30 行 | memoryScan.ts:21-22 |
| 3. 选择上限 | Sonnet 最多选 5 条,输出 ≤256 token | findRelevantMemories.ts:20-23 |
| 4. 单条截断 | 每条最多 200 行 / 4KB,超了截断并提示“用 FileRead 看完整文件” | attachments.ts:269, 277 |
| 5. 单轮总量 | 5 × 4KB = 20KB/轮 | attachments.ts:271-273 |
| 6. 会话累计 | 累计 60KB 后停止预取 | attachments.ts:288 (MAX_SESSION_BYTES) |
| 7. 去重 | 已注入过的不重注;模型已用 FileRead 读过的不注 | alreadySurfaced + readFileState |
第 6 层尤其巧妙:collectSurfacedMemories 扫消息历史算累计字节,所以压缩天然重置它——旧附件被压缩删掉后计数归零,又能重新注入。第 4 层截断不丢文件而是截一部分,因为选择器已经判定它最相关,frontmatter + 开头通常就是关键。
结语:核心信念与它的代价
通观全篇,每个机制都在印证同一个信念:
▎ 代码不做判断,只做三件事——喂数据、给指令、设边界。判断全部留给模型。
- “改还是建”:代码喂 manifest + 去重指令,模型判断。
- “相关不相关”:代码喂一行一行的清单,Sonnet 判断。
- “该不该存”:代码给四类分类 + 排除规则,模型判断。
- “陈旧不陈旧”:代码给
mtimeMs+ 验证提示,模型判断。
边界防护则是硬的:七层截断防爆、Promise.allSettled 容错、hasMemoryWritesSince 互斥、validFilenames 防幻觉、路径校验防遍历。
这个设计的收益是零基础设施——无向量库、无 schema 迁移、无类型分区、无专用索引引擎,全靠文件系统 + 一次小模型调用,且能随模型能力进化而自动变强(模型更聪明,召回和抽取就更好)。
这是一个清晰的权衡:用模型的判断力换基础设施的简洁,用“控制记忆数量”换“不必建大规模检索系统”。理解了这个权衡,就理解了这套记忆系统每一个设计选择背后的逻辑脉络。
在我们设计agent memory的过程中,cc的一些设计思路还是很值得学习的。
以上内容为阅读源码的学习心得,如有错误或建议,欢迎批评与指正。
end
更多推荐




所有评论(0)