跳到主要内容

你的智能体从未发送的幂等键

· 阅读需 12 分钟
Tian Pan
Software Engineer

一位客户曾因一次退货收到了三次退款。这不是因为模型幻觉出了某种政策,也不是因为人类填错了表单——而是因为退款工具超时了两次,智能体随之重试了两次,而每次重试都发起了一个全新的请求,导致支付处理器根本无法识别这其实是它处理过的工作。三个干净的 HTTP 200。三次真实的资金转移。智能体完全按照指令行事:当调用失败时,重试。

这个 Bug 不在模型中。Bug 出在一个从未发送的 Header(标头)中。

重试是智能体最自然不过的本能。工具调用返回错误,或者更糟,什么都不返回,而循环系统的直觉——无论是编码在框架、提示词还是模型自身的训练中——就是重新尝试该动作。这种直觉对于“读”操作是正确的,而对于“写”操作则是灾难性的。一个韧性十足的智能体与一个会向客户重复收费的智能体之间的区别,不在于智力高低,而在于每一次改变状态的工具调用是否携带了幂等键(Idempotency Key),以及另一端的系统是否真正履行了它。

超时无法告诉你任何信息

首先要面对一个会打破所有人思维模型的事实:当网络调用超时时,你无法知道工作是否已经完成。

人们很容易将超时解读为失败。其实不然。超时意味着响应没有及时到达。请求可能根本没有到达服务器;它也可能到达了服务器并已完整执行,但响应包在返回途中丢失了;或者它可能正在缓慢执行中,并在你的客户端放弃后的 30 秒才完成。从客户端来看,这三种情况完全相同。错误信息中没有任何字段可以让你消除这种歧义。

这就是穿着 HTTP 外衣的两军问题(Two Generals Problem),这不是你能通过换一个更好的库就能修复的 Bug。这是一个“不可能结果”。你无法在不可靠的网络上构建一个能够保证发送者确切知道接收者是否采取了行动的通道。每个分布式系统都生活在这种困境中。每个人都想要的“精确一次”(Exactly-once)交付在证明上是不可能的。业界实际交付的是“至少一次”(At-least-once)交付配合幂等处理,两者结合产生的“效果”与精确一次无异。你所听到的“精确一次”是接收者的属性,而不是通道的属性。

因此,一个在超时后自动重试的智能体默认就在做一件危险的事情。它将一个意为“我不知道”的事件视为了意为“它没成功,再做一次”的事件。当底层动作是读取时,并无大碍——你只是获取了两次相同的记录。但当动作为 place_order(下单)、send_email(发邮件)、issue_refund(退款)或 transfer_funds(转账)时,重试就是第二次、真实的、独立的副作用。模型的推理是正确的,但现在世界上多出了两个订单。

重试是默认行为;重复副作用是后果

看看智能体实际上是如何构建的,你会发现重试无处不在,往往有三四层之深,且每一层都互不感知。

HTTP 客户端在连接错误时重试。智能体框架在工具调用返回错误时重试。编排层在步骤“失败”时重试整个步骤。而模型本身,在看到一个看起来像错误的工具结果时,通常会直接在下一步再次调用该工具——这是一种不受任何基础设施设置控制的重试,因为它是一个 Token 级别的决策。一次超时的 charge_card(刷卡)调用可以在任何人察觉之前演变成四次真实的扣费,因为每一层都在重试它下方的层。

这并不是配置错误。这是一个技术栈的突现行为(Emergent Behavior),其中每一层都被独立设计为具有“韧性”,而韧性被定义为“重试”。每一层在局部都是正确的,但它们的组合却成了一个重复副作用生成器。

这种做法之所以能逃过代码审查,是因为“正常路径”和“失败路径”产生的结果在视觉上是完全一致的。一个成功的 send_email 返回 200。一个在超时后重试并发送了第二封副本的 send_email 同样返回 200。智能体的执行记录看起来很干净。评估套件(Eval Suite)通过了。重复只会在三天后出现在客户的收件箱、计费仪表盘或支持工单中。你无法通过查看智能体日志来捕获这一点,因为正确运行的日志和重复执行的日志是一模一样的。

所以,必须通过设计消除这种后果,而不是通过测试。而设计的切入点就在智能体与它所改变的系统之间的边界上。

精确一次是工具契约问题,而非模型问题

这里有一个至关重要的切入点:你永远无法让模型停止重试,而且你也不应该尝试这么做。

面对歧义,重试是正确的行为。模型和网络一样,无法分辨响应丢失的超时与真正的失败——它处于同样的“不可能困境”中。提示模型“小心重试”是在要求它用更好的意图去解决两军问题。它做不到,你也做不到。

你能做的是让重试变得无害。这就是幂等键的作用。客户端——即你的智能体工具层——为一次“预期工作单元”生成一个唯一的标识符,并将其附加到请求中。服务器在第一次处理该请求时记录该标识符及其结果。当一个带有服务器已见过的标识符的请求到达时,它不会再次执行,而是返回第一次执行时存储的结果。第二次 issue_refund 调用会返回相同的退款 ID、相同的金额、相同的 200——但不会产生额外的资金转移。

注意这如何解决了超时问题。智能体之所以重试,是因为它不知道第一次调用是否成功。有了幂等键,它不再需要知道了。如果第一次调用成功了,重试会返回那个成功结果;如果第一次调用根本没送达,重试会真正执行。无论哪种情况,结果都只有一次退款。歧义并没有消失——它只是变得无关紧要了。这就是全部的奥妙。你并没有消除不确定性,而是构建了一个不确定性不再起作用的系统。

这就是为什么它是一个工具契约(Tool-contract)问题。这种保证存在于调用者和被调用者之间的协议中:这个端点接受 Idempotency-Key,并承诺两个具有相同键的请求只产生一次效果。模型不是该契约的一方,也不需要是。而工具封装层(Tool Wrapper)才是。

生成能够跨重试生存的 Key

具体机制往往是团队容易悄悄出错的地方,因此在以下三点上必须做到精准。

Key 必须在旨在去重的多次重试之间保持稳定。 如果你的工具封装器在每次构建请求时都生成一个新的 UUID,那么你每次 尝试 都会得到一个唯一的 Key,这起不到任何去重作用 —— 对服务器来说,每次重试看起来都是全新的。Key 必须在智能体首次 决定 采取行动时生成一次,并在此决定的每一次重试中重复使用。一个好的来源是对重试时不会改变的事物进行确定性哈希:工作流运行 ID、步骤索引、操作类型以及核心参数。相同的决定,相同的 Key。新的决定,新的 Key。

Key 必须在真正不同的操作之间保持唯一。 这是相反的失败情况。如果针对同一客户、相同金额的两次真实且预期的退款被哈希成了相同的 Key,那么第二次退款就会被当作“重复项”悄悄吞掉,从而导致客户少收了钱。Key 必须捕捉意图,而不仅仅是参数。运行内部的步骤索引或单调递增的决策计数器,是将“同一笔退款的重试”与“恰好看起来相似的第二笔退款”区分开来的关键。

去重范围必须与爆炸半径匹配。 存储在智能体进程内存中的 Key 只能防止单次运行内的重试。如果整个智能体运行在崩溃后被编排器重试,它就起不到任何作用,因为新进程启动时内存是空的。对于任何涉及资金往来或联系人类的操作,幂等性记录必须存在于持久化存储中,以便后续的重试能够看到 —— 如果下游 API 提供去重存储,就用它的;如果不提供,就用你自己的数据库表。对每一个 Key 都要问这样一个问题:它实际上能捕获哪些重试,而哪些重试会直接绕过它?

还有一个容易被忽略的约束:不要像缓存成功结果那样缓存失败。如果请求因暂时的 500 错误或超时而失败,应该允许下一次重试真正执行 —— 这正是重试的意义所在。幂等性存储应该记录已完成的成功结果,并让其他所有情况的重试通过。如果针对某个 Key 缓存了 500 错误,你就构建了一个永远拒绝完成该工作的系统。

针对漏网之鱼的对账

幂等 Key 是必要的,但并不充分。某些副作用仍然会逃逸,成熟的设计应该为此做好预案,而不是假装它不会发生。

漏洞是真实存在的。一个你不拥有的工具 —— 合作伙伴的 API、遗留的内部服务、邮件提供商 —— 可能根本不支持幂等请求头,在这种情况下,你的 Key 只是一个没人读取的字符串。去重窗口可能会过期:许多提供商对 Key 的有效期设定为 24 小时或 7 天,在此窗口之后到达的重试将被视为全新的请求。此外,总会存在无法消除的竞态条件 —— 两个重试在同一毫秒内到达服务器,在第一个请求完成写入幂等记录之前,两者都已开始执行。

针对这些情况,你需要对账(Reconciliation):一个按自身计划运行的过程,用于比较预期效果与实际效果,并标记出差异。这个工作流运行是打算退款一次还是产生了两次退款?对账就是让你在几小时后发现这一点的方法,而不需要客户来告诉你。它是幂等层之下的审计层,两者并非冗余 —— 幂等性以较低成本防止了常见情况,而对账则捕获了幂等性在结构上无法处理的长尾问题。

这也主张了一种设计原则:让改变状态的工具以一种你可以进行对账的形式 报告它们做了什么。一个 place_order 工具应该返回它创建的订单 ID。一个 send_email 应该返回消息 ID。如果一个工具的唯一输出是“成功”,你就没有东西可以对账 —— 你无法区分是一个订单还是两个订单。输出时的溯源信息,是让后续审计成为可能的基础。

具体该怎么做

如果你运行一个触达真实系统的智能体,以下清单简短且必选。

  • 将每个工具分类为读或写。 读操作可以自由重试。写操作在没有幂等 Key 的情况下无法安全重试。这种分类应存在于工具定义中,而不是 Wiki 中。
  • 在决定时生成 Key,而不是在请求时。 每个预期动作对应一个 Key,在该动作的每次重试中重复使用,派生自重试时稳定的输入,并在真正不同的动作之间保持唯一。
  • 将去重记录存储在后续重试可以看到的地方。 进程内内存仅涵盖进程内重试。任何在崩溃后可以重试的操作都需要一个持久的、共享的幂等存储。
  • 绝不要像缓存成功一样缓存失败。 暂时性错误必须为真正的重试留出空间。
  • 让每个写操作工具返回一个标识符,并运行对账任务来比较预期写入与实际写入。

残酷的现实是,这些都不是什么新鲜事。支付系统、消息队列和云控制平面已经基于幂等 Key 运行了二十年,因为它们通过惨痛的教训学到:网络只能保证“至少一次(at-least-once)”交付,剩下的就是你自己的问题了。智能体并没有发明重复副作用的 Bug。它们只是重试得更快、覆盖面更广,而且环节中没有多少人类能注意到第二次扣费 —— 它们会一直这样“正确地”错下去,直到另一端的工具被构建得能够容错为止。

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