# Step 40: 记忆系统

一句话导读:启动时加载 CLAUDE.md(用户级 + 项目级),把内容注入系统提示,让「项目约定 / 用户偏好」跨会话持久——不必每次对话都重新交代「回答用中文、路径用相对路径」。对应真实源码 src/context.ts 的 getUserContext()。


# 一、这一步做了什么(What)

新增 memory.ts,启动时读两处 CLAUDE.md 并注入系统提示:

启动
  → loadMemory(): 读 ~/.claude/CLAUDE.md(用户级)+ ./CLAUDE.md(项目级),合并
  → 每轮 getSystemPrompt() 把 mem.combined 注入 "# Memory (from CLAUDE.md)" 段
对话中
  → #一句话    把这句追加到项目 CLAUDE.md,并重新 loadMemory(下一轮生效)
  → /memory    查看当前已加载的记忆来源 + 内容
1
2
3
4
5
6

注入后系统提示末尾长这样(实测):

# Memory (from CLAUDE.md)
# 项目记忆(.../step40/CLAUDE.md)
- 回答用中文。
- 涉及文件路径时优先用相对路径。

Today's date is 2026-06-26.
1
2
3
4
5
6

# 二、面试官视角:为什么要做?(Why)

面试题:让用户偏好「跨会话持久」,为什么是把 CLAUDE.md 塞进系统提示,而不是像 RAG 那样存进向量库、用到时再检索出来?

这题在考「什么样的信息该常驻上下文,什么样的该按需检索」。

项目约定(「回答用中文」「函数名用驼峰」「这是个教学项目」)有三个特征,决定了它该常驻而非检索:

  1. 每轮都相关:它不是「某个问题的答案」,而是「回答任何问题时都要遵守的规矩」。RAG 检索适合「大语料里捞出与当前 query 相关的少数片段」,但项目约定对每一轮都相关,检索它等于每轮都命中——那不如直接常驻。
  2. 体量小:几行到几十行,塞进系统提示的 token 成本可忽略。RAG 的价值在于「语料太大塞不下、只能选择性取」,而 CLAUDE.md 根本不存在塞不下的问题。
  3. 要影响行为而非提供事实:它是指令(怎么做),不是知识(是什么)。指令必须在模型「思考如何回答」时就在场,检索出来再拼太晚、也不可靠。

所以记忆系统的本质是:把「用户教过一次、以后每次都要遵守」的指令,固化成一段始终在场的系统提示片段。这和后面 step41 的 Skills(按需加载的流程)正好是一对反例——记忆是「始终在场」,技能是「用时才取」。


# 三、原理:它是怎么工作的(How)

# loadMemory:读两处、按优先级拼接

export function loadMemory(): MemoryResult {
  const sources: MemorySource[] = [];
  const parts: string[] = [];
  // 顺序:先用户级(全局偏好),再项目级(项目约定,更具体、优先级更高放后面)
  const candidates = [
    { label: "用户", path: userMemoryPath() },     // ~/.claude/CLAUDE.md
    { label: "项目", path: projectMemoryPath() },   // ./CLAUDE.md
  ];
  for (const c of candidates) {
    if (!existsSync(c.path)) continue;
    const content = readFileSync(c.path, "utf-8").trim();
    if (!content) continue;
    sources.push({ label: c.label, path: c.path, chars: content.length });
    parts.push(`# ${c.label}记忆(${c.path})\n${content}`);
  }
  return { combined: parts.join("\n\n"), sources };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

返回 combined(拼好的正文,注入用)+ sources(实际加载到的文件,/memory 展示用)。

# 在系统提示里注入

buildSystemPrompt 新增 memory 参数,负责拼段:

...(memory ? ["# Memory (from CLAUDE.md)", memory, ""] : []),
currentDateLine(),   // "Today's date is 2026-06-26."
1
2

currentDateLine() 也是记忆系统提供的——真实 getUserContext() 同样把 currentDate 一并注入,让模型知道「今天几号」。

# 数据流

启动 → loadMemory() → { combined, sources }
                          │
   每轮 getSystemPrompt() ─┤→ buildSystemPrompt(..., mem.combined)
                          │      → 末尾拼上 "# Memory" 段 + 当前日期
                          ↓
   模型每一轮都带着项目约定作答(无需用户重复交代)

对话中输入 "#函数名用驼峰"
   → 不是对话!追加这句到 ./CLAUDE.md
   → 立即 loadMemory() 重载 → mem.combined 更新
   → 下一轮系统提示就带上这条新记忆
1
2
3
4
5
6
7
8
9
10
11

# 两个要点

  • 优先级靠拼接顺序:用户级在前、项目级在后。项目约定更具体,放后面——靠后的指令通常权重更高。没有显式的「priority 字段」,用「后者覆盖前者」这个隐性约定表达优先级。
  • # 是写记忆,不是对话:和真实 Claude Code 一致,# 开头的输入被特判——它不进对话历史,而是「记一条到 CLAUDE.md」。追加后立即重载,下一轮生效。「教 AI 记住一件事」降到一次按键的成本。

# 四、深入追问(面试常见 follow-up)

Q:用户级和项目级冲突了(比如用户说「回答用英文」、项目说「回答用中文」)听谁的? A:听项目级。因为拼接顺序是「用户级在前、项目级在后」,而模型对靠后的指令通常给更高权重。设计逻辑是「越具体越优先」——项目约定比全局偏好更贴近当前工作现场,理应覆盖。注意这是隐性约定(靠 LLM 对位置的敏感),不是硬规则;真实版遇到强冲突也依赖类似的顺序语义。

Q:# 追加记忆后,为什么要立刻 loadMemory() 重载,而不是等下次启动? A:为了「即时生效」的体验。如果不重载,用户刚教的「函数名用驼峰」要等重启才起作用,那这个快捷记忆就废了一半。追加文件 + 立即重载 → 下一轮 getSystemPrompt() 就带上它,用户教完马上见效。代价只是多读一次小文件,可忽略。

Q:系统提示每轮都重新生成,把记忆重复注入 N 轮,不浪费 token 吗? A:getSystemPrompt 是个函数、每轮调用(step30 的设计:注入函数而非值,才能反映最新状态)。但系统提示通常走 prompt caching——重复的前缀部分被缓存、不重复计费。而且记忆体量小。用「每轮重新生成」换来「记忆能被 # 实时更新并立刻反映」,这个取舍是划算的。

Q:我们的实现和真实 getUserContext() 差在哪? A:真实版做了三件我们没做的:① 父目录向上 walk——从 cwd 一路往上找每一级的 CLAUDE.md 合并(monorepo 里子项目能继承根约定);② 子目录嵌套——进到某子目录时加载那里的 CLAUDE.md;③ 更完整的用户级/规则文件体系。我们做最小版:只读「用户级 + 当前项目级」两处,把「记忆 = 常驻系统提示片段」这个核心讲清楚。


# 五、设计权衡

  • 常驻 vs 按需:记忆选常驻,是因为它每轮都相关且体量小。如果 CLAUDE.md 变得很大(几千行),常驻就不划算了——那才需要引入「按需加载片段」,也就是 step41 Skills 的思路。「常驻还是按需」的分界线是「是否每轮都相关」。
  • 文件即数据库:记忆直接存在 CLAUDE.md 纯文本里,不引入任何 DB。好处是用户能直接用编辑器看/改、能进 git、零依赖;代价是没有结构化查询。对「几十行项目约定」这个体量,纯文件是最合适的载体。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
loadMemory() 读两处 CLAUDE.md getMemoryFiles() + getClaudeMds()(cwd 向上 walk + 用户级 + 子目录嵌套)
注入 # Memory 段 getUserContext() 返回 claudeMd 作为 userContext
currentDateLine() getUserContext() 里的 currentDate
# 追加 / /memory 真实版的 # 快捷记忆 + /memory 命令
还缺:父目录 walk、子目录嵌套、按需加载 真实版都有

# 七、一句话总结

记忆 = 把「用户教过一次、以后每轮都要遵守」的指令固化成始终在场的系统提示片段:用户级在前、项目级在后靠拼接顺序表达优先级,# 前缀让「记一条」降到一次按键成本、追加后立即重载即时生效。它的对立面是下一步的 Skills——记忆常驻,技能按需。

# 下一节预告

记忆是「始终在场」的指令。step41 的 Skills 系统则相反:技能平时只在系统提示里露个名字,模型需要时才用 Skill 工具加载完整正文——渐进式披露。