幂等、超时与可观测性
本节目标
- 防止网络重试、进程恢复和模型重复调用产生重复副作用;
- 正确处理“下游可能成功但响应丢失”的不确定状态;
- 区分 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 摘要不变。
为什么“正好一次”很难
场景:
- 下游成功创建退款;
- Dispatcher 在保存结果前崩溃;
- 客户端只看到超时;
- 重试可能再次创建。
常用策略:
- 下游本身接受同一幂等键;
- 在同一事务中写业务状态与 outbox;
- 先持久化 intent/in_progress,再执行;
- 恢复时查询下游操作状态;
- 用唯一约束防重复;
- 对不可判定状态转人工,不盲目重试。
不能仅凭“请求超时”判断副作用未发生。
超时不是单一错误码
重试策略必须携带“已执行到哪一步”的证据,而不是只看异常名:
| 状态 | 已知事实 | 默认动作 |
|---|---|---|
TIMEOUT_BEFORE_EXECUTE | deadline 在 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 的持久化表和恢复流程,覆盖:
- 第一次成功;
- 同 key 同参数重试;
- 同 key 不同 amount;
- 下游成功、结果落库前崩溃;
- 审批过期;
- 用户权限在重试前撤销;
- 下游返回 429 和
Retry-After。
为每步写出状态转移、是否可重试和用户可见状态。
然后运行 SQLite 持久化幂等与 Outbox 恢复,对照你的纸面设计是否包含原子 intent/outbox、唯一 scope、lease 过期、下游 receipt 与显式对账。
常见错误
- 内存字典当生产幂等存储;
- 同 key 不比较参数摘要;
- 摘要包含临时 call ID;
- Timeout 后直接再发写请求;
- 只监控成功率,不监控重复抑制/冲突;
- 缓存命中跳过当前授权检查;
- 日志记录完整 tool arguments 和凭据。
自测
- 幂等键为何不能只用 call ID?
- 同 key 不同参数应怎样处理?
- 超时后为什么先查状态?
- 内存缓存无法覆盖哪些故障?
- 哪些 trace 字段能证明一次重试没有产生第二个副作用?
下一步:工具调用评测与离线项目。
参考资料
- RFC 9110:HTTP Semantics—Idempotent Methods
- OpenAI API:Function calling
- SQLite:Transaction
- SQLite:Write-Ahead Logging
- SQLite:UPSERT
- Stripe API:Idempotent requests
- AWS ECS:Ensuring idempotency
来源获取日期:2026-07-21。下游幂等头、保留期和重试规则必须以对应服务官方文档为准。