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 被当成最新状态。恢复时:

  1. 严格解析,拒绝重复 key 和非有限数字;
  2. 验证 envelope、字段与 schema version;
  3. 验证 hash/签名;
  4. 检查业务不变量;
  5. 取得 run lease;
  6. 再开始推进。

业务不变量不能只验证字段类型。例如 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

若状态数据库与消息队列不是同一事务:

  1. 在状态事务中写 state + outbox event;
  2. relay 异步发送;
  3. consumer 幂等处理;
  4. 标记 delivered。

这避免状态提交但事件丢失。

Saga/compensation

多个不可原子步骤:

预留库存 → 扣款 → 创建发货

若发货失败,补偿可能是退款和释放库存。补偿本身也可能失败,需要独立状态、幂等和人工对账。不要向用户承诺传统事务式完全回滚。

版本迁移

长任务可能跨部署。Checkpoint 记录:

  • state/checkpoint schema;
  • tool schema/contract;
  • policy/runner;
  • model/provider(若影响复现);
  • capability/authorization policy。

新代码:

  1. 明确迁移旧 checkpoint;
  2. 或安全拒绝并给出人工恢复;
  3. 绝不能把缺失字段默认解释为 approved=true;
  4. 用历史 checkpoint fixture 做升级/回滚测试。

本库项目怎样演示

综合 Agent 项目:

  1. 在 waiting_approval 状态保存带 hash 的 checkpoint。
  2. 审批绑定 action fingerprint + state version + target scope + expiry。
  3. tool 提交关闭并保存内存 receipt。
  4. runtime 在写回 state 前模拟 crash。
  5. 从旧 checkpoint 恢复。
  6. 用同一 idempotency key 查询 receipt,确认 intent 一致。
  7. 不再次关闭,只记录 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 条怎样报告?

自测

  1. 只保存 current_index=20 为什么不足?
  2. 同一 idempotency key + 不同参数为何必须冲突?
  3. SHA-256 checkpoint 为何不能防恶意篡改?
  4. retry、resume 与 replay 的差别是什么?
  5. lease 与 state version 各解决什么竞争?

能画出四个 crash window,并为每个给出恢复证据,才算掌握。

下一步

进入 人在回路与控制权,把长时间人工等待也设计成持久状态。

参考资料

以下为第一方工程资料,获取/复核日期:2026-07-21。