线上故障复盘时常常出现这样的场景:模型按计划执行了一连串 Bash 命令清理日志、重命名目录、批量替换文件,结果某步误删了关键配置。开发人员本能地按 Esc+Esc 调出检查点试图回滚,却发现那些文件回不来。这一限制并不是产品缺陷,而是 Claude Code 在抽象分层上做出的明确选择——把”模型可观察、可还原的操作”与”宿主环境副作用”刻意切开。
一、检查点的工作边界
检查点(checkpoint)在每次用户输入时自动捕获一份文件快照,配合 Esc+Esc 或 /rewind 命令可把代码与会话回到指定状态。它的捕获范围有明确圈定:
| 是否被检查点追踪 | 触发方式 |
|---|---|
| 是 | Claude 文件编辑工具(Write、Edit、MultiEdit 等) |
| 否 | Bash 命令对文件系统的副作用(rm、mv、cp、sed -i、> 重定向等) |
| 否 | 用户在外部编辑器中的手动修改 |
| 否 | 同一时间其他 Claude Code 会话对相同文件的写入 |
官方文档把这条限制列在 Limitations 首条,措辞直接:”Checkpointing does not track files modified by bash commands.” 文档同时给出反例:模型执行 rm file.txt、mv old.txt new.txt、cp source.txt dest.txt 后产生的状态变更,rewind 无法还原。
二、抽象边界决定了这一限制
把 Claude Code 的运行栈拆开看,至少有四层:模型推理层、工具调用层、宿主执行层、文件系统层。检查点只对”工具调用层”中受 SDK 直接控制的子集做快照,对越过该层抵达宿主执行层与文件系统层的操作放行但不追踪。
| 抽象层 | 谁能访问 | 检查点是否覆盖 | 原因 |
|---|---|---|---|
| 模型推理 | Claude | 不直接覆盖 | 推理过程在云端,状态不可写 |
| 工具调用(文件编辑) | Claude Code SDK | 覆盖 | 操作可被 SDK 拦截与快照 |
| 工具调用(Bash) | 宿主 Shell | 不覆盖 | 命令在宿主进程内执行,副作用发散 |
| 文件系统 | 宿主 OS | 不覆盖 | 检查点无法控制 OS 级事件 |
这四层不是 Claude Code 的发明,而是几乎所有”AI 编程助手”都会碰到的分层。任何一层想要”穿透快照”覆盖 Bash 副作用,理论上都能做(譬如在每次 Bash 调用前 tar 整个工作区),但工程上代价巨大,而且会把检查点语义从”轻量本地 undo”膨胀为”全量文件系统快照系统”。
三、为什么 Bash 副作用难追踪
Bash 命令引起的状态变更之所以被明确排除,三个原因交织在一起:
- 语义不可还原:
rm -rf build/之后,文件已经物理消失;mv a b之后,原路径不再存在。检查点即使记录了事件流,也无法在不保留完整文件系统历史的前提下把”已经删除的字节”复原。 - 副作用外溢:
curl ... | sh会触发远程脚本执行、docker run会启动容器、npm install会在node_modules下写入数千文件。这些变更大多落在工作区之外,回滚要面对的是整个宿主环境。 - 幂等性差异:文件编辑工具天然幂等——重写同一段内容结果一致;而 Bash 命令去重、副作用叠加,机械式快照会出现”快照与实际状态不一致”的怪异情况。
正因为这三件事普遍存在,官方把”追踪 Bash 变更”从检查点的设计目标里直接剔除,转而把责任明确推给 Git 一类的版本控制系统。
四、这一限制如何对齐工程交付习惯
理解这条边界后,正确的工程姿势浮出水面:
- 把 Bash 视为外部命令:凡涉及重命名、批量删除、远程拉取等动作,交给 Git 负责版本管理或交给 CI 脚本负责幂等执行,而不是依赖检查点。
下面是一段把”危险 Bash 操作前置 git commit”落到 shell hook 里的最小示例,每次执行 rm/mv/cp 之前自动生成一份基线:
#!/usr/bin/env bash
# pre-bash-checkpoint.sh
# 拦截危险命令:rm / mv / cp 命中前自动 git stash
set -euo pipefail
cmd="$1"
if echo "$cmd" | grep -Eq '(^|[[:space:]])(rm|mv|cp)([[:space:]]|$)'; then
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git add -A
git stash push -m "pre-bash-checkpoint: $(date +%s)"
echo "[checkpoint] stash created before: $cmd"
else
echo "[checkpoint] not a git repo, proceed with caution" >&2
fi
fi
exec "$@"
效果:模型一旦尝试 rm -rf build/ 或 mv src/old src/new,hook 会先打一份 stash 留底,事后可通过 git stash pop 选择性回滚。exec "$@" 保证拦截后原始命令照常执行,不改变 Agent 行为。
- 危险操作前置 commit:在让模型执行 rm、mv 这类命令前先 git commit 一份基线,让 Git 成为 Bash 副作用的”快照系统”;
- 优先使用文件编辑工具:能 Write 的别
echo >,能 Edit 的别sed -i,让变更落在检查点可还原的范围内; - 定期同步到 Git:在会话关键节点手动 git commit,把检查点的”短期 undo”转化为 Git 的”长期 history”。
这套分工对工程交付有明确收益:检查点保持轻量,专注于会话内的反复试错;Git 保持权威,承担跨会话、跨机器、跨团队的可审计责任。两者各管一摊,不互相僭越。
五、抽象边界背后的产品哲学
把视角抬高看,Claude Code 对”哪些操作可被还原”的选择体现的是一种克制的工程美学:
- 不试图做万能 undo:知道自己的能力边界,把不擅长的事明确推给更合适的工具;
- 不掩盖不确定性:用户读文档时第一眼就看到这条限制,而不是被坑过之后才发现;
- 不破坏分层:工具调用层只对自家可控路径做快照,不去”穿透”宿主执行层。
这种克制正是工程交付里”职责分离”原则的延伸——一个组件只对自己管辖的状态负责,不去揽别人该揽的活。检查点放弃追踪 Bash 变更,恰恰是为了让它能专心把”会话级 undo”做到位。
落地到团队协作时,可把这条限制转化为代码评审的一条隐性规则:凡是让模型执行 Bash 副作用的提示词,必须在 prompt 里加一条 “before running destructive shell commands, make a git commit”。这相当于把”检查点不覆盖”这一工程事实,翻译为可被团队复用的工程纪律。
常见问题(FAQ)
Q1:能不能用脚本包一层,让 Bash 操作也被检查点追踪?
能,但工程代价大且容易破坏分层。一般建议用 Git 兜底,而不是改造检查点。
Q2:检查点能跨会话保留多久?
默认随会话保留 30 天,可通过 cleanupPeriodDays 调整;过期后连同会话一起清理。
Q3:为什么 Edit 工具的修改可还原,而 sed -i 不可?
Edit 走 SDK 内部路径,SDK 在写入前抓快照;sed -i 是宿主 Shell 命令,SDK 无法拦截。