Agent项目失败处理实战:从重试到幂等与熔断的可靠性设计
1. 先说结论Agent 项目的“失败”和我此前理解的不一样做 Agent智能体项目做到第二周我最大的感受是最难的不是让 Agent 变聪明而是让它在失败的时候不把业务一起拖下水。标题里那句“90% 的 Agent 项目死在‘失败’这一步”原本我觉得是夸张直到我自己把一个订单退货处理的 Agent 接到仿真环境里一个晚上把模型超时、JSON 解析错误、工具重复调用、状态错乱全部踩了一遍我才真正明白这句话的意思。我目前在做的模拟项目X是一个“订单退货处理 Agent”用户询问退货政策、查询订单、判断是否满足退货条件、调用库存系统登记退货、最后给用户一份回执。第一周的 Demo 非常顺利因为所有环境都是理想化的模型稳定返回 JSON工具接口永远在 200ms 内响应数据库里没有脏数据。第二周开始接入更真实的模拟环境各种失败像约好了一样同时出现。也正是这次被“按在地上摩擦”的经历逼着我系统地把失败处理重做了一遍。1.1 夜里十一点我的 Agent 把“失败”表演了个遍第二周的第一次联调就让我意识到事情没那么简单。用户问了一句“订单 OX-2024001 能不能退”Agent 按流程先查询订单再调用退货政策判断然后调用库存系统里的退货登记接口。前面几步都正常最后一步接口超时了。Agent 侧等不到响应按我第一周写的“失败就重试”逻辑又调了一次。结果库存系统里多了两条退货记录而且状态都显示“处理中”。那天晚上我排查到很晚发现这只是冰山一角。同一个流程里还出现过模型返回了合法 JSON 但订单号被自动转成数字、工具返回 500 错误被当成业务拒绝、子任务已经成功但主流程不知道于是重复执行等一系列问题。每一个单独看都像是“偶发故障”合在一起就是系统性崩溃。1.2 90% 这个数字不是标题党我复盘了一下手头接触过的几个 Agent 项目发现一个共性大部分项目在设计阶段只规划了“成功路径”。成功路径就是用户提问、Agent 理解、调用工具、返回结果看起来逻辑清晰。但一旦进入测试或生产失败路径的数量往往是成功路径的好几倍每个环节都可能失败失败之后还可能产生副作用比如重复扣库存、重复发消息、用户看到错误状态。LLM 天然具有不确定性它不像传统接口那样“要么成功要么失败”它有时候返回的东西“看起来成功但字段错了”有时候“过程失败但副作用已产生”。所以我认为那 90% 不是精确统计而是一个趋势判断凡是把 Agent 当成普通接口来写、只处理 try-catch 而不设计完整失败机制的项目基本都会在联调期被拖垮。2. 给 Agent 的失败做一次“体检”四类致命失败第二周我先做了一件事把所有可能出问题的点拉成一张清单按失败发生的层面分类。分类之后我才发现失败不是一种东西而是至少四种不同性质的问题。它们需要的应对方案完全不一样。失败类型典型表现能否重试核心解法模型调用失败超时、限流、输出截断、服务端错误部分可以超时控制、退避重试、截断预防工具调用失败接口 5xx、库存不足、权限拒绝看情况区分业务失败与系统失败、幂等键解析失败JSON 非法、字段缺失、枚举值错误可以重试但有上限Schema 校验、格式约束、修复再解析编排失败子任务状态丢失、并行结果错乱不建议盲目重试状态机、补偿事务、人工介入2.1 第一类模型调用失败超时、限流、截断模型调用失败是我最早遇到的一类。它主要有四种形态网络超时请求发出去了响应没回来、限流服务端返回 429、服务端错误返回 5xx、输出截断max_tokens 不够内容没生成完。其中输出截断最隐蔽。有一次我给模型设置的 max_tokens 偏小正常的回复都能生成完但只要订单号变长、退货原因描述变长输出就会在最后被截断导致 JSON 不完整。这类失败不会报错只会让下游解析环节崩溃。我当时的处理逻辑很简单所有模型调用统一走一层封装设置合理的超时时间不再无限等待并对 429 和 5xx 做退避重试。截断问题则是事后从日志里发现的因为那段回复的 finish_reason 是 length而不是正常的 stop这成了我后续日志监控里的一个重要信号。2.2 第二类工具调用失败外部系统根本不听你的Agent 的核心价值是调用工具但工具是外部系统不在你的控制范围内。工具失败还分两种一种是明确的业务拒绝比如库存不足、退货超期、账号被锁定这类失败重试一百次也没用越重试越糟另一种是系统级故障比如接口返回 5xx、网关超时这类失败换一次调用可能就成功了。真正难处理的是第三种形态响应丢失但操作已生效。比如 Agent 调用退货登记接口请求到达服务端并且成功创建记录但响应在返回途中超时了。Agent 侧看到了超时以为失败于是重试结果生成了两条记录。这种“不确定状态”是 Agent 失败处理里最需要警惕的光靠重试逻辑根本解决不了必须依赖幂等设计。2.3 第三类解析失败模型的输出是薛定谔的 JSON模型输出和工具调用之间的桥梁是结构化数据实际过程中最常用的就是 JSON。解析失败通常有三种JSON 本身非法多了逗号、括号不匹配、字段缺失或类型不对把字符串字段变成了数字、枚举值不符合约束把 pending 写成了 pending_。我遇到过一个经典问题把订单号 OX-2024001 放入 JSON 后模型在中间步骤把它当成了数字 2024001去查单的时候查不到数据Agent 也不报错而是告诉用户“订单不存在”。这类问题靠“提示词让模型好好输出”是没用的必须在解析层做严格校验不合格就重新生成或转人工。2.4 第四类编排失败状态错乱比单点失败更难查单点失败查起来相对容易因为日志里能看到哪个环节断了。但 Agent 通常不是单点调用而是多个步骤串联或并联一个分支失败、另一个分支成功主流程还傻傻地继续走最后给用户一个完全错误的结论。比如我的退货流程里Agent 先查库存再判断退货资格再调登记接口。如果登记接口失败但 Agent 没有感知到下游的失败信号它可能直接跟用户说“已登记成功”这就属于典型的编排失败。更复杂的情况是多个子任务并行执行一部分成功一部分失败回滚时不知道该回滚哪些。这类问题没有简单的重试解决方案必须做状态管理和补偿设计。3. 为什么“加个重试”是最危险的解决办法第一周写代码的时候我处理失败的方式非常朴素异常捕获等两秒重试一次。这个写法在 Demo 环境里确实管用因为 Demo 环境里大部分失败是瞬时超时重试一下就恢复了。但到了第二周无差别重试开始反复咬我。3.1 重试的直觉是如何产生的做传统后端开发的人遇到接口超时第一反应往往就是重试因为大多数网络抖动确实可以通过重试解决。这种经验在 Agent 项目里被延续下来形成了一种路径依赖。但传统接口和 Agent 工具调用有一个本质区别传统接口大多数是幂等的同样一个查询请求发两次和发一次结果一样而 Agent 调用的工具很多是非幂等的比如创建退货单、扣减库存、发送短信发两次就是两次副作用。第一周的我根本没有区分这两者的意识。代码里写了一个全局的装饰器任何工具调用失败就自动重试。结果就是前面说的一次超时导致两条退货记录。从那之后我把全局重试逻辑全部删除改成按失败类型逐个判断。3.2 真实翻车现场一次超时引发的重复退货我详细复盘一下这次事故可能比任何理论都更有说服力。退货登记接口由库存网关提供Agent 这边发起了第一次请求请求到达网关后超时。这里的“超时”是网关先收到了请求并成功处理但响应在返回过程中丢失了。Agent 侧捕获到超时异常触发重试逻辑第二次请求再次到达网关并创建了第二条退货记录。用户后来在查询页面看到的退货记录有两条而且状态相同看起来一模一样。问题发生时没有任何程序报错所有日志都显示“调用成功”只是同一请求被执行了两次。排查出来的根因有两个一是工具接口没有幂等去重机制二是 Agent 侧把“不确定是否成功”错误地当成了“确定失败”。那次事故之后我立了一条铁律工具调用如果可能产生副作用一律生成全局唯一的 request_id 透传给对端对端按 request_id 去重。如果对端不支持那 Agent 侧就要设计成“先查询确认、再决定是否写操作”。3.3 重试的三大隐藏成本第一个隐藏成本是状态不同步。每一次重试都可能产生一个新的副作用点比如重复发通知、重复扣款、重复建单。这些副作用一旦发生清理成本远高于避免成本。第二个隐藏成本是把上游系统打爆。一个 Agent 重试可能没什么但几十个 Agent 同时遇到上游抖动一起退避重试就会形成请求洪峰直接把库存网关打到限流甚至宕机。这是我实测过的场景我为了提高成功率不加停顿地重试结果上游接口的失败率不减反增。第三个隐藏成本是 Token 账单翻倍。模型调用失败后重试不是只重发那一步而是要把整个上下文重新发一遍历史消息、工具结果、中间推理全部重新计费。我第一周实测过一次端到端任务原本只要 15k token在重试了三次之后总消耗变成了 43k。也就是说无差别重试不仅能拖垮业务还能拖垮预算。4. 第二周我采用的失败处理框架可直接抄作业经过一周的翻车和复盘我把失败处理拆成了六个层次按“事前预防、事中控制、事后恢复”的顺序排布。这套框架不一定是最优解但至少让我第二周的成功率有了明显提升而且每一层都可以独立落地。4.1 从“失败后补救”改成“失败前设防”很多失败在真正发生之前是可以预防的。我列了几个实际用到的措施请求模型时把 max_tokens 调到比预期输出多 20%避免长文本截断温度参数设置在 0 附近减少模型自由发挥的空间在提示词里明确要求输出 JSON并且尽量用外部的结构约束或示例格式给模型调用设置合理的超时上限比如 10 秒到 30 秒不无限等待对上下文长度做监控超阈值提前压缩或丢弃不重要的历史内容。这些手段不复杂但效果立竿见影。截断类失败在调整 max_tokens 之后基本消失JSON 格式错误通过示例约束也明显减少。更重要的是这些预防手段没有增加任何复杂度属于低成本高收益的部分。4.2 按失败类型分层决定是否重试第二周我把重试逻辑拆分了业务失败不重试瞬时失败才重试。具体来说4xx 状态码和业务错误码一律视为“不可重试”直接走失败分支429、5xx、超时这类瞬时错误可以重试但必须配指数退避和随机抖动并且设置重试上限。我实际的 Python 逻辑大致长这样import random import time class RetryDecision: NON_RETRYABLE_STATUS {400, 401, 403, 404, 422} staticmethod def should_retry(error, attempt, max_attempts3): if attempt max_attempts: return False if getattr(error, retryable, False): return True status getattr(error, status_code, None) if status in RetryDecision.NON_RETRYABLE_STATUS: return False if status is None: # 网络超时、连接错误这类没有状态码的 return True return status 500 staticmethod def backoff(attempt): base 2 ** attempt jitter random.uniform(0, base) return min(base jitter, 8) # 上限8秒这里有一个容易被忽略的细节重试不是简单地“等几秒再来一次”而是每次等待时间要递增并且加上随机抖动。如果不加抖动所有并发请求都会在同一时刻重试形成新的波峰。加了抖动之后重试请求会分散开对上游更友好。4.3 熔断器别让失败变成雪崩重试逻辑解决的是单次失败但解决不了连续失败。如果上游系统已经故障再怎么重试都是白费反而会加重上游负担。所以我给 Agent 加了一个简单的熔断器连续失败次数超过阈值就打开熔断器后续请求不再调用模型和工具而是直接走降级分支。降级分支可以是固定话术回复、走人工处理队列或者使用预设的模板答案。熔断器在一段时间后进入半开状态放少量请求试探上游是否恢复成功就关闭熔断器失败就继续保持打开。我实现了一个最小可用的熔断器class CircuitBreaker: def __init__(self, failure_threshold5, cooldown30): self.failure_threshold failure_threshold self.cooldown cooldown self.failure_count 0 self.state closed # closed / open / half_open self.open_until 0 def record_success(self): self.failure_count 0 self.state closed def record_failure(self): self.failure_count 1 if self.failure_count self.failure_threshold: self.state open self.open_until time.time() self.cooldown def can_request(self): if self.state closed: return True if self.state open and time.time() self.open_until: self.state half_open return True return False加上熔断器之后Agent 在面对上游持续故障时不会傻傻地一直重试而是快速失败并转人工。这直接改善了我的线上稳定性指标。4.4 幂等键工具调用的“第一公民”这一条是第二周最深刻的教训所以我把它单独列出来。所有可能产生副作用的工具调用都必须带一个全局唯一的请求 ID让目标系统可以按 ID 去重。如果目标系统支持幂等那 Agent 侧把 request_id 传过去即可如果不支持那就不能用“发请求后等确认”的模式而应该改成“先读后写”或“操作后立刻查询确认”。我的实际做法是Agent 每开始一个任务就生成 task_id每个工具调用都派生一个 request_id f{task_id}:{step_index}。如果某一步超时Agent 不会盲目重试而是先调用查询接口确认这一步是否已经生效再决定是重试还是跳过。这一步操作看似多余但能挡住大量重复副作用。4.5 可观测性失败日志要能复现现场第一周我犯的最大观测错误是日志只记录“成功”或“失败”一个布尔值完全没有现场信息。导致经常出现“这个请求失败了但不知道是模型返回异常还是工具返回异常”的情况。第二周我重构了日志结构强制记录以下字段{ thread_id: t-8f2a..., request_id: req-7c2d..., step: tool_invoke:return_register, error_type: timeout, retry_count: 1, model_raw_output: ..., tool_response: {...}, latency_ms: 12000, token_usage: {...} }有了这些字段以后定位问题的速度明显提高。尤其是 model_raw_output 和 tool_response这两个字段保存了失败现场能帮我在事后判断失败到底发生在解析层还是执行层。这个改动也直接影响了后面几天的排错效率。4.6 人工介入不是示弱是产品策略有些失败无论怎么处理都不该依赖自动化硬扛。比如用户投诉里有大量情绪化表述或者连续两次失败之后业务状态已经不确定这时候就应该转人工处理而不是让 Agent 继续尝试。我的规则是同一个任务连续失败两次或者业务风险等级较高时自动把任务挂起并推送人工审核队列。当系统状态不确定时优先给用户一个诚实的回复比如“系统正在处理请稍候我们会通过短信通知结果”而不是编造一个可能错误的结果。人工介入率会影响自动化率指标但换来的是业务可靠性和用户信任。5. 实测复盘同一功能改前改后的差别有了上面的框架之后我把同一个退货 Agent 做了前后对比测试。测试用的模拟项目X环境里有 200 条模拟请求注入的失败包括随机超时、限流、解析错误、外部系统 500、响应丢失等场景。5.1 测试场景与指标口径每条请求走完整流程识别退货意图、查询用户订单、校验退货条件、调用退货登记接口、生成结果回复。指标口径如下端到端成功率用户获得正确答复且数据无重复、平均端到端耗时、平均 Token 消耗、重复操作数量比如重复建单、人工介入数量。第一周方案就是无差别重试加简单 JSON 解析。第二周方案就是前面说的分层重试、熔断器、幂等键、结构化校验、人工介入。5.2 改前与改后的指标对照我把结果整理成了表格对比非常直观。指标第一周方案第二周方案端到端成功率66%93.5%平均耗时9.2 秒6.8 秒平均 Token 消耗28k16k重复操作次数4 次0 次人工介入数量0 次6 次有一个反直觉的结果是平均耗时反而下降了。原因是第一周方案里经常出现无限等待超时和多次盲目重试一次任务能拖到 30 秒以上第二周方案里超时控制更紧失败快速转人工或降级反而把整体时间拉下来了。5.3 一个让我意外的结论人工介入从 0 变成 6一开始我觉得是“指标变差了”但仔细看数据这 6 次介入全都是状态不确定或连续失败的情况如果没有人工兜底它们大概率会变成错误回复或重复操作。也就是说人工介入不是失败的标志而是可靠性链条里不可省略的一环。第二个意外结论是加了限流和退避之后成功率反而更高了。之前我以为快速重试能提高成功率但真实结果是并发洪峰把上游打挂之后所有请求一起失败成功率惨不忍睹。慢下来反而稳定。6. 第二周的教训失败处理是 Agent 项目的“地基”不是补丁第二周过完我最大的认知变化是不要等系统上线后再补失败处理而应该在第一天就把它当成功能的一部分来设计。Agent 项目不同于传统 Web 项目失败不是异常路径而是必经路径。模型会返回次优结果工具会抖动编排会漏状态这些都是常态。6.1 给正在做 Agent 的开发者一些可以直接用的经验先画失败路径地图。把核心流程的每一个环节拆出来标注可能的失败形态和应对方案再开始写代码。所有工具调用默认视为不可靠。必须有超时、有重试判断、有幂等设计最好还有降级方案。把“失败率”和“人工介入率”当作核心指标不要只看“成功率”。成功率高但重复操作多的系统比成功率稍低但数据干净的系统危险得多。从线性流程做起。多分支并行看起来很酷但一旦其中一个分支失败状态同步和补偿的复杂度会成倍增加。6.2 第三周我会继续做的事第三周的计划是再做两件事。一是把故障注入变成自动化回归每天跑一遍随机故障测试确保失败处理机制不会在后续迭代中被无意拆掉。二是把失败日志聚合起来按失败类型做聚类分析找出哪些工具最不稳定然后针对性优化。这个领域还没有放之四海而皆准的标准答案但我越来越确定一件事Agent 的能力决定它跑多快失败处理决定它能不能活着跑到终点。第二周摔的这些跟头比第一周跑通 Demo 学到的多得多。