那个用一小时反复重试同一个 400 错误的 Agent
一个 agent 调用了某个工具。工具返回 400 Bad Request,错误体结构清晰得无可挑剔:{"error": "missing required field", "field": "email"}。Agent 的推理链一字不差地复述了错误,说"我需要在请求里加上 email 字段",然后发出了下一个工具调用——负载和上一次一模一样。框架的重试策略是多年前为不稳定 HTTPS 连接写的,把这个 400 当成瞬时错误,又发了一次。三次。八次。十五次。一小时后,这个 agent 烧掉了上下文、烧掉了预算、烧掉了限流额度,却从未发出过一个工具能接受的请求。
这种失败看起来像是模型的问题。其实不是。模型把错误读对了。是包在它外面的框架,没有给"修正"留任何落脚点。
这就是一个能_读懂_错误的模型和一个能_从错误中学习_的 agent 之间的鸿沟。模型对"缺少 email 字段"这句话有流畅的自然语言理解。Agent 的重试循环却分不清"网络抖了一下"和"你的请求结构本身就是错的"。这两种情况的解药正好相反。一种希望同样的请求再发一次,也许加点退避。另一种 希望同样的请求_永远不再发_,除非请求本身有所改变。把这两件事混为一谈,就是 agent 干出"电梯按钮按得越用力越管用"那种数字化蠢事的根源。
被框架混淆成同一种东西的两类重试
大多数 agent 框架自带的重试原语,本来是为另一个问题设计的。网络请求失败的原因往往与负载无关——TCP 连接被重置、被临时过载的后端返回 503、每秒配额擦边导致 429。这三种情况的正确处理都是等一下再用_完全相同的请求_重发一遍。指数退避、抖动、有上限的尝试次数。这是经典的形状。
这个原语遮蔽了一个更深的区别。错误其实分成两类,在运维语义上几乎毫无共同点:
- 瞬时错误。 请求本身没毛病,是外部世界临时出了状况。例如:
502、503、504、429(通常情况下)、连接重置、DNS 抖动、provider 内部超时。处理方式是延迟之后把同样的字节再发一遍。 - 确定性错误。 请求本身畸形、未授权,或者指向不存在的资源。例如:
400、401、403、404、422。处理方式是改变请求。把同样的字节再发一遍,只会得到一模一样的失败。
一个对这两类错误都会触发的重试策略,根本不是在重试,是在死磕。而今天大多数生产环境里的 agent 形态恰恰就是这样:一个 try/except 包住工具调用,except 块睡一会儿再调一次,把错误字符串扔给 LLM 让它"在下一轮推理一下"。框架从不过问:这一次的请求和上一次相比,真的有任何东西变了吗?模型的推理 trace 里说它理解了怎么改,但框架并没有任何契约把"理解"翻译成"下一次调用结构上的差异"。
这种区分之所以关键,是因为成本结构是不对称的。一次成功的瞬时重试是一份便宜的保险。一次根本不可能成功的确定性重试,要付出 token、延迟、金钱、限流余量,最后还要付出"用户的信任"——当 agent 最终放弃,在一个只要改一个字符就能解决的任务上报告失败时。
模型的流畅措辞如何掩盖了 bug
最迷人的陷阱是 LLM 看上去真的在修问题。读它两次重试之间的推理,你会看到诸如"错误提示我需要加上 email 字段——我会在下一次调用里加上"这样的句子。然后下一次调用发出去,里面还是没有 email 字段。推理链和工具调用是两条独立的通道,只有工具调用真正打到网络上。第一条通道里的流畅措辞,根本不能证明第二条通道发生了修正。
这是一个普遍的 agent 失败模式的具体实例:agent 描述了它打算做什么,而框架并没有验证描述和实际动作是否一致。在一个_动作验证型_的循环里,agent 对下一步动作的每一句声明,都可以拿下一步实际的负载去核对。在一个"信任推理"的循环里,叙述会漂移,负载却原地不动。
更糟的是,模型其实有_重复自己_的正向激励。一旦 LLM 在某个上下文窗口里发出过一个工具调用,这次调用就以高概率模板的形式留在它自己的输入里。生成是路径依赖的:最容易出现的下一个 token,就是和周围模式相匹配的那个 token。那个刚刚自信地宣布"我会加上 email 字段"的模型,接下来从一个最近三次工具调用都没带 email 的上下文里采样。修正的意图被本地的先验覆盖掉了。没有强制函数,模型就会回归到它已经做过的事情上去。
那些把工具调用藏在"由 agent 自行决定是否重试"这种抽象后面的框架,让这一切完全不可见。Trace 里显示了三次重试,账单里加了几行成本,团队的结论是"API 不稳定"。API 没问题。是循环从未要求 agent 在重新触发之前先做一次"承诺改变"。
这个区分必须活在循环层
把这件事推到 prompt 里——"如果你看到错误,请确保_真的_修改你的请求"——在 demo 里管用,在生产里失效。Prompt 是建议性的。循环策略才是强制性的。这两类错误需要不同的代码路径,而不是给模型不同的提示。
一个把这种区分内化了的循环看起来是这样的:
- 对于瞬时错误: 按状态码或异常类型分类,使用带抖动的指数退避,设置尝试上限(大多数生产 agent 选择 3 次),用完全相同的负载重发。重试本身不需要调用模型。
- 对于确定性错误: 不要用相同的负载重新触发工具。把结构化错误暴露给模型,_强制_要求下一次调用在结构上有所不同(不同的字段集、不同的取值、不同的端点),并且拒绝下发那种负载哈希与已失败调用相同的请求。
最后这一句是承重墙。框架对工具调用做哈希——工具名加上规范化后的参数——然后拒绝下发当前回合中已经失败过的哈希。模型想生成什么推理都行。但只要结果负载和已知失败完全字节一致,框架就拒绝它,并告诉模型"你说你会修改 X,但负载并没有变。再来一次,而且变化必须体现在参数里。"这就是强制函数。哈希相等性检查是唯一一个无法靠话术绕过的机制。
对于模棱两可的情况——比如 429,它可能意味着"等一下",也可能意味着"你今天的配额用完了"——循环要读响应头和错误体。一个 Retry-After 头让它变成瞬时错误。一个"月度配额已耗尽"的响应体让它变成确定性错误。正确分类这些模糊错误的代价是一个 switch 语句。错误分类的代价,是 agent 用一小时去死磕一个错的端点。
让"修正"真正承重的 Prompt 模式
即便有了严格的循环,模型也还是需要被引导,在下一轮真的生成不同的调用。有两种模式值得花力气:
重发前先预承诺。 在生成下一个工具调用之前,要求 agent 先输出一条结构化声明:"上一次调用失败是因为 X。我具体的修改是 Y,体现在字段 Z 上。"这条声明作为独立的消息进入对话,跟工具调用本身分开。然后框架可以检查下一次工具调用里的 Z 是否真的和上一次不同。模型已经被迫把它对修复的理论_显式表达_出来,这个理论于是变得可验证。
Schema 优先的反思。 不再让模型用散文复述错误,而是把工具的 schema 连同错误一起给它,问:"在你上一次调用里,这个 schema 中哪些字段缺失或者错了?"模型生成一个列表。下一次调用必须解决列表上的每一项。这把模糊的"我会修复错误"转化成了循环可以核对的逐项清单。
这两种模式有共同的结构:把模型本来会做的隐式推理,变成显式的、结构化的、可核对的东西。模型仍然可以幻觉出"修复方案",但只要这个幻觉产生的负载在结构上和上一次一致,它就会在下发边界被拦截。
工具层有一个互补的模式:错误应该被设计成可以教会调用者。一个只回 "bad request" 的 400 是信息贫乏的。一个回 {"error": "missing_required_field", "field": "email", "received_keys": ["name", "phone"], "expected_keys": ["name", "phone", "email"]} 的 400,让对应的修正动作变得机械化。写得用心的错误响应,能省去 agent 设计者大量的工夫。错误是工具契约的一部分,而一个只告诉你"不行"却不解释"为什么不行"的契约,是一个 agent 无法集成的契约。
让"卡住的循环"浮出水面的埋点
这种失败模式最难处理的地方在于:在账单到来之前,它几乎是不可见的。Agent 看起来很忙。Span 一个接一个亮起。工具调用源源不断。默认的可观测面板上,没有任何一项指标能区分"15 次工具调用,15 个有用动作"和"15 次工具调用,15 次反复重试同一个错的负载"。
填上这个空缺的埋点,既小又具体:
- 每个工具的重复调用率。 对每个工具,统计单个回合内参数哈希命中过往同一回合调用的比例。健康的工具接近零。重复调用率上升的工具正在被死磕。
- 同错误重试计数。 当工具返回某个错误码时,同一回合内有多少次调用返回的是相同错误码加相同的负载签名。这个数字一旦超过 2,就是一个卡住的循环,毫无悬念。
- 按任务的循环率。 完成一项任务的平均迭代次数。在任务类型保持不变的情况下,循环率上升意味着某个下游环节——工具、schema、模型升级——不再配合了。
- 按"完成任务"而不是"按调用"算成本。 按调用算的成本面板会把卡住的循环藏在调用量背后。按_已完成_任务的成本算,这个失败模式就会以一条线图的形式直接显示出来。
一旦有了这些信号,问题就从"agent 是否成功了"转向"产出成功的这套系统是否健康"。卡住的循环成了一类一等公民的运维问题,而不是一种没人注意、直到财务复盘才被发现的慢性失血。
工具是契约,不是便利设施
藏在这种失败模式背后更深的原则是:工具的错误响应是它契约的一部分。一个 agent,在输入正确时能成功调用工具,在输入错误时却忽略响应——它并没有真正集成这个工具 。它只集成了 happy path。Unhappy path——结构化错误、限流响应头、校验失败的响应体——才是工具 API 中最能告诉 agent 下一步该怎么做的部分,却也是大多数 agent 框架直接扔掉的那部分。
要构建尊重这份契约的 agent,大体上不是模型的问题。它是循环的问题、prompt 的问题、埋点的问题,大致按这个顺序。模型早就会读错误了。是框架要负责让"读懂"翻译成"行动"。你的 agent 即将第 16 次重试的那个 400,并不神秘。它只是一份 agent 尚未履行的契约。在循环层把它履行一次,你原本要烧掉的一小时,就会变成一次成功的修正调用。
- https://sparkco.ai/blog/mastering-retry-logic-agents-a-deep-dive-into-2025-best-practices
- https://medium.com/@connect.hashblock/7-langchain-retry-timeout-patterns-for-flaky-tools-a371c3edc1d3
- https://apxml.com/courses/prompt-engineering-agentic-workflows/chapter-3-prompt-engineering-tool-use/addressing-tool-errors-via-prompts
- https://apxml.com/courses/building-advanced-llm-agent-tools/chapter-1-llm-agent-tooling-foundations/tool-error-handling
- https://fast.io/resources/ai-agent-retry-patterns/
- https://ingramhaus.com/action-verification-and-retries-in-llm-agent-execution-loops
- https://www.askui.com/blog-posts/challenge-stuck-vision-ai-agents
- https://www.fixbrokenaiapps.com/blog/ai-agents-infinite-loops
- https://medium.com/@ThinkingLoop/13-agent-eval-tests-that-catch-silent-tool-failures-79ac312d70a4
- https://dev.to/gabrielanhaia/tracing-agent-tool-calls-so-you-can-catch-a-stuck-loop-24a9
- https://dev.to/mostafa_ibrahim_774fe947b/what-is-agent-observability-traces-loop-rate-tool-errors-and-cost-per-successful-task-bl5
- https://medium.com/@kacperwlodarczyk/stuckloopdetection-how-we-stopped-an-agent-burning-12-on-47-identical-calls-a12b5ea1f193
- https://platform.claude.com/docs/en/build-with-claude/structured-outputs
