09 Claude Code Checkpoint 实现原理深度解析

Claude Code Checkpoint 实现原理深度解析

从"撤销代码修改"到三层存储机制,一次深入底层的技术之旅

引言:当你说"撤销"时,发生了什么?

想象这样一个场景:你正在用 Claude Code 写代码,AI 帮你改了三个文件。你看了眼输出,发现改动方向错了,想回到修改前的状态。

如果是在传统编辑器里,你会按 Ctrl+Z(撤销)。但在对话式编程工具里,"撤销"意味着什么?你需要的不只是文件回滚,还有对话上下文的回退。

这就是 Claude Code Checkpoint 要解决的问题。

但实现起来没那么简单。如果每次对话都保存所有文件的完整内容,体积会爆炸;如果只保存对话,文件状态又恢复不了。Claude Code 的解决方案是------两个独立的系统,三层存储机制

这篇文章会带你深入底层,看看这个设计是怎么做到既轻量又完整的。


一、整体架构:三个独立的层

Claude Code 的 Checkpoint 系统分三层,每层都有自己的触发时机、作用和侵入方式。

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  Layer 1: 追踪层 (Track Edit)                                   │
│  触发时机: 工具修改文件前                                         │
│  作用: 创建单文件备份,记录变更历史                                │
│  侵入方式: 同步前置钩子,阻塞式                                   │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  Layer 2: 快照层 (Make Snapshot)                                │
│  触发时机: 用户消息处理完毕后                                     │
│  作用: 为所有追踪文件创建完整快照,建立恢复点                       │
│  侵入方式: 异步后置钩子,非阻塞                                   │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  Layer 3: 持久化层 (Record Snapshot)                            │
│  触发时机: Layer 1 或 Layer 2 完成后                             │
│  作用: 将快照写入 JSONL transcript,支持会话恢复                   │
│  侵入方式: 异步追加写入,不阻塞主流程                              │
└─────────────────────────────────────────────────────────────────┘

看到这儿你可能会有疑问:为什么需要三层?一层不够吗?

答案是:不够,因为它们解决的是不同的问题


二、Layer 1:追踪层------"修改前"的保险

2.1 触发时机:每次文件修改前

Layer 1 的核心职责是:在任何文件被修改前,先备份它

比如你让 Claude Code 修改 a.py,系统会在实际修改前,先调用 fileHistoryTrackEdit()

typescript 复制代码
// FileEditTool.ts:416
if (fileHistoryEnabled()) {
  await fileHistoryTrackEdit(
    updateFileHistoryState,
    absoluteFilePath,
    parentMessage.uuid,  // 绑定到当前对话消息
  );
}

// 之后才是实际的文件修改
await fs.mkdir(dirname(absoluteFilePath))
// ... read-modify-write ...

注意这里的 await------这意味着备份是同步阻塞的。如果备份失败,后续的文件修改不会执行。这是为了保护数据一致性。

2.2 为什么必须同步阻塞?

假设改成异步:

typescript 复制代码
void fileHistoryTrackEdit(...)  // ❌ 不等待
await modifyFile(...)           // 可能先于备份完成

结果是什么?备份可能捕获的是"修改后"的状态,而不是"修改前"的状态。这就失去了备份的意义。

所以 Layer 1 必须是同步阻塞的,确保"修改前"的状态被正确捕获。

2.3 备份流程

Layer 1 的备份流程分四个阶段:

  1. 检查是否需要备份:如果文件已在最近快照中追踪,跳过(避免重复备份)
  2. 创建物理备份~/.claude/backups/{file_hash}/v1
  3. 更新内存状态FileHistoryState.backups[path] = {...}
  4. 持久化到 transcript :写入 JSONL(isSnapshotUpdate=true

关键设计:

  • 幂等性:如果文件已在快照中,跳过
  • 内容哈希:备份文件名包含内容哈希,相同内容不重复备份
  • 版本号:从 v1 开始,每次修改递增

三、Layer 2:快照层------建立一致的恢复点

3.1 触发时机:每条用户消息后

Layer 1 只是记录每次文件修改,但如果你想恢复到某个对话点,你需要的是所有文件的完整状态

这就是 Layer 2 的作用:在每条用户消息处理完毕后,为所有追踪文件创建完整快照

typescript 复制代码
// handlePromptSubmit.ts:556
if (fileHistoryEnabled()) {
  newMessages.filter(selectableUserMessagesFilter).forEach(message => {
    void fileHistoryMakeSnapshot(  // ← 异步不等待
      updateFileHistoryState,
      message.uuid,
    );
  });
}

注意这里的 void------Layer 2 是异步非阻塞的。为什么?

3.2 为什么可以异步非阻塞?

因为 Layer 2 是"锦上添花",不影响数据一致性。

即使 Layer 2 失败:

  • Layer 1 的备份已经存在(每个文件都有独立的备份)
  • 用户可以手动恢复到最近的 Layer 1 备份
  • 只是少了一个"完整的恢复点"

所以权衡之下,Layer 2 选择异步非阻塞,不阻塞用户体验。

3.3 完整快照流程

Layer 2 的快照流程:

  1. 遍历所有追踪文件:检查哪些文件自上次快照后发生了变化(通过 mtime 判断)
  2. 为变化文件创建新备份:版本号递增(如 v1 → v2)
  3. 创建完整快照记录:包含所有追踪文件的状态
  4. 持久化到 transcript :写入 JSONL(isSnapshotUpdate=false

关键设计:

  • 增量检测:通过 mtime 判断文件是否变化,避免重复备份
  • 完整快照:包含所有追踪文件的状态,形成一致的恢复点
  • 版本递增:每个文件独立维护版本号

四、Layer 3:持久化层------把快照写入磁盘

4.1 触发时机:Layer 1/2 完成后

Layer 3 的职责很简单:把快照写入 JSONL transcript

typescript 复制代码
// sessionStorage.ts:1098-1112
async insertFileHistorySnapshot(
  messageId: UUID,
  snapshot: FileHistorySnapshot,
  isSnapshotUpdate: boolean,  // 区分 Layer 1 (true) vs Layer 2 (false)
) {
  await this.appendEntry(fileHistoryMessage)  // 追加写入 JSONL
}

注意:Layer 1 和 Layer 2 都用 void 调用 Layer 3,异步非阻塞

4.2 为什么选择追加写入?

如果改成重写整个文件:

typescript 复制代码
await rewriteEntireFile(...)  // ❌ 每次都重写

结果:

  • 文件越来越大时,写入越来越慢
  • 每次修改都要等待 I/O 完成,用户体验差

追加写入的好处:

  • 性能高:只在文件末尾追加一行
  • 不阻塞主流程:后台异步写入
  • 恢复友好:重放 transcript 即可重建状态

4.3 Transcript 格式

Layer 3 写入的 JSONL 格式:

jsonl 复制代码
{"type":"file-history-snapshot","messageId":"msg-002","snapshot":{...},"isSnapshotUpdate":true}
{"type":"file-history-snapshot","messageId":"msg-003","snapshot":{...},"isSnapshotUpdate":false}

isSnapshotUpdate 的含义:

  • true:Layer 1 的增量备份(单文件)
  • false:Layer 2 的完整快照(所有文件)

五、三层协作:一个完整的时间线

让我们看一个完整的场景:用户说 "修改 a.pyb.py"。

复制代码
T0: 用户输入 "修改 a.py 和 b.py"
    │
T1: LLM 决定修改 a.py
    │
    ├─ ★ Layer 1: Track Edit (a.py) ★
    │   └─ 创建备份 a.py@v1
    │   └─ ★ Layer 3: Record Snapshot (异步) ★
    │       └─ 写入 JSONL: {"isSnapshotUpdate":true, ...}
    │
T2: 实际修改 a.py
    │
T3: LLM 决定修改 b.py
    │
    ├─ ★ Layer 1: Track Edit (b.py) ★
    │   └─ 创建备份 b.py@v1
    │   └─ ★ Layer 3: Record Snapshot (异步) ★
    │
T4: 实际修改 b.py
    │
T5: LLM 响应完成
    │
T6: 用户消息处理完毕
    │
    └─ ★ Layer 2: Make Snapshot (msg-001) ★
        ├─ 检查 a.py 是否变化 → 是 → 创建 a.py@v2
        ├─ 检查 b.py 是否变化 → 是 → 创建 b.py@v2
        └─ ★ Layer 3: Record Snapshot (异步) ★
            └─ 写入 JSONL: {"isSnapshotUpdate":false, ...}

六、Checkpoint vs Transcript:两个独立的系统

这是很多人容易混淆的地方:Layer 2 保存工具调用信息吗?

答案:❌ 不会。

Layer 2 只保存文件快照(文件路径 + 版本号 + 时间戳),不保存工具调用信息。

完整的对话记录(包括工具调用)由独立的 Transcript 持久化系统保存。

6.1 为什么需要两个独立的系统?

File History System(文件快照系统)

  • 目标 :支持 /rewind 恢复文件状态
  • 粒度:文件级(物理备份)
  • 数据:文件路径 + 版本号 + 时间戳
  • 触发:文件修改前 + 消息后

Transcript System(对话持久化系统)

  • 目标:支持会话恢复、历史重放、审计
  • 粒度:消息级(完整对话)
  • 数据:消息内容 + 工具调用 + 工具结果 + 父子链
  • 触发:每条消息生成时

6.2 如果合并会怎样?

如果 File History 也保存对话内容

  • ❌ 每次文件修改都要保存完整的对话 → 体积爆炸
  • ❌ 同一文件修改 10 次 → 10 个完整副本
  • ❌ 文件内容可能很大(MB级)→ Transcript 过大

如果 Transcript 也保存文件内容

  • ❌ 每条消息都要保存所有文件的完整内容 → 体积爆炸
  • ❌ 对话历史被文件内容污染 → 难以阅读和审计

分离设计

  • File History:只保存引用(轻量 ~200 bytes),支持文件恢复
  • Transcript:只保存对话(适中 ~500 bytes),支持会话恢复

6.3 两者的协作:messageId 的桥梁作用

messageId 在两个系统间起到桥梁作用:

恢复流程

  1. 用户执行 /rewind msg-002
  2. 在 Transcript 找到 msg-002 → 确认对话位置
  3. 在 File History 找到 messageId=msg-002 的快照
  4. 应用快照到文件系统 → 恢复文件状态
  5. (可选)在 Transcript 删除后续消息 → 恢复对话历史

七、设计精髓总结

7.1 三层设计的权衡

层级 侵入方式 阻塞性 为什么这样设计?
Layer 1 同步前置钩子 ✅ 阻塞 保证"修改前"状态被捕获
Layer 2 异步后置钩子 ⚠️ 非阻塞 不影响用户体验,"锦上添花"
Layer 3 异步追加写入 ⚠️ 非阻塞 性能优先,持久化是"锦上添花"

7.2 两个系统的职责分离

系统 保存内容 粒度 目标
File History 文件备份路径 + 版本号 文件级 支持文件恢复 (/rewind)
Transcript 完整对话 + 工具调用 消息级 支持会话恢复、历史重放

7.3 适用场景

  • 交互式编程助手:Claude Code、Cursor 等
  • 对话式工具:需要"撤销栈"的场景
  • 轻量级版本控制:会话级,非跨会话
  • 团队协作:应用 Git
  • 长期历史管理:应用 Git

结语:Claude Code 的 Checkpoint 定位

很多人会问:这和 Git 有什么区别?

定位差异

  • Checkpoint:对话上下文的撤销栈(面向单用户、单会话)
  • Git:版本控制的提交图(面向团队、长期历史)

Checkpoint 的设计目标不是替代 Git,而是为对话式编程提供轻量级的撤销能力。它解决的是"刚才 AI 改错了,我想回退"的场景,而不是"三个月前的代码长什么样"的问题。

如果你正在构建类似的对话式工具,这个三层存储机制 + 双系统分离的设计,值得参考。


附录:最简 Python 实现

如果你想快速理解 Checkpoint 的核心逻辑,这里有一个最简实现(~280 行):

python 复制代码
class FileCheckpoint:
    def track_edit(self, file_path: Path, message_id: str) -> None:
        """编辑前追踪文件(Layer 1)"""
        # 检查是否需要备份
        if file_path in self.state.backups:
            return
      
        # 创建备份
        backup_path = self.backup_root / f"{file_path.name}@v1"
        shutil.copy2(file_path, backup_path)
      
        # 更新内存状态
        self.state.backups[file_path] = {
            "version": 1,
            "backup_path": str(backup_path),
        }
      
        # 持久化到 transcript
        self._record_snapshot(message_id, is_update=True)
  
    def make_snapshot(self, message_id: str) -> None:
        """创建完整快照(Layer 2)"""
        tracked_file_backups = {}
      
        for file_path in self.state.tracked_files:
            # 检查文件是否变化
            if self._has_changed(file_path):
                # 创建新备份
                next_version = self.state.backups.get(file_path, {}).get("version", 0) + 1
                backup_path = self.backup_root / f"{file_path.name}@v{next_version}"
                shutil.copy2(file_path, backup_path)
              
                tracked_file_backups[file_path] = {
                    "version": next_version,
                    "backup_path": str(backup_path),
                }
      
        # 持久化到 transcript
        self._record_snapshot(message_id, is_update=False)
  
    def rewind(self, message_id: str) -> dict:
        """恢复到指定消息的快照"""
        target_snapshot = self._find_snapshot(message_id)
        if not target_snapshot:
            return {"error": "Snapshot not found"}
      
        for file_path, backup in target_snapshot.backups.items():
            # 恢复文件
            shutil.copy2(backup["backup_path"], file_path)
      
        return {"success": True}

参考资源

  • Claude Code 源码:src/utils/fileHistory.ts
  • Transcript 持久化:src/utils/sessionStorage.ts
  • 三篇分析文档:见本文开头引用
相关推荐
lincats11 小时前
上下文工程:AI 智能体为什么离不开它?
codex·vibe coding·claude code
梦想的初衷~1 天前
【科研自动化实战】Codex × Claude Code × OpenClaw × Hermes:四位AI研究员的科研协作流水线搭建指南
人工智能·codex·claude code·openclaw·科研自动化·agent协作·ai科研助手
lincats1 天前
5个Claude Code Skill,把产品经理的反馈分析从45分钟压到了10分钟
claude code·skills
星云_byto1 天前
五分钟从零配置好VSCode + Claude Code + DeepSeek V4 Pro
ide·vscode·编辑器·cursor·codex·claude code·deepseekv4
月♡下ღchase梦2 天前
Claude Code安装教程
经验分享·codex·claude code
Briwisdom2 天前
Agent 不是工具调用器——理解 Agent 的工作机制
人工智能·codex·ai-agent·claude code·opencode·agent机制
菜地里的小菜鸟2 天前
vscode接入claudecode
vscode·claude code·vscode接入claudecode
码哥字节4 天前
AI Agent 替你写代码没问题,但这 3 类后端任务让它当场翻车
claude code·ai编程工具·ai agent 记忆
码哥字节6 天前
213000星的Superpowers,90%的人只用了它10%的功能
claude code·ai编程工具·claude code技巧