跳到主要内容

智能体幂等性是一项编排契约,而非工具属性

· 阅读需 12 分钟
Tian Pan
Software Engineer

客服工单在上午 9:41 送达:“我被扣了三次费。”链路追踪看起来无异常。一条用户消息,一次规划器轮转,三次对 charge_card 的调用 —— 每次都有唯一的工具调用 ID,每次都返回 200 OK,每次都写入了不同的 Stripe 扣款。工具本身有幂等键,后端有去重表,支付处理器也遵循 Idempotency-Key。每一层都是幂等的,但客户依然支付了三次。

如果你构建 Agent 的时间足够长,这类 Bug 迟早会出现在你的桌上。它不是任何工具的 Bug,而是 Agent 循环与工具之间契约的 Bug,而这种契约几乎总是只存在于资深工程师的脑海中。

直觉反应是在某处增加另一个重试键。真正的修复方法是意识到,幂等性并不是一个你可以强行附加给每个工具的属性。它是一个必须贯穿编排边界的协议。而在 Agent 中,编排边界是一个异常艰险的领域:决定何时重试的实体是一个非确定性的语言模型,它对自己已经做过的事情没有记忆。

工具是幂等的,但 Agent 不是

工具层面的幂等性是一个被广泛理解的规范。你为端点提供一个客户端生成的 Idempotency-Key,将该键与响应一起存入去重表,任何带有相同键的后续调用都会返回缓存的结果,而不是重新执行任务。Stripe 让这种模式声名大噪。每一个对接支付集成的生产工程师都至少实现过一次。

这一规范隐含地假设了客户端知道自己何时在进行重试。在经典的 HTTP 重试循环中确实如此:客户端捕获了超时,保留了原始键,然后重新发起请求。由于客户端是同一个进程,且变量存储在内存中,键在多次重试之间是稳定的。

Agent 循环打破了这一假设。当规划器第二次发出 charge_card 指令时,它并不是在重试,而是在“重新决定”。模型并没有一个存储“我上次使用的键”的隐藏变量。它只有对话历史、系统提示词以及它即将采样的 Token。如果第一个工具调用结果没有清晰地回到上下文中 —— 调用超时、响应被截断、用户中断、子 Agent 中途崩溃、审批 UI 已渲染但用户点击了两次批准 —— 模型会愉快地重新规划相同的动作,而你的编排层也会愉快地执行它。这会带上一个全新的工具调用 ID,因为在工具契约层面,一个全新的 ID 就意味着一个全新的请求。

三次扣款,三个 Stripe 幂等键,三次去重表未命中,三个成功的 200。工具是幂等的,但 Agent 不是。

幂等键必须存在于编排边界

修复方法说起来简单,实现起来却很难:幂等键必须由编排器推导出来,而不是由工具调用产生。它必须在 LLM 重新规划、崩溃恢复回放、人工介入中断时保持稳定,并且必须由运行时贯穿到工具调用中,而不是由模型合成。

在实践中行之有效的结构类似于 (agent_run_id, step_id, tool_name, business_scope)agent_run_id 将键锁定到特定的用户请求。step_id 是“逻辑”步骤,而不是工具调用计数器 —— “向客户收费”的两次重新规划应该折叠为同一个 step_idtool_name 限定了作用域,使得同一运行中的 refund_card 不会与 charge_card 冲突。business_scope —— 客户 ID、订单 ID 等动作涉及的对象 —— 则是最后的防线,防止 Agent 在稍后的运行中为了一个真正不同的目的决定调用相同的工具。

关键在于,键不能源自模型的工具参数。对参数进行哈希是一个诱人的捷径 —— “参数相同,键就相同” —— 但在模型第一次改述自己的计划时它就会失效。用户说“再试一次”,模型重新发出 charge_card,金额的小数位可能取整不同,或者货币单位变成了小写,或者备注字符串飘移了一个 Token。新的哈希,新的键,重复扣款。键必须来自 Agent 运行的结构化上下文,而不是来自模型输出的任何字符串。

模型是一个不可靠的客户端,因此运行时必须充当可靠的客户端

一旦你接受了编排器拥有该键的事实,运行时就承担起了模型无法履行的职责。当模型发出看似相同的两次工具调用时,它必须决定是:

  • 合并 (Coalesce):将第二次发送视为第一次的重试,抑制执行,并将缓存的结果返回给模型,就像调用刚刚发生一样。
  • 拒绝 (Refuse):向模型返回一个结构化的“你已经执行过此操作”错误,让规划器自行恢复。
  • 绕过 (Bypass):人工操作员明确请求重新执行,因此生成一个新的键并接受重复执行的副作用。

哪种做法是正确的,不应该由模型来投票。它应该是系统所有者预先配置的策略,通常是针对每个工具进行配置。charge_card 的策略是“激进地合并,未经人工签核绝不绕过”。而 send_notification_to_self 的策略可能是“始终允许 —— 用户要求了两次是因为他们想要两个提醒”。

难点在于,模型会很乐意进行辩解。问它“我应该重试扣款吗?”,它会针对任何能完成对话叙事的答案给出听起来合理的辩解。这就是反模式:让散文式的确认(“好的,让我再试一次。”)充当生成新键的授权。授权必须存在于语言模型之外,存在于运行时的策略表中,并以工具名称作为键。

崩溃恢复、人工审批和子智能体都会导致朴素的幂等键失效

三种特定情况会使一个原本简单的智能体变成“重复副作用工厂”。

崩溃恢复。 像 Temporal 和 Restate 这样的持久化执行运行时 (Durable-execution runtimes) 将自己定位为智能体可靠性的解决方案,正是因为它们解决了这个问题。在重放 (replay) 时,持久化工作流会重新执行其历史记录直到崩溃点;如果没有幂等性,工具调用会再次触发。如果贯穿了幂等性,重放会命中去重表 (dedup table),读取原始响应并继续。这只有在幂等键可以从工作流状态中重构时才有效——这正是上面提到的由编排器拥有的派生方式。仅存储在 LLM 上下文中的键在重放时会随着 LLM 上下文一起消失。

人机协同审批 (Human-in-the-loop approvals)。 几个智能体框架都曾出现过 Bug:人工审批流程为单个工具调用产生了重复的工具结果,或者中断并恢复的周期重新执行了整个工具节点。其模式总是一样的:审批 UI 暂停了循环,某些操作推动循环重新启动,而这个“操作”在运行时看来就像是一次全新的输出。如果幂等键是派生自 (run_id, step_id) 而不是模型生成的工具使用 ID,那么恢复操作就会变成一次去重命中,而不是一次重复执行。

子智能体和并行工具调用。 当规划器将任务分发给子智能体 (subagents) 时,每个子智能体通常会获得自己的工具使用 ID 空间。如果没有一个跨越整个运行过程的关联 ID (correlation ID),两个被分配了重叠职责的子智能体可能会为了同一个业务目的调用同一个工具。解决方法是在幂等键中加入 agent_run_id,并配合一种传播机制,使每个子智能体都能继承它。这就是微服务团队在十年前学到的关联 ID 机制,只是在智能体时代换了个名字。

编程时需要遵循的不变量

描述这一契约最清晰的方式是将其视为一个不变量 (invariant):在编排边界,“此工具被调用了两次”必须与“此工具被调用了一次”无法区分。运行时(而非工具)负责维护这一点。工具的幂等性保证变成了备份——针对运行时遗漏情况的深度防御——而不是主要机制。

基于这一不变量进行设计,许多下游决策会变得更容易。在运行时层进行重试是安全的,因为幂等键已经生成。部分失败恢复——后端调用成功但响应丢失——变成了去重读取,而不是补偿事务。人机协同流程可以暂停和恢复,而不会产生重复的副作用。崩溃恢复的重放能够正确执行。最重要的是,智能体循环的非确定性停止了叠加:即使模型对同一个动作重新规划了十次,这些规划中最多也只有一个会产生副作用。

跳过这一规范的团队通常会通过一系列特定事故在生产环境中发现它。首先,工具超时后出现重复付款。接着,崩溃恢复后发送了重复邮件。然后,当两个子智能体都认为自己负责结账时,出现了重复订单。每次事故后的复盘都会归咎于具体的工具,然后每个工具都会获得更强大的幂等保护。但事故仍会发生,因为 Bug 从未出现在工具层。

“优秀”的设计是什么样的

一个设计良好的智能体运行时将幂等键视为一等工作流产物 (first-class workflow artifact),而不是工具适配器碰巧设置的一个 HTTP 标头。具体来说:

  • 幂等键由编排器根据运行层级状态派生,而不是根据模型输出的参数派生。
  • 幂等键在工具调用发出之前就已持久化,因此在“生成键”和“调用工具”之间的崩溃是可恢复的。
  • 每个工具适配器都将幂等键作为运行时参数接收(而非模型可控参数),并将其传递给后端。
  • 去重查询发生在运行时边界,即在工具适配器运行之前,因此合并后的调用实际上是零成本的。
  • 只要结构上下文匹配,模型重新规划的动作就会被映射到现有键上,模型会获得缓存的结果,而不是执行一次新的调用。
  • 针对每个工具的策略决定了在缺失键或歧义键的情况下,应该是合并调用、拒绝执行还是升级给人处理。

对此进行评估的一个健康标准是“设计上的枯燥”。在一个不稳定的工具后端运行脚本化智能体,让该后端随机返回超时、重复和缓慢的成功响应。测量:对于每个用户请求,外部系统观察到了多少次副作用?答案必须恰好是一次,无论会话记录中包含多少个工具使用 ID,无论有多少个子智能体参与,也无论工作流崩溃并恢复了多少次。如果这个数字超过了一次,那么契约就在某处断裂了,任何针对单个工具的幂等性都无法弥补这一漏洞。

不要以“工具是幂等的”作为对话的终点

在智能体系统中,造成生产环境损失最多的一句话就是:“但这个工具是幂等的。”它让设计讨论过早结束,让事故工单过早关闭,并让团队交付那些在演示中看起来很可靠、但在生产环境中会产生重复副作用的智能体。工具具备幂等性是必要的,但它从来都不是充分的。

请将智能体循环视为一个与“至少一次” (at-least-once) 执行层对话的非确定性客户端,并在它真正属于的地方构建幂等协议:在编排边界,由运行时拥有,从结构化状态派生,并贯穿每一个子智能体和每一次恢复。做到这一点,“工具被调用两次”就不再是一次故障,而会变成去重表中的一行记录——这本就是它该有的样子。

References:Let's stay in touch and Follow me for more thoughts and updates