# 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 查看当前已加载的记忆来源 + 内容
2
3
4
5
6
注入后系统提示末尾长这样(实测):
# Memory (from CLAUDE.md)
# 项目记忆(.../step40/CLAUDE.md)
- 回答用中文。
- 涉及文件路径时优先用相对路径。
Today's date is 2026-06-26.
2
3
4
5
6
# 二、面试官视角:为什么要做?(Why)
面试题:让用户偏好「跨会话持久」,为什么是把 CLAUDE.md 塞进系统提示,而不是像 RAG 那样存进向量库、用到时再检索出来?
这题在考「什么样的信息该常驻上下文,什么样的该按需检索」。
项目约定(「回答用中文」「函数名用驼峰」「这是个教学项目」)有三个特征,决定了它该常驻而非检索:
- 每轮都相关:它不是「某个问题的答案」,而是「回答任何问题时都要遵守的规矩」。RAG 检索适合「大语料里捞出与当前 query 相关的少数片段」,但项目约定对每一轮都相关,检索它等于每轮都命中——那不如直接常驻。
- 体量小:几行到几十行,塞进系统提示的 token 成本可忽略。RAG 的价值在于「语料太大塞不下、只能选择性取」,而 CLAUDE.md 根本不存在塞不下的问题。
- 要影响行为而非提供事实:它是指令(怎么做),不是知识(是什么)。指令必须在模型「思考如何回答」时就在场,检索出来再拼太晚、也不可靠。
所以记忆系统的本质是:把「用户教过一次、以后每次都要遵守」的指令,固化成一段始终在场的系统提示片段。这和后面 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 };
}
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."
2
currentDateLine() 也是记忆系统提供的——真实 getUserContext() 同样把 currentDate 一并注入,让模型知道「今天几号」。
# 数据流
启动 → loadMemory() → { combined, sources }
│
每轮 getSystemPrompt() ─┤→ buildSystemPrompt(..., mem.combined)
│ → 末尾拼上 "# Memory" 段 + 当前日期
↓
模型每一轮都带着项目约定作答(无需用户重复交代)
对话中输入 "#函数名用驼峰"
→ 不是对话!追加这句到 ./CLAUDE.md
→ 立即 loadMemory() 重载 → mem.combined 更新
→ 下一轮系统提示就带上这条新记忆
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 工具加载完整正文——渐进式披露。