Ch15: Claude Code 记忆——索引驱动的实时学习
Part 4: 生产级 Coding Agent 记忆系统 前置章节:Ch14: MemOS——记忆操作系统与 Dream Pipeline 后续章节:Ch16: Codex 记忆——AI 驱动的批量整合
15.1 一句话定位
Claude Code 是 Anthropic 的 Coding Agent,采用文件索引 + LLM 语义检索两层架构,以”实时学习”为哲学——每次对话结束就写入记忆,每次新对话就从索引选相关记忆注入上下文,形成一个持续学习的闭环。
15.2 日常类比:个人图书馆 + 图书管理员
想象你有一个个人图书馆:
- MEMORY.md 是书架目录(索引):上面列着每本书的标题、位置和一句话摘要。目录本身很薄,不超过 200 行,但扫一眼就知道你有哪些书。
- 记忆文件是具体的书:每本书(每个
.md文件)存着一段具体的知识——比如”这个项目用 pnpm 不用 npm”或者”用户偏好深色主题”。 - 图书管理员是 Sonnet:每次你提一个问题,管理员不会把所有书搬出来(那太多了),而是先看目录,挑出最相关的 5 本,翻到对应书架取下来,摊在你面前。
关键洞察:管理员挑书不是靠关键词匹配,而是靠理解你的问题语义后做判断。这就是”LLM 做语义筛选”——和传统向量搜索不同,不是算余弦相似度,而是让大模型自己判断”哪些记忆和当前任务相关”。
类比的边界
| 图书馆类比 | 对应物 | 类比失效之处 |
|---|---|---|
| 书架目录 | MEMORY.md | 目录是静态的,MEMORY.md 由 AI 自动维护 |
| 具体的书 | 记忆文件 (.md) | 书不自动更新,记忆文件会由 autoDream 整合 |
| 图书管理员 | Sonnet 模型 | 管理员不会写新书,Sonnet 会实时写入新记忆 |
| 借书限额 | 最多 5 个文件/轮 | 借书限额是经济考量,这里是认知负载考量 |
15.3 读取路径详解
Claude Code 的记忆读取分为两个阶段:启动时全量注入索引,每轮对话按需注入记忆文件。这不是全量把所有记忆塞进去(太占上下文),也不是纯向量搜索(精度不够),而是”索引 + 按需检索”的混合模式。
15.3.1 启动阶段:MEMORY.md 全量注入
当你启动 Claude Code 时(比如 claude 命令或新开一个 session),系统会:
- 读取项目根目录下的
MEMORY.md文件 - 将其全量注入到 system prompt 中
- 这个文件是索引,包含所有记忆文件的标题、路径和一行摘要
约束条件:
| 参数 | 限制 | 原因 |
|---|---|---|
| 行数 | 不超过 200 行 | system prompt 空间有限 |
| 文件大小 | 不超过 25KB | 同上 |
| 记忆文件总数 | 不超过 200 个 | 索引行数限制的自然结果 |
MEMORY.md 的典型结构:
# Memory Index
## User Preferences
- [pnpm-preference](.claude/memory/pnpm-preference.md): 项目使用 pnpm,不用 npm
- [dark-theme](.claude/memory/dark-theme.md): 用户偏好深色主题
## Project Conventions
- [api-error-format](.claude/memory/api-error-format.md): API 错误响应统一用 {code, message, data} 格式
- [test-location](.claude/memory/test-location.md): 测试文件放在 src/__tests__/ 下
## Feedback History
- [review-2026-06-20](.claude/memory/review-2026-06-20.md): code review 要求函数注释加 @example
## Reference
- [internal-api-doc](.claude/memory/internal-api-doc.md): 内部 API 文档地址和认证方式
为什么全量注入索引而不是全量注入内容? 因为 200 个记忆文件如果全量注入,每个文件平均 200 行就是 40000 行,远超上下文窗口。而索引只有 200 行,相当于”先看目录再选书”。
15.3.2 每轮对话:LLM 语义检索注入
在每轮对话中,Claude Code 的读取流程是:
- 用户发送消息
- Sonnet 模型同时处理用户消息和 MEMORY.md 索引
- Sonnet 从不超过 200 个记忆文件中选出不超过 5 个最相关的文件
- 被选中的文件全量注入到当前对话的上下文中
- Sonnet 基于用户消息 + 注入的记忆生成响应
约束条件:
| 参数 | 限制 | 原因 |
|---|---|---|
| 每轮选入文件数 | 不超过 5 个 | 控制认知负载,5 个是经验值 |
| 单文件行数 | 不超过 200 行 | 单个记忆粒度不宜过大 |
| 单文件大小 | 不超过 4KB | 同上 |
| Session 累计记忆大小 | 不超过 60KB | 上下文窗口预算分配 |
语义筛选 vs 向量搜索:传统 RAG 系统用 embedding 向量做余弦相似度搜索,优点是快(毫秒级),缺点是语义理解浅——”pnpm” 和 “package manager” 可能向量距离远。Claude Code 让 Sonnet 自己做筛选,理解更深,但代价是每轮对话多一次 LLM 推理调用。
15.3.3 “索引 + 按需检索” 模式定位
在记忆检索的设计空间中,有三种典型方案:
| 方案 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| 全量注入 | Codex 的 memory_summary.md | 简单,零延迟 | 内容量受限于上下文窗口 |
| 纯向量搜索 | 传统 RAG | 快,可扩展 | 语义理解浅,recall 不稳定 |
| 索引 + LLM 按需检索 | Claude Code | 语义精度高 | 每轮多一次 LLM 调用,成本高 |
Claude Code 选了第三种,本质是用 LLM 的理解能力换检索精度。代价是每轮对话的 Sonnet 调用成本增加(筛选 5 个文件也需要一次推理),但好处是不会把无关记忆塞进上下文浪费 token。
15.4 写入路径详解
Claude Code 的记忆写入是”实时”的——每次对话结束后就写入,不等批量整合。这是”Claude Code 记忆哲学”的核心:学习不能等,每次交互都是学习机会。
15.4.1 extractMemories:Forked Agent 机制
写入不是由主对话的 Sonnet 完成的,而是由一个 forked agent 完成:
主对话(Sonnet)
│
├─ 用户消息 → 生成响应 → 返回用户
│
└─ 响应中无工具调用时
│
└─ 异步启动 extractMemories agent
│
├─ 共享主对话的 prompt cache(不重复计算)
├─ maxTurns = 5(最多 5 轮工具调用)
└─ 分析对话内容 → 写入记忆文件 → 更新 MEMORY.md 索引
什么是 forked agent? 它是从主对话 fork 出来的一个独立 Sonnet 实例,共享主对话的 prompt cache(前面已经计算的 KV cache 可以复用,省 token),但有自己的工具调用权限和回合限制。
为什么 fork?
- 不阻塞主对话:用户看到响应后就可以继续输入,记忆写入在后台默默进行
- 共享 prompt cache:复用主对话的 KV cache,写入 agent 不需要重新理解整个对话上下文
- 限制回合数:maxTurns=5 防止写入 agent 无限循环(比如反复读写文件)
15.4.2 Fire-and-Forget 模式
extractMemories 是 fire-and-forget 的:
- Fire:在主对话产生”无工具调用响应”后触发
- Forget:主对话不等待写入结果,不依赖写入成功
这意味着:
- 记忆写入失败不影响主对话——最坏情况是这轮对话的内容没被记住,下一轮还能正常工作
- 记忆写入延迟不影响用户体验——用户不需要等”记忆写入完成”才能继续
- 记忆写入可能和用户的下一轮输入并发——如果用户很快回复,写入还没完成,新消息照样处理
15.4.3 四类型分类
extractMemories 会将提取的记忆分为四类:
| 类型 | 语义 | 示例 | 存储位置 |
|---|---|---|---|
user |
用户偏好和习惯 | “我喜欢用 vim 快捷键” | .claude/memory/user/ |
feedback |
显式反馈(纠正、偏好声明) | “不要用 var,用 const” | .claude/memory/feedback/ |
project |
项目级约定和发现 | “这个项目用 Next.js App Router” | .claude/memory/project/ |
reference |
参考信息(URL、文档位置) | “API 文档在 internal.dev/api” | .claude/memory/reference/ |
分类的目的是什么? 为了让 MEMORY.md 索引有结构——按类型分组,Sonnet 做语义筛选时能更快定位。同时也影响记忆的淘汰优先级:feedback 类(用户显式声明)通常优先级最高,reference 类(可能过时的 URL)优先级最低。
15.4.4 明确排除规则
不是所有对话内容都值得记忆。extractMemories 有明确的排除规则:
排除 1:可推导信息
如果一条信息可以从已有代码或配置推导出来,不需要记忆。比如:
- ❌ “项目使用 TypeScript” → 可以从
tsconfig.json推导 - ❌ “入口文件是 src/index.ts” → 可以从
package.json的 main 字段推导 - ✅ “部署时必须先跑 migrate 脚本” → 无法从代码推导,需要记忆
排除 2:CLAUDE.md 已有内容
CLAUDE.md(项目指令文件)已经包含的信息不需要重复记忆。比如:
- ❌ “测试框架用 Vitest” → 已在 CLAUDE.md 声明
- ✅ “Vitest 的 cache 目录会导致 CI 失败,需要加 –no-cache” → CLAUDE.md 没有的细节
为什么排除? 因为记忆空间有限(200 个文件),每存一条无用记忆就挤占一条有用记忆的位置。排除可推导信息和已有信息,是保证记忆信噪比的核心策略。
15.5 autoDream 整合机制
记忆写入是实时的,但实时写入会积累碎片——同一主题可能分散在多个文件中,过时信息可能和最新信息矛盾。autoDream 就是定期整理的机制。
15.5.1 类比延伸:图书馆的年度盘点
如果 extractMemories 是”每天把新书放上架”,autoDream 就是”每年盘点一次,把过时的书淘汰,把同主题的合并,更新目录”。
15.5.2 触发条件
autoDream 不是每轮都跑,而是有明确的触发门槛:
| 条件 | 值 | 原因 |
|---|---|---|
| 距上次 Dream 时间 | 超过 24 小时 | 太频繁浪费算力,太稀疏碎片积累 |
| 最少 session 数 | 至少 5 次 | 确保有足够新记忆值得整合 |
两个条件同时满足才触发。这意味着:
- 如果一天内只用了 3 次 Claude Code,不会触发 Dream(新记忆太少)
- 如果 5 次session 集中在 2 小时内,也不会触发(距上次 Dream 不到 24h)
- 典型触发场景:第二天上班开始用 Claude Code
15.5.3 整合操作
autoDream 做两件事:
- 整合去重:扫描所有记忆文件,找到内容重叠或主题相同的,合并为一个文件
- 合并相似记忆:比如
pnpm-preference.md和package-manager.md可能说的同一件事,合并后只保留一个
整合完成后会更新 MEMORY.md 索引,确保索引和实际文件一致。
15.5.4 autoDream 的局限
- Dream 本身也消耗 LLM 调用——整合 200 个记忆文件可能需要多轮推理
- 整合可能丢失细节——合并时 Sonnet 可能误判两条记忆”相似”而丢弃有价值差异
- Dream 不做语义验证——合并后的记忆不会回溯验证是否仍然正确
15.6 Freshness Caveat:时间衰减警告
记忆不是越老越可信。Claude Code 对超过 1 天的记忆加 freshness caveat(新鲜度警告)。
15.6.1 机制
当 Sonnet 从记忆索引中选中一个文件准备注入时,会检查该记忆的最后修改时间:
- 修改时间 < 1 天前:正常注入,无警告
- 修改时间 >= 1 天前:注入时附加警告前缀
警告格式类似:
[注意:此记忆最后更新于 2026-06-20,信息可能已过时]
15.6.2 为什么是 1 天?
1 天的阈值偏保守(很多记忆一周后仍然有效),但这是刻意的设计选择:
- 宁可多警告也不漏警告:过时信息被当作有效信息,危害远大于有效信息被标记为”可能过时”
- 促发更新:警告提示用户或 AI 主动验证这条记忆是否仍然有效,验证后刷新时间戳
- 低成本实现:不需要复杂的衰减函数,简单的二元判断(<1天 / >=1天)就够了
15.6.3 Freshness Caveat 的局限
- 所有超 1 天的记忆都加警告,不区分类型——”项目用 pnpm”这种稳定偏好和”临时 bug 的 workaround”获得相同警告,但前者大概率仍有效,后者大概率已过时
- 没有自动验证机制——警告只是提示,不会主动检查记忆是否仍然正确
- 不会自动淘汰——即使记忆明显过时,也只是加警告,不会删除
15.7 团队记忆:多层路径安全设计
Claude Code 的记忆不仅服务个人,还支持团队共享。但共享带来了安全问题——你不能让项目的记忆覆盖个人的偏好,也不能让队友的敏感信息泄露给你。
15.7.1 三层记忆路径
~/.claude/memory/ ← 用户全局记忆(跨项目共享)
├── user/
│ └── editor-vim.md ← "我喜欢 vim 快捷键"
└── feedback/
项目根/.claude/memory/ ← 项目记忆(团队共享)
├── project/
│ └── api-format.md ← "API 用 {code, message, data}"
└── reference/
.claude/memory/local/ ← 本地记忆(不提交 git)
└── temp-workaround.md ← "CI 第 3 步要加 --force"
15.7.2 读取优先级
当多层有同主题记忆时,优先级从高到低:
- 项目记忆:项目约定优先于个人偏好(在项目语境下)
- 用户全局记忆:个人偏好跨项目生效
- 本地记忆:最低优先级,通常是不重要的临时信息
15.7.3 安全设计
| 安全措施 | 说明 |
|---|---|
| 路径隔离 | 项目记忆和全局记忆在不同目录,不会互相覆盖 |
| Git 忽略 | .claude/memory/local/ 在 .gitignore 中,临时记忆不提交 |
| 权限检查 | extractMemories 写入前检查目标路径是否在允许列表中 |
| 敏感信息排除 | 写入时检测并排除 token、密码、API key 等敏感信息 |
| 跨项目隔离 | 全局记忆只存用户偏好,不存项目特定信息,防止项目 A 的记忆污染项目 B |
15.7.4 团队共享的实际挑战
- 记忆冲突:A 记忆”用 tabs 缩进”,B 记忆”用 spaces 缩进”——谁的优先?
- 记忆膨胀:10 人团队每人每天产生 5 条记忆,一个月就是 1500 条,索引超限
- 隐私边界:个人的编码习惯是否应该共享给团队?如果共享,是否需要同意机制?
Claude Code 当前的方案偏保守——项目记忆由 git 管理版本(可回滚),全局记忆仅限用户偏好,没有跨用户的记忆共享机制。这避免了冲突但限制了团队级知识的积累。
15.8 核心权衡:精度 vs 成本
Claude Code 记忆系统的设计选择处处是权衡,核心矛盾是:
LLM 语义检索精度高,但成本高;实时写入保证不丢信息,但增加后台开销。
15.8.1 语义检索的成本分析
每轮对话的记忆筛选都需要一次 Sonnet 推理调用。假设:
- 每次筛选处理 200 行索引 + 用户消息 ≈ 1000 input tokens
- Sonnet 输出 5 个文件选择 ≈ 200 output tokens
- 每千 token 成本约 $0.003(input)+ $0.015(output)
单次筛选成本约 $0.006,看起来不多,但一个 session 可能 20 轮对话,一天可能 5 个 session:
$0.006 × 20 轮 × 5 session = $0.6/天
一个月下来就是 ~$18 的额外成本,全部花在”选哪 5 个记忆文件”上。这是纯向量搜索不需要的开销。
15.8.2 实时写入的开销分析
extractMemories 的开销更隐蔽:
- 每次 fire-and-forget 启动一个 forked Sonnet 实例
- 共享 prompt cache 节省了部分 input token,但仍有 output token 成本
- maxTurns=5 的工具调用可能涉及文件读写,增加 I/O 延迟
一天 5 个 session,每个 session 假设 10 次无工具响应(触发 10 次 extractMemories):
10 次 × 5 session = 50 次/天的后台写入
50 次后台 Sonnet 推理,每次约 500 output tokens,成本约 $0.375/天。加上 autoDream 的定期整合,记忆相关的总成本可能占 Claude Code 日常使用成本的 10-15%。
15.8.3 精度收益
这些成本换来的是什么?
- 相关记忆的 recall 率:语义筛选比向量搜索更准,因为 Sonnet 理解”用户在问测试配置”时,会选中
vitest-workaround.md而不是被关键词匹配误导到jest-config.md - 不相关记忆的误注入率更低:5 个精心挑选的文件 vs 向量搜索 top-5,前者更少包含噪音
- 实时性:上一轮对话学到的偏好,下一轮就能生效——不需要等批量整合
15.8.4 权衡总结
| 维度 | 选择 | 收益 | 代价 |
|---|---|---|---|
| 检索方式 | LLM 语义筛选 | 高精度 | 每轮多一次推理调用 |
| 写入时机 | 实时 fire-and-forget | 不丢信息 | 后台 Sonnet 实例开销 |
| 索引方式 | MEMORY.md 全量注入 | 简单可靠 | 200 文件上限 |
| 新鲜度 | 1 天二元判断 | 实现简单 | 不区分稳定/临时记忆 |
| 整合频率 | 24h + 5 session | 避免碎片 | 整合本身消耗算力 |
15.9 与传统记忆方案对比
| 特性 | Claude Code | 传统 RAG | 简单全量注入 |
|---|---|---|---|
| 检索方式 | LLM 语义筛选 | 向量余弦相似度 | 无检索,全量塞入 |
| 检索精度 | 高 | 中 | N/A(全量) |
| 检索延迟 | ~1s(LLM 推理) | ~10ms | 0 |
| 写入方式 | 实时异步 | 离线 embedding | 实时 |
| 写入延迟 | <2s | 分钟级(建索引) | <1s |
| 上下文效率 | 高(只注入 5 个) | 中(top-K 可能含噪音) | 低(全量占空间) |
| 成本 | 高(每轮额外推理) | 低(向量搜索便宜) | 中(token 成本) |
| 可扩展性 | 受 200 文件限制 | 强(百万级向量) | 弱(上下文窗口限制) |
15.10 实战观察:Claude Code 记忆的行为模式
15.10.1 记忆注入的”命中”与”未命中”
在实际使用中,语义筛选并非完美:
命中场景(记忆被正确注入):
- 用户说”这个项目怎么跑测试” → 注入
test-location.md(测试文件位置约定) - 用户说”上次 review 说要改什么” → 注入
review-2026-06-20.md(最近的 review 反馈)
未命中场景(记忆应该注入但没注入):
- 用户说”这个 API 报错了” → 没注入
api-error-format.md(因为”报错”和”错误格式”的语义距离对 Sonnet 来说不够近) - 记忆文件太多时,5 个名额被其他更”显眼”的记忆占满
15.10.2 记忆写入的”过度记忆”问题
extractMemories 有时会写入不该记的内容:
- 临时性的对话上下文被当成长期记忆(如”现在在改 login.ts”——这是临时状态,不需要记忆)
- 推导性信息没被正确排除(如”这个函数返回 Promise”——可以从类型定义推导)
这些”噪音记忆”会挤占索引空间,降低整体信噪比。autoDream 的整合可以部分缓解,但不完美。
15.10.3 MEMORY.md 的”漂移”问题
MEMORY.md 索引和实际记忆文件之间可能出现不一致:
- 手动删除记忆文件但没更新索引 → 索引指向不存在的文件(死链)
- 手动添加记忆文件但没更新索引 → 文件存在但 Sonnet 看不到(孤儿文件)
- autoDream 整合失败中断 → 索引和文件部分不一致
Claude Code 没有内置的索引一致性检查,这是当前架构的一个弱点。
15.11 章节小结
Claude Code 的记忆系统是一个索引驱动的实时学习方案,核心设计可以概括为三句话:
- 读时精选:MEMORY.md 索引全量注入,Sonnet 每轮从 200 个记忆中选 5 个注入——用 LLM 的理解能力换检索精度,代价是每轮多一次推理调用。
- 写时即记:extractMemories 在每轮对话后异步启动,fire-and-forget 地写入新记忆——保证不丢信息,代价是后台 Sonnet 实例开销。
- 定期整理:autoDream 在 24h + 5 session 后整合去重——解决碎片问题,但整合本身消耗算力且可能丢失细节。
这三个环节形成闭环:实时写入积累记忆 → 索引驱动精准检索 → 定期整合防止碎片 → 检索精度保持高位。闭环的代价是额外的 LLM 调用成本(约占 10-15%),换来的收益是不丢失任何学习机会的高精度记忆系统。
下一章我们将看 Codex 的截然不同方案——不是实时写入,而是批量整合;不是索引 + LLM 筛选,而是全量注入摘要。两种哲学的对比会揭示记忆设计空间的核心权衡。
导读目录:README.md 上一章:Ch14: MemOS——记忆操作系统与 Dream Pipeline 下一章:Ch16: Codex 记忆——AI 驱动的批量整合