Agent 写操作超时后,最危险的动作是“再试一次”

超时只说明调用方没有拿到确定结果,不说明远端什么都没发生。对带副作用的操作,"再试一次"可能不是恢复,而是制造第二次副作用。

Agent 很擅长自动恢复。

Tool 调用失败,它会换参数;网络抖动,它会重试;流程中断,它会从上一步继续。对查询、下载或纯计算任务,这种韧性通常是优点。到了退款、发消息、创建工单、更新生产配置这类写操作,情况变了。

设想一条很普通的调用链:

text 复制代码
Agent 发送"创建采购单"请求
→ 服务端完成写入
→ 响应在返回途中丢失
→ Agent 看到超时

Agent 此时知道的只有一件事:它没有收到可确认成功的响应。

它不知道采购单是否已经创建。如果把"没收到成功"推理成"执行失败",然后生成一个新请求再试,系统里可能出现两张采购单。

这类状态叫 Unknown Outcome。它不是普通失败,也不该被一个通用重试器顺手抹掉。

超时不是失败状态,而是证据不足

很多调用代码只有两个分支:

text 复制代码
success
failure

本文用下面三个状态治理分布式写操作的结果:

text 复制代码
CONFIRMED_SUCCESS
CONFIRMED_FAILURE
UNKNOWN

这是一种架构抽象,不是 HTTP、RFC 或厂商定义的标准状态机。具体系统可以有 PENDINGIN_PROGRESSTIMED_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

模型可以阅读这份结果,但不能把 decisionreconcile_first 改成 retry_now

跨系统流程不能只靠一个幂等键

一次 Agent Action 可能跨越多个系统:

text 复制代码
创建订单
→ 扣减库存
→ 发起支付
→ 发送通知

入口幂等只能防止整条工作流被重复创建,不能自动保证每个下游步骤只发生一次。

更现实的目标是:

text 复制代码
至少一次投递
+ 每一步的唯一 admission
+ 幂等或可补偿的业务效果
+ 持久化步骤回执
+ 最终对账

例如入口 Workflow ID 保证同一订单流程只有一个实例;库存步骤使用订单与库存版本做条件写入;支付步骤使用自己的业务幂等键;通知步骤保存消息 ID;恢复器根据持久化步骤状态继续,而不是从头重放全部 Tool。

如果某个下游不支持幂等,也没有可靠查询接口,它就不适合被通用自动重试器驱动。高风险场景应缩小自动化范围,增加人工异常队列,或者先改造下游合同。

上线前必须故意制造 Unknown Outcome

正常网络下跑通一次,证明不了恢复语义。

至少要测试下面这些场景:

  1. 服务端提交后,故意丢弃响应;
  2. Client 超时后,让原响应晚到;
  3. 同一个 key、同一个 payload 并发发送两次;
  4. 同一个 key 携带不同 payload;
  5. 同一个 key 换 target、tenant、subject 或授权引用;
  6. 幂等记录已存在,但第一次执行返回 5xx;
  7. 写入成功,读回暂时落后;
  8. 主资源成功,外部通知或下游步骤未知;
  9. 本地校验器在请求发送后抛错;
  10. 恢复进程重启后,能否继续查询原 Action,而不是生成新意图。

每个测试都要看最终副作用数量,而不是只看接口响应:

text 复制代码
目标对象只创建一次
金额只扣减一次
消息只发送一次
预算只占用一次
审计只关联到一个业务意图
恢复后返回同一个 Action 结果

这才是幂等测试。断言"第二次调用也返回 200"远远不够。

什么情况下可以简单重试

不是所有失败都要进入重型对账。

纯读取、无外部副作用的确定性计算、可完整覆盖的缓存刷新,通常可以使用常规重试、指数退避和 jitter。某些低风险写入如果服务端提供成熟幂等合同、稳定结果查询和足够长的幂等窗口,也可以安全自动恢复。

关键不是禁止重试,而是让重试资格成为接口合同的一部分:

text 复制代码
哪些操作可重试
由谁生成业务意图键
重复请求返回什么
参数变化如何处理
幂等状态保存多久
授权变化是否使恢复失效
如何查询真实结果
多系统副作用怎样对账

如果服务端回答不了这些问题,Agent 不应靠"再试一次"补齐合同。

结论

Agent 的价值之一,就是能从失败中继续工作。

但在写操作里,继续之前要先知道现实世界已经发生了什么。网络错误、超时、本地异常和 5xx 都可能只说明证据链断了,不说明副作用不存在。

一个成熟的恢复路径应该是:

text 复制代码
冻结同一业务意图
→ 保留同一幂等身份
→ 阻止新的 mutation
→ 从权威系统读回
→ 分类为成功、失败或未知
→ 再决定结束、补偿、重试或人工处理

真正危险的不是系统偶尔超时。

而是 Agent 把"不知道"自动翻译成"没发生",然后非常勤奋地再做一次。


参考资料

  1. RFC 9110:HTTP Semantics,9.2.2 Idempotent Methods
  2. AWS Builders' Library:Making retries safe with idempotent APIs
  3. Stripe API Reference:Idempotent requests

发布摘要

Agent 的写操作超时后,最危险的恢复动作往往是直接重试。超时只说明调用方没有拿到确定结果,不代表远端副作用没有发生。本文区分确认成功、确认失败和 Unknown Outcome,解释 HTTP 幂等与业务幂等的差异,并给出幂等身份、权威读回、确定性重试决策、跨系统恢复和故障注入测试方法。

标签

AI Agent MCP Agent 安全 幂等 分布式系统 重试 Action Contract 企业架构 API 设计 可靠性

相关推荐
Sky1987star11 分钟前
Sales Agent OS 为什么必须保留人工接管与结果回写?
大数据·人工智能
茉莉玫瑰花茶12 分钟前
知识库的构建 [ 4 ]
人工智能·机器学习
机械改造鹅14 分钟前
从零开始拆解Pi系列——(6)skills 机制
agent
pnoker14 分钟前
MCP 落地工业平台:从大模型对话到设备点位
人工智能·物联网·智能体·mcp
方方洛17 分钟前
vllm教程-03-架构设计
算法·架构
Microsoft Word17 分钟前
AI Agent入门:从LLM、上下文和工具到Harness工程
大数据·人工智能·机器学习
tachibana220 分钟前
Agent 的长短期记忆系统
人工智能·ai·大模型·llm·agent
Rain的Java大神之路21 分钟前
如何保证接口幂等
java·经验分享·后端·面试·架构
前端的阶梯24 分钟前
AI Agent 全景知识基础
人工智能