阅读源码: CLaude Code 如何实现Agent Memory (上)
阅读源码 CLaude Code 如何实现Agent Memory 上一、数据模型封闭四类一文件一记忆封闭的四类分类法记忆被约束在一个四值枚举里memoryTypes.ts:14-19user、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:****Howto apply:**MEMORY.md 是索引不是记忆MEMORY.md是入口索引每条一行指针- [Title](file.md) - hook无 frontmatter指令反复强调“Never write memory content directly into MEMORY.md”。它和主题文件是两个东西。二、存储一个扁平的、不分类型的池子四种类型共用同一个目录没有user/、feedback/子目录。scanMemoryFiles过滤只看.md和非MEMORY.md不看 typeformatMemoryManifest把 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 开着的情况下才存储。consthowToSaveskipIndex?[## 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.,]互斥防重复两者互斥。hasMemoryWritesSinceextractMemories.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:39turn 1 并行 Read 所有“可能要改”的候选文件不是全部是它看清单挑的子集turn 2 并行 Write/Edit。先批量读、再批量写且只读候选。四、召回没有向量只有一次小模型的判断两条召回路径路径 AMEMORY.md 始终在系统提示里。loadMemoryPrompt构建系统提示时同步读MEMORY.md截断到 200 行/25KB 后拼进去。这是“始终在场”的部分。路径 B相关记忆动态注入。每次用户请求且超过一个词触发findRelevantMemories选 ≤5 条相关记忆作为system-reminder附件注入。这是“按需召回”。扫描原语scanMemoryFiles两条路径的下游、以及 extract 的清单都依赖同一个扫描原语scanMemoryFilesmemoryScan.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 判断selectRelevantMemoriesfindRelevantMemories.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 行 / 25KBmemdir.ts:35-382. 扫描上限最多扫 200 个文件每个只读前 30 行memoryScan.ts:21-223. 选择上限Sonnet 最多选 5 条输出 ≤256 tokenfindRelevantMemories.ts:20-234. 单条截断每条最多 200 行 / 4KB超了截断并提示“用 FileRead 看完整文件”attachments.ts:269, 2775. 单轮总量5 × 4KB 20KB/轮attachments.ts:271-2736. 会话累计累计 60KB 后停止预取attachments.ts:288 (MAX_SESSION_BYTES)7. 去重已注入过的不重注模型已用 FileRead 读过的不注alreadySurfacedreadFileState第 6 层尤其巧妙collectSurfacedMemories扫消息历史算累计字节所以压缩天然重置它——旧附件被压缩删掉后计数归零又能重新注入。第 4 层截断不丢文件而是截一部分因为选择器已经判定它最相关frontmatter 开头通常就是关键。结语核心信念与它的代价通观全篇每个机制都在印证同一个信念▎ 代码不做判断只做三件事——喂数据、给指令、设边界。判断全部留给模型。“改还是建”代码喂 manifest 去重指令模型判断。“相关不相关”代码喂一行一行的清单Sonnet 判断。“该不该存”代码给四类分类 排除规则模型判断。“陈旧不陈旧”代码给mtimeMs 验证提示模型判断。边界防护则是硬的七层截断防爆、Promise.allSettled容错、hasMemoryWritesSince互斥、validFilenames防幻觉、路径校验防遍历。这个设计的收益是零基础设施——无向量库、无 schema 迁移、无类型分区、无专用索引引擎全靠文件系统 一次小模型调用且能随模型能力进化而自动变强模型更聪明召回和抽取就更好。这是一个清晰的权衡用模型的判断力换基础设施的简洁用“控制记忆数量”换“不必建大规模检索系统”。理解了这个权衡就理解了这套记忆系统每一个设计选择背后的逻辑脉络。在我们设计agent memory的过程中cc的一些设计思路还是很值得学习的。以上内容为阅读源码的学习心得如有错误或建议欢迎批评与指正。end