幂等、超时与可观测性

本节目标

  • 防止网络重试、进程恢复和模型重复调用产生重复副作用;
  • 正确处理“下游可能成功但响应丢失”的不确定状态;
  • 区分 call、operation、approval 与 idempotency 标识;
  • 建立可审计 trace、指标和故障恢复。

幂等的直觉

幂等操作重复执行一次或多次,预期业务效果与执行一次相同。HTTP RFC 9110 对方法语义定义了 idempotent 概念,但业务工具不能只看 HTTP method:

  • GET order 通常天然幂等;
  • POST create_refund 可能重复创建;
  • send_email 即使请求相同也会重复发送;
  • set_status("closed") 可能幂等,但触发的通知未必幂等。

必须按业务效果设计。

稳定幂等键与请求摘要

写操作接收客户端/编排层产生的 idempotency key。服务持久化:

idempotency_key
tenant / subject / action
canonical_argument_digest
status: in_progress | succeeded | failed
result reference
created_at / expires_at

规则:

  • 先按 tenant、subject/service principal 与 action/tool 定义 key 的命名空间,不能让其他租户用相同字符串占用全局 key;
  • 同 key + 同请求摘要:返回原状态/结果,不再执行;
  • 同 key + 不同摘要:IDEMPOTENCY_CONFLICT;
  • key 不存在:原子创建 in_progress,再执行;
  • 结果过大:保存引用,不复制整份敏感数据;
  • 缓存/记录需跨进程持久化,内存只适合教学。

Tool Result v2 dispatcher 只证明单 runtime 内的顺序重试;它的内存映射没有原子 in_progress 预占、数据库唯一约束或跨连接恢复证据。SQLite 持久化项目在不改变 v2 digest/call binding 语义的前提下,补充 BEGIN IMMEDIATE、唯一约束、operation ledger、transactional outbox、lease、receipt 对账和多连接竞争测试。它证明本地约束与恢复流程,仍不证明分布式 exactly-once。

摘要示例:

Canonical JSON 要固定键顺序、Unicode 和数字表示。业务 request digest 使用完整 64 位 SHA-256,不包含临时 call ID 或 idempotency key:前者是供应商关联,后者是重试/执行身份,都不应改变“业务意图是否相同”的判断;输出/effect revision 改变时则必须产生不同摘要,避免把旧结果当成新合同的缓存。Idempotency key 仍必须进入独立 call binding,使同一 package 不能被换到另一个 key 的调用上下文。对已触达下游的结果,call binding 还必须覆盖 downstream request ID、receipt ID 与 opaque status_ref;否则攻击者可只替换恢复证据而保持 request/result 摘要不变。

为什么“正好一次”很难

场景:

  1. 下游成功创建退款;
  2. Dispatcher 在保存结果前崩溃;
  3. 客户端只看到超时;
  4. 重试可能再次创建。

常用策略:

  • 下游本身接受同一幂等键;
  • 在同一事务中写业务状态与 outbox;
  • 先持久化 intent/in_progress,再执行;
  • 恢复时查询下游操作状态;
  • 用唯一约束防重复;
  • 对不可判定状态转人工,不盲目重试。

不能仅凭“请求超时”判断副作用未发生。

超时不是单一错误码

重试策略必须携带“已执行到哪一步”的证据,而不是只看异常名:

状态已知事实默认动作
TIMEOUT_BEFORE_EXECUTEdeadline 在 handler/下游提交前到期,已证明无副作用检查剩余总 deadline 后可受控重试
OUTCOME_UNKNOWN下游可能已提交,但响应或本地落库丢失使用 dispatcher 返回的 opaque status_ref 调用显式状态查询;无证据时人工复核
已对账成功receipt 的主体、工具、请求摘要与结果匹配回填本地记录并返回原结果,不再次执行
对账冲突同 key 对应不同请求摘要或下游状态漂移停止并升级,不能选择性相信其中一个结果

因此单个 retryable=true/false 不足以表达恢复协议。本项目改用有限 recovery:retry_after 只用于已知未开始的暂态失败,query_status 用于结果未知,human_review 用于冲突。生产 trace 还应记录 timeout stage、downstream request/receipt ID、对账来源和最终 resolution。

v2 与 SQLite 两个项目都要求:receipt 可查询时,调用者必须显式执行 query_operation_status(status_ref, expected_request_sha256) 才能回填原结果;receipt 暂不可得时,查询仍返回未知,直到外部对账或人工处置。再次调用原写工具只会继续返回未知,不会偷偷对账,更不会再次执行。状态查询会重新检查 tenant/resource 授权、请求摘要、效果/producer revision、receipt 与严格格式的 status_ref,并把 provider call identity 绑定到 query_status 用途,不能再复用为新的 dispatch 意图。07 用内存标记演示控制流,08 则把 ledger/outbox/receipt 落到 SQLite 并验证重启和多连接语义。

写 handler 抛异常或返回无法通过输出合同的数据时,宿主不能据此断言“没有发生副作用”。v2 示例把该幂等 scope 记为 unknown、返回恢复引用并阻止 dispatch 重放;只有外部证据能把它收敛为成功、失败或人工处置。只读 handler 异常则可安全归为工具错误,因为示例合同明确其无写副作用。

Call ID、Operation ID 与 Key

标识是否可跨模型重试稳定主要用途
call ID通常不保证关联一次 call/result
operation ID应稳定追踪整个任务/工作流
idempotency key对同一业务意图稳定抑制重复副作用
approval ID/digest绑定确切动作人类授权与审计
downstream request ID下游生成与依赖排障

不要把一个 ID 承担所有职责。

Deadline 与重试

预算层级:

  • 连接、读取与单 attempt timeout;
  • 单工具 deadline;
  • 单模型回合 deadline;
  • 整个 operation deadline;
  • 审批等待 expiry。

重试规则:

  • 只重试确定为 transient 的错误;
  • 使用指数退避与 jitter;
  • 遵守 Retry-After;
  • 检查剩余总 deadline;
  • 写操作先查幂等状态;
  • 限制 attempts 与并发;
  • 熔断持续故障依赖;
  • Retry budget 本身纳入指标。

参数错误、无权限、审批无效和幂等冲突默认不自动重试。

审批与幂等顺序

首次写入:

schema/auth → idempotency conflict/local-record/uncertain check
            → approval binding for new execution → execute → persist result
 
OUTCOME_UNKNOWN → explicit query_operation_status(status_ref)
                → auth + request/effect/source binding + receipt check

已成功的同 key/同摘要重试可在重新确认当前主体仍有权读取结果后返回缓存,而不再次产生副作用,也不要求重放一次性审批。若权限已撤销,不能因为缓存存在就泄露数据;若记录仍是 uncertain,则只能对账或转人工,不能当成首次执行重新越过审批。

可观测性

Trace 字段

  • operation ID、provider response ID、call ID;
  • tool/schema/adapter/handler revision;
  • tenant/subject 的脱敏标识;
  • idempotency key 哈希、request digest;
  • approval ID/policy revision;
  • start/end、queue、attempt、deadline;
  • result/error code、outcome、delivery、recovery;
  • request/result/call binding 摘要与验证失败分类;
  • downstream request ID、receipt ID 与 opaque status reference;
  • 参数摘要与数据分类;
  • 模型回合、token/成本与终止原因。

不要记录密钥、认证头、完整敏感参数或未经必要性评估的 tool result。

指标

  • 工具选择/参数合规;
  • auth denied、approval required/expired;
  • idempotency hit/conflict;
  • side-effect duplicate(应为 0);
  • timeout stage、outcome unknown、receipt reconciliation、rate limit、retry exhausted;
  • p50/p95/p99;
  • partial success/compensation;
  • per-tool 成功、成本和任务完成。

平均延迟会掩盖尾部。按工具、tenant、风险和版本切片。

动手实践

设计 create_refund 的持久化表和恢复流程,覆盖:

  1. 第一次成功;
  2. 同 key 同参数重试;
  3. 同 key 不同 amount;
  4. 下游成功、结果落库前崩溃;
  5. 审批过期;
  6. 用户权限在重试前撤销;
  7. 下游返回 429 和 Retry-After。

为每步写出状态转移、是否可重试和用户可见状态。

然后运行 SQLite 持久化幂等与 Outbox 恢复,对照你的纸面设计是否包含原子 intent/outbox、唯一 scope、lease 过期、下游 receipt 与显式对账。

常见错误

  • 内存字典当生产幂等存储;
  • 同 key 不比较参数摘要;
  • 摘要包含临时 call ID;
  • Timeout 后直接再发写请求;
  • 只监控成功率,不监控重复抑制/冲突;
  • 缓存命中跳过当前授权检查;
  • 日志记录完整 tool arguments 和凭据。

自测

  1. 幂等键为何不能只用 call ID?
  2. 同 key 不同参数应怎样处理?
  3. 超时后为什么先查状态?
  4. 内存缓存无法覆盖哪些故障?
  5. 哪些 trace 字段能证明一次重试没有产生第二个副作用?

下一步:工具调用评测与离线项目。

参考资料

来源获取日期:2026-07-21。下游幂等头、保留期和重试规则必须以对应服务官方文档为准。