零、从第一性原理思考:记忆该怎么设计?
本节是基于源码的设计推导,不是源码本身的直接描述。每个问题先推理"应该怎样",再用源码印证"实际怎样"。
0.1 起点:LLM 天然没有记忆
想象你有一个特别厉害的助手,但他有个毛病:每天早上醒来都失忆。你每天来上班,都得重新告诉他"我是做后端的"、"项目周四冻结代码"、"别加没用的注释"。
这就是 LLM 的现状 —— 每次对话都是无状态的,上下文窗口用完即弃。要让它"记住"东西,就必须在外部建一套持久化机制。
那该怎么设计?沿着一条记忆的生命周期,自然会遇到四个问题:
0.2 问题链:写入 → 存储 → 检索 → 注入
1. 该记什么?(写入)
不是所有信息都值得记。代码长什么样、目录怎么组织、git 历史是什么 —— 这些随时可以通过 grep、git log、读文件获取,不需要记忆。
那什么才需要记?那些从当前项目状态推导不出来的上下文:你是谁、你的脾气、项目的非技术背景、外部资源在哪。
源码印证:memoryTypes.ts 开头注释 —— "Memories are constrained to four types capturing context NOT derivable from the current project state."(记忆被限制为四种类型,只捕获无法从当前项目状态推导出来的上下文。)
2. 怎么存?(存储)
记忆需要一个存储介质。它应该满足:用户能直接看到和编辑、不需要额外依赖、能和版本控制兼容。
源码印证:选择了文件系统 —— 每条记忆是一个带 frontmatter 的 Markdown 文件,存在~/.claude/projects/{slug}/memory/下,外加一个MEMORY.md做索引。
3. 怎么取?(检索)
记忆积累到几十上百条时,不可能全塞进上下文窗口。需要一个检索机制:快速扫描所有记忆的摘要,挑出和当前对话最相关的几条。同时,旧记忆可能已经过时,取用时需要提醒。
源码印证:用 Sonnet 做侧面查询选最多 5 条(findRelevantMemories.ts),超过 1 天的记忆附加新鲜度警告(memoryAge.ts)。
4. 怎么用?(注入)
选出来的记忆要放进提示词里,但放在哪?系统提示(角色定义)和消息列表(对话上下文)是 API 的两个不同字段,职责不同,不应该混在一起。
源码印证:操作指南走系统提示(systemPromptSection),具体规则走消息列表开头(prependUserContext),两条管道分离。
接下来的四章,按这四个阶段逐一展开源码实现。
一、写入:该记什么?
1.1 核心原则:只记"推导不出来的"
源码在 memoryTypes.ts 中定义了一份明确的排除清单:
- Code patterns, conventions, architecture, file paths, or project structure —— 这些可以通过读取当前项目状态推导出来
- Git history, recent changes, or who-changed-what —— git log / git blame 才是权威来源
- Debugging solutions or fix recipes —— 修复已经在代码里了,commit message 里有上下文
- Anything already documented in CLAUDE.md files
- Ephemeral task details: in-progress work, temporary state, current conversation context
能从代码、git、CLAUDE.md 推导出来的,都不该记。剩下的,按内容性质分成四种类型(memdir/memoryTypes.ts:14):
export const MEMORY_TYPES = [ 'user', // 你是谁——身份、专长、偏好 'feedback', // 你的脾气——对 Claude 工作方式的修正和认可 'project', // 项目的事——非技术背景、决策、约束 'reference', // 去哪找——外部系统的位置和用途 ] as const
| 类型 | 大白话解释 | 举例 | 源码提到的特性 |
|---|---|---|---|
| user | 记住你的身份和水平 | "用户是 Go 老手,React 新手" | 几乎不变 |
| feedback | 记住你喜欢什么、讨厌什么 | "不要在回复末尾写总结" | 要同时记录修正和认可 |
| project | 记住项目的背景和约束 | "周四代码冻结,别搞大改动" | 源码标注 "decay fast"(变化快) |
| reference | 记住外部信息在哪 | "bug 跟踪在 Linear 的 INGEST 项目" | 通常是团队共享的 |
1.2 谁来记、什么时候记?
知道了"该记什么",下一个问题是:谁来执行"记"这个动作?
源码中有两条路径同时在工作,互为补充:
| 路径 | 谁在做 | 什么时候 | 怎么决定 |
|---|---|---|---|
| 主动保存 | 主 Claude 自己 | 对话中随时 | 按 when_to_save 规则判断 |
| 后台提取 | 独立的提取 Agent | 每个 turn 结束后 | 回顾最近消息,提取值得记住的 |
路径 A:主动保存
主 Claude 在对话过程中,根据系统提示里的 when_to_save 规则,自己判断"现在该存一条记忆了",然后直接用 Write 工具写文件。每种类型有自己的触发条件:
- user:学到了用户的身份、偏好、专长
- feedback:用户纠正了你("别这样做")或认可了你("对,就这样")
- project:学到了项目的背景、截止日期、决策原因
- reference:学到了外部系统的位置和用途
路径 B:后台提取 🚧 未启用
⚠️ 此特性尚未启用:feature('EXTRACT_MEMORIES')当前返回false,以下内容为源码中的设计实现,不会在运行时执行。
如果主 Claude 在对话中没有主动保存,系统会在每个 turn 结束后启动一个后台提取 Agent(services/extractMemories/)。这个 Agent 的工作很简单:回顾最近的对话消息,看看有没有值得记住的信息。
触发逻辑在 query/stopHooks.ts:141:
// 每个 turn 结束后,如果不是子 agent,就尝试提取记忆 if (feature('EXTRACT_MEMORIES') && !toolUseContext.agentId) { void extractMemoriesModule.executeExtractMemories(stopHookContext) }
提取 Agent 收到的指令(extractMemories/prompts.ts:29):
// "你现在是记忆提取子 agent。分析上面最近的 ~N 条消息, // 用它们来更新你的持久化记忆系统。" `You are now acting as the memory extraction subagent. Analyze the most recent ~${newMessageCount} messages above and use them to update your persistent memory systems.`
这个提取 Agent 的权限是受限的 —— 只能读文件和往 memory 目录写,不能执行 bash、不能调 MCP、不能启动子 Agent。
两条路径是互斥的
如果主 Claude 已经在这个 turn 里写了记忆文件,后台提取就跳过(hasMemoryWritesSince() 检测),避免重复。
1.3 一条记忆的边界是什么?
没有"分割算法"。每条记忆是一个独立的 .md 文件,一个话题一个文件。"分"的依据不是字数或段落,而是语义。系统提示中明确要求:
- "organize memory semantically by topic, not chronologically"(按话题组织,不要按时间顺序)
- "do not write duplicate memories. First check if there is an existing memory you can update before writing a new one."(不要写重复的记忆。先检查是否有已存在的记忆可以更新)
保存记忆是一个两步操作(memdir/memdir.ts:205):
- Step 1:写一个独立的
.md文件(如user_role.md),带 frontmatter - Step 2:在
MEMORY.md索引中加一行指针(标题 + 一句话描述,不超过 150 字符)
所以判断"一条记忆结束了"很简单 —— 文件写完、索引更新完,这条记忆就完整了。
1.4 还有一种"记忆"不是自动的:CLAUDE.md
除了 Auto Memory(Claude 自己写的小纸条),还有 CLAUDE.md(你自己手写的规则文件)。两者的区别用大白话说:
- CLAUDE.md = 你贴在办公桌上的"工作须知",谁来都得看
- Auto Memory = 助手自己的小本本,记着跟你打交道学到的经验
| 维度 | CLAUDE.md | Auto Memory |
|---|---|---|
| 谁维护 | 用户手动编写 | Claude 自动读写 |
| 存放位置 | 项目目录中 | ~/.claude/projects/ 下 |
| 是否入库 | 可以 git 管理 | 不入库,个人私有 |
| 注入方式 | 消息列表开头(伪用户消息) | 系统提示(角色定义区) |
二、存储:怎么存?
2.1 每条记忆 = 一个 Markdown 文件
比如你跟 Claude 说"我是数据科学家",它就会创建:
~/.claude/projects/{slug}/memory/user_role.md
其中 {slug} 由项目的 git 根目录路径生成(memdir/paths.ts:203):
// slug 来源:优先用 git 根目录,没有就用项目根目录 function getAutoMemBase(): string { return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot() } // 拼完整路径:~/.claude/projects/{sanitize(slug)}/memory/ const projectsDir = join(getMemoryBaseDir(), 'projects') return join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME)
路径还有安全校验,防止目录遍历攻击(paths.ts:109)—— 拒绝相对路径、根目录、UNC 网络路径、空字节等。
2.2 文件内容:frontmatter + 正文
--- name: 用户角色 description: 用户是数据科学家,关注日志和可观测性 type: user --- 用户是数据科学家,当前在调研项目的日志体系。 解释代码时侧重数据流和可观测性角度。
Frontmatter 就是 Markdown 文件开头用 --- 包裹的 YAML 元数据块,和 Obsidian 的"属性面板"读的是同一套规范 —— 我们给这篇笔记加的 title、tags、status,和记忆文件里的 name、description、type,格式完全一样。
它的设计意义是让一个文件同时兼顾人能读和程序能快速扫。在检索阶段(详见下一章),代码只读每个文件的前 30 行提取 frontmatter,拼成摘要清单交给 Sonnet 挑选 —— 就像"只看封面选书",被选中的才会读全文。
索引文件:MEMORY.md
所有记忆的"目录",每条一行,方便快速查找。有两层保护防止撑爆上下文(memdir/memdir.ts:35):
export const ENTRYPOINT_NAME = 'MEMORY.md' export const MAX_ENTRYPOINT_LINES = 200 // 最多 200 行 export const MAX_ENTRYPOINT_BYTES = 25_000 // 最多 25KB
截断逻辑是先按行截,再按字节截,超了会在末尾追加警告(memdir/memdir.ts:57):
// 先按 200 行截断 let truncated = wasLineTruncated ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n') : trimmed // 再按 25KB 字节截断,在最后一个换行符处切割 if (truncated.length > MAX_ENTRYPOINT_BYTES) { const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES) truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES) }
三、检索:怎么取?
3.1 检索策略总览:静态加载 vs 动态加载
并不是所有记忆都走同一条路。源码中实际存在两种加载策略:
| 策略 | 什么东西 | 加载时机 | 特点 |
|---|---|---|---|
| 静态加载 | CLAUDE.md、MEMORY.md 索引 | 每次对话都完整加载 | 不看对话内容,无条件注入 |
| 动态加载 | 具体的记忆文件(user_role.md 等) | 由 Sonnet 按需选取 | 根据当前对话内容,最多选 5 条 |
用大白话说:索引和规则是"常驻口袋"的,具体小纸条是"现翻现拿"的。
MEMORY.md 索引虽然总是加载,但有截断保护(200 行 / 25KB),所以不会撑爆上下文。真正"贵"的是具体记忆文件的全文 —— 这部分才需要动态筛选。
下面看动态加载的三个步骤:扫描 → 挑选 → 标记新鲜度。
3.2 扫描:只看标题区
memdir/memoryScan.ts:21,每个文件只读前 30 行 frontmatter,不读全文:
const FRONTMATTER_MAX_LINES = 30 // 只读标题区,快速提取 name/description/type const { content, mtimeMs } = await readFileInRange(filePath, 0, FRONTMATTER_MAX_LINES) const { frontmatter } = parseFrontmatter(content, filePath)
扫描结果按修改时间倒序排列,只保留最近的 200 个文件(memoryScan.ts:66):
return headers .sort((a, b) => b.mtimeMs - a.mtimeMs) // 最新优先 .slice(0, MAX_MEMORY_FILES) // 最多 200 个
3.3 挑选:让 Sonnet 打杂
把扫描出的清单交给 Sonnet,让它根据当前对话内容选最相关的(memdir/findRelevantMemories.ts:97):
const result = await sideQuery({ model: getDefaultSonnetModel(), // 用便宜的 Sonnet,不用 Opus system: SELECT_MEMORIES_SYSTEM_PROMPT, // "从下面的记忆中选最相关的" messages: [{ role: 'user', content: `Query: ${query}\n\nAvailable memories:\n${manifest}`, }], max_tokens: 256, // 回复很短,只需要返回文件名列表 output_format: { type: 'json_schema', ... }, // 强制返回 JSON }) // 解析出 Sonnet 选中的文件名列表 const parsed = jsonParse(textBlock.text) return parsed.selected_memories.filter(f => validFilenames.has(f))
Sonnet 收到的指令(SELECT_MEMORIES_SYSTEM_PROMPT)要求它:
- 最多选 5 个
- 不确定有没有用的,就不选
- 如果没有相关的,可以返回空列表
- 正在使用的工具的参考文档不选(已经在用了),但警告和陷阱要选
这是一个模型调用模型的设计:主模型用 Opus 干活,检索记忆用 Sonnet 打杂。
3.4 新鲜度:过期就提醒
超过 1 天的记忆,取用时自动附加警告(memdir/memoryAge.ts:6):
// 算年龄:(现在 - 文件修改时间) / 一天的毫秒数 export function memoryAgeDays(mtimeMs: number): number { return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000)) } // 超过 1 天的记忆,返回警告文字 export function memoryFreshnessText(mtimeMs: number): string { const d = memoryAgeDays(mtimeMs) if (d <= 1) return '' // 新鲜的,不警告 // 返回的警告文字大意: // "这条记忆已有 N 天。记忆是某个时间点的快照,不是实时状态—— // 关于代码行为或 file:line 引用的说法可能已经过时。 // 在当作事实断言之前,先对照当前代码验证。" return ( `This memory is ${d} days old. ` + `Memories are point-in-time observations, not live state — ` + `claims about code behavior or file:line citations may be outdated. ` + `Verify against current code before asserting as fact.` ) }
源码注释说了这个设计的来历:用户反馈旧记忆中的 file:line 引用被当成事实断言,而引用反而让过时信息显得更权威。所以加了这个警告。
四、注入:怎么用?
记忆选好了,要塞进提示词里。但 Auto Memory 和 CLAUDE.md 走不同的管道。
4.1 管道 A:Auto Memory → 系统提示
入口:constants/prompts.ts:495,把记忆注册为系统提示的一个段落:
// 告诉系统提示:"你有一个叫 memory 的段落,内容从 loadMemoryPrompt() 获取" systemPromptSection('memory', () => loadMemoryPrompt()),
加载逻辑:memdir/memdir.ts:285,读取 MEMORY.md 并拼接记忆操作指南:
// 读取 MEMORY.md 索引文件 entrypointContent = fs.readFileSync(entrypoint, { encoding: 'utf-8' }) // 截断后塞进系统提示 const t = truncateEntrypointContent(entrypointContent) lines.push(`## ${ENTRYPOINT_NAME}`, '', t.content)
系统提示中的记忆段落包括:四种类型的说明、什么该记什么不该记、怎么保存和更新 —— 相当于给 Claude 一本"记忆操作手册"。
4.2 管道 B:CLAUDE.md → 消息列表开头
核心函数:utils/api.ts:449,把 CLAUDE.md 内容包装成一条"假的用户消息",插到对话最前面:
export function prependUserContext(messages, context) { return [ createUserMessage({ // 包装成 <system-reminder> 标签,Claude 能看到,用户在终端看不到 content: `<system-reminder>\n...\n# claudeMd\n${claudeMdContent}\n</system-reminder>`, isMeta: true, // 标记为元消息,终端不显示 }), ...messages, // 后面才是真正的用户对话 ] }
4.3 为什么分两条管道?
- 系统提示是 Claude 的"角色卡",放操作指南(怎么管理记忆)
- 消息前置是 Claude 的"工作参考",放具体规则(你的 CLAUDE.md)
- 两者分离,改 CLAUDE.md 不影响记忆系统,反之亦然
用一张图回顾完整流程:
你输入:"帮我看看这个 bug"
│
▼
┌─── 写入(已完成)──────────────────────────────┐
│ 之前的对话中,Claude 已经把学到的信息写成小纸条 │
└─────────────────────────────────────────────────┘
│
▼
┌─── 存储 ───────────────────────────────────────┐
│ ~/.claude/projects/{slug}/memory/ │
│ ├── MEMORY.md (索引) │
│ ├── user_role.md │
│ ├── feedback_testing.md │
│ └── project_deadline.md │
└─────────────────────────────────────────────────┘
│
▼
┌─── 检索 ───────────────────────────────────────┐
│ 1. 扫描 frontmatter(只读前 30 行) │
│ 2. Sonnet 挑选最相关的 ≤5 条 │
│ 3. 超过 1 天的标记新鲜度警告 │
└─────────────────────────────────────────────────┘
│
▼
┌─── 注入 ───────────────────────────────────────┐
│ 管道A:Auto Memory → 系统提示(角色卡) │
│ 管道B:CLAUDE.md → 消息列表开头(伪用户消息) │
└─────────────────────────────────────────────────┘
│
▼
Claude 读到了所有背景,开始干活
五、关键源码速查
| 阶段 | 文件 | 核心职责 |
|---|---|---|
| 写入 | memdir/memoryTypes.ts | 四种类型定义 + 排除清单 + when_to_save 规则 |
| 写入 | services/extractMemories/ | 后台提取 Agent 🚧 未启用 |
| 写入 | query/stopHooks.ts | 提取触发点(每个 turn 结束后)🚧 未启用 |
| 存储 | memdir/paths.ts | 路径拼接、slug 生成、安全校验 |
| 存储 | memdir/memdir.ts | MEMORY.md 索引截断、记忆操作手册构建 |
| 检索 | memdir/memoryScan.ts | 扫描 frontmatter、按时间排序、限 200 文件 |
| 检索 | memdir/findRelevantMemories.ts | 调用 Sonnet 选最多 5 条相关记忆 |
| 检索 | memdir/memoryAge.ts | 新鲜度计算、过期警告生成 |
| 注入 | constants/prompts.ts | 系统提示模板,注册 memory section |
| 注入 | utils/api.ts | prependUserContext() 把 CLAUDE.md 插到消息开头 |
| 注入 | utils/claudemd.ts | CLAUDE.md 的发现、加载、合并 |
| 注入 | context.ts | 用户上下文和系统上下文的构建 |