凌晨两点,手机连震三次。告警群里那张 Multi-Agent 任务执行失败的截图,配着一行“请重试”。我揉着眼睛爬起来,点了重试,等了五分钟,又失败。再重试,还是失败。最后发现根本不是偶发网络抖动,而是其中一个子 Agent 的鉴权 token 过期了——重试一百次也是同一个结果。
这类事情我见过太多次了。“Multi-Agent 里某个 Agent 执行失败,第一反应是重试”,这个习惯几乎刻在所有做 Agent 编排的人骨子里。但说句得罪人的话:只会重试,说明还没把失败处理当成一个完整的工程问题来对待。Multi-Agent 的失败处理,本质上是一套“分类、决策、降级、补偿、预防”的闭环体系。重试只是其中最基础的一个动作,甚至连“最基础”都算不上——它是最后的手段,不是默认的动作。
这篇文章我不会跟你讲那些花哨的 Agent 框架有什么炫酷功能,只聊一件事:当一个 Agent 在编排链路里倒下的时候,成熟的做法到底是什么。我会从失败分类、可重试性判定、工程化重试策略、降级补偿、乃至架构层面的容错设计,把这条完整链路拆开讲透,顺便附上我在实际项目中踩过的坑和调参经验。代码和思路都可以直接抄,适用场景不限框架——LangGraph、AutoGen、自研调度器,都能用同一套逻辑。
1. 先搞清楚一件事:Multi-Agent 为什么总在执行失败
很多人把 Multi-Agent 执行失败简单归类为“网络不好”或者“模型抽风”,然后直接重试。这是最偷懒的做法。实际排障时,失败原因至少可以拆成五类,每一类的处理方式完全不同。
第一类:瞬时错误。网络闪断、HTTP 5xx、限流(rate limit)、上游服务短暂的不可用。这类错误有个共同点:只要等一会儿,系统自己就会恢复。重试对它们有效。第二类:配置与凭证错误。模型 API key 失效、token 过期、调用的模型名不存在、某个 Agent 依赖的外部服务地址配错。这类错误有个典型特征:你重试一万次,结果都一样。它们需要的是“刷新凭证”“修改配置”“换一个服务地址”,而不是重试。第三类:上下文与资源超限。最常见的表现是“上下文窗口溢出”——类似“启用 1m 上下文后重试”这种提示,本质上是当前任务的输入长度已经超过了模型能处理的窗口。还有显存不足、磁盘写满、API 配额耗尽。这类错误的解法是“重新分配资源”或“处理输入”,而不是简单重试。第四类:逻辑与输出格式错误。Agent 的推理结果不符合下游要求的 schema,或者模型只输出了思考过程、没输出正文。这类错误源于模型行为的不确定性,简单重试可能成功,但成本极高——你等于让模型把同样的思考再跑一遍。第五类:级联传播错误。上游 Agent 给出了错误的中间结果(比如把日期格式解析错),下游 Agent 基于这个错误输入继续处理,最终导致整个链路的输出是垃圾。这种情况最讽刺:重试的不是出错的那个环节,出错的是上游。你盯着下游失败点反复重试,永远修不好。
这五类错误混在一起,就是很多人在 Multi-Agent 项目里“天天救火”的根源。你没有对失败分类,就没有决策依据;没有决策依据,重试就是抓阄。
我见过一个真实案例:某团队做了一个“信息抽取→去重→入库”的三 Agent 流水线,每天凌晨跑批总有几个任务失败。负责的小哥非常勤奋,每次失败都手动触发重试,一天能重试几十次。后来我帮他看了日志,发现 80% 的失败都来自同一个根因——上游抽取 Agent 偶尔会在结果末尾多打印一行调试信息,导致下游 schema 校验不过。这种问题,你重试一百遍,唯一的区别就是多花一百遍的 token 钱。把解析逻辑改成“取有效 JSON 片段”之后,一晚上再也没炸过。
2. 重试前先回答三个问题:可重试性判定才是核心
既然失败分了类型,下一步就是建立一套“可重试性判定”的规则。这套规则解决的核心问题是:拿到一个异常之后,先决策,再行动。
2.1 先给错误分门别类:定义统一的异常结构
第一步很朴素:把 Multi-Agent 链路里所有可能出现的异常统一收敛到一个结构化的错误对象里。别管是框架抛出的、HTTP 层返回的、还是模型输出解析失败的,全部转成同一种表达方式。
我用 Python 举个最小的示例:
from enum import Enum class ErrorCategory(str, Enum): TRANSIENT = "transient" # 瞬时错误,可重试 CONFIG = "config" # 配置/凭证错误,不可重试 RESOURCE = "resource" # 资源超限,调整后可重试 OUTPUT_FORMAT = "output_format" # 输出格式错误,需要修正后重试 CASCADE = "cascade" # 级联错误,需追溯上游 class AgentError(Exception): def __init__(self, message: str, category: ErrorCategory, retryable: bool): super().__init__(message) self.category = category self.retryable = retryable这个结构看起来简单,但它解决了一个实际问题:把“错误信息”和“错误语义”解耦了。日志里打印“failed to refresh token: invalid”只是信息,但只有当它被标记为CONFIG类别时,系统才能正确地选择“去重新登录刷新 token”而不是“无脑重试”。
对应的分类映射逻辑大概是这个样子:
def classify_error(err: Exception, context: dict) -> ErrorCategory: if isinstance(err, TimeoutError): return ErrorCategory.TRANSIENT if "token" in str(err).lower() or "auth" in str(err).lower(): return ErrorCategory.CONFIG if "context length" in str(err).lower() or "window" in str(err).lower(): return ErrorCategory.RESOURCE if "schema" in str(err).lower() or "json" in str(err).lower(): return ErrorCategory.OUTPUT_FORMAT if context.get("upstream_failed"): return ErrorCategory.CASCADE return ErrorCategory.TRANSIENT # 默认可重试,但记日志注意最后一行的默认值:拿不准时,默认按可重试处理,但必须留下结构化日志。这背后的策略是“宁可错杀也不能卡死”——很多分布式系统里,偶发故障的比例远高于永久故障,所以默认可重试是合理的。但如果你连分类都没有,就谈不上“默认”二字。
2.2 三种明确禁止重试的情况
分类之后,我一般会直接列一个“禁止重试清单”。以下三种情况,见了直接走其他分支,别碰重试按钮:
第一种:鉴权与配置错误。凡是包含invalid token、auth failed、permission denied、model not found、invalid request字样的异常,一律不重试。重试只会增加无意义的 API 调用。正确做法是:触发凭证刷新流程、加载新的配置、或者直接把任务标记为“需人工介入”。你有没有想过,为什么很多系统提示“failed to refresh token: invalid”之后你手动重试一百次都没用?因为问题出在“刷新 token 这个动作本身失败了”,要先修刷新逻辑,不是修业务重试。
第二种:上下文与资源溢出。类似“上下文长度超过限制”的错误,重试多少次都不可能把输入变短。除非你在重试前做了输入裁剪、摘要压缩、或者切换到一个更大上下文的模型,否则重试无意义。这里顺便提一句:现在很多平台宣传“1m 上下文全量可用”,但“可用”不等于“任何任务都能塞进去”。1m 上下文的实际效果跟你用的模型、检索方式、任务的 token 消耗规律强相关。当报错说“请启用 1m 上下文后重试”时,你应该意识到这已经是一个资源调配问题,不是重试能解决的。
第三种:级联失败。当错误上下文里带有“上游 Agent 已失败”标记时,当前 Agent 的失败只是一个“果”,不是“因”。此时你要做的是回溯上游处理链路,而不是在这个节点上重试。举个例子:A Agent 负责从网页里抽取结构化字段,B Agent 负责把字段填入数据库。A 抽错了字段类型,B 反复重试“填写”动作,数据库里收获一堆错误记录。这不叫解决问题,这叫放大错误。
3. 工程化重试:把“再跑一次”变成指数退避与幂等控制
好,现在到了重试真正该上场的地方。对瞬时错误,重试是非常有效的策略,但“有效”的前提是重试本身被工程化,而不是简单地在 catch 块里写个 for 循环。
3.1 别再固定间隔重试了:指数退避与抖动
固定间隔重试有个致命问题:如果下游服务因为过载而返回 5xx,你每隔 2 秒打一次请求,等于持续加重下游负担,让恢复变得遥遥无期。这就是“修复失败时把自己也拖进去”的经典案例。
指数退避的思路很简单:每次重试的等待时间按指数增长,并加入随机抖动(jitter)防止多个请求同时重试造成“惊群效应”。下面这个伪代码我直接用了很多次:
import random import time def run_with_retry(func, max_retries=3, base_delay=1.0, max_delay=30.0): for attempt in range(max_retries + 1): try: return func() except RetryableError as e: if attempt >= max_retries: raise delay = min(max_delay, base_delay * (2 ** attempt)) delay = delay * (0.5 + random.random()) # 加入 50%~100% 的抖动 time.sleep(delay) raise RuntimeError("unreachable")几个我实测出来的参数经验:
max_retries不要超过 3~5 次。超过 5 次,等待时间已经拉到十几秒以上,对用户侧感知是“卡死”,而且大概率下游已经彻底挂掉了。base_delay从 1 秒起步比较温和;如果你调用的是非常耗时的模型推理,base_delay建议直接翻倍到 2~3 秒,因为模型推理的耗时本身就有几秒,你太快重试只会相互叠加。max_delay一定要设上限。没有上限的指数退避,会让任务在后台默默等待十几分钟,用户早就跑了,调度系统还以为任务在推进。
3.2 幂等性陷阱:重试两次不等于跑了一次
这是 Multi-Agent 重试里最隐蔽的坑。你以为“重试是把没做完的事情再做一遍”,但很多时候,第一次执行可能已经部分完成了。
举个非常典型的场景:一个 Agent 做“订单创建 + 扣款 + 发送通知”三步操作。如果扣款成功、但发送通知时网络超时,你无脑重试整个 Agent 任务,会发生什么?第二次执行会再创建一单、再扣一次款。重试不是“修正”,是“重复”。
破解方法是给每个任务绑定全局唯一的trace_id,并在任务开始时执行幂等检查:
task_id = "task_20250607_001" if redis.exists(f"done:{task_id}"): print("任务已完成,跳过重试") return load_result(task_id)这里的关键点在于:执行前先检查结果,执行成功后写入结果。“先查再做”能挡住大多数重复执行问题。尤其在 Multi-Agent 场景下,多个子任务并行跑,重试只应该发生在“确认没有产生副作用”的环节。副作用是否发生,判断依据是状态存储里的任务状态;重试的入口也应该是状态机驱动的,而不是异常驱动。
还有一点:重试时要区分“整个链路重试”还是“失败节点重试”。推荐只重试失败节点,而不是把整个 Multi-Agent 链路从头跑一遍。因为链路里其他节点可能已经成功,重跑整个链路等于把所有成功节点又变成不确定因素——纯属给自己找事。
3.3 聪明的重试会逐级提升预算
这个细节很多人完全没注意到。现状是:有些系统确实会“自动重试”,但每次重试都用一模一样的参数,那基本等于“把命运交给随机”。
实际上,模型类 Agent 的失败有一个显著特征:某些失败是因为“这次推理的预算不够”。比如模型“只输出了思考过程、没有产出正文”——很多平台的自动重试机制会“逐级提升输出预算”再试 2 次,这比我见过的大多数手动重试都聪明。因为思考过程已经消耗了大量 token,剩余配额不够生成正文了;如果你第二次重试还是给同样的max_tokens,大概率还是同样的结局。
把这个思路通用化,重试参数应该是动态的:
| 重试轮次 | 温度 | max_tokens | 模型等级 | 失败应对 |
|---|---|---|---|---|
| 第 1 次 | 0.2 | 默认值 | 默认模型 | 瞬时故障 |
| 第 2 次 | 0.3 | 默认值 × 1.5 | 默认模型 | 输出截断/思考过长 |
| 第 3 次 | 0.5 | 默认值 × 2 | 更强模型 | 输出格式不稳 |
第一轮解决“偶发抖动”,第二轮解决“预算不够”,第三轮解决“模型力不从心”。每一轮重试的背后都有一个自己的假设,而不是简单地说“刚才失败了,所以再来一次”。这就是“工程化重试”和“傻瓜式重试”的分水岭。
在工程实现上,你可以在异常对象里带上attempt计数,重试调度器根据计数调整参数:
def build_payload(attempt: int, base_payload: dict) -> dict: payload = base_payload.copy() if attempt >= 1: payload["max_tokens"] = int(payload.get("max_tokens", 1024) * 1.5) if attempt >= 2: payload["temperature"] = min(0.6, payload.get("temperature", 0.2) + 0.2) payload["model"] = "stronger-model-name" return payload别小看这个细节,很多线上 Multi-Agent 任务的失败率,就是靠这种“逐级加码”的方式压下去的。
4. 降级与补偿:失败之后,任务还要继续走下去
重试终究是有上限的。当重试耗尽、错误又是不可重试类型时,你要面对的问题是:这个失败的任务,怎么收场?两种可落地的思路:降级处理,或者补偿回滚。
4.1 降级链:主方案失败,备选方案顶上
降级的本质是:“目标不变,路径更换”。Multi-Agent 场景里最常见的降级做法,是给每个关键 Agent 准备一个备选 Agent,或者准备一套非模型逻辑的兜底方案。
举个例子:主 Agent 负责从一封邮件中提取“会议时间”,它是个大模型,能力最强但偶尔不稳定。一旦它连续失败 3 次,我并不会无限重试,而是降级到备用 Agent——一个专门微调过的小模型,只做时间抽取这一件事,速度快、稳定性好。如果备用 Agent 还是失败,再降级到正则表达式规则引擎,从常见日期格式里硬匹配。虽然覆盖场景窄,但它绝不会“只输出思考过程不输出正文”。
在设计降级链时,有一个判断清单非常实用:
| 场景 | 能否降级 | 降级方案示例 |
|---|---|---|
| 摘要生成 | 能 | 换成抽取式摘要(提取关键句而非生成式总结) |
| 意图分类 | 能 | 用规则匹配兜底 |
| 结构化信息抽取 | 部分能 | 先用规则模板,失败再上模型 |
| 代码生成 | 一般不建议 | 宁可标记失败也不要乱生成 |
| 支付/删除等有副作用操作 | 绝不能降级 | 必须走人工审核 |
降级最忌讳的是一锅端。比如“支付没成功,就自动换个支付渠道再扣一次”——这种行为已经不是降级了,是事故策划。有副作用、涉及资金、涉及不可逆操作的任务,失败后合适的归宿是“挂起 + 通知人工”,而不是自动换路。
4.2 补偿:部分成功的任务怎么收尾
Multi-Agent 链路里最尴尬的不是“全部失败”,而是“部分成功”。A Agent 完成了数据写入,B Agent 在生成报告时挂掉了。此时如果你直接把整个任务标记为“失败”,你还要考虑 A 已经写入的数据怎么办;如果你把它标记为“成功”,那 B 缺失的部分就变成了线上盲区。
补偿机制解决的就是这个问题。补偿动作是“反向操作”:A Agent 写入了一条数据,那么补偿任务就是删除这条数据;但如果 A 和 B 的操作逻辑上是先后依赖的,补偿也可以是“手动补跑 B”。具体选择哪种,取决于业务需求的最终一致性。
我见过很多团队在这里犯一个低级错误:补偿逻辑不是幂等的。补偿任务本身也可能失败(因为它运行在同样的不稳定环境里),如果补偿动作没有幂等性,补偿重试就会产生二次破坏。所以,补偿操作也得有trace_id,也得更谨慎,还是那句话:先查再做。
另一点值得强调:补偿不等于“把状态改回失败”。补偿的目标是让系统达到“逻辑上一致”的状态,而不是“时间上回到过去”。比如订单 Agent 先创建了订单号,后面库存 Agent 扣减失败,这时候补偿不一定是删除订单,也可能是“把订单标记为待支付”并通知用户。灵活设计补偿动作,比简单地 rollback 有用得多。
4.3 缓存的妙用:失败时的最后一根稻草
很多从零搭建 Multi-Agent 的人会忽略一个现成的降级资源:缓存。这里说的缓存不只是“之前算过的结果”,还包括“外部数据的最新快照”。
举个例子:某个 Agent 的任务是“根据当前库存数据生成补货建议”,而库存数据来自一个第三方 ERP 接口。如果 ERP 接口超时,与其盲目重试或者直接失败,不如退而使用“两小时前缓存的最新库存数据”生成一个附带“数据可能不是最新”标签的建议。这比直接失败要好得多——因为很多场景下“有延迟的数据”比“没有数据”对业务更有价值。
在设计缓存兜底时,注意给结果打上“stale”标记。下游 Agent 看到这个标记后,会调整自己的置信度,不会把基于旧数据的建议当成最新事实来用。这种“带置信级别联传播”的做法,是进阶的 Multi-Agent 设计思维。
5. 在设计阶段就把失败率降下来:拓扑、超时与观测性
前面讲的是“事后处理”,但一个成熟的系统,事前的设计对失败率的影响远超你的想象。很多失败其实是架构设计阶段埋下的雷。
5.1 拓扑结构决定了失败传播的范围
Multi-Agent 的拓扑和通信机制不是什么纸上谈兵的概念,它直接决定了“一个失败会死多大面积”。
- 链式拓扑:A→B→C,B 挂了,C 拿不到输入,失败沿着链路一路传导。优点是结构简单,缺点是“一死一大片”。
- 星型拓扑:一个编排器(Orchestrator)统一调度所有子 Agent,任何子 Agent 失败,编排器都能拦截并把任务重路由到其他节点。这是我最推荐你优先采用的拓扑,因为失败的传播被隔离在“子节点内部”,编排器是天然的“熔断器”。
- 网状拓扑:Agent 之间任意通信,灵活性最强,但失败传播路径不可控,一个 Agent 的异常可能像病毒一样在整个网络里游走,排查起来极度痛苦。除非你有很强的可观测性基建,否则别轻易上这个拓扑。
另外,通信机制也很关键。我建议:Agent 之间的通信尽量用异步消息队列,而不是同步 HTTP 调用。同步调用的问题是:一个超时,调用方的线程/协程全被拖住,重试又多,整个系统的并发资源很快就耗尽了。异步队列天然提供“缓冲 + 解耦 + 消息重投”的能力。如果非要同步调用,至少要给每个调用设置独立的超时上限,并且不要无脑嵌套重试。
这里必须点名一个经典问题:通信通道故障常被误判成“子 Agent 失败”。热词里“设备已离线,请确认某个桥接层已连接后重试”就是典型的通道错误。Agent 本身可能活得好好的,问题是编排器和它之间的消息通道断了。对这种错误,重试前必须先执行“探活”动作——确认通信通道恢复,再谈重试业务。
5.2 超时预算:把“无限等待”从根上消灭
Multi-Agent 里最贵的资源是什么?不是 token,不是 API 调用次数,是人类的注意力。一个任务卡在“等待某个 Agent 响应”的状态,用户盯着进度条等了两分钟,这比失败更让人崩溃。
策略是给每个 Agent 设“单个调用超时”,还要给整个链路设“全局超时预算”。全局预算要分成几段:每个子 Agent 预留多少时间,Agent 之间通信预留多少,最后留多少缓冲。如果全局预算快用完了,优先保障“已成功的子任务落库”,而不是继续等最后一个失败节点。
GLOBAL_BUDGET_SECONDS = 60.0 start = time.monotonic() for agent in agents: remaining = GLOBAL_BUDGET_SECONDS - (time.monotonic() - start) if remaining <= 5: raise BudgetExceeded("剩余预算不足,提前终止链路") result = await asyncio.wait_for(agent.run(), timeout=remaining * 0.6)这段代码的思路是:每个 Agent 最多使用当前剩余预算的 60%,剩下的 40% 留给后续处理。宁可最后让任务“超时失败”,也不能让它在某个 Agent 上无限阻塞。
5.3 不总结失败教训的重试体系等于白搭
最后一个建议可能听上去不够“技术”,但它是我这么多年最深刻的感受:把每次失败都当成数据记录好,它就是你系统里最值钱的信息资产。
你可以为每个失败记录这样一条结构化日志:
task_id: xxx agent_name: extractor error_category: resource retry_count: 2 final_action: downgrade_to_rule_engine latency_ms: 4200 input_preview: ... upstream_status: ok把这些数据汇总起来,你能得到一张“失败热力图”——哪个 Agent 最容易挂、哪个错误类型占比最高、哪个时间段失败率最高。热力图指向哪里,优化资源就投向哪里。没有数据支撑的容错设计,说到底都是瞎猜。
6. 常见问题速查:那些提示“重试”背后的真问题
最后放一张我在实战中积累的速查表。它对应了那些特别容易迷惑人的报错场景,也是很多人在群里反复问的“经典问题”。先看现象,再找根因,别急着点重试。
| 现象 | 根因类型 | 真正的处理方式 |
|---|---|---|
| 网络连接失败,请检查网络后重试(3002) | 瞬时/通道故障 | 先探活,再指数退避重试,不要连续点重试 |
| 当前设备已离线,请确认某桥接层已连接后重试 | 通信通道故障 | 检查桥接层进程与 socket 连接,恢复通道后任务无需重试 |
| failed to refresh token: invalid | 凭证过期 | 修“刷新 token 的逻辑”,而不是重试业务任务 |
| 上下文长度超限,请启用更大上下文后重试 | 资源超限 | 对输入做摘要/裁剪,或切换更大上下文模型后再跑 |
| 模型只输出了思考过程、没有产出正文 | 输出预算/模型状态问题 | 动态提升 max_tokens,或换更强的模型,而不是同参数重试 |
| 无法显示目录下文件,请刷新重试 | 资源索引过期 | 重新拉取文件索引,业务逻辑本身没有失败 |
| 安装更新时出现一些问题,稍后重试 | 瞬时/本地锁冲突 | 检查是否有并发进程占用了文件锁,解冲突后再重试 |
这几条里,我最想展开聊的是“模型只输出了思考过程、没有产出正文”这个现象。很多平台的自动重试已经做得很好了——“系统已自动重试 2 次(逐级提升输出预算)”这个描述,背后其实就是我在第三章里提到的“重试参数动态化”。但如果你自己搭的 Multi-Agent 遇到了这个问题,请务必检查两个地方:第一,max_tokens是否足够正文输出;第二,是否在 Agent 的输出解析层把“思考过程”误当成了“最终结果”。后者是逻辑 bug,跟重试八竿子打不着。
还有那个 3002 错误,我遇到过很多次。它的诡异之处在于:报错信息只说“网络连接失败”,但实际原因五花八门——可能是防火墙切断了长连接、可能是 DNS 解析间歇性失败、也可能是目标服务主动断连。这种错误的正确解法是先做分层探活:进程活着吗?端口通吗?握手成功吗?哪一层断了修哪一层,修完直接跑,根本不需要浪费一次重试。
最后聊几句实话
我自己的经验是:凡是需要靠“手动重试”来维持稳定的 Multi-Agent 系统,方案设计一定有问题。
有个对比我记得特别清楚:刚开始搭建自己的 Agent 编排服务时,我有一张 Table,记录着线上任务的重试次数——平均每个任务要重试 1.8 次才成功。当时我还觉得“做了重试,挺成熟的”。后来我花了两周时间把错误分类、幂等控制、降级链、超时预算全部补齐,重试次数直接降到了平均 0.2 次。注意,0.2 次不是“重试机制变聪明了”,而是“失败发生率变低了”。好的容错设计,效果不是让你更会救火,而是让你基本不用救火。
如果你现在正被某个“只提示重试”的错误折磨,不妨先停下自动重试的手,花十分钟做一件小事:把最近一周的失败日志导出来,按错误信息分组,统计每一类的占比。你大概率会发现,80% 的失败集中在两三种固定原因上。把这几个根因修掉,你的 Multi-Agent 系统自然就稳定了。这个动作,比我上面写的所有策略都更值得你先做。