← Claude Code 源码学习
01 · 核心系统 · v1.2

记忆系统详解

LLM 天然没有记忆,每次对话都是无状态的。要让它"记住"东西,必须在外部建一套持久化机制 —— 沿着记忆的生命周期,会自然遇到四个问题:写入、存储、检索、注入。本文按这四阶段逐一展开源码实现。

SCOPEsrc/memdir/ · src/utils/claudemd.ts · src/context.ts · src/constants/prompts.ts · src/utils/api.ts · src/services/extractMemories/ · src/query/stopHooks.ts
章节目录
  1. 从第一性原理思考
  2. 写入:该记什么?
  3. 存储:怎么存?
  4. 检索:怎么取?
  5. 注入:怎么用?
  6. 关键源码速查

零、从第一性原理思考:记忆该怎么设计?

本节是基于源码的设计推导,不是源码本身的直接描述。每个问题先推理"应该怎样",再用源码印证"实际怎样"。

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 工具写文件。每种类型有自己的触发条件:

路径 B:后台提取 🚧 未启用

⚠️ 此特性尚未启用feature('EXTRACT_MEMORIES') 当前返回 false,以下内容为源码中的设计实现,不会在运行时执行。

如果主 Claude 在对话中没有主动保存,系统会在每个 turn 结束后启动一个后台提取 Agentservices/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 文件,一个话题一个文件。"分"的依据不是字数或段落,而是语义。系统提示中明确要求:

保存记忆是一个两步操作(memdir/memdir.ts:205):

  1. Step 1:写一个独立的 .md 文件(如 user_role.md),带 frontmatter
  2. Step 2:在 MEMORY.md 索引中加一行指针(标题 + 一句话描述,不超过 150 字符)

所以判断"一条记忆结束了"很简单 —— 文件写完、索引更新完,这条记忆就完整了。

1.4 还有一种"记忆"不是自动的:CLAUDE.md

除了 Auto Memory(Claude 自己写的小纸条),还有 CLAUDE.md(你自己手写的规则文件)。两者的区别用大白话说:

维度CLAUDE.mdAuto 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 的"属性面板"读的是同一套规范 —— 我们给这篇笔记加的 titletagsstatus,和记忆文件里的 namedescriptiontype,格式完全一样。

它的设计意义是让一个文件同时兼顾人能读程序能快速扫。在检索阶段(详见下一章),代码只读每个文件的前 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)要求它:

这是一个模型调用模型的设计:主模型用 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 为什么分两条管道?

用一张图回顾完整流程:

你输入:"帮我看看这个 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.tsMEMORY.md 索引截断、记忆操作手册构建
检索memdir/memoryScan.ts扫描 frontmatter、按时间排序、限 200 文件
检索memdir/findRelevantMemories.ts调用 Sonnet 选最多 5 条相关记忆
检索memdir/memoryAge.ts新鲜度计算、过期警告生成
注入constants/prompts.ts系统提示模板,注册 memory section
注入utils/api.tsprependUserContext() 把 CLAUDE.md 插到消息开头
注入utils/claudemd.tsCLAUDE.md 的发现、加载、合并
注入context.ts用户上下文和系统上下文的构建