超时只说明调用方没有拿到确定结果,不说明远端什么都没发生。对带副作用的操作,"再试一次"可能不是恢复,而是制造第二次副作用。
Agent 很擅长自动恢复。
Tool 调用失败,它会换参数;网络抖动,它会重试;流程中断,它会从上一步继续。对查询、下载或纯计算任务,这种韧性通常是优点。到了退款、发消息、创建工单、更新生产配置这类写操作,情况变了。
设想一条很普通的调用链:
text
Agent 发送"创建采购单"请求
→ 服务端完成写入
→ 响应在返回途中丢失
→ Agent 看到超时
Agent 此时知道的只有一件事:它没有收到可确认成功的响应。
它不知道采购单是否已经创建。如果把"没收到成功"推理成"执行失败",然后生成一个新请求再试,系统里可能出现两张采购单。
这类状态叫 Unknown Outcome。它不是普通失败,也不该被一个通用重试器顺手抹掉。
超时不是失败状态,而是证据不足
很多调用代码只有两个分支:
text
success
failure
本文用下面三个状态治理分布式写操作的结果:
text
CONFIRMED_SUCCESS
CONFIRMED_FAILURE
UNKNOWN
这是一种架构抽象,不是 HTTP、RFC 或厂商定义的标准状态机。具体系统可以有 PENDING、IN_PROGRESS、TIMED_OUT 等更多中间状态,但不能丢掉"现有证据还不足以判断副作用是否发生"这一类。
三者的区别不在于错误信息长什么样,而在于我们能证明什么。
CONFIRMED_SUCCESS 表示权威系统已经确认目标状态成立。它可能来自写接口的可靠结果,也可能来自后续查询和业务读回。
CONFIRMED_FAILURE 表示系统能确认副作用没有发生,或者请求在进入执行前就被拒绝。例如参数校验失败、授权拒绝,且执行路径保证这些拒绝发生在任何写入之前。
UNKNOWN 表示证据还不足。常见原因包括:
- 请求已发出后连接断开;
- Client 超时,但 Server 仍在执行;
- Server 完成事务后,响应没有送达;
- Gateway 返回异常,但下游调用状态未知;
- 写入成功,后续索引、通知或发布步骤状态未知;
- 调用方自己的校验器在请求发出后报错。
最后一种很容易被忽略。报错来自本地验证逻辑,不代表远端 mutation 没有执行。如果验证器在副作用之后才发现字段类型、响应结构或日志格式不符合预期,调用方看到的是红色错误,现实世界却可能已经变化。
所以判断规则应该是:
text
没有成功证据
≠
拥有失败证据
HTTP 幂等不等于业务幂等
RFC 9110 将幂等方法定义为:对同一个请求执行一次或多次,服务器端预期效果相同。规范同时指出,客户端不应自动重试非幂等方法,除非它知道该请求的实际语义是幂等的,或者能够确认原请求从未被应用。
这个边界很重要,但还不够。
业务系统关心的不是 HTTP 动词,而是业务意图:
text
POST /refunds
可能创建一笔新退款,也可能根据一个稳定业务键返回原退款。两个接口都使用 POST,重试语义完全不同。
反过来,PUT 常被视为幂等,也不能自动证明一次业务流程安全。某个 PUT 可能更新主记录,但同时触发外部邮件、审批流或异步任务。主资源最后看起来相同,外部副作用却可能发生两次。
因此要分开看:
text
协议方法是否幂等
业务意图是否唯一
执行效果是否去重
跨系统副作用是否收敛
最终状态是否可读回
Agent Runtime 不能因为方法名是 PUT 就放心重试,也不能因为 POST 通常非幂等就放弃恢复。它需要理解 Action Contract 定义的真实语义。
幂等键不是随手生成的 Request ID
最常见的改进是给写请求加一个 idempotency_key。
方向没错,但"有一个字段"不等于幂等已经成立。
AWS Builders' Library 在讨论幂等 API 时,强调由调用方提供唯一请求标识,让同一调用方使用同一标识的后续请求被识别为重复请求。文章还专门讨论了"同一个 Client Request ID、不同意图"的问题:如果标识被错误复用,系统不能把两个不同业务意图静默合并。
Stripe 的 API 文档给出了一种具体实现:服务端保存某个幂等键第一次请求的状态码和响应体,后续相同键返回相同结果;同时比较后续请求参数与原请求,参数不同则报错,防止误用。
这些实现说明,可靠幂等至少要回答四个问题。
1. 谁生成键
键应在一次业务意图形成时生成,而不是每次网络尝试时重新生成。
错误方式:
text
第一次请求超时
→ 重试器生成新 UUID
→ 服务端看到一个全新请求
正确方向:
text
业务意图形成
→ 生成稳定 idempotency_key
→ 所有网络重试复用同一个 key
2. 键绑定什么
服务端不能只保存一个孤立字符串。幂等记录应绑定足以识别同一业务意图的上下文,例如:
yaml
idempotency_record:
key: idem_01...
tenant: tenant_a
subject: user_42
actor: refund_agent_v3
action: refund_order
target: order_1001
expected_version: "17"
payload_digest: sha256:...
authorization_ref: grant_...
这份 YAML 是示意合同,不是行业标准。
重点是:同一个 key 携带了不同 target、payload 或前置版本时,系统应拒绝,而不是猜测调用方想覆盖旧请求。对于高风险动作,还应考虑授权是否仍然有效,不能让一个旧幂等记录变成跨用户、跨任务或跨权限边界的通行证。
3. 重复请求返回什么
理想情况不是简单返回:
json
{ "duplicate": true }
调用方还需要第一次执行的稳定结果:
json
{
"action_id": "act_01...",
"status": "COMPLETED",
"resource_id": "refund_01...",
"result_version": "18"
}
否则 Agent 仍然不知道现实世界变成了什么。
4. 幂等记录保存多久
如果幂等键的保留时间比任务恢复窗口更短,晚到的重试仍可能被当成新请求。
保留多久没有统一答案。它取决于业务风险、消息延迟、人工恢复时间、审计要求和数据成本。关键是把期限写进合同,并让调用方知道过期后不能把同一个 key 当作永久保险。
先读回,再决定下一步
当结果进入 UNKNOWN,安全流程不是立刻重试,而是先查证。
text
收到不确定结果
→ 冻结该业务意图的新 mutation
→ 使用稳定 Action ID / Idempotency Key / Target 查询
→ 从权威系统读取当前状态
→ 将结果分类为成功、失败或仍未知
→ 再决定结束、补偿、重试或转人工
这里要特别区分正向证据和否定证据。
查到匹配的 Action、幂等记录、目标版本或业务唯一 ID,通常可以证明原操作至少进入了对应执行阶段。一次"未找到"或"仍是旧版本"却往往只是弱否定证据:缓存、索引、只读副本和最终一致性都可能暂时落后。
只有满足明确的未执行证明,才能把 UNKNOWN 转成 CONFIRMED_FAILURE,例如:
- 权威执行系统明确返回
NOT_APPLIED; - admission 或幂等记录证明请求没有进入执行;
- 查询接口的强一致性合同足以支撑"没有发生";
- 经过系统声明的最大可见性延迟和稳定复查,仍能确定目标状态未变化。
如果这些条件都不成立,结果就应继续保持 UNKNOWN,进入对账或人工处理。不能因为一次读取没看到变化,就立即发起第二次 mutation。
读回必须回答业务问题,而不只是技术问题。
下面这些证据不够:
text
HTTP 返回 200
日志里出现 success
任务队列显示 done
Agent 自己说已完成
它们分别证明某个接口、日志、队列或模型状态发生了变化,未必证明目标业务状态成立。
例如文章更新操作,真正的结果可能需要核对:
- 目标文章是否还是同一篇;
- 线上正文是否等于获批候选;
- 标题、分类和标签是否保持不变;
- 公开页面是否已经展示新内容;
- 是否只出现一次预期区块;
- 原技术正文是否被意外改写。
退款则要从支付或账务权威系统确认退款对象、金额、状态和唯一业务 ID。发消息要从发送平台确认目标、消息 ID 和最终接受状态。部署要读回运行版本、健康状态和目标环境,而不是只看发布命令退出码。
读回的对象应当与授权时批准的对象相同。
一次内容更新里的真实 Unknown Outcome
我最近维护一篇已经公开的技术文章,只允许增加系列导读和导航,标题、标签、分类和原技术正文都不能变化。
写入前先冻结了:
text
目标账号
目标文章
远端原始版本
允许差异
候选内容摘要
第一次写入没有返回可用的成功证明。接下来没有直接假设失败,而是重新读取编辑源和公开状态。旧版本读回本身仍然只是弱否定证据;这个案例还结合了请求返回的明确应用错误、写入阶段和稳定复查,确认第一条请求没有形成可见写入后,才允许修正请求继续。如果当时只有一次旧版本读回,流程仍应停在 UNKNOWN。
后来换到官方编辑路径。提交动作已经发出,但本地校验器因为字段表示差异报错。这次如果只看本地异常,很容易得出"保存失败"的结论。新的草稿读回却证明,远端已经保存了精确候选。
发布阶段又出现了相似情况:请求已经发送,事后校验器才拒绝当前响应。下一轮先读取公开页面,发现候选实际上已经上线,正文摘要、导航链接和允许差异全部匹配。流程于是停止,没有再次发布。
这段经历里,真正避免重复副作用的不是"接口最终成功了",而是每一次不确定结果之后都重新建立证据:
text
请求报错
→ 不推断远端失败
→ 查询草稿或公开状态
→ 比对冻结候选
→ 已完成则停止
→ 未完成且确认可安全继续,才进入下一步
案例中的账号、文章标识、内部路径和认证细节都不重要。重要的是一个很普遍的故障模式:副作用先发生,调用方后报错。
不要让模型决定能不能重试
模型可以帮助解释错误、选择查询工具或生成恢复建议,但重试许可应该由确定性策略给出。
可以把错误和恢复路径分成几类:
| 结果 | 能否自动重试 | 前提 |
|---|---|---|
| 本地参数校验失败,确认请求未发送 | 可以修正后发起新请求 | 新请求重新走授权和候选校验 |
| 连接建立前失败,确认服务端未收到 | 可以 | 仍复用原业务意图和授权边界 |
| 明确 401/403 或策略 Deny | 不可以 | 停止,不能换凭证绕过 |
| 明确版本冲突 | 不可直接覆盖 | 重新读取状态并重新决策 |
| 请求发出后超时 | 不可以直接重试 | 先查 idempotency record 或目标状态 |
| 返回 5xx,但服务端可能已执行 | 不可以直接重试 | 取决于服务端幂等合同和读回 |
| 权威执行记录、强一致查询或稳定复查确认原操作未发生 | 可以按合同重试 | 不能只依赖一次未找到或旧版本读回;授权、前置条件和候选仍然有效 |
| 查询确认已经完成 | 不可以 | 返回原结果并结束 |
| 查询后仍未知 | 不可以自动扩大动作 | 继续对账、补偿评估或转人工 |
表中的规则是架构建议,不是 HTTP 状态码的通用真值表。具体系统必须声明:请求在哪个阶段可证明未执行,哪些错误会保存幂等结果,查询接口是否覆盖所有副作用。
通用 Agent 重试器至少应拿到这些确定性输入:
yaml
retry_decision:
operation_class: mutation
request_sent: true
server_idempotency_supported: true
same_intent_key_available: true
authorization_still_valid: true
preconditions_still_valid: unknown
readback_status: unknown
decision: reconcile_first
模型可以阅读这份结果,但不能把 decision 从 reconcile_first 改成 retry_now。
跨系统流程不能只靠一个幂等键
一次 Agent Action 可能跨越多个系统:
text
创建订单
→ 扣减库存
→ 发起支付
→ 发送通知
入口幂等只能防止整条工作流被重复创建,不能自动保证每个下游步骤只发生一次。
更现实的目标是:
text
至少一次投递
+ 每一步的唯一 admission
+ 幂等或可补偿的业务效果
+ 持久化步骤回执
+ 最终对账
例如入口 Workflow ID 保证同一订单流程只有一个实例;库存步骤使用订单与库存版本做条件写入;支付步骤使用自己的业务幂等键;通知步骤保存消息 ID;恢复器根据持久化步骤状态继续,而不是从头重放全部 Tool。
如果某个下游不支持幂等,也没有可靠查询接口,它就不适合被通用自动重试器驱动。高风险场景应缩小自动化范围,增加人工异常队列,或者先改造下游合同。
上线前必须故意制造 Unknown Outcome
正常网络下跑通一次,证明不了恢复语义。
至少要测试下面这些场景:
- 服务端提交后,故意丢弃响应;
- Client 超时后,让原响应晚到;
- 同一个 key、同一个 payload 并发发送两次;
- 同一个 key 携带不同 payload;
- 同一个 key 换 target、tenant、subject 或授权引用;
- 幂等记录已存在,但第一次执行返回 5xx;
- 写入成功,读回暂时落后;
- 主资源成功,外部通知或下游步骤未知;
- 本地校验器在请求发送后抛错;
- 恢复进程重启后,能否继续查询原 Action,而不是生成新意图。
每个测试都要看最终副作用数量,而不是只看接口响应:
text
目标对象只创建一次
金额只扣减一次
消息只发送一次
预算只占用一次
审计只关联到一个业务意图
恢复后返回同一个 Action 结果
这才是幂等测试。断言"第二次调用也返回 200"远远不够。
什么情况下可以简单重试
不是所有失败都要进入重型对账。
纯读取、无外部副作用的确定性计算、可完整覆盖的缓存刷新,通常可以使用常规重试、指数退避和 jitter。某些低风险写入如果服务端提供成熟幂等合同、稳定结果查询和足够长的幂等窗口,也可以安全自动恢复。
关键不是禁止重试,而是让重试资格成为接口合同的一部分:
text
哪些操作可重试
由谁生成业务意图键
重复请求返回什么
参数变化如何处理
幂等状态保存多久
授权变化是否使恢复失效
如何查询真实结果
多系统副作用怎样对账
如果服务端回答不了这些问题,Agent 不应靠"再试一次"补齐合同。
结论
Agent 的价值之一,就是能从失败中继续工作。
但在写操作里,继续之前要先知道现实世界已经发生了什么。网络错误、超时、本地异常和 5xx 都可能只说明证据链断了,不说明副作用不存在。
一个成熟的恢复路径应该是:
text
冻结同一业务意图
→ 保留同一幂等身份
→ 阻止新的 mutation
→ 从权威系统读回
→ 分类为成功、失败或未知
→ 再决定结束、补偿、重试或人工处理
真正危险的不是系统偶尔超时。
而是 Agent 把"不知道"自动翻译成"没发生",然后非常勤奋地再做一次。
参考资料
- RFC 9110:HTTP Semantics,9.2.2 Idempotent Methods
- AWS Builders' Library:Making retries safe with idempotent APIs
- Stripe API Reference:Idempotent requests
发布摘要
Agent 的写操作超时后,最危险的恢复动作往往是直接重试。超时只说明调用方没有拿到确定结果,不代表远端副作用没有发生。本文区分确认成功、确认失败和 Unknown Outcome,解释 HTTP 幂等与业务幂等的差异,并给出幂等身份、权威读回、确定性重试决策、跨系统恢复和故障注入测试方法。
标签
AI Agent MCP Agent 安全 幂等 分布式系统 重试 Action Contract 企业架构 API 设计 可靠性