记忆不是存储,而是遗忘的艺术——只保留对未来有用的东西。
10.1 核心问题
第 9 章的 Agent 能感知当前项目,但它有两个"失忆"问题:
问题 A:知识不跨会话
第一次会话:
用户:我们项目用 4 空格缩进,记住了
Agent:好的,我记住了!
第二次会话(新进程):
用户:我们的缩进规范是什么?
Agent:我没有相关信息…
每次会话都是全新的进程,所有"记忆"随进程销毁。
问题 B:Agent 改错了文件没法撤销
用户:帮我重构 auth.ts
Agent:好的!(把 auth.ts 重写成了完全错误的版本)
用户:不对不对,恢复原来的!
Agent:很抱歉,我没有保存原来的内容……
Agent 在修改文件前没有保存快照,出错后无法还原。
本章解决这两个问题:
- 记忆系统(Memory System):Agent 主动把重要信息写入
MEMORY.md,下次启动时注入系统提示,实现跨会话记忆 - 文件历史检查点(File History Checkpointing):每轮用户消息到来前自动为被修改文件创建快照,错了可一键还原
10.2 原理讲解
10.2.1 记忆 vs 历史 vs 上下文
这三个概念容易混淆,需要明确区分:
| 机制 | 存储格式 | 生命周期 | 用途 |
|---|---|---|---|
| 上下文(CLAUDE.md) | Markdown 文件 | 永久,人工维护 | 项目说明、团队约定 |
| 记忆(MEMORY.md) | Markdown 文件 | 永久,Agent 主动写入 | 用户偏好、工作习惯 |
| 历史(.jsonl) | JSONL 文件 | 永久,自动追加 | 完整对话记录,用于 resume |
| 文件快照 | 硬链接备份 | 内存 + 磁盘,会话内 | 文件修改前的内容备份 |
10.2.2 记忆系统:两层目录结构
Claude Code 的记忆系统基于文件系统,存储在 ~/.claude/ 下:
~/.claude/
├── MEMORY.md ← 用户级记忆(跨所有项目)
├── memory/
│ ├── MEMORY.md ← 全局记忆入口(索引)
│ ├── user_preferences.md ← 话题文件(按语义分类)
│ ├── coding_standards.md
│ └── project_context.md
└── projects/
└── <project-hash>/
└── memory/
├── MEMORY.md ← 项目级记忆入口(索引)
├── architecture.md
└── decisions.md
MEMORY.md 是索引,不是内容:
<!-- ~/.claude/memory/MEMORY.md 的内容示例 -->
- [用户偏好](user_preferences.md) — 缩进4空格,偏好函数式风格,中文回复
- [项目记录](project_context.md) — 电商系统,PostgreSQL+Prisma,部署在 AWS
- [反馈日志](feedback_coding.md) — 不要生成过长的函数,prefer 小函数组合
MEMORY.md 始终注入 context(最多 200 行 / 25,000 字节),话题文件按需读取(Agent 主动调用 Read 工具)。这种两层设计避免了把所有记忆内容都塞进上下文的浪费。
10.2.3 记忆的四种类型
Claude Code 通过系统提示教会 LLM 记忆的分类(src/memdir/memoryTypes.ts):
user — 用户的角色、背景、技能水平
例:"用户是 Python 专家,倾向于函数式编程"
feedback — 对 Agent 行为的偏好(偏好/避免)
例:"不要生成超过 50 行的函数"
例:"总是用中文回复"
project — 当前代码库的上下文知识
例:"数据库迁移用 prisma migrate,不要手写 SQL"
reference — 指向外部资源的指针
例:"API 文档在 https://internal.example.com/docs"
什么不应该记忆(内置 WHAT_NOT_TO_SAVE 规则):
- 可以从代码库直接推导出的信息(架构、代码风格——看代码就知道了)
- 仅在当前会话有意义的临时状态("我们现在在第 3 步")
- 完整的代码片段(应放进 CLAUDE.md 或文档,不是记忆)
10.2.4 保存记忆的两步流程
Claude Code 系统提示明确规定保存记忆是两步操作:
第 1 步:将记忆写入话题文件
~/.claude/memory/user_preferences.md
内容格式(YAML frontmatter):
---
name: 缩进偏好
description: 用户偏好的代码缩进风格
type: feedback
---
用户偏好 4 空格缩进,不使用 Tab。
第 2 步:在 MEMORY.md 中添加一行索引
- [缩进偏好](user_preferences.md) — 4空格缩进,非Tab
每行索引应控制在 ~150 字符以内(MEMORY.md 超过 200 行后会触发截断警告)。
10.2.5 自动记忆提取(extractMemories)
除了 Agent 主动调用工具保存记忆,Claude Code 还有一个后台自动提取机制:
Agentic Loop 完成一轮(模型输出最终文本,无工具调用)
↓
handleStopHooks() 触发
↓
initExtractMemories() 中的 shouldExtract() 检查:
- 本轮有足够多的新消息?(threshold: 4 条模型可见消息)
- 距上次提取有足够多的新消息?
↓ 是
runForkedAgent():fork 一个子 Agent,共享父 Agent 的 prompt cache
↓
子 Agent 分析本轮对话,判断是否有值得记忆的新信息
如果有 → 写入 memory/ 目录(两步流程:话题文件 + MEMORY.md 索引)
如果没有 → 直接返回,不写入
forked agent 的设计精髓:子 Agent 是父 Agent 的"完美克隆"——共享相同的系统提示和工具列表。由于 Anthropic API 的 prompt cache 工作在前缀级别,两个 Agent 的系统提示完全一致,子 Agent 的首次 API 请求几乎完全命中缓存,额外成本极低。
10.2.6 文件历史检查点:三阶段协议
src/utils/fileHistory.ts 实现了精心设计的三阶段协议,确保并发安全:
文件检查点触发时机:
┌─────────────────────────────────────────────────────────┐
│ 每轮用户消息到来时(fileHistoryMakeSnapshot) │
│ └→ 为所有被追踪文件创建版本快照 │
│ │
│ 工具开始修改文件前(fileHistoryTrackEdit) │
│ └→ 将该文件加入追踪列表,立即备份 v1 版本 │
└─────────────────────────────────────────────────────────┘
三阶段执行(以 fileHistoryMakeSnapshot 为例):
Phase 1 — Capture(无副作用读取当前状态):
updateFileHistoryState(state => { captured = state; return state })
// 通过 no-op updater 读取状态,不触发重渲染
Phase 2 — Async I/O(在 updater 外部执行所有磁盘操作):
await Promise.all(trackedFiles.map(file => createBackup(file, nextVersion)))
// 并行备份所有文件,不持有状态锁
Phase 3 — Commit(原子更新状态):
updateFileHistoryState(state => {
// 重新读取最新状态(Phase 2 期间可能有 trackEdit 并发写入)
// 合并新快照 + 继承现有备份
return newState
})
为什么分三阶段?
如果在 updater 函数内部做 I/O,会阻塞 React 的状态更新队列。Phase 1 先读,Phase 2 在外部做耗时 I/O,Phase 3 再原子更新——这是 Claude Code 中状态管理的一贯模式(与第 7 章中的不可变 State 模式一脉相承)。
10.2.7 备份文件的命名与存储
备份文件存储在 ~/.claude/file-history/<session-id>/ 目录下:
// 备份文件名格式:{文件路径 SHA1 前 8 位}@v{版本号}
// src/utils/fileHistory.ts
function getBackupFileName(filePath: string, version: number): string {
const fileNameHash = createHash('sha1').update(filePath).digest('hex').slice(0, 8)
return `${fileNameHash}@v${version}`
}
// 存储路径
function resolveBackupPath(backupFileName: string): string {
return join(getClaudeConfigHomeDir(), 'file-history', getSessionId(), backupFileName)
}
示例:修改 /home/user/work/auth.ts,第一版备份:
~/.claude/file-history/session-uuid-xxx/a3f7b291@v1
硬链接优化:实际存储使用 link()(硬链接)而非 copyFile()。相同内容的文件共享同一 inode,不占用额外磁盘空间。只有当文件内容真正改变时才创建新文件。
变化检测(checkOriginFileChanged):
1. stat 比较文件大小和权限 → 不同则已变化(快速路径)
2. 比较 mtime:原文件 mtime < 备份 mtime → 没变化(时间戳快速路径)
3. 读取两个文件内容逐字节比较 → 慢速路径(兜底)
快照上限:最多保留 100 个快照(MAX_SNAPSHOTS = 100),超出时 FIFO 淘汰最旧的快照。
10.2.8 还原(Rewind)
用户执行 --rewind-files <messageId>
↓
fileHistoryRewind(updateFileHistoryState, messageId)
↓
找到目标快照:findLast(snapshot => snapshot.messageId === messageId)
↓
applySnapshot():对每个追踪文件:
- 目标快照中有备份?→ copyFile(备份, 原路径)
- 目标快照中 backupFileName === null?→ 文件在那个时间点不存在 → unlink(原路径)
- 目标快照中没有这个文件?→ 继承更早快照中的备份(getBackupFileNameFirstVersion)
/rewind vs --rewind-files 的区别:
/rewind <messageId>:截断对话历史(消息分支回溯),下一轮对话从那个节点继续--rewind-files <messageId>:还原磁盘文件内容到那个消息时刻- 两者互补:通常需要同时执行,才能让对话历史和磁盘状态重新一致
10.3 源码细节
10.3.1 isAutoMemoryEnabled() 的判断链
// src/memdir/paths.ts
export function isAutoMemoryEnabled(): boolean {
// 1. CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 → 强制关闭
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY)) return false
// 2. CLAUDE_CODE_DISABLE_AUTO_MEMORY=0 → 强制开启
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY)) return true
// 3. --bare 模式 → 关闭
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) return false
// 4. 远程模式且无持久化目录 → 关闭
if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) return false
// 5. settings.json 中的 autoMemoryEnabled 字段
if (settings.autoMemoryEnabled !== undefined) return settings.autoMemoryEnabled
// 6. 默认开启
return true
}
10.3.2 MEMORY.md 截断保护
// src/memdir/memdir.ts
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000
export function truncateEntrypointContent(raw: string): EntrypointTruncation {
// 先按行数截断(200行),再按字节数截断(25KB)
// 在截断末尾追加警告信息,告知 LLM MEMORY.md 不完整
}
警告信息示例:
> WARNING: MEMORY.md is 247 lines (limit: 200). Only part of it was loaded.
Keep index entries to one line under ~200 chars; move detail into topic files.
10.3.3 fileHistoryEnabled() 的开关逻辑
// src/utils/fileHistory.ts
export function fileHistoryEnabled(): boolean {
if (getIsNonInteractiveSession()) {
// SDK 模式:需要显式开启
return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING) &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
}
// 交互模式:默认开启,可用配置或环境变量关闭
return getGlobalConfig().fileCheckpointingEnabled !== false &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
}
10.3.4 追踪文件路径的缩短处理
备份文件名中不存储完整路径,而是用路径的 SHA1 前 8 位:
// 路径缩短:将长路径映射到短哈希,避免文件名过长
function maybeShortenFilePath(filePath: string): string {
if (filePath.startsWith(getOriginalCwd())) {
// 项目内文件:使用相对路径(便于调试)
return relative(getOriginalCwd(), filePath)
}
// 项目外文件:使用完整路径
return filePath
}
10.4 最小化产出物
代码骨架位于
../chapters/10/src/,参考实现位于../chapters/10/solution/。 前置条件:需要ANTHROPIC_API_KEY环境变量(npm start需要)。
本章要实现什么
本章新增两个模块:
A. ../chapters/10/src/memory.ts — 跨会话记忆
export const MEMORY_FILE: string // ./MEMORY.md 路径
export const MAX_MEMORY_LINES: number // 200
export async function loadMemory(): Promise<string | null>
// 读取 MEMORY.md,超过 200 行时截断并追加 WARNING
export async function saveMemory(content: string): Promise<string>
// 追加写入 MEMORY.md,返回确认消息
B. ../chapters/10/src/history.ts — 文件历史检查点
export type FileBackup = { filePath: string; backupPath: string | null }
// backupPath === null 表示文件在那个时刻不存在(null 语义)
export async function loadCheckpoints(): Promise<CheckpointStore>
export async function backupFile(filePath: string, messageId: string): Promise<FileBackup>
export async function makeCheckpoint(messageId: string, trackedFiles: string[]): Promise<void>
export async function rewindToCheckpoint(messageId: string): Promise<void>
你需要实现:
loadMemory():读取文件,按行截断(200行),追加 WARNINGsaveMemory():追加写入,文件不存在时创建loadCheckpoints():读取 JSON 索引,失败时返回{ checkpoints: [] }backupFile():检查文件是否存在,存在则 copyFile,不存在则返回{ backupPath: null }makeCheckpoint():并行备份 + 更新索引 + FIFO 淘汰(最多 100 个)rewindToCheckpoint():按 null 语义还原文件