# 第 10 章:记忆系统与文件历史
> 记忆不是存储,而是遗忘的艺术——只保留对未来有用的东西。
---
## 10.1 核心问题
第 9 章的 Agent 能感知当前项目,但它有两个"失忆"问题:
**问题 A:知识不跨会话**
```
第一次会话:
用户:我们项目用 4 空格缩进,记住了
Agent:好的,我记住了!
第二次会话(新进程):
用户:我们的缩进规范是什么?
Agent:我没有相关信息…
```
每次会话都是全新的进程,所有"记忆"随进程销毁。
**问题 B:Agent 改错了文件没法撤销**
```
用户:帮我重构 auth.ts
Agent:好的!(把 auth.ts 重写成了完全错误的版本)
用户:不对不对,恢复原来的!
Agent:很抱歉,我没有保存原来的内容……
```
Agent 在修改文件前没有保存快照,出错后无法还原。
本章解决这两个问题:
1. **记忆系统(Memory System)**:Agent 主动把重要信息写入 `MEMORY.md`,下次启动时注入系统提示,实现跨会话记忆
2. **文件历史检查点(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` 是索引,不是内容**:
```markdown
<!-- ~/.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>/` 目录下:
```typescript
// 备份文件名格式:{文件路径 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()` 的判断链
```typescript
// 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` 截断保护
```typescript
// 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()` 的开关逻辑
```typescript
// 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 位:
```typescript
// 路径缩短:将长路径映射到短哈希,避免文件名过长
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`** — 跨会话记忆
```typescript
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`** — 文件历史检查点
```typescript
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>
```
**你需要实现**:
1. `loadMemory()`:读取文件,按行截断(200行),追加 WARNING
2. `saveMemory()`:追加写入,文件不存在时创建
3. `loadCheckpoints()`:读取 JSON 索引,失败时返回 `{ checkpoints: [] }`
4. `backupFile()`:检查文件是否存在,存在则 copyFile,不存在则返回 `{ backupPath: null }`
5. `makeCheckpoint()`:并行备份 + 更新索引 + FIFO 淘汰(最多 100 个)
6. `rewindToCheckpoint()`:按 null 语义还原文件
### 验收步骤