最近做 Agent 的同学应该大部分都有研读 Claude Code 泄漏的源码,网上出了各种 AI 加持下的各种解读,教程,细节分析,甚至包括换了一种语言实现的版本,如 Python,Go,Rust 等等。感觉有点「一鲸落,万物生」的感觉。
之前学习了 Claude Code 的系统提示词,写了一篇关于记忆系统的提示词。今天我们再深入其源码,看看其实现的细节。
从其源码来看,
Claude Code 这套记忆系统把几类完全不同的问题拆开处理了:长期记忆、当前轮相关记忆、会话压缩摘要、子代理独立记忆。和 OpenClaw 不同,OpenClaw 使用了统一的 Memory Service,加上一个向量库做检索,Claude Code 走的是另一条路:文件系统优先,分层清晰,召回时机明确,代价可控。
1. Claude Code 到底要记了什么
在 memoryTypes.ts#L14-L31 里,长期记忆的类型是的四类:
-
user -
feedback -
project -
reference
这四类东西有一个共同点:它们都不容易从当前代码状态直接推导出来。用户习惯、项目背景、团队约束、外部系统入口,这些信息不写下来,下次对话就丢了。反过来,代码结构、文件路径、Git 历史、当前临时任务,这些内容源码里明确要求不要进长期记忆,因为它们本来就有权威来源。memoryTypes.ts#L183-L195
记忆系统只该保存「代码外的信息」和「会跨轮次继续影响决策的信息」。以编程为例,当我们把代码事实也塞进去,后面一定会出现双份真相。你会遇到一个很尴尬的局面:代码说 A,memory 说 B,模型开始摇摆。
2. 四层分工
第一层是 auto memory / team memory。这是长期记忆,负责跨会话保存信息。目录逻辑在 paths.ts#L79-L259 和 teamMemPaths.ts#L66-L94。
第二层是 relevant memories。这一层不关心长期存储,它只负责一件事:用户当前这一问,应该把哪几条历史记忆临时塞进上下文。入口在 findRelevantMemories.ts#L39-L141 和 attachments.ts#L2197-L2425。
第三层是 session memory。这层服务的是长会话压缩,不负责跨会话记忆。位于当前 session 下的 summary.md。sessionMemory.ts#L183-L350
第四层是 agent memory。子代理如果要持久化自己的经验,可以放 user/project/local 三种 scope 的独立目录。agentMemory.ts#L12-L177
这四层拆开之后,很多设计选择就顺了:
-
长期记忆用文件,便于审计和手工修复 -
当前轮召回走轻量检索,减少 prompt 污染 -
长会话压缩用单独 summary,避免每次 compact 都从头总结 -
子代理隔离状态,减少串味
3. 长期记忆为什么选文件
Claude Code 的长期记忆是使用的 Markdown 文件。每条记忆一个文件,外加一个 MEMORY.md 入口索引。这部分规则在 memdir.ts#L199-L316 里写得很明白。
源码里的写入约束是这样的:
'## 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}\`.`,
实现位置见 memdir.ts#L219-L230。
还有有三个工程判断。
第一,MEMORY.md 只是索引,不承载正文。如果把所有记忆都堆到一个大文件里,前期简单,后期灾难。Claude Code 从一开始就做拆分,每条记忆单文件,这样更新一条信息时不会引起全量重写。
第二,frontmatter 强制有 description 字段,这个字段后面要参与召回。很多团队做知识条目,只写正文,不写检索摘要,最后靠 embedding 硬扛。Claude Code 反过来,它要求记忆写入阶段就产出一条高质量摘要。召回质量在写入那一刻就埋下去了。
第三,完全基于文件系统,调试成本低。你可以直接去目录里看文件,团队同步时还能走 Git 或远端同步链路。数据库方案最大的问题不在性能,在可观察性。出了问题你要查 schema、查索引、查 embedding 版本、查写入日志,排障很慢。
文件方案当然也有代价。文件一多,目录扫描成本会上升;MEMORY.md 入口过长也会逼近 prompt token 上限。Claude Code 后面靠动态召回机制兜住了这个问题,这个设计是连起来看的。
4. 写入链路
它怎么把记忆真正落盘
4.1 主模型直接写
长期记忆的第一条写入链路,是主模型自己写。系统 prompt 里已经告诉它记忆目录在哪、允许写什么、怎么写文件、什么时候写。
loadMemoryPrompt() 会把 memory rules 注入系统提示词,入口在 [loadMemoryPrompt] memdir.ts#L419-L507。这一段 prompt 并没有替模型做决策,它只是把写入协议放进脑子里:目录、类型、索引格式、读取时机、失效校验。
这意味着 Claude Code 对模型的假设很明确:模型可以自己判断「这条信息值不值得保存」,然后调用写文件工具去落盘。写入不是一个外置 API,写入就是普通文件操作。
这条路有个好处:反馈延迟很低。用户刚说完「记住这个偏好」,主模型当轮就能写,不用等后台任务。
4.2 后台抽取器补写
如果主模型这一轮没动手写,系统会在 turn end 触发后台抽取器。stop hook 在 stopHooks.ts#L141-L156:
if (
feature('EXTRACT_MEMORIES') &&
!toolUseContext.agentId &&
isExtractModeActive()
) {
void extractMemoriesModule!.executeExtractMemories(
stopHookContext,
toolUseContext.appendSystemMessage,
)
}
真正逻辑在 extractMemories.ts#L329-L567。
这条链路里最重要的一段判断是:
if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) {
logForDebugging(
'[extractMemories] skipping — conversation already wrote to memory files',
)
...
return
}
位置见 extractMemories.ts#L345-L360。
它防的是双写。主模型已经写过,后台抽取器就别再重做一遍。很多系统做异步归档时忘了这件事,最后要么生成重复记忆,要么覆盖用户刚刚确认的内容。
后台抽取器的权限也非常收敛。createAutoMemCanUseTool() 明确规定,只准:
-
Read / Grep / Glob -
只读 Bash -
memory 目录内的 Edit / Write
实现见 [createAutoMemCanUseTool] extractMemories.ts#L166-L222。
extractor 的职责:它只做归档,不许顺手验证代码,不许顺手修改业务文件,不许借机跑工具链。权限如果不锁死,后台代理迟早会从归档器膨胀成第二个主代理。
4.3 KAIROS 下的写法
KAIROS 模式更有意思。它不要求模型实时维护 MEMORY.md,新记忆先按天追加到日志文件里。规则在 [buildAssistantDailyLogPrompt] memdir.ts#L318-L370。
"This session is long-lived. As you work, record anything worth remembering by **appending** to today's daily log file:",
` \`${logPathPattern}\` `,
'Write each entry as a short timestamped bullet. Create the file (and parent directories) on first write if it does not exist. Do not rewrite or reorganize the log — it is append-only. A separate nightly process distills these logs into `MEMORY.md` and topic files.',
这条策略很适合长驻 Agent。会话存活时间长时,频繁重写 topic files 和索引很贵,冲突也多。先写 append-only 日志,夜间再蒸馏,吞吐更稳,模型也更不容易在白天工作时把记忆目录写乱。
5. 召回链路
它怎么决定哪段记忆该进来
Claude Code 的召回要分成两种看。
一种是静态注入,也就是固定随上下文加载的那些东西。另一种是动态召回,根据当前 query 临时挑选最相关的记忆文件。
很多系统只做前者,结果上下文越来越肥。很多系统只做后者,结果基本行为约束丢了。Claude Code 两条都做。
5.1 静态注入
getUserContext() 会构造一个 claudeMd 字段,位置在 context.ts#L155-L188。
核心调用是:
const claudeMd = shouldDisableClaudeMd
? null
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
getMemoryFiles() 的实现很长,在 claudemd.ts#L790-L1075。它会统一加载:
-
Managed 指令 -
User 指令 -
Project 指令 -
Local 指令 -
AutoMem 的 MEMORY.md -
TeamMem 的 MEMORY.md
然后 getClaudeMds() 把这些文件串成提示词内容,[getClaudeMds] claudemd.ts#L1153-L1195。
它给模型一个稳定的全局工作框架。它会知道项目规则、用户偏好、团队共享记忆索引。它适合放那些「大方向会持续生效」的内容。
5.2 动态召回
静态注入解决不了所有问题。长期记忆正文一多,全部塞进 prompt 代价太高。Claude Code 的处理方式,是每轮用户发言后启动一个相关记忆预取。
入口在 query.ts#L297-L304:
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
state.messages,
state.toolUseContext,
)
这个预取不会阻塞主流程。到后面条件满足时再消费,query.ts#L1595-L1617。
真正检索逻辑在 attachments.ts#L2197-L2425。它会先决定搜索哪个目录:如果用户显式提到某个 agent,就搜 agent memory;否则搜 auto memory。
然后调用 [findRelevantMemories] findRelevantMemories.ts#L39-L141。
这里最有意思的点在于,它没有用向量库。它的步骤是:
-
scanMemoryFiles()扫 memory 目录里的.md文件,读 frontmatter,产出一个 manifest
见 memoryScan.ts#L35-L94 -
把
用户 query + manifest发给一个 sideQuery 模型
见 findRelevantMemories.ts#L77-L141 -
让这个模型返回最多 5 个文件名
-
再去读取这些文件正文,截断到限定行数和字节数
见 [readMemoriesForSurfacing] attachments.ts#L2280-L2333
这套方案的好处:
-
没有 embedding 构建成本 -
没有索引维护复杂度 -
manifest 很小,side query 很快 -
召回逻辑对开发者可见,容易调
缺点也明确。召回质量强依赖 frontmatter 的 description。写入时 description 写差了,后面召回一定差。
6. 记忆怎么进入上下文
不是一处注入,是四处入口
很多人看 Agent 源码时老在问「上下文是在什么地方拼进去的」。这个问题本身就有误导性。Claude Code 里记忆的注入入口不止一个。
6.1 system prompt 入口
第一处是 system prompt。这里进来的内容主要是「记忆系统的使用规则」,比如什么时候读、什么时候存、什么时候验证失效。对应实现是 prompts.ts#L492-L527 调 loadMemoryPrompt()。
这是行为层指令。
6.2 user context 入口
第二处是 getUserContext() 构造的 claudeMd。这里进来的是 CLAUDE.md、rules、MEMORY.md 这种比较稳定的文本。context.ts#L155-L188
这是稳定背景层。
6.3 attachment 入口
第三处是 relevant memory attachment。被召回的正文不会直接拼到 claudeMd,而是先变成 attachment,再由 messages.ts#L3743-L3756 包装成 <system-reminder>。
return wrapMessagesInSystemReminder(
attachment.memories.map(m => {
const header = m.header ?? memoryHeader(m.path, m.mtimeMs)
return createUserMessage({
content: `${header}\n\n${m.content}`,
isMeta: true,
})
}),
)
这意味着这些记忆是临时的、按轮次加载的、带 freshness header 的系统提醒。它的优先级和普通用户消息不同。
6.4 compact summary 入口
第四处是 session memory compact。上下文过长后,系统会把会话前半段替换为一条 summary message,summary 内容来自 summary.md 的裁剪版。sessionMemoryCompact.ts#L437-L503
这是上下文续命层。
四处入口分工以后,就能看明白为什么 Claude Code 的行为相对稳定:规则、稳定背景、临时相关信息、压缩摘要,各走各的通道,互相不抢角色。
7. 真正的压缩发生在哪里
很多人一听「记忆系统」,第一反应是长期 memory 压缩。Claude Code 里最成熟的压缩逻辑,实际上落在 session memory 上。
前面说过,session memory 是 summary.md,它本身就是会话结构化摘要。维护逻辑在 sessionMemory.ts#L272-L350。
当上下文真的不够时,系统优先尝试 trySessionMemoryCompaction(),sessionMemoryCompact.ts#L514-L619。
它的动作顺序:
-
先确认 session memory 功能和 compact 功能都开着 -
等待正在进行中的 session memory 抽取结束 -
读取 summary.md -
如果还是空模板,放弃,退回传统 compact -
计算需要保留的 recent messages 窗口 -
用 summary.md的裁剪版构造 compact summary -
组装 boundary + summary + recent messages + attachments + hooks
这里的「保留 recent messages」特别关键。作者没有图省事把所有旧消息都抹掉,而是保留一段最近窗口。窗口大小由 [DEFAULT_SM_COMPACT_CONFIG] sessionMemoryCompact.ts#L56-L66 定义,默认:
-
minTokens = 10000 -
minTextBlockMessages = 5 -
maxTokens = 40000
保留窗口的计算在 [calculateMessagesToKeepIndex] sessionMemoryCompact.ts#L324-L397。
这个策略解决的问题是:摘要永远会损失细节,最近一段工作现场最好保留原始消息,模型续做时不至于失真。要是所有内容都只剩 summary,模型会失去工具调用上下文、局部错误信息、最近的计划变更。
更细的一层防御在 adjustIndexToPreserveAPIInvariants()。 sessionMemoryCompact.ts#L232-L314
它干的事情很硬核,也很必要:如果最近保留窗口里出现了 tool_result,系统必须把匹配的 tool_use 也补进来;如果 assistant 消息因为流式输出被拆成多个共享 message.id 的块,thinking 和 tool_use 也要一起补齐。否则 compact 后发给 API 的消息链会断,直接报错。
这一段代码说明作者踩过坑,或者至少认真想过 API 侧不变量。很多开源 Agent 框架在消息压缩这里写得很草,最后线上 bug 都长一个样:tool result 找不到 parent,thinking 丢了,message 合并失败。
8. session memory 自己也会被裁剪
就算 summary.md 已经是摘要,compact 时还会再做一次 section 级裁剪。逻辑在 [truncateSessionMemoryForCompact] prompts.ts#L249-L295。
过程如下:
-
按 # section拆段 -
每个 section 允许的大小用 MAX_SECTION_LENGTH * 4粗略换算成字符数 -
超过就按行保留前半部分 -
最后插入 [... section truncated for length ...]
实际截断函数见 [flushSessionSection] prompts.ts#L298-L324。
这套逻辑谈不上优雅,语义理解也谈不上深入,但它有一个优点:非常稳。系统真的到了上下文极限时,保底截断总比把整个 compact 失败掉强。工程里很多时候要的是「退化可接受」,不是「完美压缩」。
然后 createCompactionResultFromSessionMemory() 把裁剪后的 session memory 包成 summary message。 sessionMemoryCompact.ts#L437-L503
这里还有一个细节:如果发生过裁剪,它会额外附一句话,告诉模型和人类完整 session memory 文件路径在哪。排障时你可以直接打开原始 summary.md,不用猜裁掉了什么。
9. KAIROS 的压缩逻辑和普通模式不一样
KAIROS 里还有另一种「压缩」,它压的不是当前上下文,而是长期事件流。
在 memdir.ts#L321-L349 里能看到,KAIROS 模式下白天写的是 append-only daily log。到夜间,/dream 流程会把这些日志蒸馏成 topic files 和 MEMORY.md。
这是另一类压缩:
-
输入是时间顺序日志 -
输出是主题化长期记忆
session memory compact 处理的是「上下文窗口」问题。KAIROS dream 处理的是「长期事件沉淀」问题。这两类压缩混在一起看会非常乱,源码里其实已经把它们分得很开。
10 小结
这套设计的工程代价与收益
10.1 一些值得学习的点
第一,分层彻底。长期记忆、当前轮召回、会话摘要、子代理记忆,各自有自己的存储形态和注入入口。系统复杂度是被隔离开的。
第二,文件优先。排查方便,审计方便,人工纠错方便。很多团队高估了数据库和向量库的必要性,低估了可观察性的重要性。
第三,动态召回走轻量 manifest + side query。对 CLI Agent 这种高频交互场景,这个方案的性价比很高。它把复杂度留给模型的小规模选择,而不是重型检索基础设施。
第四,压缩时保 recent window,并修补 tool_use/tool_result 不变量。这一点极少有团队一开始就写对。
10.2 一些代价
第一,frontmatter 的 description 质量变成关键依赖。这个字段一旦写烂,召回效果会大幅波动。它省掉了 embedding 的复杂度,也把一部分压力前置给写入质量。
第二,双通道写入意味着状态机会更复杂。主模型可以写,后台 extractor 也能写。虽然代码里有跳过逻辑,但这类架构天然比单通道更需要小心。
第三,session memory 的 section 截断是粗粒度的。它靠字符数近似 token,再按行截断,这属于保底工程,不属于精细压缩。能用,谈不上漂亮。
第四,MEMORY.md 仍然有索引容量压力。即便动态召回已经分担了很大一部分负担,入口索引的组织质量依然重要。
10.3 我们能用什么
如果把这套思路迁移到我们自己的 Agent 系统,可以借鉴(抄):
第一,先拆问题,再选技术。你要先决定自己在解哪件事:跨会话长期记忆、当前轮检索、超长对话压缩、团队共享经验。不要一上来就建一个统一 Memory API。
第二,先用文件,再考虑数据库。只要你的系统规模还没逼到那个份上,文件系统几乎总是更划算。它便宜、透明、好调试。很多团队用数据库,是因为觉得那样「更像正经系统」,这个判断没什么含金量。
第三,把召回质量的责任前移到写入阶段。Claude Code 用 description 做 manifest 检索这件事,给我的启发很大。与其指望后面靠复杂召回算法弥补,不如要求写入时就产出高质量摘要和类型信息。
如果你们团队正在做本地化的企业内 Agent,我甚至建议先抄一版这种架构原型:
长期记忆用 Markdown + frontmatter,当前轮召回走 manifest + 小模型筛选,会话压缩单独维护结构化 summary。三周内就能跑起来。比起一开始堆向量库、事件总线、关系数据库,这条路短得多。
11. 其它
Claude Code 的记忆系统没有神秘技术。它的难点不在某个单点算法,在边界控制和时机设计。什么时候写,写到哪里,什么时候读,读多少,压缩后保留什么,这些问题都比「用什么模型做召回」更重要。
如果你把这篇文章里的结论压成一句工程建议,那就是:
先把长期记忆、短期相关记忆、会话压缩拆开,再谈检索和存储。
源码入口可以优先读这几组文件:
-
长期记忆规则与路径:
[memdir.ts] [paths.ts] [memoryTypes.ts] -
记忆静态注入与动态召回:
[claudemd.ts] [attachments.ts] [findRelevantMemories.ts] -
会话记忆与压缩:
[sessionMemory.ts] [prompts.ts] [sessionMemoryCompact.ts] -
团队共享记忆:
[teamMemPaths.ts] [teamMemorySync/index.ts]
以上。