09 Claude Code Checkpoint 实现原理深度解析
Claude Code Checkpoint 实现原理深度解析从撤销代码修改到三层存储机制一次深入底层的技术之旅引言当你说撤销时发生了什么想象这样一个场景你正在用 Claude Code 写代码AI 帮你改了三个文件。你看了眼输出发现改动方向错了想回到修改前的状态。如果是在传统编辑器里你会按 CtrlZ撤销。但在对话式编程工具里撤销意味着什么你需要的不只是文件回滚还有对话上下文的回退。这就是 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()// FileEditTool.ts:416if(fileHistoryEnabled()){awaitfileHistoryTrackEdit(updateFileHistoryState,absoluteFilePath,parentMessage.uuid,// 绑定到当前对话消息);}// 之后才是实际的文件修改awaitfs.mkdir(dirname(absoluteFilePath))// ... read-modify-write ...注意这里的await——这意味着备份是同步阻塞的。如果备份失败后续的文件修改不会执行。这是为了保护数据一致性。2.2 为什么必须同步阻塞假设改成异步voidfileHistoryTrackEdit(...)// ❌ 不等待awaitmodifyFile(...)// 可能先于备份完成结果是什么备份可能捕获的是修改后的状态而不是修改前的状态。这就失去了备份的意义。所以 Layer 1 必须是同步阻塞的确保修改前的状态被正确捕获。2.3 备份流程Layer 1 的备份流程分四个阶段检查是否需要备份如果文件已在最近快照中追踪跳过避免重复备份创建物理备份~/.claude/backups/{file_hash}/v1更新内存状态FileHistoryState.backups[path] {...}持久化到 transcript写入 JSONLisSnapshotUpdatetrue关键设计幂等性如果文件已在快照中跳过内容哈希备份文件名包含内容哈希相同内容不重复备份版本号从 v1 开始每次修改递增三、Layer 2快照层——建立一致的恢复点3.1 触发时机每条用户消息后Layer 1 只是记录每次文件修改但如果你想恢复到某个对话点你需要的是所有文件的完整状态。这就是 Layer 2 的作用在每条用户消息处理完毕后为所有追踪文件创建完整快照。// handlePromptSubmit.ts:556if(fileHistoryEnabled()){newMessages.filter(selectableUserMessagesFilter).forEach(message{voidfileHistoryMakeSnapshot(// ← 异步不等待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写入 JSONLisSnapshotUpdatefalse关键设计增量检测通过 mtime 判断文件是否变化避免重复备份完整快照包含所有追踪文件的状态形成一致的恢复点版本递增每个文件独立维护版本号四、Layer 3持久化层——把快照写入磁盘4.1 触发时机Layer 1/2 完成后Layer 3 的职责很简单把快照写入 JSONL transcript。// sessionStorage.ts:1098-1112asyncinsertFileHistorySnapshot(messageId:UUID,snapshot:FileHistorySnapshot,isSnapshotUpdate:boolean,// 区分 Layer 1 (true) vs Layer 2 (false)){awaitthis.appendEntry(fileHistoryMessage)// 追加写入 JSONL}注意Layer 1 和 Layer 2 都用void调用 Layer 3异步非阻塞。4.2 为什么选择追加写入如果改成重写整个文件awaitrewriteEntireFile(...)// ❌ 每次都重写结果文件越来越大时写入越来越慢每次修改都要等待 I/O 完成用户体验差追加写入的好处性能高只在文件末尾追加一行不阻塞主流程后台异步写入恢复友好重放 transcript 即可重建状态4.3 Transcript 格式Layer 3 写入的 JSONL 格式{type:file-history-snapshot,messageId:msg-002,snapshot:{...},isSnapshotUpdate:true} {type:file-history-snapshot,messageId:msg-003,snapshot:{...},isSnapshotUpdate:false}isSnapshotUpdate的含义trueLayer 1 的增量备份单文件falseLayer 2 的完整快照所有文件五、三层协作一个完整的时间线让我们看一个完整的场景用户说 “修改 a.py 和 b.py”。T0: 用户输入 修改 a.py 和 b.py │ T1: LLM 决定修改 a.py │ ├─ ★ Layer 1: Track Edit (a.py) ★ │ └─ 创建备份 a.pyv1 │ └─ ★ Layer 3: Record Snapshot (异步) ★ │ └─ 写入 JSONL: {isSnapshotUpdate:true, ...} │ T2: 实际修改 a.py │ T3: LLM 决定修改 b.py │ ├─ ★ Layer 1: Track Edit (b.py) ★ │ └─ 创建备份 b.pyv1 │ └─ ★ Layer 3: Record Snapshot (异步) ★ │ T4: 实际修改 b.py │ T5: LLM 响应完成 │ T6: 用户消息处理完毕 │ └─ ★ Layer 2: Make Snapshot (msg-001) ★ ├─ 检查 a.py 是否变化 → 是 → 创建 a.pyv2 ├─ 检查 b.py 是否变化 → 是 → 创建 b.pyv2 └─ ★ 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 找到messageIdmsg-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 行classFileCheckpoint:deftrack_edit(self,file_path:Path,message_id:str)-None:编辑前追踪文件Layer 1# 检查是否需要备份iffile_pathinself.state.backups:return# 创建备份backup_pathself.backup_root/f{file_path.name}v1shutil.copy2(file_path,backup_path)# 更新内存状态self.state.backups[file_path]{version:1,backup_path:str(backup_path),}# 持久化到 transcriptself._record_snapshot(message_id,is_updateTrue)defmake_snapshot(self,message_id:str)-None:创建完整快照Layer 2tracked_file_backups{}forfile_pathinself.state.tracked_files:# 检查文件是否变化ifself._has_changed(file_path):# 创建新备份next_versionself.state.backups.get(file_path,{}).get(version,0)1backup_pathself.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),}# 持久化到 transcriptself._record_snapshot(message_id,is_updateFalse)defrewind(self,message_id:str)-dict:恢复到指定消息的快照target_snapshotself._find_snapshot(message_id)ifnottarget_snapshot:return{error:Snapshot not found}forfile_path,backupintarget_snapshot.backups.items():# 恢复文件shutil.copy2(backup[backup_path],file_path)return{success:True}参考资源Claude Code 源码src/utils/fileHistory.tsTranscript 持久化src/utils/sessionStorage.ts三篇分析文档见本文开头引用