ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多智能体系统失败处理全链路:从重试到熔断的工程实践

多智能体系统失败处理全链路:从重试到熔断的工程实践 前阵子线上多智能体系统Multi-Agent又报警了日志里躺着一句冷冰冰的“模型请求失败请稍后重试4054”。我看了眼身边的同事他正忙不迭地往代码里加 retry加完一层还嫌不够准备再包一层 while True。那一刻我挺感慨的在 Multi-Agent 的执行链里一个子任务失败你条件反射地重试说明你还停留在“用蛮力对抗随机性”的阶段。重试不是不能用但它是最初级的手段而且用不好会变成事故放大器。这篇文章我想认真聊一聊执行失败这件事。你会发现真正成熟的系统处理一次失败会走一整套链路先分清楚失败类型再决定是重试、降级、补偿、熔断还是路由切换每一步都要留观测痕迹最后还要把失败信息推到该看到的人那里。这不是理论是我在真实编排任务里一条一条趟出来的。1. 失败的真相你连“为什么失败”都没搞清谈什么重试1.1 三类失败只有一类适合无脑重试先说个常识Multi-Agent 里的失败从来不是一种而是三种。第一类是瞬时故障transient fault。网络连接失败、请求超时、服务重启、模型服务限流都属于这一类。典型的报错就是“网络连接失败请检查网络后重试 3002”。这类失败的特点是故障是暂时的只要环境恢复同一请求重放大概率能成功。唯一需要小心的是重放的频率和并发别把正在恢复的服务又压垮了。第二类是确定性故障deterministic fault。上下文超限、参数格式错误、权限不足、内容被内容安全策略拦截都属于这一类。比如你请求时没有开启 1M 上下文窗口模型平台直接返回“请启用 1m 上下文后重试”又比如内容平台检测到输入违反社区规范返回错误码 983。这类失败的特点是无论你重试多少次结果都一样因为问题出在请求本身而不是环境。这种情况下重试不仅没有意义还会拉长整个任务的耗时把用户的耐心耗光。第三类是语义故障semantic fault这是最隐蔽的一类。HTTP 状态码是 200返回体格式也正常但内容完全不可用。最典型的就是“模型本轮只输出了思考过程、没有产出正文”——它没有报错只是没干活。工具调用也可能这样返回了一个空结果但你没办法从错误码里判断这到底是一次成功还是失败。下面这张表是我在代码里给失败分类时常用的依据失败类型典型表现重试是否有效正确动作瞬时故障网络断开、超时、限流、4054 等错误码有效但要控制节奏指数退避重试配合熔断确定性故障参数错误、上下文未开启、内容被拦截无效修改请求或直接失败语义故障200 但结果为空、只输出思考过程不确定先校验输出再决定是否重试1.2 失败指纹把每次失败当成一个可分类的对象如果你只在日志里看一行报错是没有办法做判断的。我现在的做法是给每一个失败建立一个“指纹”fingerprint它由这些字段组成错误码、异常类型、发生阶段、经过幂等校验后的任务 ID、上下文摘要。有了指纹你的决策就能从“失败了重试”变成“识别到 4054 指纹走限额重试策略识别到 983 指纹直接标记为不可重试失败”。举个实际例子。你看到“当前设备已离线请确认 coze-bridge 已连接后重试”第一反应是重试连接。但如果你把“coze-bridge 未连接”当成一个独立指纹来看就应该先检查连接状态让任务进入等待恢复流程而不是每两秒重试一次把没连上的连接请求打到飞起。失败指纹的意义就是逼你先看一眼它到底是什么。1.3 为什么“只会重试”会显得初级因为重试本质上是在赌赌这个失败是瞬时的赌它下一秒就会恢复。但大多数时候你的赌注没有任何依据。重试无法回答三个问题这次失败会不会重复发生重试会不会放大影响即使重试成功了返回的结果可信吗我见过最典型的初级操作是把重试写成一个死循环任务不成功不退出。一个查询类 Agent 失败后它会把同一条慢 SQL 反复执行二十次把数据库直接拖垮。这不是容错这是自杀式攻击。2. 就算要重试也请按高级的方式重试2.1 指数退避加抖动先算清楚重试预算如果你判断这确实是瞬时故障那么重试也不能用固定间隔、固定次数。正确做法是指数退避加抖动exponential backoff with jitter。退避时间的公式是delay min(cap, base * 2^attempt) random(0, jitter)。我常用的参数是base 为 1 秒cap 为 30 秒最大尝试次数是 3 到 4 次。第一次失败后等 1 秒左右第二次等 2 秒左右第三次等 4 秒左右而不是每次都快速重试。加抖动的目的是避免多个任务在同一个时间点一起重试形成请求对下游的同步冲击。我自己封装的一个简单实现大概是这样的import random import time def retry_with_backoff( fn, *, max_attempts3, base_delay1.0, max_delay30.0, jitter0.2, retryable_exceptions(TimeoutError, ConnectionError), ): last_exc None for attempt in range(max_attempts): try: return fn() except retryable_exceptions as exc: last_exc exc delay min(max_delay, base_delay * (2 ** attempt)) delay random.uniform(0, jitter) time.sleep(delay) raise last_exc注意这里的 retryable_exceptions 一定要明确。千万不要把异常全部捕获之后重试否则确定性失败也会被你当成瞬时失败白白消耗预算。2.2 重试必须有幂等保护否则就是二次事故在多 Agent 的编排里一个任务往往带有副作用发消息、扣款、写库、调用外部系统。如果你重试整个任务等于把这些副作用全部执行了两遍。没有幂等保护的 retry百分之百会在某个夜深人静的凌晨给你整出线上事故。正确做法是每个任务生成一个全局唯一的幂等键idempotency key下游系统在收到请求时先查这个键是否已经处理过处理过就直接返回第一次的结果。这样即使你的重试机制发生了误判也不会造成重复扣款、重复发短信这类不可挽回的后果。2.3 分清请求级重试和任务级重试这是很多人忽略的点。一次执行失败到底是重试“当前请求”还是重试“整个任务”请求级重试指的是调用一个模型接口失败了重新调用这个接口。任务级重试指的是整个 Agent 的编排流程失败了需要从头开始重新规划任务分派。判断标准很简单看失败发生在哪一层以及失败之后上下文是否还完整。如果一个写周报的 Agent第一步调用搜索工具拿数据第二步调用模型做总结是总结那一步模型返回超时你重试“当前总结请求”就够了。但如果是搜索那一步返回了成功但数据是残缺的导致后续所有推理都建立在错误信息上那就需要整个任务重跑。2.4 升级式重试每次都换一个更强的配置还有一种重试比原样重放高明得多叫升级式重试。热词里那个“模型本轮只输出了思考过程、没有产出正文。系统已自动重试 2 次逐级提升输出预算”就是典型。不是原样重新发同一个请求而是发现上一次输出预算不足之后提升预算再试一次再不行换个更强的模型配置再试一次。这种重试的思路是每一次重试都不是简单的复制粘贴而是根据上一次失败的原因调整参数。失败原因如果是指标不足就给更多预算是超时就给更长等待时间是模型能力不够就换路由。只有做到了上面这四点你才有资格说自己“会重试”。3. 降级很多时候成功可以不那么“完美”3.1 核心思想缩小对成功的定义为什么说重试初级因为它只认一条路你必须成功而且必须按原方式成功。但工程上我们要的是“任务以可接受的方式被完成”。降级就是系统在无法达成“完美成功”时退而求其次达成一个“可接受的替代成功”。它不一定要完整但要明确、可用、诚实。我常用的降级手段有四种。内容降级模型返回为空时用规则模板生成一个礼貌的临时回复。模型降级主模型超时或限流时切换到一个备用模型继续执行。工具降级主数据源不可用时换一个备选 API 或本地缓存。产品降级如果所有路都走不通就明确告知用户“当前服务暂时不可用请稍后重试”而不是让用户面对一个永远在转圈的加载动画。热词里“请使用官方应用继续操作或稍后重试”其实就是产品层的一种降级提示。3.2 一个客服 Agent 的降级案例假设你做一个客服智能体用户问“我的订单到哪了”这时订单查询接口连接异常报错是“coze-bridge 已断开”。初级做法是后台不断重试连接让用户盯着聊天窗口等待二十秒最后还可能等来一个超时错误。高级做法是立即识别到这是连接类瞬时故障走降级策略回复用户“订单查询服务暂时不可用建议您稍后重试或者先查看订单列表页”。与此同时后台触发连接恢复检查确认连接恢复后再把用户可能关心的订单数据主动推送。这样一个交互过程里任务没有“完美成功”但用户的体验是完整的。3.3 降级也有代价别把次优结果包装成完美成功降级不是免费的它意味着你交付的是一个次优结果。所以降级发生的时候一定要在返回结果里显式打上 degraded 标记让上层调用方知道这不是完整成功后续如果要做补偿或重跑有据可依。尤其要小心的是不要降级出一个“假成功”。支付场景里你不能因为接口不通就把订单状态改成已支付那种降级不是容错是造假。4. 补偿与状态修复在真的走不通时把系统救回一致态4.1 多 Agent 编排里的脏状态是怎么来的重试解决的是“再试一次能不能往前走”的问题但现实里总有试也走不通的时候。举个例子Agent A 负责扣款已经扣成功了Agent B 负责出票连着失败三次每次都超时。如果你只会重试 B那么系统里就会一直躺着一笔“钱扣了、票没出”的脏账。就算最后 B 重试成功了用户也经历了很长时间的不确定状态如果 B 最终确定失败这条脏数据会一直趴在数据库里直到哪天对账把它翻出来。4.2 给每个子任务一张状态表和一条补偿回调多 Agent 编排层必须记录每个子任务的状态不能只记录“Agent 是否成功”。我常用的状态有pending、running、succeeded、failed、compensated。每个子任务在注册的时候必须同时注册一个补偿回调compensation callback。在任务编排里我会维护一张类似这样的表task_idagentstateparent_idcompensation_endpointerror_fingerprintt-001paymentsucceeded空/compensate/payment无t-002ticketfailedt-001/compensate/ticket4054当 Agent B 失败且重试预算耗尽系统应该去执行 Agent A 的补偿动作把已经扣掉的钱退回去并把 A 的状态从 succeeded 改成 compensated。最终整条链路标记为 failed并触发告警。4.3 先说清楚先重试再降级最后补偿这不是一个随便的顺序而是一条由轻到重的策略链路。瞬时失败先重试重试到上限之后看有没有降级方案降级不可接受再走补偿补偿完成再整链失败并告警。补偿的代价比重试和降级都大因为它要撤销已经完成的操作可能会引入新的不确定因素。所以补偿一定要有准确的幂等保护防止补偿动作本身被重复执行。4.4 调度系统里的重跑DolphinScheduler 案例热词里提到的“DolphinScheduler 执行调度任务失败”是另一个很有意思的视角。调度任务失败后很多人会直接点重跑。但重跑如果没有考虑幂等可能把上游已经产出的半成品再加工一遍产生重复数据。正确做法是看这次失败是否可以安全重跑任务本身是否幂等、上游有没有生成半成品文件、当前重跑窗口是否还允许。不要点一下重跑按钮就完事要把失败信息、最近一次成功时间、重跑建议一起推送出来比如通过企业微信机器人把告警发给值班的人。5. 熔断与隔离别让一个 Agent 拖垮整个系统5.1 没有熔断的重试就是在故障时继续给故障服务递刀“重试”和“熔断”听上去矛盾实际上是一对配套机制。重试处理瞬时故障熔断处理长期故障。假设一个 Agent 对接的模型服务已经过载返回 4054 的概率是百分之八十。没有熔断时你的重试逻辑会把所有请求继续打给它相当于在故障期间仍然不断给故障服务输送流量。做得越多服务恢复得越慢。熔断器有三个状态closed正常放行、open直接拒绝快速失败、half-open放少量探测请求试探。我在代码里常用这样一组阈值最近 120 秒内失败率超过 40% 就打开熔断进入 open 状态后快速失败 5 秒然后进入 half-open放 1 个探测请求成功就关闭熔断失败就继续保持 open。5.2 舱壁隔离别让一个慢任务占住所有线程多 Agent 系统里一个日志分析 Agent 可能执行一次要跑很久。如果它失败后不断重试并且和别的 Agent 共用一个线程池那么其他 Agent 的请求都可能被阻塞。舱壁bulkhead模式就是给重要 Agent 分配独立的线程池或信号量让故障隔离在一个舱壁之内不波及整艘船。踩过一次坑之后我把所有重量级 Agent 都单独拉了线程池每个池子的最大并发数按它的 QPS 预估来定。虽然只是很小的改动但在高负载期效果立竿见影。5.3 路由切换把流量从故障节点导走熔断器的 open 状态不能只用来快速失败还可以作为路由切换的触发信号。当模型 A 被熔断时直接把流量路由到备用模型 B或者路由到本地缓存服务。和“降级”不同降级偏重输出层面的妥协路由切换偏重执行层面的换路。我常用的配合方式是主模型返回 4054 且熔断打开时新任务直接走备用模型同时把 4054 的请求样例保存下来用于之后的模型服务故障复盘。6. 失败可观测没有告警和追踪的自愈都是盲人摸象6.1 一个失败事件至少需要这些字段在 Multi-Agent 系统里重试、降级、补偿执行得再多如果失败信息没有结构化地记录你事后想复盘都是抓瞎。我在每次失败处理时都会结构化成一条事件至少包含trace_id、agent_name、error_code、error_type、attempt_count、strategyretry / degrade / compensate / circuit_break、duration_ms、input_digest、partial_output、exception_class。这样无论是看日志还是搜 trace都能快速还原当时发生了什么。6.2 告警分级不是所有失败都值得打扰人如果每一次失败都立刻告警那系统离“狼来了”就不远了。瞬时故障要让重试机制自己消化只有重试多次仍然失败或者直接影响关键业务链路时才推送告警。我通常会分三级P0 是核心业务链路失败立即通过企业微信机器人推送P1 是某个 Agent 成功率低于阈值聚合后按批次推送P2 是瞬时失败只记录不打扰。企业微信机器人的推送很简单一个 webhook 请求就行curl -X POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyyour-key-here \ -H Content-Type: application/json \ -d { msgtype: text, text: { content: [Multi-Agent告警] 出票Agent失败\n任务ID: t-20250101-001\n错误码: 4054\n策略: retry_escalation\nTrace: http://trace/view/t-20250101-001 } }告警里一定要带任务的 Trace 链接光说一句“失败了”没有意义要让收到告警的人能直接点进详情页看上下文。6.3 自愈闭环不是“重试”两个字如果把前面所有内容串起来一个成熟的失败处理链路应该是失败发生采集指纹按策略依次判断执行重试或降级或补偿更新任务状态必要时触发告警。重试只是这个链路里的一个动作不是你处理失败的全部。7. 几个我踩过的坑希望你别再踩接在前面这些策略后面我想分享几个实际踩坑记录每一个都是花钱买来的教训。第一个坑没有幂等保护就重试。之前有个发通知的 Agent因为下游接口超时我给它加了重试。结果下游只是响应慢了其实请求已经处理成功了。重试一触发用户就收到了两条一模一样的通知。后来所有可能产生副作用的任务我都强制上了幂等键宁可多查一次状态也不让副作用重复发生。第二个坑把“重试成功”当成了“流程成功”。有一次模型接口返回了 200但内容主体是空的我的校验逻辑没有拦住直接把它当成正确结果发给了下游 Agent下游拿着空内容继续往后跑生成了一份空报告。后来我养成了一个习惯对模型的输出做 schema 校验和内容完整性检查200 不代表一切正常。第三个坑盲目重试把数据库打挂。一个查询 Agent 慢查询失败后我给它配置了二十次重试结果二十次重试就是二十次慢查询直接把数据库的连接池打满了。现在我的所有重试都带退避、带次数上限、带熔断任何情况下重试都不允许超过预算。如果你想知道我现在判断一次失败时脑子里过的是哪张清单大概是下面这样的这是瞬时故障吗是进重试通道带退避和幂等键。重试预算耗尽了吗耗尽了有降级方案吗有走降级。降级方案不可接受吗不可接受子任务有补偿回调吗有走补偿。这个 Agent 的失败率持续在升高吗是打开熔断同时路由切换到备用节点。这些动作都做完了还有异常没兜住吗结构化成事件推企微告警等复盘。这套链路看起来很长但真正落地之后多 Agent 系统的稳定性会有质的提升。比起每天熬夜看日志调重试次数把失败当成一个可以系统化处理的问题才是更值得做的事。
返回列表