我给 AI Agent 的工具调用加了失败就自动重试、自以为更健壮了,结果有的任务卡在那对着一个参数本来就填错了的调用一遍遍重试、试满五次全失败白白烧掉一堆时间和 token,我盯着日志才反应过来不是所有失败都该重试、有一类失败你重试一万遍它还是会用同样的方式失败
这是一次让我把 AI Agent 里"工具调用失败重试"这件事,从"失败就重试、更稳",重新理解成"失败分两类、只有一类该重试、另一类重试一万遍也没用"的事故。我给 Agent 的工具调用加了"失败就自动重试",自以为更健壮了。结果有的任务卡在那,对着一个参数本来就填错了的调用一遍遍重试,试满五次全失败、白白烧掉一堆时间和 token。我盯着日志才反应过来:不是所有失败都该重试——有一类失败,你重试一万遍,它还是会用同样的方式失败。这篇就把这次"对必然失败的调用反复重试"的事故,从头到尾复盘一遍。
故障现场:对着一个填错参数的调用,重试五次全失败
我的 Agent 会调各种工具(查接口、写数据、调下游)。为了让它更"抗造",我给工具调用统一加了一层重试:只要这次调用失败了,就自动重试,最多重试五次。我想着网络偶尔抖一下、下游偶尔超时,重试一下就过去了,挺稳。
可上线后,有些任务变得又慢又费:Agent 在某一步卡很久、最后还是报错。我看日志,发现它在对同一个工具调用反复重试五次,而五次的失败原因一模一样——比如它给工具传的参数格式不对(400 Bad Request: invalid date format)、或者要查的资源根本不存在(404 Not Found)、或者它没有那个操作的权限(403 Forbidden)。这些失败,第一次失败和第五次失败的原因完全相同,重试纯粹是把同一个注定失败的请求,原封不动地又发了四遍。我一开始还以为是下游不稳定,直到把失败的状态码一看,才彻底明白根因——失败其实分成截然不同的两类:一类是"瞬时的、可恢复的"失败(网络抖动、连接超时、限流 429、下游临时不可用 503),这类失败过一会儿再试很可能就成功了,重试是有意义的;另一类是"确定性的、不可恢复的"失败(参数错误 400、资源不存在 404、权限不足 403、业务规则拒绝),这类失败的原因不会因为你再试一次而改变——你用同样的参数、同样的身份、再发一遍,必然还是同样的失败。我给所有失败一刀切地都加了重试,对第一类失败它确实管用,可对第二类失败,重试就成了纯粹的浪费:把一个必然失败的请求重复发五遍,既拖慢了任务、又烧掉了五倍的时间和 token,如果那个调用还有副作用(比如"创建订单"因某个非幂等原因失败),反复重试甚至可能造成重复执行。我以为"失败就重试"是个放之四海皆准的健壮性策略,却没意识到它只对"会恢复的失败"有效,对"不会恢复的失败",重试不是健壮、是徒劳还添乱。
# 我的写法: 所有失败一刀切都重试
def call_tool(tool, args, max_retry=5):
for attempt in range(max_retry):
try:
return tool.invoke(args)
except Exception:
continue # ★ 任何失败都重试, 不分类型
raise ToolFailed(tool, args)
# 问题: 对"确定性失败"反复重试毫无意义
# 400 参数错: 同样参数再发 5 遍 → 还是 400(参数没变, 结果不变)
# 404 不存在: 资源就是没有 → 再查 5 遍还是 404
# 403 没权限: 身份没变 → 再试 5 遍还是 403
# → 浪费 5 倍时间和 token, 任务还是失败; 有副作用的还可能重复执行
# 而对"瞬时失败"重试才有用:
# 网络抖动/超时/429 限流/503 临时不可用 → 过会儿再试很可能就成了
# 根因: 失败分两类(瞬时可恢复 vs 确定性不可恢复),
# 一刀切地都重试 = 把只对前者有效的策略, 错套到了后者上
问题被钉死在这个认知错位上:我以为"失败就重试"是个通用的、对任何失败都有益的健壮性手段,但重试的前提假设是"这次失败是暂时的、再试一次可能就好了";这个假设只对"瞬时的、可恢复的"失败成立。对"确定性的、不可恢复的"失败(参数错、资源不存在、没权限、被业务规则拒绝),失败的原因是固定的、不会随时间改变的,你用完全相同的输入再发一遍,必然得到完全相同的失败——重试在这里没有任何"恢复"的可能,只是把徒劳重复了 N 遍。我把这两类性质完全不同的失败,用同一套"无脑重试"去对待,于是对该重试的有效、对不该重试的就成了纯浪费(甚至因副作用而有害)。我没有去区分失败的性质,就盲目地施加了一个只对其中一类有效的策略。我以为"再试一次"是面对任何失败都值得一做的事,可有些失败的根源压根不在"运气",而在"输入或前提本身就错了"——这种错,试一万次也还是错。
第一件事:想明白失败分"瞬时可恢复"和"确定性不可恢复"两类
把这次事故彻底想清楚,关键是理解调用失败大体分成两类,它们对"重试"的反应截然不同:其一是"瞬时的、可恢复的"失败——失败由临时的、会变化的外部因素造成(网络抖动、连接/读取超时、限流 429、下游临时过载 503、死锁回滚),这类失败过一会儿、换个时机再试,很可能就成功了,所以重试(配合退避)是对的;其二是"确定性的、不可恢复的"失败——失败由固定的、不会随时间改变的原因造成(参数/格式错误 400、资源不存在 404、认证/权限失败 401/403、参数校验不过、业务规则拒绝),这类失败只要输入和前提不变,再试多少次结果都一样,重试毫无意义、纯属浪费、对有副作用的操作还可能有害。
所以正确的重试,前提是先判断这次失败属于哪一类,再决定怎么办:对"瞬时可恢复"的失败,做有限次、带指数退避和抖动的重试(给外部恢复的时间、也别把已经吃力的下游再砸垮);对"确定性不可恢复"的失败,立即停止重试,转而采取别的策略——把错误信息反馈给上层(或反馈给 LLM 让它换个参数/换个方法重新规划)、或降级、或如实地报告失败,而不是徒劳地重发同样的请求。对 Agent 而言,这一点尤其重要:Agent 的工具调用失败,很多恰恰是它自己把参数填错了(确定性失败)——这时正确的做法不是"用同样的错参数重试五遍",而是"把'参数错了、错在哪'告诉 LLM,让它改对参数再调一次",这才是真正能"恢复"的路径。关键认知是:重试只是应对"瞬时性失败"的手段,它的有效性建立在"失败会自行恢复"这个前提上;面对失败,第一步永远是判断"这是一个再试一次就可能好的失败,还是一个不改变输入/前提就永远不会好的失败",再据此选择重试、换策略、还是放弃——而不是不分青红皂白地一律重试。
# 正解: 先判失败类型, 再决定重试还是换策略
import time, random
# 明确哪些是"瞬时可恢复"(才重试)、哪些是"确定性不可恢复"(立即放弃)
RETRYABLE = {429, 500, 502, 503, 504} # 限流/临时错误 → 重试
NON_RETRYABLE = {400, 401, 403, 404, 422} # 参数/权限/不存在/校验 → 别重试
def call_tool(tool, args, max_retry=3):
for attempt in range(max_retry + 1):
try:
return tool.invoke(args)
except HttpError as e:
if e.status in NON_RETRYABLE:
# 确定性失败: 重试无意义, 立即把错误抛上去(交给上层/LLM 换策略)
raise NonRetryable(f"{tool.name} {e.status}: {e.body}") from e
if e.status in RETRYABLE and attempt < max_retry:
time.sleep((2 ** attempt) + random.random()) # 指数退避+抖动
continue
raise # 重试用尽 or 未知错误, 抛出
# 注意: 有副作用的工具, 重试前要确保幂等(否则可能重复执行)
# 对 Agent: 确定性失败(尤其参数错)要回传给 LLM, 让它改对再调, 而非死重试
def agent_step(step):
try:
return call_tool(step.tool, step.args)
except NonRetryable as e:
# 把"错在哪"喂回 LLM, 让它修正参数/换个工具重新规划(这才是真"恢复")
return llm_fix_and_retry(step, error=str(e))
想通这一层,我才明白自己错在哪:我把"失败就重试"当成了对任何失败都有益的通用策略,而没意识到重试只对"瞬时可恢复"的失败有效;对"确定性不可恢复"的失败(参数错、404、403),用同样的输入重试,必然得到同样的失败,重试纯属浪费。我没有去区分失败的性质,就盲目地对所有失败施加了重试,于是对该重的有用、对不该重的就白白烧时间烧 token、甚至因副作用添乱。根治之道,是先判断失败属于哪一类:瞬时的就带退避有限重试,确定性的就立即停手、把错误反馈上去或交给 LLM 换个参数/方法重新来。不是把"再试一次"当成万灵药,而是先看清这个失败会不会自行恢复,再决定是该等等再试、还是该换条路。
第二件事:正解——分类失败,瞬时的退避重试、确定性的换策略
找到根因,正解就清晰了:面对工具调用失败,先判断它属于哪一类再决定怎么办——"瞬时可恢复"的(超时、429、5xx、连接抖动)做有限次 + 指数退避 + 抖动的重试;"确定性不可恢复"的(400/401/403/404/校验失败/业务拒绝)立即停止重试,把错误反馈上层;对 Agent,确定性失败(尤其它自己填错参数)要把"错在哪"回传给 LLM 让它修正参数/换工具重规划;有副作用的工具重试前必须保证幂等。
import time, random
RETRYABLE = {408, 429, 500, 502, 503, 504} # 瞬时: 才重试
NON_RETRYABLE = {400, 401, 403, 404, 409, 422} # 确定性: 别重试
class RetryableError(Exception): pass
class NonRetryableError(Exception): pass
def classify(status):
if status in RETRYABLE: return "retry"
if status in NON_RETRYABLE: return "fail"
return "fail" # 未知错误保守按不重试(或单独评估)
def call_with_policy(tool, args, max_retry=3):
for attempt in range(max_retry + 1):
try:
return tool.invoke(args)
except HttpError as e:
kind = classify(e.status)
if kind == "fail":
raise NonRetryableError(f"{tool.name} {e.status}: {e.body}")
if attempt < max_retry:
time.sleep(min(2 ** attempt, 30) + random.random()) # 退避+抖动+上限
continue
raise RetryableError(f"{tool.name} retries exhausted")
# Agent 层: 确定性失败 -> 回传 LLM 修正(真正的"恢复"路径), 而非死重试
def agent_call(step, llm):
try:
return call_with_policy(step.tool, step.args)
except NonRetryableError as e:
# 把错误原因喂回 LLM, 让它改参数/换工具重新规划这一步
fixed = llm.revise(step, error=str(e))
return call_with_policy(fixed.tool, fixed.args) # 用修正后的再调
这套做法的精髓,是把"失败"先分成"会自行恢复的"和"不改变输入就永远不会好的"两类,再对症下药:前者用退避重试给它恢复的时间,后者立刻停手、走"换参数/换方法/反馈上层"这种真正可能改变结果的路径。对瞬时失败,重试 + 指数退避 + 抖动 + 上限,既给外部恢复机会又不雪上加霜;对确定性失败,重试是死路,唯一的"恢复"是改变那个导致失败的原因——而对 Agent,这往往意味着把"哪里错了"告诉 LLM、让它生成对的参数或换个工具。不是对所有失败都"再试一次",而是先看清这个失败靠"再来一次"能不能好,不能好就别在原地耗、去改那个真正错了的东西。
【给 Agent 工具调用加重试, 我现在认死的几条】
1. 失败分两类: 瞬时可恢复(超时/429/5xx)和 确定性不可恢复(4xx 参数/权限/不存在)
2. 重试只对"瞬时可恢复"有效——它假设"再试一次可能就好了"
3. 对"确定性失败"重试 = 用同样输入再发一遍, 必然同样失败, 纯浪费
4. 瞬时失败: 有限次 + 指数退避 + 抖动 + 上限(给恢复机会又不砸垮下游)
5. 确定性失败: 立即停, 反馈上层; Agent 把"错在哪"回传 LLM 改参数重调
6. 有副作用的工具重试前必须幂等, 否则重试可能重复执行
7. 未知错误保守处理: 默认不盲目重试, 单独评估它属于哪一类
第三件事:其他"对确定性失败盲目重试/坚持"的同类坑
顺着"对一个'不改变前提就必然再次失败'的事,徒劳地反复重试/坚持"这条线,我把同类的坑都排查了一遍:
第一个,消息队列对确定性失败无限重投。一条消息因数据格式错(必然失败)被消费失败,队列一直重投、堵住后面的消息;这种"毒消息"该进死信队列、人工处理,而非无限重试。
第二个,定时任务失败了原样重跑。任务因配置错/依赖缺失(确定性)失败,调度器到点又原样跑一遍,次次失败、徒增噪声;要区分可重试与需人工介入。
第三个,前端请求 4xx 还自动重试。表单校验失败(400)、未登录(401)却自动重发,既没用又可能放大问题;4xx 该提示用户改、而非重试。
第四个,用同样的方法反复尝试、期待不同结果(更一般)。代码/配置/输入没变,却反复运行期待这次能成——确定性的东西不会因为多试几次而改变。
第四件事:瞬时失败 vs 确定性失败——一张对照表
我把两类失败摆在一起对比,核心看"再试一次会不会好、该怎么应对":
| 维度 | 瞬时可恢复失败 | 确定性不可恢复失败 |
|---|---|---|
| 典型 | 超时、429 限流、5xx、连接抖动、死锁 | 400 参数错、401/403、404、校验/业务拒绝 |
| 失败原因 | 临时的、会变化的外部因素 | 固定的、不随时间改变的 |
| 再试一次 | 很可能就成功 | 必然同样失败 |
| 该不该重试 | 该(有限次+退避) | 不该, 立即停 |
| 正确应对 | 退避重试给它恢复时间 | 换参数/换方法/反馈上层 |
| Agent 怎么办 | 退避重试 | 把"错在哪"回传 LLM 修正重调 |
看清这张表,应对就有谱了:瞬时失败靠"退避重试"等它自行恢复;确定性失败靠"改变导致失败的原因"(换参数/换方法/反馈),而不是重试。我这次踩坑,正是把确定性失败(参数填错)当瞬时失败,用同样的错参数重试五遍。区分这两类、再决定重试还是换策略,是 Agent 工具调用健壮又不浪费的关键。
第五件事:我曾经对失败重试想当然的几个误区
这次事故也把我对重试的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| 失败就重试是通用的健壮性手段 | 只对瞬时可恢复失败有效, 确定性失败重试是浪费 |
| 多重试几次总没坏处 | 对确定性失败浪费时间 token, 有副作用还可能重复执行 |
| 参数错了重试一下可能就好了 | 参数没变, 重试一万遍还是同样的参数错 |
| Agent 工具失败就自动重试最省事 | 它常是自己填错参数, 该回传 LLM 改而非死重试 |
| 重试就行, 不用管失败类型 | 必须先分类, 再决定重试/换策略/放弃 |
这些误区的根子是同一个:我把"重试"当成了对任何失败都适用的万能药,而没意识到重试本质是赌"这次失败是暂时的、再来一次运气会变好"——这个赌注只在失败确实由"会变化的因素"造成时才划算。对那些由"固定的、不会变的原因"(参数、权限、不存在)造成的失败,根本没有"运气"可言,再试只是把同一个注定的结果重演一遍。把"再试一次"当成面对一切失败的默认动作,而不先问"这个失败是会变的还是固定的",是这类徒劳重试的共同根源。
第六件事:给 Agent 加重试、排查"反复重试还是失败"时,我现在的自检习惯
现在每当我给 Agent 工具调用加重试、或排查"某步反复重试 N 次还是失败、又慢又费",我都会先按这张图问自己:
这张图的精髓,是"反复重试还失败先看每次原因是否相同;相同就是确定性失败别再重试、去改导致失败的原因"。设计就先给失败分类(瞬时 vs 确定性)、瞬时退避重试、确定性立即换策略、Agent 把确定性失败回传 LLM 修正、排查就看每次重试的失败原因是不是一模一样(相同即确定性、重试纯浪费)。这套习惯,让我从"失败就重试"变成了"先看这失败会不会自行恢复"——核心始终是:调用失败大体分成两类、它们对重试的反应截然不同:其一是瞬时的可恢复的失败——失败由临时的会变化的外部因素造成(网络抖动、连接/读取超时、限流 429、下游临时过载 503、死锁回滚),这类失败过一会儿换个时机再试很可能就成功了所以重试配合退避是对的;其二是确定性的不可恢复的失败——失败由固定的不会随时间改变的原因造成(参数/格式错误 400、资源不存在 404、认证/权限失败 401/403、参数校验不过、业务规则拒绝),这类失败只要输入和前提不变再试多少次结果都一样、重试毫无意义纯属浪费、对有副作用的操作还可能有害;所以正确的重试前提是先判断这次失败属于哪一类再决定怎么办:对瞬时可恢复的失败做有限次带指数退避和抖动的重试(给外部恢复的时间也别把已经吃力的下游再砸垮),对确定性不可恢复的失败立即停止重试转而采取别的策略——把错误信息反馈给上层或反馈给 LLM 让它换个参数换个方法重新规划、或降级、或如实报告失败,而不是徒劳地重发同样的请求;对 Agent 尤其重要因为它的工具调用失败很多恰恰是它自己把参数填错了(确定性失败),这时正确的做法不是用同样的错参数重试五遍而是把参数错了错在哪告诉 LLM 让它改对参数再调一次这才是真正能恢复的路径;一句话,重试只是应对瞬时性失败的手段、它的有效性建立在失败会自行恢复这个前提上,面对失败第一步永远是判断这是一个再试一次就可能好的失败还是一个不改变输入/前提就永远不会好的失败再据此选择重试、换策略、还是放弃,而不是不分青红皂白地一律重试。
我立下的几条规矩
这场"对必然失败的调用反复重试"的事故,换来了我设计 Agent 重试时,刻进骨子里的几条铁律:
- 失败分两类:瞬时可恢复(超时/429/5xx)和确定性不可恢复(4xx 参数/权限/不存在)。
- 重试只对"瞬时可恢复"有效——它假设"再试一次可能就好了"。
- 对"确定性失败"重试 = 用同样输入再发一遍,必然同样失败,纯浪费。
- 瞬时失败:有限次 + 指数退避 + 抖动 + 上限,给恢复机会又不砸垮下游。
- 确定性失败:立即停,反馈上层;Agent 把"错在哪"回传 LLM 改参数重调。
- 有副作用的工具重试前必须幂等,否则重试可能重复执行。
- 排查反复失败:看每次原因是否相同,相同即确定性、别再重试。
附:我现在给 Agent 工具调用做"分类重试 + LLM 修正"的骨架
这是我现在给 Agent 工具调用加重试固定套的骨架——把这次踩坑的教训(先分类失败、瞬时退避重试、确定性回传 LLM 修正、幂等)固化成一套结构,让"对必然失败反复重试"那种坑再不会埋进系统:
import time, random
RETRYABLE = {408, 429, 500, 502, 503, 504} # 瞬时可恢复 -> 退避重试
NON_RETRYABLE = {400, 401, 403, 404, 409, 422} # 确定性不可恢复 -> 别重试
class ToolError(Exception):
def __init__(self, status, body): self.status, self.body = status, body
def call_with_retry(tool, args, max_retry=3):
last = None
for attempt in range(max_retry + 1):
try:
return tool.invoke(args) # 成功直接返回
except ToolError as e:
last = e
if e.status in NON_RETRYABLE:
raise # 确定性失败: 立即抛, 不重试
if e.status in RETRYABLE and attempt < max_retry:
time.sleep(min(2 ** attempt, 30) + random.random()) # 退避+抖动+上限
continue
raise # 未知错误/重试用尽: 抛出
raise last
def agent_step(step, llm, max_fix=2):
"""先分类重试; 确定性失败(尤其参数错)回传 LLM 修正后重调"""
cur = step
for _ in range(max_fix + 1):
try:
return call_with_retry(cur.tool, cur.args)
except ToolError as e:
if e.status in NON_RETRYABLE:
# 把"错在哪"喂回 LLM, 让它改对参数/换工具(真正的恢复路径)
cur = llm.revise(cur, error=f"{e.status}: {e.body}")
continue # 用修正后的再调
raise # 瞬时失败已重试用尽, 上抛
raise RuntimeError(f"step failed after fixes: {step.name}")
# 提醒: 有副作用的工具(写库/下单/发消息)重试前必须幂等(带幂等键), 否则重复执行
这套骨架把我这次的教训钉死在了结构里:工具调用先按状态码分类——确定性失败(4xx)立即抛不重试、瞬时失败(429/5xx)才有限次指数退避+抖动+上限重试;Agent 层对确定性失败(尤其参数错)把"错在哪"回传 LLM 让它修正参数/换工具再调(走真正的恢复路径)、且修正也设上限;有副作用的工具重试前保证幂等。这样,瞬时失败靠退避重试熬过去、确定性失败靠改正根源解决,而不再是当初那个"对着填错的参数无脑重试五遍、白烧时间 token"的局面。把"判断失败根源会不会变、只对会变的重试、对不变的去改根源"这个道理,沉淀成 Agent 工具调用的固定骨架,这是我对这次"徒劳重试"最实在的交代——毕竟,撞墙了,该退后看看墙在哪、绕过去,而不是闭着眼一遍遍往同一个地方撞。
写在最后
回头看,这场由"无脑重试"引发的"对必然失败反复重试"事故,真正教给我的,远不止"给重试加个错误分类"这一个技巧。它让我对"面对一次失败,我们的本能反应往往是'再试一次';但'再试一次'这个动作能不能带来不同的结果,取决于这次失败的根源是'会变的'还是'不会变的':如果失败源于一时的运气(网络、负载、时机),再试一次确实可能撞上好运;但如果失败源于一个固定的、不改就不会变的前提(输入错了、条件不满足、权限不够),那么用完全一样的方式再来一次,得到的必然是完全一样的结果——重复同样的行为却期待不同的结果,是徒劳",有了一次刻骨的体会。我栽跟头,是因为我把"再试一次"当成了面对任何失败都值得一做的万能动作,而没去分辨这次失败的根源是"会变的运气"还是"不变的前提"——我以为失败大多是"一时不顺",重试就能熬过去;我没意识到,很多失败的根源压根不在运气,而在"我喂进去的东西本身就错了"(参数填错了)、或"这件事在当前前提下就是不被允许的"(没权限、不存在)——这些根源是固定的,不会因为我多试几次就改变;于是我让 Agent 对着一个填错了参数的调用,用那个错参数一遍遍地重发,每一次都精确地、必然地、以同样的方式失败,白白烧掉时间和 token,却离成功没有近一步。这让我领悟到一个关于"重试与失败根源"的深刻认知:"重试/再尝试"是一种只在特定前提下才有效的策略——它的全部效力,建立在"导致失败的因素是会变化的、再试一次时它可能已经不一样了"这个假设上;所以面对任何失败,真正该先做的不是"立刻再试一次",而是判断"这次失败的根源,是会变的(一时的运气、时机、外部状态),还是不变的(固定的输入、前提、规则)":对会变的,重试(等它变好)是对的;对不变的,重试是纯粹的徒劳,唯一可能改变结果的,是去改变那个导致失败的、固定的根源本身(换输入、换方法、补足前提);把这两种失败混为一谈、对一切失败都用"重试"去应对,就会在"不变的失败"上做无用功,既浪费资源、又延误了去解决真正问题(改变根源)的时机。这给了我一种看待"一切'失败后要不要再来一次'之事"时的清醒:每当一件事失败、我想"再试一次"时,要追问"这次失败的原因,是会变的还是不会变的?如果我用完全一样的方式再来一次,有理由相信结果会不同吗?如果没有,我该改变的是哪个固定的根源,而不是重复同样的尝试"——先分辨失败根源是瞬时可变还是确定不变,只对前者重试、对后者去改根源,而不是无脑地一律再试一次;"判断失败根源会不会变、只对会变的重试、对不变的去改根源",是设计 Agent 重试、也是面对一切失败时的关键。认清失败分瞬时与确定性两类、重试只对瞬时有效、确定性失败要换策略改根源——这,是我用一次"对填错参数的调用重试五遍"的事故,换来的、关于 AI Agent、也关于如何看待失败与重试的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给工具调用加"失败就重试"时,先停一秒想想"哪些失败再试一次会好、哪些再试一万次也是同样的错?",并据此分类处理,那我对着那个"对着错参数重试五遍还是失败"的 Agent 排查的大半天,就值了。
—— 别看了 · 2026