一个会自己调工具的 AI Agent,因为重试和重复决策,把一封通知邮件发了三遍、一个订单提交了两次:一次 Agent 工具副作用失控、有副作用的写操作被重复执行的深度复盘

我们给 AI Agent 配了能发邮件、能下单的工具,直到用户投诉收到重复邮件、重复订单:同一封通知邮件被发了三遍,同一个订单被提交了两次。排查发现是 Agent 因两种情况重复调用了有副作用的工具——一是工具调用超时重试(其实已成功只是返回慢,框架以为失败又调一次),二是多步任务里模型不确定自己做过没又决定做一次;而这些工具有副作用却没做幂等,多调一次就多发一封、多下一单。这篇复盘从故障现场讲到 Agent 为什么天然会重复调工具、有副作用的工具为什么必须幂等,再到给有副作用工具做幂等键、框架对相同调用去重、高危操作加人工确认、读写分离的完整正解,以及别让不可靠的 Agent 无防护地反复操作真实世界的工程认知。

一个会自己调工具的 AI Agent,因为重试和重复决策,把一封邮件发了三遍、一个订单下了两次:一次 Agent 工具副作用失控的深度复盘

那个事故是用户投诉"收到了重复的邮件和重复的订单"才暴露的:我们的 AI Agent 能调用工具帮用户办事——查信息、发通知邮件、提交订单。可线上偶尔出现:同一个任务,Agent 把同一封通知邮件发了两三遍,甚至同一个订单提交了两次。我翻 Agent 的执行日志,看清了它是怎么"手抖"的,后背发凉:有两种情况导致了重复。情况一:工具调用超时重试——Agent 调"发邮件"工具,工具其实已经发成功了,但因为网络慢、返回结果超时了;Agent(或它的框架)以为"失败了",就重试调了一次——于是邮件发了两遍。情况二:模型在后续步骤里"重复决策"——Agent 第一步发了邮件,可在后面的某一步,模型"忘了"或"不确定"自己发过没有(尤其多步任务、上下文里信息一多),又决定再发一次。而这些工具都是有副作用的(发邮件、下单是真的会产生真实后果的""操作),多调一次就多发一封、多下一单问题的根,是我给 Agent 配了"有副作用的工具",却没有保证这些工具"被重复调用也只生效一次"(幂等),也没防住"Agent 重试/重复决策"这种它天然会发生的行为。这篇就把这次"Agent 工具副作用、重复执行"的坑,从头到尾复盘一遍。

故障现场:有副作用的工具被 Agent 重复调用

问题在于把有副作用的工具直接交给 Agent、却没做幂等防护:

# ✗ 出问题的工具: 有副作用(真的发邮件/下单), 但没做幂等
def send_email(to, subject, body):
    smtp.send(to, subject, body)   # ✗ 真的发邮件; 调几次发几次, 没有去重
    return "邮件已发送"

def create_order(user_id, items):
    order = db.insert_order(user_id, items)  # ✗ 真的下单; 调几次下几单
    return f"订单已创建: {order.id}"

# Agent 调用这些工具时, 两种情况导致重复:
# 情况一(重试): Agent调 send_email → 工具其实发成功了, 但返回超时;
#   → Agent/框架以为失败, 重试调 send_email → 又发了一遍 → 重复邮件。
# 情况二(重复决策): 多步任务里, 模型在后面某步"不确定自己发过没", 又决定调一次 send_email;
#   → 重复邮件。(模型是概率性的, 它的"决策"本就可能重复/反复)

# 为什么Agent天然容易重复调用有副作用的工具:
# - Agent是"循环决策"的: 它可能在多个步骤里、或重试时, 多次决定调同一个工具;
# - 工具调用涉及网络/超时, 重试机制会重复发起;
# - 模型是概率性的, "它是否记得自己已经做过某事"不可靠 → 可能重复决策;
# - → 这些都让"同一个有副作用的操作被执行多次"成为Agent里的常见风险。

# 而有副作用的工具(发邮件/下单/扣款/删除)被多执行一次, 就是真实的损失(重复邮件/重复下单/重复扣款)。

# 关键: Agent会因重试和重复决策而多次调用工具; 若有副作用的工具没做幂等, 就会重复执行、
#       造成重复邮件/重复下单等真实后果——给Agent用有副作用的工具, 必须防重复执行。

第一次理清这两种重复时,我又懊恼又警醒:"我光想着给 Agent 配上能干活的工具,却没想到它会'手抖'地把''操作干好几遍。"这个坑最危险的地方在于:它结合了两个东西的风险——Agent 的"不确定、会重试、会重复决策",和有副作用工具的"每执行一次就产生真实后果";这两者一叠加,就是"不可靠的决策者,反复地触发真实的、不可逆的操作"而且它偶发(要恰好超时/恰好重复决策),但一旦发生就是实打实的资损或骚扰(重复扣款、重复发货、重复骚扰用户)。下面就来拆解,怎么让 Agent 安全地使用有副作用的工具。

第一件事:搞懂 Agent 为什么会重复调工具,以及为什么必须幂等

我顺着这次事故,把"Agent + 有副作用工具"的风险彻底理清了。

Agent 为什么会重复调用工具? 为什么有副作用的工具必须幂等?

【核心: Agent决策不确定+会重试, 天然可能重复调工具; 有副作用的工具被重复调=重复的真实后果; 必须做幂等+人工确认+读写分离】

1. Agent 为什么天然容易重复调用工具:
   - ① 重试: 工具调用涉及网络/超时, 调用方(Agent框架)在超时/失败时常会重试;
     而"超时"不等于"没执行成功"(可能执行成功了、只是返回慢) → 重试就重复执行;
   - ② 重复决策: Agent是多步循环决策的, 模型是概率性的;
     它可能在不同步骤"忘了/不确定自己做过", 又决定做一次 → 重复;
   - → "重复调用工具"是Agent的一种【常态风险】, 不是偶然bug。

2. 有副作用工具被重复调 = 真实损失:
   - "读"操作(查询)重复调没事(查几次结果一样, 无副作用);
   - "写"操作(发邮件/下单/扣款/删除/调外部)重复调 = 真的多发/多下/多扣 → 真实后果;
   - → 危险的是"有副作用的工具"被重复执行。

3. 所以: 给Agent用的有副作用工具, 必须防重复执行
   - ① 工具做幂等: 带一个"幂等键/请求ID", 同一个键多次调用只生效一次(同514篇的幂等);
   - ② 框架/调用层去重: 对相同的工具调用(同工具+同参数)做去重, 别重复发起;
   - ③ 高危副作用工具加人工确认(human-in-the-loop): 下单/扣款/删除等, 让人确认再执行;
   - ④ 读写分离: 把"读"工具和"写"工具分开, 读工具随便调、写工具严格控制(幂等+确认)。

4. 更深一层: Agent是"不可靠的执行者", 别让它直接、无防护地触发不可逆操作
   - 模型是概率性的、会犯错、会重复——它是个"能干但不完全可靠"的执行者;
   - 让这样一个执行者直接触发"不可逆的真实操作", 风险很大;
   - → 要在Agent和"真实副作用"之间, 加上"幂等/确认/限制"这层防护垫。

一句话: Agent因重试和重复决策天然会重复调工具; 有副作用的工具被重复调=重复的真实后果(重复发/下/扣);
   必须给有副作用工具做幂等(幂等键)、框架去重、高危加人工确认、读写分离——别让不可靠的Agent无防护地触发不可逆操作。

这套认知,是整个坑的根。Agent 为什么天然容易重复调工具:①重试(工具调用涉及网络/超时,"超时"不等于"没执行成功",重试就重复执行);②重复决策(Agent 多步循环、模型概率性,可能忘了/不确定自己做过又做一次)——重复调用是 Agent 的常态风险有副作用工具被重复调=真实损失:读操作重复没事,写操作(发邮件/下单/扣款/删除)重复=真的多发/多下/多扣所以必须防重复执行:①工具做幂等(带幂等键、同键多次只生效一次);②框架/调用层去重;③高危副作用工具加人工确认;④读写分离(读随便调、写严格控制)更深一层:Agent 是"不可靠的执行者"(概率性、会犯错会重复),别让它直接无防护地触发不可逆操作,要在它和真实副作用之间加"幂等/确认/限制"这层防护垫一句话:Agent 因重试和重复决策天然会重复调工具;有副作用的工具被重复调=重复的真实后果;必须给有副作用工具做幂等、框架去重、高危加人工确认、读写分离——别让不可靠的 Agent 无防护地触发不可逆操作。

第二件事:正解——工具幂等、框架去重、高危人工确认、读写分离

搞懂了原理,正解就清晰了:给有副作用的工具做幂等(幂等键)、对相同工具调用去重、高危操作加人工确认、把读工具和写工具分开严格管控写工具

# ====== 正解一: 给有副作用的工具做幂等(带幂等键) ======
def send_email(to, subject, body, idempotency_key):  # ★ 带幂等键
    # 用幂等键去重: 同一个key已发过, 就不再发(直接返回上次结果)
    if email_log.exists(idempotency_key):             # 这个key处理过了
        return "邮件已发送(幂等: 跳过重复)"
    smtp.send(to, subject, body)
    email_log.save(idempotency_key)                   # 记录已处理(原子地)
    return "邮件已发送"
# → 幂等键可以是"任务ID+动作"(如 task123:send_welcome_email);
#   Agent重试/重复决策时, 用同一个幂等键 → 同一个键只真正发一次 → 不重复。

# ====== 正解二: 高危副作用工具, 加人工确认(human-in-the-loop) ======
def create_order(user_id, items):
    # 下单这种高危/不可逆操作, 不让Agent自己拍板, 暂停等人确认
    confirmation = request_human_confirmation(
        f"Agent想为用户{user_id}创建订单: {items}, 确认吗?")
    if not confirmation.approved:
        return "用户未确认, 订单未创建"
    order = db.insert_order(user_id, items)
    return f"订单已创建: {order.id}"
# → 高危操作(下单/扣款/删除/对外发布)经人确认再执行, 既防Agent重复、也防Agent决策错误。
# ====== 正解三: 框架/调用层对工具调用去重 ======
# - 在Agent框架层, 对"相同的工具调用(同工具名+同参数)"在一个任务内做去重缓存;
#   → 同一个任务里, Agent若重复决定调"同工具同参数", 直接返回上次结果, 不重复执行;
# - 注意: 这只防"完全相同的调用", 业务幂等(正解一)更可靠。

# ====== 正解四: 读写分离, 区别对待 ======
# - 把工具分成"只读工具"(查询, 无副作用)和"写工具"(有副作用);
# - 只读工具: 可以放心让Agent随便调、重试(重复调没害);
# - 写工具: 严格管控——必须幂等、可能要人工确认、记录每次调用、可审计、可回滚;
# - → 用"读/写"这个维度, 把宽松和严格分开, 该松的松、该严的严。

# ====== 配套 ======
# - 给工具调用设合理超时, 别因超时就盲目重试(重试也要幂等保护);
# - 在prompt/上下文里明确告诉模型"哪些已经做过了"(减少重复决策);
# - 记录Agent的每一次工具调用(可审计、可追溯、出问题能查)。

# 核心: 有副作用的工具必须幂等(幂等键, 重复调只生效一次); 框架对相同调用去重; 高危操作加人工确认;
#   读写分离(读宽松写严格); 别让Agent的"重试+重复决策"把真实的"写"操作执行多次。

修复的核心,是"在 Agent 和真实副作用之间加防护:幂等、去重、确认"正解一:给有副作用工具做幂等(带幂等键)——幂等键可以是"任务 ID+动作",同一个键已处理就跳过,Agent 重试/重复决策时用同一个键、只真正发一次正解二:高危操作加人工确认——下单/扣款/删除等暂停等人确认再执行,既防重复也防决策错误正解三:框架/调用层对相同工具调用去重(同任务内同工具同参数返回上次结果)。正解四:读写分离——只读工具随便调,写工具严格管控(幂等+确认+记录+可回滚)配套:设合理超时、prompt 里明确告知"哪些已做过"减少重复决策、记录每次工具调用可审计归根结底:有副作用的工具必须幂等(幂等键,重复调只生效一次);框架对相同调用去重;高危操作加人工确认;读写分离(读宽松写严格);别让 Agent 的"重试+重复决策"把真实的"写"操作执行多次。

第三件事:AI Agent 操作真实世界的其他常见风险

排查后我把 Agent 调用工具操作真实世界相关的其他风险也系统梳理了一遍。

AI Agent 操作真实世界的其他风险

# 1. 有副作用工具重复执行(本文): 重试/重复决策致重复发/下/扣。→ 幂等+去重+确认。

# 2. 高危操作Agent自己拍板: 删库/转账/对外发布没人把关。→ human-in-the-loop确认。

# 3. 工具参数填错却照样执行: 模型填错参数(同529篇)+有副作用 = 错误地操作真实世界。→ 参数校验。

# 4. 不可逆操作没法回滚: Agent做了删除/发送, 错了也收不回。→ 不可逆操作格外谨慎/加确认/软删除。

# 5. 权限过大: 给Agent的工具权限太大(能删能改一切)。→ 最小权限, 只给它完成任务必需的能力。

# 6. 没有审计/追溯: Agent做了什么没记录, 出事查不到。→ 记录每次工具调用(谁/何时/做了什么)。

# 7. 工具失败没正确反馈: 失败信息没回给模型, 它不知道该重试还是换法(同529篇)。→ 返回清晰结果。

# 8. 没有成本/速率限制: Agent疯狂调有成本的工具(同505篇)。→ 限流/预算/熔断。

# 共同根源: Agent是"概率性、会犯错、会重复"的执行者, 却被赋予了"操作真实世界(写/删/发/扣)"的能力;
#   这种"不可靠的智能 + 真实的副作用"的组合, 必须用工程手段(幂等/确认/权限/审计/限制)严加防护。

# 核心: 给Agent操作真实世界的能力时, 必须加防护层: 有副作用工具幂等、高危人工确认、参数校验、最小权限、
#   全程审计、读写分离、限流; 把"不可靠的Agent"和"真实的副作用"之间, 隔上一道严密的安全垫。

排查让我把 Agent 操作真实世界的其他风险也梳理清了。一、有副作用工具重复执行(本文)。二、高危操作 Agent 自己拍板(人工确认)。三、参数填错却照样执行(参数校验)。四、不可逆操作没法回滚五、权限过大(最小权限)。六、没有审计/追溯七、工具失败没正确反馈八、没有成本/速率限制它们的共同根源是:Agent 是"概率性、会犯错、会重复"的执行者,却被赋予了"操作真实世界(写/删/发/扣)"的能力;这种"不可靠的智能+真实的副作用"的组合,必须用工程手段严加防护核心是:给 Agent 操作真实世界的能力时必须加防护层:有副作用工具幂等、高危人工确认、参数校验、最小权限、全程审计、读写分离、限流;把"不可靠的 Agent"和"真实的副作用"之间隔上一道严密的安全垫下面这张图,是这次 Agent 工具副作用坑的成因与解法:

第四件事:读工具 vs 写工具,该怎么区别对待

这次踩坑后,我把给 Agent 的"只读工具"和"有副作用的写工具"对比成一张表,因为它俩的管控强度天差地别。

维度 只读工具(查询/搜索) 写工具(发邮件/下单/扣款/删除)
有无副作用 无(不改变世界) 有(产生真实后果)
重复调用 无害(结果一样) 有害(重复发/下/扣)
是否需幂等 不需要 ★ 必须(幂等键)
是否需人工确认 不需要 高危的需要
能否随便重试 不能(重试也要幂等保护)
管控强度 宽松 严格(幂等+确认+审计+可回滚)

这张表把读、写两类工具钉清了。核心是:给 Agent 的工具,""和""是两个世界——读工具无副作用、重复调无害,可以宽松(随便调、随便重试);写工具有副作用、重复调有害,必须严格(幂等、可能要确认、审计、可回滚);把这两类一视同仁地宽松对待,就会出本文这种"写操作被重复执行"的事故它给我的最大启发是:区分"无副作用的操作"和"有副作用的操作",并区别对待,是设计一切系统的一个基本功——""可以乐观、宽松、可重试、可缓存;""必须谨慎、严格、要幂等、要防重;这不止在 Agent 工具里,在 HTTP 方法(GET vs POST/DELETE)、在 CQRS、在数据库读写分离里,都是同一个智慧:把"不改变状态的"和"改变状态的"分开,用不同的强度去对待这给了我一种设计上的清醒:面对任何一个操作,先问"它有没有副作用?它改不改变真实世界的状态?"——有副作用的、改变状态的操作,是系统里"危险的那部分",要重点防护(幂等、确认、审计、限权);无副作用的读操作,则可以放松;"用'有无副作用'这把尺子去区分操作、并据此决定管控强度",是把好钢用在刀刃上的关键用"有无副作用"区分读写、对有副作用的写操作重点防护——是这个坑带给我的设计认知。

第五件事:这次事故暴露的"不可靠执行者+真实副作用"的组合风险

这次让我反思更深一层:危险的本质,是"一个不可靠的执行者(Agent)"被赋予了"触发真实副作用"的能力。我把这种组合的特性对比成表。

执行者特性 / 操作特性 无副作用操作(读) 有副作用操作(写/删/发/扣)
可靠执行者(确定性代码) 安全 较安全(逻辑确定, 可控)
不可靠执行者(Agent/人/网络) 较安全(读错了无害) ★ 高危(错误/重复地改变真实世界)
风险来源 不可靠×不可逆 = 真实损失
应对 加防护层: 幂等/确认/校验/审计/限权

这张表道出了风险的本质。核心是:风险最高的那个格子,是"不可靠的执行者"× "有副作用/不可逆的操作"——Agent 是概率性的、会犯错会重复的"不可靠执行者",而发邮件/下单/扣款是"真实的、有副作用的、往往不可逆的操作";让前者无防护地触发后者,就是把"不确定性"直接灌进了"真实世界",错误和重复都会变成实打实的损失它给我的深刻启发是:当你要把"某种不可靠的东西"(AI、人的手动操作、不稳定的网络、外部系统)和"真实的副作用"连接起来时,必须在它们之间加一个"防护层/缓冲层"——幂等(防重复)、确认(防错误)、校验(防非法)、审计(可追溯)、限权(限范围)、可回滚(能挽回);这个防护层的作用,就是"吸收掉执行者的不可靠性, 不让它直接传导成真实世界的损失"这给了我一种架构上的审慎:识别系统里"不可靠的输入/执行者"和"有副作用的、不可逆的操作",并在二者之间刻意地设置防护——越是不可靠的源、越是不可逆的操作,防护要越厚;"在不可靠与不可逆之间, 加一道防护垫",是构建一个能容错、能兜底的健壮系统的核心思想之一认清"不可靠执行者×真实副作用"是最高危的组合、要在二者间加防护层——是这个 Agent 坑带给我的工程态度。

第六件事:给 Agent 配工具时,我现在的自检习惯

现在每当我要给一个 AI Agent 配一个新工具,我都会先按这张图问自己:

这张图的精髓,是"先分这工具有没有副作用,有副作用就必须加幂等、高危再加人工确认"无副作用可宽松、有副作用必须幂等、高危不可逆再加人工确认,外加参数校验+审计+最小权限这套习惯,让我从"有啥工具就都丢给 Agent"变成了"先分读写、有副作用的严加防护再给"——核心始终是:给 Agent 有副作用的工具必须做幂等(重复调只生效一次)、高危操作加人工确认,别让会重试会重复决策的 Agent 无防护地反复操作真实世界。

我立下的几条规矩

这场"Agent 重复发邮件、重复下单"的事故,换来了我给 AI Agent 配工具时,刻进骨子里的几条铁律:

  1. Agent 天然会重复调工具。重试(超时≠没成功)和重复决策(模型概率性)都会导致重复。
  2. 有副作用的工具(发/下/扣/删)被重复调=真实损失。必须防重复执行。
  3. 给有副作用的工具做幂等(幂等键)。同一个键多次调用只真正生效一次。
  4. 高危/不可逆操作加人工确认(human-in-the-loop)。下单/扣款/删除让人把关。
  5. 读写分离。只读工具宽松(随便调),写工具严格(幂等+确认+审计+可回滚)。
  6. 最小权限 + 全程审计。只给完成任务必需的能力,记录每一次工具调用。
  7. 别让不可靠的 Agent 无防护地触发真实副作用。在二者间加一道防护垫。

写在最后

回头看,这场由"Agent 把一封邮件发了三遍、一个订单下了两次"引发的事故,真正教给我的,远不止"给工具加幂等键"这一个技巧。它让我对"当我们把'能办事的能力'交给一个'不完全可靠的执行者'时,必须为'它可能办错、办重复'做好兜底",有了一次刻骨的体会。我栽跟头,是因为我满心欢喜地给 Agent 配上了能发邮件、能下单的工具,觉得"它终于能帮用户真正办事了"——我只陶醉于赋予它能力,却没有正视它的不可靠。可我忽略了:AI Agent(以及任何基于概率模型的系统),本质上是一个"大概率能办好、但小概率会办错/办重复"的执行者;当它操作的只是""(查信息)时,偶尔的错误无伤大雅;可一旦它操作的是""(发邮件、下单、扣款这种会改变真实世界、且往往收不回的事),它那"小概率的不可靠",就会变成"真实世界里实打实的损失"这让我领悟到一个关于"能力与责任、智能与防护"的深刻认知:赋予一个系统(尤其是不完全可靠的 AI)"操作真实世界的能力",是一件必须配套以"相应的防护与约束"的事——能力越大、操作越不可逆,需要的防护就越严;"给它能力"和"防它出错",是必须同时做的一体两面;只给能力不给防护(像我那样直接把有副作用的工具裸交给 Agent),就是把一把"可能走火的枪"递了出去这给了我一种构建 AI 系统时的根本审慎:在让 AI/Agent 去"操作真实世界"时,永远要假设"它会出错、会重复、会做出意料外的决策",并据此为每一个有副作用的操作铺好"防护垫"——幂等(它重复了也不出事)、确认(它要做大事先问人)、校验(它给的参数先验)、审计(它做了啥能查)、限权(它能闯的祸有边界)、可回滚(它闯了祸能挽回);"拥抱 AI 的能力, 但绝不信任它的可靠性, 用工程防护兜住它的不确定性",是负责任地构建一个能操作真实世界的 AI 系统的核心原则认清把操作真实世界的能力交给不可靠的 Agent 必须配套严密防护、给能力和防出错要同时做——这,是我用一次 Agent 重复操作的事故,换来的、关于 AI Agent 工程、也关于如何负责任地驾驭"不可靠的智能"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给 Agent 配一个有副作用的工具时,先停下来给它加上幂等键、给高危操作加上人工确认,那我对着那些重复的邮件和订单排查的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个图省事用 any 接住的 JSON 数据,像墨水一样把后面一整片代码的类型检查都染没了,拼错的属性名 TS 一声不吭:一次 any 扩散的深度复盘

2026-6-2 19:45:36

技术教程

一个生成器我先遍历了一遍算总数、再遍历一遍做处理,结果第二遍啥也没有、处理了零条数据:一次 Python 迭代器只能消费一次、把一次性的流当成可反复遍历的列表的深度复盘

2026-6-2 19:58:36

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索