Checkpoint 的核心价值不是“保存一份当前状态”,而是记录 执行如何到达当前状态。只有保留这条因果链,系统才能知道应该从末端续跑,还是从中间节点重放,以及哪些外部副作用可能再次发生。
存储可以回到旧版本,现实世界却不会同步回退。邮件已经发出、工单已经创建、文件已经覆盖时,恢复一份旧消息数组并不能撤销这些结果。这是 Checkpoint 设计的起点。
Checkpoint 记录的是可恢复边界
一次 Agent 运行可能经历多轮模型调用和工具执行。只在整轮结束时保存,进程崩溃后就会重做整轮;每次迭代完成后保存,则最多丢失当前一步,但写入成本更高。
保存内容也不能只有 messages:
| 状态 | 恢复时解决的问题 |
|---|---|
| 消息历史 | 模型当时看到了什么 |
| 当前执行位置 | 下一步应该从哪里开始 |
| 待完成工具调用 | 是否还欠模型一个工具结果 |
| 累积业务状态 | 待办、计数器和已处理对象是否丢失 |
| 运行绑定 | 工具集、工作区和版本是否仍与当时一致 |
恢复的目标是还原“当时能够继续执行的完整条件”,而不只是还原说过的话。
安全恢复要求历史结构完整
工具调用与工具结果必须配对。如果系统在工具执行中被打断并直接落盘,历史末尾可能只剩一个悬空调用;恢复后将这份历史重新提交给模型,协议本身就可能无效。
因此可控打断不应等同于立即终止:
- 请求停止后先尝试运行到工具结果已经记录的安全边界;
- 无法等待时,为未完成调用写入明确的失败结果;
- 把待完成调用作为显式状态保存,而不是只放在调用栈局部变量中。
这只能保证历史结构完整,不能撤销工具已经产生的副作用。崩溃与打断的差别在于:打断还有机会完成配对;两者对外部世界造成的变化都需要额外处理。
链结构区分续跑与重放
每个 Checkpoint 指向前一个节点,形成一条可追溯链。链不仅支持查看历史,也明确区分两种操作:
- 续跑:从链末端继续,已经完成的步骤不再执行,适合崩溃恢复;
- 重放:从中间节点重新执行,其后的步骤可能再次产生副作用。
重放一封已经发送过的邮件会再发一封,即使内部状态恢复得完全正确。缓解方法包括:
- 在原链旁创建分支,不覆盖已有记录;
- 区分纯计算步骤与有副作用步骤;
- 让工具端使用幂等键识别重复请求;
- 在重放不可逆动作前请求人工确认。
所以“恢复”和“重新生成”不应该共用一条默认执行路径:前者可以自动续跑,后者必须显式承担重复副作用的风险。
我的选择:优先保证边界正确
我会在每次模型响应与对应工具结果完整写入后创建 Checkpoint,并让恢复逻辑只从完整节点续跑。对于高频、低价值步骤,可以在确认恢复窗口允许后降低落盘频率,但不能跨过不可逆工具调用而不留记录。
这套方案的代价是写放大和状态迁移成本。只有当任务本身无外部副作用、失败后整轮重试可以接受时,我才会退化为轮次级快照。存储层如何保留这条链,下一篇继续说明:只追加记录为什么不能随意删除中间节点。