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_attempts=3): 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_threshold=5, cooldown=30): 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 消耗 | 28k | 16k |
| 重复操作次数 | 4 次 | 0 次 |
| 人工介入数量 | 0 次 | 6 次 |
有一个反直觉的结果是平均耗时反而下降了。原因是第一周方案里经常出现无限等待超时和多次盲目重试,一次任务能拖到 30 秒以上;第二周方案里超时控制更紧,失败快速转人工或降级,反而把整体时间拉下来了。
5.3 一个让我意外的结论
人工介入从 0 变成 6,一开始我觉得是“指标变差了”,但仔细看数据,这 6 次介入全都是状态不确定或连续失败的情况,如果没有人工兜底,它们大概率会变成错误回复或重复操作。也就是说,人工介入不是失败的标志,而是可靠性链条里不可省略的一环。
第二个意外结论是:加了限流和退避之后,成功率反而更高了。之前我以为快速重试能提高成功率,但真实结果是并发洪峰把上游打挂之后,所有请求一起失败,成功率惨不忍睹。慢下来反而稳定。
6. 第二周的教训:失败处理是 Agent 项目的“地基”,不是补丁
第二周过完,我最大的认知变化是:不要等系统上线后再补失败处理,而应该在第一天就把它当成功能的一部分来设计。Agent 项目不同于传统 Web 项目,失败不是异常路径,而是必经路径。模型会返回次优结果,工具会抖动,编排会漏状态,这些都是常态。
6.1 给正在做 Agent 的开发者一些可以直接用的经验
- 先画失败路径地图。把核心流程的每一个环节拆出来,标注可能的失败形态和应对方案,再开始写代码。
- 所有工具调用默认视为不可靠。必须有超时、有重试判断、有幂等设计,最好还有降级方案。
- 把“失败率”和“人工介入率”当作核心指标,不要只看“成功率”。成功率高但重复操作多的系统,比成功率稍低但数据干净的系统危险得多。
- 从线性流程做起。多分支并行看起来很酷,但一旦其中一个分支失败,状态同步和补偿的复杂度会成倍增加。
6.2 第三周我会继续做的事
第三周的计划是再做两件事。一是把故障注入变成自动化回归,每天跑一遍随机故障测试,确保失败处理机制不会在后续迭代中被无意拆掉。二是把失败日志聚合起来,按失败类型做聚类分析,找出哪些工具最不稳定,然后针对性优化。
这个领域还没有放之四海而皆准的标准答案,但我越来越确定一件事:Agent 的能力决定它跑多快,失败处理决定它能不能活着跑到终点。第二周摔的这些跟头,比第一周跑通 Demo 学到的多得多。