乐于分享
好东西不私藏

从源码拆解 Claude Code 的记忆体系:它是怎么记住你说过的话的

从源码拆解 Claude Code 的记忆体系:它是怎么记住你说过的话的

当我们和大模型对话时,经常遇到一个问题:昨天明明告诉过它"别这么做",今天再开一个新会话,它又犯同样的错。不是因为模型的能力有问题,而是它根本不记得昨天发生过什么——每个 session 之间没有任何延续。

那记忆应该存在哪?一个很自然的想法是做一个全局记忆库,所有项目共享。但问题是,你在 A 项目里跟它说"我们不用 ORM,直接写 SQL",这条记忆对 B 项目可能完全不适用。项目之间的约定、风格、技术栈差异很大,混在一起反而会互相污染。所以 Claude Code 选择按项目隔离。

那按什么维度隔离?一个直觉是按当前工作目录(cwd)——在哪个目录下启动 Claude Code,就存哪个目录的记忆。但问题是同一个项目你可能从不同路径进入:今天在 `~/work/my-app` 里开,明天 cd 到 `~/work/my-app/packages/server` 里开,这两次应该共享同一份记忆还是各存一份?显然该共享——它们是同一个项目。那怎么判断"同一个项目"?Claude Code 用的是 git:找到当前目录所属的 git 仓库根目录(`findCanonicalGitRoot`),用它作为 key。只要是同一个 git 仓库——哪怕你从子目录启动、哪怕你在另一个 worktree 里——命中的都是同一份记忆。git root 是目前最可靠的"项目边界"标识符:几乎所有代码项目都用 git,它的根目录天然就是项目边界。

于是每个 git 仓库对应一个记忆目录:`~/.claude/projects/<sanitized-repo-path>/memory/`。

这个目录里有什么?一个 MEMORY.md 和若干 topic 文件。MEMORY.md 是索引,每一行是一个指针,格式大概是 `- [标题](文件名.md) — 一句话说明`,限制在 200 行以内、25KB 以内。为什么要分索引和正文?因为 MEMORY.md 每次 session 启动时会被整个注入 system prompt——如果所有细节都堆在一个文件里,很快就会撑爆 token 上限。所以它只做索引,具体内容放在各个 topic 文件中。

每个 topic 文件开头有 frontmatter(文件顶部用 `---` 分隔的一段 YAML 格式元数据)标注这条记忆是什么类型。类型有四种:user(用户是谁、偏好什么)、feedback(用户纠正过的行为)、project(项目正在发生什么)、reference(外部资源在哪)。分类型不是为了分类而分类,而是为了在写入时划定边界——什么该存、什么不该存。`src/memdir/memoryTypes.ts` 的模块注释明确规定了这个边界:

Memories are constrained to four types capturing context NOT derivable from the current project state. Code patterns, architecture, git history, and file structure are derivable (via grep/git/CLAUDE.md) and should NOT be saved as memories.

也就是说,只有那些你不看对话就无从知晓的信息才值得记下来。能从当前代码、git 历史、文件结构中推导出来的东西,全部排除。同一个文件里导出了一个 `WHAT_NOT_TO_SAVE_SECTION`,会被注入 system prompt,把排除规则具体化:

- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.- Git history, recent changes, or who-changed-what — git log / git blame are authoritative.- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.- Anything already documented in CLAUDE.md files.- Ephemeral task details: in-progress work, temporary state, current conversation context.

甚至连用户主动要求存也会被拦。源码( 193-194 行)写道:

These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.

如果用户说"把这周的 PR 列表存下来",agent 应该反问:这里面什么是意外的、不明显的?只有那部分才值得记。

每次 session 启动时,MEMORY.md 的内容被直接注入 system prompt。所以 Claude Code 每次醒来,第一件事就是读自己上次留下的笔记。但光是索引还不够——如果当前用户的问题涉及某条具体记忆怎么办?一种方案是做关键词匹配,但太粗了,一个词可能同时命中十几个文件。另一种是建 embedding 向量库做语义检索,但那需要额外的基础设施来维护索引。Claude Code 选了一个更直接的方式:用一个轻量模型做判断。findRelevantMemories 函数会扫描所有 topic 文件的 frontmatter(文件名、描述、类型),把用户的问题和所有记忆的摘要一起发一次独立的 API 请求给 Sonnet(源码中叫 sideQuery——主对话之外的旁路查询,不影响主对话的 token 消耗),让 Sonnet 选出最多 5 个最相关的 topic 文件注入上下文。这样既能理解语义("数据库"和"不要 mock"之间的关联),又不需要维护一个外部向量数据库。所以不是每条记忆都被加载,而是按需召回

以上是读取。那写入靠谁?

最直接的方式是 Agent 在对话中自己判断——用户说了一句重要的话,它立刻调用 Write 工具写到 memory 目录里。系统 prompt 中有完整的指令告诉它什么该存、什么不该存、格式是什么。但实际运行中会出现一个问题:Agent 有时忙着完成手头的任务(写代码、跑测试),来不及或者忘了存。有时它判断失误,觉得某条信息不值得记。

为了解决这个遗漏,有一个叫 extractMemories 的服务(`src/services/extractMemories/extractMemories.ts`)。为什么不在主 agent 的流程里多加一步——比如在每次回复前追加一轮"你刚才有没有漏记的"?因为那样做会打断用户体验:用户以为对话结束了,突然又冒出一堆 tool call;而且会消耗主对话的 output token 配额。所以 extractMemories 选择 fork 出一个独立 agent 在后台静默运行,用户完全感知不到。fork 共享主对话的 prompt cache——Anthropic API 有一个缓存机制:如果两次请求的 system prompt 和前面的消息完全相同,第二次只需要付“缓存读取”的钱(很便宜),不需要重新“创建缓存”(很贵)。fork 出来的 agent 和主对话的前缀完全一样,所以它命中缓存,只产生 output token 的增量成本。

这个 fork 的权限被严格限制:只能读文件(Read/Grep/Glob)、跑只读的 shell 命令、以及向 memory 目录写入——不能碰项目代码(`createAutoMemCanUseTool` 实现了这个权限约束)。而且它和主 agent 有一个互斥关系:如果主 agent 在这轮对话中已经写过 memory 文件(`hasMemoryWritesSince` 检测),extractMemories 就跳过——因为主 agent 已经干了这事,再来一遍只会重复。

extractMemories 在每轮对话结束后触发,是秒级的。但还有一个更大时间尺度的问题:记忆会随着时间碎片化。今天存一条、明天存一条,过一个月回头看,有的条目过时了、有的互相矛盾、有的其实是同一件事分两条存了。人的大脑有一个自然过程来处理这件事——REM 睡眠阶段会重放白天的经历、巩固重要记忆、清除无用信息。Claude Code 里对应的机制叫 autoDream(`src/services/autoDream/autoDream.ts`),源码注释里直接叫它 "dream"。

为什么不是每次 session 结束都跑一次整合?因为 dream 的开销很大——它要读所有现有的记忆文件、grep 历史 session 的 transcript、然后做合并和裁剪,整个过程消耗大量 token。而且如果记忆还没积累到一定量就跑,产出的增量很少,白花钱。所以 autoDream 有两个触发条件:距离上次整合超过 24 小时,并且中间至少积累了 5 个 session。两个条件同时满足才触发——这是在"整合够频繁以保持记忆新鲜"和"不浪费 token"之间的平衡。源码注释还特别标注了 gate 的检查顺序:

Gate order (cheapest first):1. Time: hours since lastConsolidatedAt >= minHours (one stat)2. Sessions: transcript count with mtime > lastConsolidatedAt >= minSessions3. Lock: no other process mid-consolidation

autoDream 每轮对话结束时都会判断要不要触发,但绝大多数时候条件不满足,白跑一遍。为了让这个"白跑"尽可能便宜,检查顺序按开销从低到高排列:第一步只读一次锁文件的修改时间(一次文件系统 stat 调用,微秒级),看距上次整合是否超过 24 小时——如果没超过,直接返回,后面的步骤都不用跑。只有时间条件满足了,才进入第二步:遍历 transcript 目录,数有多少个 session 文件的修改时间晚于上次整合——这一步要 readdir + 逐文件 stat,比第一步贵一个数量级,但也只在时间条件通过后才执行。两个条件都满足了,最后一步是去抢一把文件锁(写入锁文件的 mtime),防止用户同时开了多个 session 导致重复触发——如果锁已经被别的进程持有,就放弃。这整个设计就是注释里说的 cheapest first:用最便宜的检查最先排除掉不需要跑的情况,只有前面的便宜检查全部通过了,才去做贵的操作。

触发后它启动一个四阶段流程(定义在 `src/services/autoDream/consolidationPrompt.ts`):首先 Orient——读 memory 目录现有的所有文件,看看当前记忆的全貌是什么;然后 Gather——从历史 session 的 transcript 中搜索新的有价值的信息(用 grep,不全量读取);接着 Consolidate——把新信号合并到已有的 topic 文件中,修正过时的事实,把相对日期转成绝对日期("昨天" → "2026-03-05",否则一个月后这条记忆就无法理解了);最后 Prune——更新 MEMORY.md 索引,删除过时指针,确保索引保持简洁。

整个过程也是一个 fork 出来的 agent 执行的,权限和 extractMemories 一样——只读 + memory 目录写。它有一把锁(`tryAcquireConsolidationLock`),防止多个 session 同时触发 dream。如果用户觉得 dream 跑太久了,可以从 UI 里 kill 掉,lock 会被回滚,下次条件满足时重新触发。

到这里,个人的记忆链路已经完整了:

写入(主动 + extractMemories 补漏)→ 存储(MEMORY.md 索引 + topic 文件)→ 整合(autoDream 定期清理)→ 召回(启动时注入 + findRelevantMemories 按需加载)。

但个人记忆只解决了单人场景。如果一个团队在同一个仓库里协作呢?张三告诉他的 Claude "我们的 CI 不支持 Python 3.8",李四的 Claude 不知道这件事。每个人的 memory 目录是隔离的,团队知识没有共享渠道。

为了解决这个问题,Claude Code 在个人记忆目录下开了一个 `team/` 子目录:`memory/team/`。结构和个人记忆完全一样——有 MEMORY.md 索引,有 topic 文件。区别在于它通过 Anthropic 的 API 做云端双向同步。本地编辑了 team memory,watcher 会检测到变化,计算 delta(只上传内容 hash 变化的文件),push 到服务器。定期 pull 时用 ETag 做 conditional GET(客户端告诉服务端“我上次拿到的版本标识是 X”,服务端如果内容没变就回复 304 Not Modified,不传内容——这样避免每次都下载全量数据),没变化就跳过。

冲突怎么办?当两个人同时改了同一条记忆时,系统需要决定保留谁的版本。一种做法是三方 merge(拿到“上次公共版本”和“双方各自的改动”,自动合并成一个新版本——git 对代码就是这么做的),但 Agent 产生的记忆是自然语言段落,不像代码那样有精确的行级语义,自动合并基本不可能做对。另一种做法是 last-write-wins(谁最后写入就保留谁的),但这意味着两个人同时编辑时,后写入的那个会直接覆盖先写入的,先写的那份修改被静默丢弃且无法恢复。Claude Code 选的策略是 server wins。`src/services/teamMemorySync/index.ts` 的注释定义了这个同步语义:

Sync semantics:- Pull overwrites local files with server content (server wins per-key).- Push uploads only keys whose content hash differs from serverChecksums (delta upload). Server uses upsert: keys not in the PUT are preserved.- File deletions do NOT propagate: deleting a local file won't remove it from the server, and the next pull will restore it locally.

也就是说,pull 时以服务端为准;push 只传有变化的文件。还有一个不太直觉的设计:删除不会同步。你在本地删了一个 team memory 文件,这个删除动作不会 push 到服务端,下次 pull 时服务端还存着那个文件,会把它重新拉回你本地。为什么不同步删除?因为在团队协作场景下,一个人的"删除"可能是另一个人还需要的记忆。如果删除能传播,张三误删了一条团队记忆,所有人下次 pull 都丢了。不传播删除相当于一个天然的防误删机制——最坏情况只是"已经没用的记忆还留在服务端占位",不会出现"有用的记忆被别人干掉了"。那真想清理过时的团队记忆怎么办?不是靠删文件,而是靠 autoDream 的 Prune 阶段把内容合并或清空——记忆的生命周期管理走整合流程,不走文件系统的删除指令。如果两边同时改了同一个 key,先 push 成功的那个留下来,后 push 的那个会遇到 412 冲突(HTTP 412 Precondition Failed——服务端发现你 push 时基于的版本已经过时了,别人在你之前改过了,所以拒绝你的写入),此时客户端会重新 fetch 服务端最新的 checksums,基于新版本重新计算 delta,再试一次。

这套同步的安全防御很强(可能因为是付费用户的特权):文件路径有 symlink 检测(`realpathDeepestExisting` 会逐层 realpath 验证,防止有人在 team 目录里放一个指向 `~/.ssh` 的符号链接)、有 path traversal 防御(null byte、URL encoding、Unicode normalization 全部在 `sanitizePathKey` 中检查)、有 secret scanner 用 gitleaks 模式扫描,含凭证的文件不会被上传。需要 OAuth 认证才能同步——说明 Team Memory 是面向 Anthropic 付费用户的协作特性,不是开放 API。

Agent 面对一条记忆时,不仅要判断"该不该存",还要判断"存到 private 还是 team"。如果让 agent 每次都自己推断应该存哪里,判断会不稳定——同样一条信息,这次存 private,下次可能存 team。所以源码 `memoryTypes.ts` 的 `TYPES_SECTION_COMBINED` 中,在每种记忆类型的定义里直接用 `<scope>` 标签给出了明确的默认建议:user 类型永远是 private(`always private`——我不想让别人知道我喜欢怎样的回复风格);feedback 类型默认 private,只有明确是团队级别的约定才存 team(`default to private. Save as team only when the guidance is clearly a project-wide convention`);project 类型偏向 team(`private or team, but strongly bias toward team`——比如"CI 不支持 Python 3.8"这种每个人都该知道的事);reference 类型通常是 team(`usually team`)。这样 agent 不需要每次从头推理,看一眼 scope 标签就知道默认往哪放。

再说另一个隔离问题。Claude Code 里可以用 `/agent` 命令创建专用的 sub-agent——比如一个专门跑测试的 agent、一个专门做 code review 的 agent。这些 sub-agent 如果共享主 agent 的记忆,会互相干扰。测试 agent 记住的"这个模块的 mock 要这样写",对 code review agent 可能是噪音。

所以 sub-agent 有自己独立的记忆空间,叫Agent Memory,路径是 `~/.claude/agent-memory/<agentType>/`。它有三种 scope:user scope 存在 `~/.claude/` 下,跨所有项目通用;project scope 存在 `.claude/agent-memory/` 下,可以入 VCS(Version Control System,即 git 这类版本控制系统)跟团队共享;local scope 存在 `.claude/agent-memory-local/` 下,不入版本控制。选哪种取决于这条记忆的适用范围——一条"总是用 pytest 跑测试"的记忆是 user scope 的(哪个项目都适用),一条"这个项目的 fixture 在 conftest.py 里"是 project scope 的。Agent Memory 和主记忆系统完全隔离——`isAgentMemoryPath()` 单独判断,路径不交叉。

最后说一个解决完全不同问题的机制。前面的所有记忆都是跨 session 的——这轮对话存的东西,下次开新 session 还在。但有一个场景只发生在当前 session 内:对话太长了,context window 要爆了,Claude Code 会做 auto-compact——压缩上下文,把老消息丢掉。丢掉之后,它可能会忘记这个 session 中间已经讨论过的东西。

为了解决这个问题,有一个叫 Session Memory 的机制(`src/services/SessionMemory/sessionMemory.ts`)。它在当前 session 内维护一份结构化的 markdown 笔记(`session-memory-*.md`),记录这个 session 里发生了什么:当前在做的任务、修改了哪些文件、遇到了什么错误、工作流走到了哪一步。这个笔记不是每轮都更新——它有触发条件:context window 的 token 数增长超过 5000,并且中间至少有 3 次 tool call。两个条件同时满足时,fork 一个 agent 出来更新这份笔记。更新后,如果后续发生 compact,被压缩掉的内容中的关键信息已经沉淀在这份笔记里了,不会真正丢失。

Session Memory 和前面所有记忆有一个根本区别:它不跨 session。为什么不把它也持久化?因为它记的是"当前任务的临时状态"——走到哪一步了、哪些文件动过了。这些信息在 session 结束后就过时了。如果持久化,下次启动时会加载一堆上次的残留状态,反而干扰新任务。真正值得跨 session 保留的信息,已经被 extractMemories 和 autoDream 提取到个人记忆里了——Session Memory 不需要承担长期记忆的职责,它只做 session 内部的容错。

回过头来看整个体系:Claude Code 在处理"记住事情"这件事上,针对不同的遗忘场景给出了不同粒度的解决方案——对话中 agent 主动记、对话后 extractMemories 补漏、长时间尺度 autoDream 整合、团队间 Team Memory 同步、sub-agent 间 Agent Memory 隔离、session 内 Session Memory 防 compact 丢信息。每一个机制的出现,都是因为前一个机制存在边界——而解决边界问题的方式不是把前一个机制的覆盖范围硬撑大,而是引入一个新的机制来补上那个缺口,让整个体系闭环

召回侧也遵循同样的思路:不是把所有记忆一股脑塞进 prompt,而是分层加载——MEMORY.md 索引每次都带上(因为它足够短),具体 topic 文件只在相关时按需召回(findRelevantMemories 用 Sonnet 判断相关性),Session Memory 只在 compact 时起作用。