Claude Code 记忆系统深度解析
基于源码分析:
src/memdir/·src/services/extractMemories/·src/utils/attachments.ts·src/query/stopHooks.ts
一、从两种维度看记忆类型
Claude Code 的记忆系统由两个维度构成,理解这两个维度是理解整个系统的关键。
维度 A:内容语义类型(记忆"说的是什么")
| 类型 | 含义 | 衰减速度 |
|---|---|---|
user |
用户角色、目标、背景知识 | 极慢,几乎永不过期 |
feedback |
对 Claude 行为的纠正或认可 | 慢,偏好稳定 |
project |
进行中工作、决策、里程碑、截止日期 | 快,源码注释写了 "decay fast" |
reference |
外部系统资源指针 | 中,外部系统相对稳定 |
维度 B:存储范围类型(记忆"存在哪里")
前三种属于 CLAUDE.md 指令体系 (用户手写规则),后三种属于 AutoMem 体系(Fork Agent 自动维护)。
| 类型 | 体系 | 存储路径 | 跨项目 | 进版本控制 |
|---|---|---|---|---|
User |
CLAUDE.md | ~/.claude/CLAUDE.md |
✓ | ✗ |
Project |
CLAUDE.md | <project-root>/CLAUDE.md 或 .claude/CLAUDE.md |
✗ | ✓ |
Local |
CLAUDE.md | .claude/CLAUDE.md.local |
✗ | ✗ |
Managed |
CLAUDE.md | 外部注入(frontmatter 标记) | --- | --- |
AutoMem |
AutoMem | ~/.claude/projects/<hash>/memory/ |
✓ | ✗ |
TeamMem |
AutoMem | ~/.claude/projects/<hash>/memory/team/ |
✓ | ✗(同步) |
两个维度的关系
维度 A(内容语义)× 维度 B(存储范围)是正交的两个轴。AutoMem 是四种内容类型的主要载体 ------Fork Agent 提取记忆时,根据内容语义将其分类写入对应 topic 文件,文件名即反映类型(如 user_role.md、feedback_testing.md)。
写入决策逻辑(Fork Agent 内部):
- 判断内容语义类型(user / feedback / project / reference)
- 判断作用域:
user类型始终写私有 AutoMem;其余类型若属团队共识则写 TeamMem,否则写私有 AutoMem - 写入对应 topic 文件 + 更新同目录的
MEMORY.md索引
二、完整闭环:一次对话的生命周期
先看完整时序图,再逐阶段展开。
各阶段设计意图
① 会话启动:为什么要同时加载两套内容?
MEMORY.md(AutoMem)和 CLAUDE.md 是两套完全独立的体系,分别承载不同的职责:前者是 Claude 自己从历史对话中学到的东西,后者是用户主动告诉 Claude 的规则。两者都在会话启动时全量加载,是因为它们都属于"无论聊什么都应该知道的背景"------不做相关性筛选,直接进 System Prompt。
② 记忆预取:为什么不等预取完成再开始推理?
预取(prefetch)和工具执行是并发的。Claude 收到用户消息后,立刻启动记忆预取,同时开始执行工具调用。工具调用本身有 I/O 等待时间,预取就在这段时间里悄悄完成。等到第二轮 API 请求时,记忆已经就绪,可以一起发给模型。这样用户感知到的延迟只有推理本身,记忆检索的耗时被完全隐藏在工具执行的等待里。
③ 语义评分:为什么 Sonnet 只看 description 而不看文件内容?
扫描阶段只读每个文件的前 30 行(frontmatter),把所有文件的 description 字段汇总成一个清单,一次 sideQuery 就完成选择。如果让 Sonnet 看完整文件内容,就需要先把所有文件都读进来,既慢又占 token。description 字段正是为此设计的------写记忆时要求 description 精准描述内容,让评分阶段能在不读正文的情况下准确判断相关性。
④ 记忆注入时机:为什么记忆是在两次 API 请求之间注入的?
Claude Code 的核心是一个 while(true) 循环,每次循环对应一次 API 请求。推理(thinking)和响应(response)是同一次 API 请求里流式输出的两个 block,无法在中间插入新内容。因此记忆只能在两次请求之间注入------第一轮请求触发工具调用,工具执行期间预取完成,第二轮请求时记忆和工具结果一起发给模型。对于没有工具调用的简单问答,第一轮直接 break,可能拿不到记忆。
⑤ 记忆提取:为什么用 fire-and-forget 而不是等待结果?
提取记忆只需要副作用(写文件),不需要返回值。用户已经拿到响应,主线程没有理由继续等待。fire-and-forget 让主线程立刻释放,提取在后台异步完成。同时,系统内置了互斥和排队机制:若主 Agent 本轮已自行写过记忆,Fork Agent 跳过;若上一次提取仍在运行,新请求进入等待队列,完成后触发 trailing run。这样既不阻塞用户,也不会产生并发写冲突。
⑥ 游标机制:为什么要记录上次提取到哪条消息?
每次对话结束后,Fork Agent 只需要分析"上次提取之后新增的消息",而不是重新扫描整个对话历史。lastMemoryMessageUuid 游标记录了上次提取的边界,确保增量处理。没有游标,每次提取都要重新分析全部历史,既浪费 token,又可能产生重复记忆。
§1 会话启动 --- System Prompt 构建(同步阻塞)
scss
Claude 主线程
├─ loadMemoryPrompt() → 读取 MEMORY.md(≤200行/25KB)→ 注入 System Prompt
└─ getMemoryFiles() → 读取 CLAUDE.md / rules/*.md(cwd 向上遍历)→ 注入 User Context
两个函数的本质区别:
loadMemoryPrompt() |
getMemoryFiles() |
|
|---|---|---|
| 读取内容 | ~/.claude/.../MEMORY.md(AutoMem 摘要索引) |
CLAUDE.md / rules/*.md(项目目录树) |
| 谁写入 | Extract Agent 自动维护 | 用户手动编辑 |
| 一句话 | Claude 自己记住的东西 | 用户告诉 Claude 的规则 |
§2 用户发消息 --- 相关记忆并发预取
用户发消息后,startRelevantMemoryPrefetch() 立即并发启动,不阻塞主对话响应。
函数名拆解:
start--- 立即启动,不等结果Relevant--- 按相关性筛选,不是全量加载Prefetch--- 预取,利用工具执行的 I/O 等待时间
6 步检索流程:
- 扫描
scanMemoryFiles():递归读取 memory 目录所有.md,每文件只读前 30 行(frontmatter),按 mtime 倒序,最多 200 个 - 过滤 :去除本 session 已注入过的文件(
alreadySurfaced去重) - 格式化清单
formatMemoryManifest():每行格式- [type] filename (时间戳): description - Sonnet 评分
sideQuery():独立侧边请求,Sonnet 看到的只有文件名和 description,看不到文件内容 ,输出 JSON{"selected_memories": [...]}最多选 5 个 - 防幻觉校验 :返回的文件名与
validFilenames做交集,过滤掉不存在的文件名 - 读内容:每文件 ≤200 行且 ≤4KB,mtime > 1 天附加过期警告
为什么评分只用 description 而不用文件内容?
性能权衡:扫描阶段只读 frontmatter,把所有文件的 description 汇总成一个清单发给 Sonnet,一次 sideQuery 就完成选择。description 字段正是为此设计的------写记忆时要求 description 精准描述内容,让评分阶段能准确判断相关性。
各层限制:
| 限制项 | 数值 |
|---|---|
| 每轮最多召回文件数 | 5 个 |
| 每个文件最大行数 | 200 行 |
| 每个文件最大字节 | 4 KB |
| 每轮最大注入量 | ≈20 KB |
| session 累计上限 | 60 KB |
| 扫描文件上限 | 200 个 |
§3 推理 --- 记忆注入时机
记忆以 relevant_memories attachment 形式追加为 <system-reminder>,在用户消息之前呈现给 Claude。
关键细节:记忆注入发生在两次 API 请求之间,不是推理和响应之间。
Claude Code 的核心是一个 while (true) 循环:
| 轮次 | 记忆状态 |
|---|---|
| 第 1 轮(用户发消息,prefetch 刚启动) | 无记忆 |
| 工具执行期间(prefetch 在后台完成) | 注入记忆 |
| 第 2 轮(工具结果 + 记忆一起发给模型) | 有记忆 |
| 无工具的简单问答(第 1 轮直接 break) | 可能无记忆 |
推理(thinking)和响应(response)是同一次 API 请求里流式输出的两个 block,无法在中间插入新内容。
§4 对话结束 --- Stop Hook 记忆提取(fire-and-forget)
两套独立的提取机制并行运行:
Auto Memory 提取 executeExtractMemories():
- 每轮对话结束后,
handleStopHooks()无条件触发 - 互斥:若主 Agent 本轮已自行写过记忆文件,Fork Agent 跳过
- 并发保护:若上一次提取仍在运行,新请求 stash 到
pendingContext,完成后触发 trailing run
Session Memory 提取 extractSessionMemory():
- 触发条件:context window 累计 token ≥ 10,000(首次),之后每增长 ≥ 5,000 token 且满足工具调用或对话断点条件
- 用途:结构化会话摘要,用于 Compact 替代
Fork Agent 提取流程(5 步):
- 预扫描现有 topic 文件,生成 manifest 注入提示词(避免重复创建文件)
- 构建提示词(含新消息数量、已有记忆清单、四类内容定义、写入规则)
runForkedAgent(maxTurns=5):与主对话共享 System Prompt 前缀(命中 Prompt Cache),工具权限受限(只读 + 仅限记忆目录的写入)- 写入 topic 文件(
user_*.md/feedback_*.md/project_*.md/reference_*.md)+ 更新MEMORY.md索引 - 推进游标
lastMemoryMessageUuid,下次提取只处理新增消息
三、文件格式详解
指令类(CLAUDE.md 体系)--- 纯 Markdown,用户手写
markdown
# ~/.claude/CLAUDE.md
不要使用 any 类型。
永远先写测试。
回复用中文。
@./docs/coding-standards.md
支持 @include 引用其他文件,从 cwd 向上遍历目录树,越近优先级越高。
AutoMem 类(topic 文件体系)--- 带 frontmatter,自动维护
topic 文件格式:
markdown
---
name: 回复风格偏好
description: 不要在回复末尾总结刚做了什么
type: feedback
---
不要在回复末尾用"总结"段落重复已完成的操作。
**Why:** 用户说"我能看到 diff,不需要你再重复"
**How to apply:** 所有回复结尾直接停,不加总结段
MEMORY.md 索引格式:
markdown
- [用户角色](user_role.md) --- 研发架构师,侧重系统设计
- [回复风格](feedback_style.md) --- 不要在末尾总结
- [项目状态](project_auth.md) --- auth 重构由合规驱动
两套体系对比:
| 维度 | 指令类(CLAUDE.md) | AutoMem 类(topic 文件) |
|---|---|---|
| 文件格式 | 纯 Markdown,无 frontmatter | 带 YAML frontmatter |
| 谁写 | 用户手写 | Extract Agent 自动生成 |
| 加载时机 | 每次会话启动,全量加载 | MEMORY.md 必加载;topic 文件按相关性每轮最多召回 5 个 |
| 跨项目 | User 类全局生效;Project/Local 限当前项目 | 始终项目隔离 |
四、检索 vs 提取:并发机制对比
| 维度 | 检索记忆(Retrieval) | 提取记忆(Extraction) |
|---|---|---|
| 触发点 | 用户发消息时 | 对话结束时 |
| 并发机制 | Disposable 对象 + 非阻塞轮询 | fire-and-forget Promise |
| 取消机制 | [Symbol.dispose]() 显式中止 |
feature gate 控制 |
| 错误处理 | .catch() 返回空数组 |
错误被吞掉,不影响主对话 |
| 何时完成 | 工具执行期间 | 对话结束后 |
检索为什么非阻塞轮询?
- 不等 prefetch 完成就开始推理 → 响应延迟最小
- 工具执行期间 prefetch 在后台完成 → 利用 I/O 等待时间
- 多轮循环给 prefetch 多次机会 → 最终一定会消费到
提取为什么 fire-and-forget?
- 不需要结果,只需要副作用(写文件)
- 节流 + 互斥 + 排队 → 防止重复和冲突
为什么用 Fork Agent 而不是 sideQuery?
sideQuery:轻量级评分,Sonnet 够用Fork Agent:完整推理 + 执行,需要 Claude 的完整能力 + 工具权限- Fork 共享 prompt cache → 复用主线程的缓存,加速且省 token
五、新鲜度机制
| 文件年龄 | 处理 |
|---|---|
| ≤ 1 天 | 直接注入,无提醒 |
| > 1 天 | 附加:"This memory is X days old. Memories are point-in-time --- verify against current code before asserting as fact." |
六、存储路径速查
| 文件 | 路径 | 何时读取 | 何时写入 |
|---|---|---|---|
MEMORY.md |
~/.claude/projects/<hash>/memory/MEMORY.md |
每次会话启动 | Extract Agent 完成后 |
topic *.md |
~/.claude/projects/<hash>/memory/ |
每轮对话 prefetch(≤5个) | Extract Agent(按内容语义类型分文件) |
CLAUDE.md |
项目根 / 父目录 / ~/.claude/CLAUDE.md |
每次会话启动 | 用户手动编辑 |
team/*.md |
~/.claude/projects/<hash>/memory/team/ |
同 topic 文件(TEAMMEM flag 开启时) | Extract Agent(scope=team) |
session-memory |
~/.claude/projects/<hash>/session-memory/ |
auto-compact 触发时 | Session Memory subagent |
七、设计哲学
整个记忆系统体现了几个核心设计原则:
1. 不阻塞主流程:检索和提取都是异步的,用户感知到的延迟只有推理本身。
2. 精准而非全量:每轮最多注入 5 个文件,用 Sonnet 做语义评分而不是关键词匹配,宁缺毋滥。
3. description 是一等公民:评分阶段 Sonnet 只看 description,这意味着写记忆时 description 的质量直接决定记忆能否被正确召回。
4. 两套体系各司其职:CLAUDE.md 是用户的意图表达,AutoMem 是 Claude 的学习积累,两者互不干扰,加载路径也完全分离。
5. 游标机制保证增量 :lastMemoryMessageUuid 确保每次提取只处理新增消息,避免重复分析历史内容。