Agent 长任务检查点、恢复与幂等
本节目标
学完后,你应能:
- 识别“外部动作已提交、状态尚未记录”的 crash window。
- 设计可验证、可迁移的 checkpoint 与恢复协议。
- 用 idempotency key + intent digest + receipt 避免重复副作用。
- 区分 retry、resume、replay、compensation 与 exactly-once 幻觉。
为什么长任务一定会中断
长任务会跨越:
- 模型/API timeout 与限流;
- worker 重启、部署和机器故障;
- 人工审批等待;
- 第三方服务暂时不可用;
- context 压缩或 provider 会话失效;
- 用户取消和预算暂停。
因此“进程一直活着、内存对象一直存在”不是可接受前提。Durable execution 的目标是:控制状态持久化后,新的 worker 能从已知点继续,并能判断外部动作到底发生过没有。
Checkpoint 保存什么
建议在模型回合、工具副作用、审批边界和重要子目标后保存:
- state/checkpoint schema version;
- run、tenant/user、目标与 phase;
- state version 与最后 event sequence;
- 计划摘要、完成/待办;
- pending action 全参数或安全引用、fingerprint、idempotency key;
- approval 的 decision、scope、版本和 expiry;
- 已完成 action ID 与外部 receipt;
- budget 已用/剩余、deadline;
- observation 的来源、hash、受控引用;
- runner/tool/model/schema 版本;
- next eligible time 与 retry count。
不应依赖保存完整 provider 内部对象;context 应能从状态、事件和外部内容重建。
原子性与完整性
Checkpoint 写入要原子:临时写→fsync/事务→rename/commit,不能让半个 JSON 被当成最新状态。恢复时:
- 严格解析,拒绝重复 key 和非有限数字;
- 验证 envelope、字段与 schema version;
- 验证 hash/签名;
- 检查业务不变量;
- 取得 run lease;
- 再开始推进。
业务不变量不能只验证字段类型。例如 event sequence 与 state version 应连续;waiting_approval 必须存在冻结的 pending action 和形成该动作所需的前置 observation;非等待状态不应保留仍可执行的 pending action;completed 必须符合明确的完成证据路径:写入完成要有动作与外部 receipt,无写入的“目标已满足”也要有当前外部读取证据。否则一个“能解析、hash 也匹配”的不可能状态仍可能跳过读取、审批或验证步骤。
普通 SHA-256 只能发现偶然损坏;能修改 payload 的攻击者也能重算 hash。对敌手篡改需要受保护数据库、MAC/签名、访问控制和审计。
四个 crash window
| 崩溃位置 | 外部副作用 | 状态记录 | 恢复策略 |
|---|---|---|---|
| action 前 | 无 | 可能有 pending intent | 安全重试 |
| 请求发出、结果未知 | 未知 | pending | 用 idempotency key/查询状态,不能盲重试 |
| 外部已提交、receipt 未进 checkpoint | 有 | 仍显示 pending | 查 receipt/目标状态,识别已完成 |
| receipt 与状态已耐久记录后 | 有且已记录 | completed action | 从下一步继续 |
第三行是本库项目实际模拟的窗口。
Exactly-once 通常是跨系统幻觉
本地数据库事务无法原子覆盖任意第三方 API。更现实的目标:
- at-least-once delivery + idempotent effect;
- 每个副作用有稳定 idempotency key;
- server 持久保存 key、intent digest 与 result/receipt;
- 重放相同 key + 相同 intent 返回缓存结果;
- 相同 key + 不同 intent 必须冲突;
- 结果未知时先 query/reconcile。
idempotency_key = run_id + logical_action_id + normalized_target + contract_version
intent_digest = hash(tool + normalized arguments + target)不要用随机重试 ID;那会让每次重试看起来都是新动作。
Intent 与 receipt
执行前持久化 intent:
{ // 一个准备执行外部写操作的冻结 intent
"action_id": "close-current-ticket", // runtime 内稳定的动作标识,恢复时不得替换
"tool": "close_ticket", // 将被调用的受控写工具,仍需 allowlist 与授权校验
"target": "ticket-7", // 精确目标,不能由不可信 observation 临时改写
"idempotency_key": "run-42:ticket-7:close:v1", // 同一 intent 重试时复用,帮助工具端去重
"intent_digest": "sha256:..." // 对规范化 intent 的摘要,用来发现同 key 不同意图
}JSONC 教学表示
这些
//注释只服务阅读;提交给严格 JSON API 或文件前应删除。
完成后记录:
{ // 工具端返回、可用于崩溃恢复的最小回执
"idempotency_key": "run-42:ticket-7:close:v1", // 必须回显调用时的 key,才能安全关联原 intent
"intent_digest": "sha256:...", // 必须与冻结 intent 相同,防止误复用其他写操作的回执
"receipt_id": "receipt-123", // 外部系统的可审计回执 ID
"target_version": 9, // 写入后的目标版本,可辅助检查并发更新
"status": "closed" // 外部系统确认的最终状态,而非模型的文字声明
}恢复时只有 digest 一致才可复用 receipt。相同 key 指向 ticket-8 是严重冲突,不能“为了继续”覆盖。
若写请求可能已经发出,但 adapter 返回畸形结果、连接在响应前断开或无法证明 receipt,结果是未知而不是“失败所以可重新发送”。持久保留 action、target、idempotency key 与错误分类,先用同 key 查询/reconcile;不要换 key 重放。示例将这类结果标为 tool_result_uncertain 并失败关闭,真实系统通常还要提供受控的人工对账/恢复入口。
Retry 不是 Resume
- retry:重新尝试一个失败且可重试的调用。
- resume:从持久状态继续整个 run。
- replay:按事件历史重建确定性状态;外部 I/O 必须隔离,不能在 replay 中再次产生副作用。
- compensation:执行一个语义上的反向动作,非数据库 rollback。
Temporal 等 durable workflow 平台通过持久事件与确定性 replay 提供编排基础;使用时仍要理解 activity 的 timeout、retry 和幂等。框架不能替第三方 API 自动实现业务 exactly-once。
Retry policy
只重试明确 transient 的情况,例如限流、短暂网络中断、服务 5xx;通常不重试:
- schema/参数错误;
- 权限或业务拒绝;
- 用户 rejection;
- idempotency conflict;
- 不支持的工具/版本。
Retry 需要:
- 每类错误最大次数;
- exponential backoff + jitter(真实网络);
- 单次与总 deadline;
- respect
Retry-After; - 同一 idempotency key;
- 可观测 attempt 与最终 stop reason。
Agent 的“换一个参数试试”是新 intent,不应复用旧 idempotency key。
Lease 与并发恢复
两个 worker 可能同时看见可恢复 run。使用带过期的 lease:
run_id, owner_worker, lease_version, expires_at取得 lease 后仍用 state version 乐观锁。Lease 过期允许接管,但旧 worker 必须在每次写状态/外部动作前确认自己仍持有有效 lease。仅靠 “正在运行” 布尔字段无法处理 worker 死亡。
Outbox 与补偿
Transactional outbox
若状态数据库与消息队列不是同一事务:
- 在状态事务中写 state + outbox event;
- relay 异步发送;
- consumer 幂等处理;
- 标记 delivered。
这避免状态提交但事件丢失。
Saga/compensation
多个不可原子步骤:
预留库存 → 扣款 → 创建发货若发货失败,补偿可能是退款和释放库存。补偿本身也可能失败,需要独立状态、幂等和人工对账。不要向用户承诺传统事务式完全回滚。
版本迁移
长任务可能跨部署。Checkpoint 记录:
- state/checkpoint schema;
- tool schema/contract;
- policy/runner;
- model/provider(若影响复现);
- capability/authorization policy。
新代码:
- 明确迁移旧 checkpoint;
- 或安全拒绝并给出人工恢复;
- 绝不能把缺失字段默认解释为
approved=true; - 用历史 checkpoint fixture 做升级/回滚测试。
本库项目怎样演示
- 在 waiting_approval 状态保存带 hash 的 checkpoint。
- 审批绑定 action fingerprint + state version + target scope + expiry。
- tool 提交关闭并保存内存 receipt。
- runtime 在写回 state 前模拟 crash。
- 从旧 checkpoint 恢复。
- 用同一 idempotency key 查询 receipt,确认 intent 一致。
- 不再次关闭,只记录 evidence 并完成。
限制:示例 tool store 仍在同一 Python 进程内,用来模拟“外部系统继续存活”;真实 receipt 必须持久化到独立可靠存储。示例会在一次未中断的 run 内把 lookup、receipt 查询和写入都计入工具预算;但若进程在新计数 checkpoint 前崩溃,旧 checkpoint 仍不知道那次在途调用。生产系统要在外部 I/O 前持久记录 attempt/intent,并结合 provider 侧限额、审计和 reconciliation,不能把内存计数当跨崩溃配额证明。
动手练习
设计“分批发送 100 条通知”的状态机。进程在第 20 条发送成功但写 checkpoint 前崩溃:
- idempotency key 如何构造?
- receipt 存哪里?
- 第 20 条结果未知时先查什么?
- 第 21 条什么时候可开始?
- 若下游不支持幂等,如何做 intent、查询和人工对账?
- 取消时对已发送 19/20 条怎样报告?
自测
- 只保存
current_index=20为什么不足? - 同一 idempotency key + 不同参数为何必须冲突?
- SHA-256 checkpoint 为何不能防恶意篡改?
- retry、resume 与 replay 的差别是什么?
- lease 与 state version 各解决什么竞争?
能画出四个 crash window,并为每个给出恢复证据,才算掌握。
下一步
进入 人在回路与控制权,把长时间人工等待也设计成持久状态。
参考资料
以下为第一方工程资料,获取/复核日期:2026-07-21。