← 返回信息流

dev.to #ai短讯

AI 代理重复发送邮件的幂等性修复方案

dev.to作者:Rakesh Singh教程AI评分:50/100

当 AI Agent 调用邮件发送工具时,若因网络超时导致模型误判为未发送而重试,且重试时参数(如主题)发生变化,会导致用户收到重复邮件。简单的去重方法失效,因为参数不一致。核心解决方案是假设每次工具调用都可能执行两次,并预先设计好第二次执行的逻辑以处理幂等问题。

智能体调用 send_email。邮件 API 响应缓慢,HTTP 客户端在 30 秒后超时,模型将“超时”解读为“未发送”。于是它重写邮件主题并再次发送。客户因此收到了两封邮件。

显而易见的修复方法是对工具参数进行去重。但在此场景下此法失效,因为模型修改了参数。

有效的修复方案是:假设每次工具调用都会执行两次,并预先决定第二次执行时的行为。本文探讨了智能体重试的来源、使第二次执行变得无害的关键机制,以及证明其有效性的三项测试。本文不涉及消息队列中的精确一次(exactly-once)投递。

我最初在 GroundedDocs(我的 RAG 系统)中遇到这个问题,而非在智能体本身。问题出在索引器上。一个中途失败并重跑的任务将每个数据块存储了两次,导致搜索结果中同一内容出现两次,从而挤出了其他证据。

重复的工具调用长什么样?

这是我编写的一个示例追踪记录,用于展示该模式。它并非来自真实系统的输出。

step 4  send_email(subject="Your refund is approved")      -> timeout after 30s
step 5  send_email(subject="Refund approved, order 8812")  -> ok, msg_1042

mail provider, sent log:
  msg_1041  "Your refund is approved"       sent at step 4
  msg_1042  "Refund approved, order 8812"   sent at step 5

第 4 步超时,但邮件已发出。模型看到失败后,重新措辞了主题并尝试再次发送。智能体自身的追踪记录中没有任何迹象表明客户收到了两封邮件。

根本原因始终相同:操作已经发生,但系统未记录该操作已完成。因此,某一层级采取了看似安全的做法并尝试重试。Temporal 的文档描述了最清晰的情况:工作进程完成了任务,但在报告完成之前崩溃。任务被重试,若无保护措施,就会出现“支付处理场景中的重复扣款”(Temporal)。

阻止这一问题的属性有一个名称。当执行两次操作与执行一次操作产生相同效果时,该操作具有幂等性(idempotent)。后端工程师多年来一直在支付和队列系统中依赖这一特性。智能体以更难的形式将这一问题带回了我们面前。

智能体重试从何而来?

错误的认知是:“追踪记录中的一个工具调用意味着一次副作用。”在智能体架构中,四个层级都可能重试相同的操作,且大多数情况下它们不会告知你。

  1. HTTP 客户端。库会静默地重试超时和服务端错误。一个真实的默认设置是:Anthropic Python SDK 在连接错误、408、409、429 和 5xx 错误时重试 2 次,并且也会重试超时。你的工具所使用的 HTTP 客户端、网关或服务网格可能也会这样做。你看到的现象是:追踪记录中显示一次工具调用,而下游日志中有两个请求。
  1. 编排器。你的智能体框架会重试失败的节点或工具调用。你看到的现象是:带有相同参数的同一步骤被记录了两次。
  1. 模型。工具返回错误,模型便再次调用它。这是设计使然。模型上下文协议(Model Context Protocol)将工具错误返回给模型,以便其能够“自我纠正并使用调整后的参数重试”。你看到的现象是:两次工具调用带有略微不同的参数。
  1. 恢复运行。进程崩溃或暂停等待审批,随后从最后一个检查点重新启动运行。LangGraph 不会重新运行已完成的节点,但检查点之后的节点会“重新执行”,包括任何 LLM 调用、API 请求或中断。“一个在其工作完成后、保存检查点之前崩溃的节点会再次运行。”你看到的现象是:仅在部署、崩溃或暂停等待审批后才出现的重复项。

智能体通过以下三种方式使这一问题比常规后端更复杂:

  • 常规客户端重试相同的请求。模型则使用重新措辞的参数重试,因此第二次调用看起来不像重复项。
  • 常规客户端在遇到错误时会停止。模型则会读取错误并尝试其他路径。
  • 智能体运行时间长且可恢复,而恢复运行会重新执行步骤。

层级还会叠加。一个尝试重试 3 次的客户端,嵌套在一个尝试重试 3 次的编排器中,而该编排器又由一个尝试重试 2 次的模型驱动,那么针对单个用户请求,一个动作最多可能被执行 18 次。简历生成过程可能会重复上述所有操作。

Four nested retry layers around one send_email tool: resumed run, model x2, orchestrator x3, HTTP client x3, up to 18 a…
Four nested retry layers around one send_email tool: resumed run, model x2, orchestrator x3, HTTP client x3, up to 18 a…

是什么让第二次运行变得安全?

七条规则。它们是我应用它们的顺序。

  1. 将每个工具分为三类。只读工具是安全可重复的。某些写入操作本身就可重复:“设置状态为关闭”会产生相同的结果。危险的一类是发送、创建、收费或追加的工具。只有这一类需要遵循以下规则。
  1. 让一层负责重试。对于危险的写入操作,在其他层关闭重试功能。否则,重试次数会相乘叠加。
  1. 为每个危险写入分配幂等键。由编排器生成该键。模型不得生成它。基于动作的内容构建它:运行 ID、工具名称以及业务 ID(如订单号)。不要基于模型参数的哈希值构建键,因为模型会改写这些参数。将客户电子邮件等个人数据排除在键之外;Stripe 也建议这样做。
  1. 记录键和结果。将键、状态和结果存储在具有唯一约束的表中,或使用 set-if-absent 机制存储在 Redis 中。如果可能,在与动作相同的事务中写入该记录。Stripe 就是这样做的:它保存了某个键首次请求的状态码和正文,使用相同键的重复请求会获得相同的响应。
  1. 重复调用返回第一次的结果,并视为成功。AWS 称之为“语义等效响应”(AWS Builders' Library)。永远不要将“已存在”作为错误返回给模型。模型会将此解读为失败并尝试其他路径。如果带有不同参数的相同键再次出现,则拒绝它。Stripe 和 AWS 都采取了这种做法。
  1. 超时后,重试前先查询。向下游服务询问该键是否已完成。超时并不能告诉你动作是否已执行。
  1. 对不可撤销的操作设置关卡。支付、发送和删除操作需要人工批准或每次运行的硬性限制。保留键的时间应长于你可能的最长恢复时间。Stripe 至少保留 24 小时。

这在代码中看起来是什么样的?

以下是 Python 中的概念草图,并非生产级代码:

def run_write_tool(run_id, tool, args):
    key = f"{run_id}:{tool.name}:{tool.business_id(args)}"
    row = store.get(key)
    if row and row.status == "done":
        return row.result              # 重复调用:返回与第一次相同的答案
    store.insert_if_absent(key, status="started")
    result = tool.call(args, idempotency_key=key)   # 也将键传递给下游
    store.update(key, status="done", result=result)
    return result

最关键的一行是第一行。键来自 business_id(args),即订单号,而不是模型撰写的主题行。这就是为什么上面追踪中经过改写的重试会在检查处停止的原因。

The same four retry layers, now with an idempotency key check in front of send_email. Repeats get the stored result, an…
The same four retry layers, now with an idempotency key check in front of send_email. Repeats get the stored result, an…

在 GroundedDocs 中,这变成了三个决策,从成本最低到工作量最大依次排列。

摄入过程是可重复的。每份文档由其内容的哈希值标识,每个块都有稳定的 ID。对已经见过的文档再次运行索引器,不会写入任何新块。

代理的工具被设计为只读。代理路径只有一个工具,即文档查找。它不改变任何内容,因此所有四种类型的重试对它来说都是无害的。最便宜的幂等性修复是不存在任何危险写入。

围绕代理的写入操作获得了键。每个请求仍然写入两样东西:一个审计事件和一个针对速率限制的使用计数。重试的步骤不应被记录两次或计数两次,因此每个步骤都携带一个由请求 ID 和步骤组成的键。

什么时候幂等键无法保护你?

崩溃发生在“开始”和“完成”之间。包装器记录了“开始”,邮件发出,但在写入“完成”之前进程死亡。恢复运行时,存储无法告诉你邮件是否已发送。只有两种情况能填补这一空白:下游服务尊重你传递的键,或者你询问它(规则 6)。如果它两者都不支持,将该工具视为不可逆并对其进行限制(规则 7)。

键在恢复之前过期。一次运行等待三天以获得批准,但键的有效期仅为 24 小时。恢复后的调用看起来完全是新的。Stripe 明确说明:被修剪后重用的键将成为新请求。

键过于粗糙。run_id:send_email:order_8812 阻止了关于该订单的第二封邮件,包括紧随“退款批准”之后的合法“退款已支付”邮件。将意图放入键中,例如电子邮件模板名称,以便两个实际动作获得两个键。

如何测试你的代理是否存在重复?

在接下来的 20 分钟内,对一次危险的写入执行以下操作:

  1. 列出所有工具,并将其标记为只读、可重复写入或危险写入。
  2. 对于一次危险写入,写下所有可以重试它的层以及重试次数。相乘。
  3. 成功之后崩溃:在工具返回后立即终止进程,然后恢复运行。
  4. 丢失回复和伪造错误:使工具成功但返回超时,然后使工具成功但向模型返回错误。
  5. 统计每次测试后的副作用。目标是恰好一个。

我现在使用的规则是:假设每次工具调用都会运行两次,并提前决定第二次运行做什么。

你堆栈中的哪一层重试了你不知道它可以重试的写入,并且它重复了什么?

译文已达到本站中文翻译的字数上限,剩余内容请查看原文。

阅读原文