一个多步 Agent 任务跑了 40 分钟,第 12 步调外部接口超时,整个任务失败。用户点重试,它从第 1 步重新开始——已经调过的 11 次工具、已经付掉的 token、已经写了一半的中间产物,全部作废。第二次又在前面的某一步遇到限流,第三次才成功。用户等了两个多小时。
这类问题的修法不是"加大重试",而是在任务里放检查点:把"完成到哪一步、产出是什么"落成可恢复的状态。检查点的价值不是"更快",是让失败的成本从"全损"变成"损失最后一段"。
1. 检查点有三种,作用完全不同
| 类型 | 记录什么 | 失败后能做什么 |
|---|---|---|
| 进度检查点 | 走到第几步 | 从该步继续 |
| 数据检查点 | 中间产物(文件、结构化结果) | 复用不必重算 |
| 决策检查点 | 已经做出的判断与依据 | 不重复询问/推理 |
第一行只解决"跳过",第二行才解决"省成本"。只有进度而没有产物时,重试仍然要重跑那些步骤——因为它们的输出没留下来。
// 一次 12 步任务的成本分布(无检查点 vs 有)
无检查点:第 12 步失败 → 重跑 1–12,累计成本 2 倍
有进度检查点:从第 12 步重跑,成本 1.08 倍(仍重算中间产物)
有数据检查点:只重跑第 12 步,成本 1.03 倍
// 差别不在"重试机制",在"上一次的产出有没有留下来"
2. 检查点放哪:三个判据
| 位置 | 该放吗 | 理由 |
|---|---|---|
| 每次工具调用之后 | ✅ | 一次调用就是一个成本单元 |
| 每次不可逆操作之前 | ✅ | 需要人工确认的天然断点 |
| 长耗时步骤中途 | ❌(通常) | 恢复语义复杂,收益低 |
| 纯推理步骤之间 | ⚠️ 视情况 | 重算便宜时可跳过 |
第三行是最容易做过度的地方:把检查点放进步骤内部(比如一个 30 分钟的批处理的第 15 分钟),恢复时要处理"半批数据",语义复杂度远高于收益。除非那一步本身是可分片的幂等操作。
// 一个够用的检查点结构(存在会话状态里,不进 prompt)
checkpoint:
run_id: r-8821
step_index: 12
status: failed # running | failed | done
artifacts: # 数据检查点:产出落在哪
- { step: 3, kind: file, ref: s3://tmp/r-8821/extract.json }
- { step: 7, kind: structured, ref: state/r-8821/analysis.json }
decisions: # 决策检查点:别再问第二遍
- { step: 5, question: "时间范围", answer: "2025 全年" }
next_action: retry_step_12
3. 恢复语义:三件事必须先定清楚
| 问题 | 不定清楚的后果 |
|---|---|
| 从哪一步恢复 | 重复执行已完成的副作用 |
| 中间产物是否可靠 | 用半截数据继续算,结果错但看起来像对 |
| 外部副作用是否已发生 | 邮件发两次、订单下两单 |
第三行是恢复语义的核心:检查点必须记录"哪些不可逆动作已经执行"。这与 S21 中断那篇的 irreversible_done 是同一条纪律——能安全重跑的前提是"知道什么不能再跑"。
// 恢复时的判定表
step 已完成 + 无副作用 → 跳过,复用产物
step 已完成 + 有副作用 → 绝不重跑,标记为 done
step 未完成 + 幂等 → 重跑
step 未完成 + 非幂等且可能已发出 → 先去查状态(查单、查消息),再决定
// 最后一行是唯一安全做法:不能靠"大概没发出去"
4. 检查点的存储:别塞进上下文
| 存法 | 优点 | 问题 |
|---|---|---|
| 写进 prompt / 对话历史 | 实现简单 | 占 token、且会被截断 |
| 存会话状态(KV / DB) | 不占上下文、可跨会话 | 需要一次读写 |
| 存工作流引擎(Temporal 等) | 自动重试、可观测 | 引入依赖 |
第二种是多数场景的平衡点:检查点是"系统的记忆",不是"模型的记忆"。把它塞进 prompt 有两个坏处——白花钱,且长任务里它一定会在某个时刻被截掉,于是"记忆"在最需要的时候消失。
5. 什么时候该重来而不是恢复
| 场景 | 选择 | 理由 |
|---|---|---|
| 输入数据变了 | 重来 | 旧产物与新输入不一致 |
| 中间某步逻辑改了(发版) | 重来 | 新旧产物混用不可解释 |
| 失败了 3 次同一步 | 重来或改策略 | 说明该步本身有问题 |
| 产物已不可信(校验失败) | 重来 | 宁可全损也不要用错数据 |
第二行是很容易踩的:发版后恢复旧检查点,会把旧逻辑的产物和新逻辑的步骤拼在一起。所以检查点要么带版本号、要么发版时作废。
// 恢复前的三步校验(任何一步不过就丢弃检查点)
$ sha256sum input/*.json > /tmp/fp && diff -q /tmp/fp state/r-8821/fingerprint
$ jq -r .logic_version state/r-8821/cp.json // 与当前发版号比对
$ python3 verify.py state/r-8821/artifacts/ // schema + 行数 + 关键字段
// 三条都过 → 恢复;任一不过 → 从头开始,别硬凑
// 检查点的有效性判定(三条全过才允许恢复)
1 输入指纹未变(输入内容 hash 相同)
2 逻辑版本未变(checkpoint.logic_version == 当前版本)
3 产物通过校验(schema / 行数 / 关键字段)
// 任一不满足 → 丢弃检查点,从头开始
6. 排错:五种"有检查点还是重跑"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 恢复后仍从头跑 | 只存了进度没存产物 | 加数据检查点 |
| 恢复后结果错乱 | 产物是半截的 | 产物落盘要原子写 |
| 恢复后副作用重复 | 未记录不可逆动作 | 加 irreversible_done |
| 检查点越来越大 | 把原始数据也存了 | 只存引用,不存内容 |
| 发版后恢复报错 | 无版本号 | 检查点带 logic_version |
// 原子写产物的顺序(避免读到半截文件)
1 写 artifacts/step-7.json.tmp
2 fsync(确保落盘而不是在页缓存里)
3 rename step-7.json.tmp → step-7.json
// 第 3 步在同文件系统内是原子操作;跨设备 rename 会失败,不要跨挂载点
第二行的修法是"先写临时文件再 rename"——原子替换保证读到的产物要么是完整的旧版、要么是完整的新版,绝不会是写了一半的。
7. 边界:什么任务不需要检查点
| 场景 | 为什么 | 说明 |
|---|---|---|
| 单步任务(一次调用) | 没有中间态 | 重试就是恢复 |
| 总耗时 < 30 秒 | 重跑成本可忽略 | 不值得 |
| 完全幂等且便宜 | 重跑无损失 | 直接重试 |
| 中间态无法序列化 | 强行做会出错 | 接受重跑 |
判断口径一句话:检查点的收益 = (已完成步骤的成本)×(失败概率),成本 = 序列化与恢复的复杂度——所以只在"任务够长 + 失败不罕见 + 中间态可序列化"三者同时成立时才值得做。反过来,短任务加检查点只是把复杂度提前引入。
8. 自测
- 三种检查点分别记录什么?为什么只有"进度"而没有"产物"时重试仍要重算?
- 检查点该放在哪三个位置?为什么放进"长耗时步骤中途"通常不划算?
- 恢复语义必须先定清楚哪三件事?哪一件关乎重复副作用?
- 为什么检查点不该塞进 prompt?除了费钱还有什么更严重的后果?
- 什么情况下该重来而不是恢复?发版后恢复旧检查点会发生什么?
- 检查点收益的公式是什么?三者中缺哪一个就不值得做?
9. 一句话总结
检查点的价值不是"更快",是让失败成本从"全损"变成"损失最后一段"——所以它必须同时记"进度"与"产物",只记进度等于没记。三件必须先定的事:从哪步恢复、产物是否原子、哪些不可逆动作已经发生(后者与中断那篇的 irreversible_done 同源)。检查点属于系统的记忆、不属于模型的记忆,塞进 prompt 只会在最需要时被截断。判据:任务够长 + 失败不罕见 + 中间态可序列化,三者缺一就别做。