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 的备份流程分四个阶段:
- 检查是否需要备份:如果文件已在最近快照中追踪,跳过(避免重复备份)
- 创建物理备份 :
~/.claude/backups/{file_hash}/v1 - 更新内存状态 :
FileHistoryState.backups[path] = {...} - 持久化到 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 的快照流程:
- 遍历所有追踪文件:检查哪些文件自上次快照后发生了变化(通过 mtime 判断)
- 为变化文件创建新备份:版本号递增(如 v1 → v2)
- 创建完整快照记录:包含所有追踪文件的状态
- 持久化到 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.py 和 b.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 在两个系统间起到桥梁作用:

恢复流程:
- 用户执行
/rewind msg-002 - 在 Transcript 找到 msg-002 → 确认对话位置
- 在 File History 找到
messageId=msg-002的快照 - 应用快照到文件系统 → 恢复文件状态
- (可选)在 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 - 三篇分析文档:见本文开头引用