第 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 是索引,不是内容

<!-- ~/.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>

你需要实现

  1. loadMemory():读取文件,按行截断(200行),追加 WARNING
  2. saveMemory():追加写入,文件不存在时创建
  3. loadCheckpoints():读取 JSON 索引,失败时返回 { checkpoints: [] }
  4. backupFile():检查文件是否存在,存在则 copyFile,不存在则返回 { backupPath: null }
  5. makeCheckpoint():并行备份 + 更新索引 + FIFO 淘汰(最多 100 个)
  6. rewindToCheckpoint():按 null 语义还原文件

验收步骤