Agent失败排查:从粗错误码到失败链的完整追踪方法
做Agent开发这两年,我觉得最折磨人的不是写Agent本身,而是排查Agent为什么失败。传统后端出问题,异常堆栈一打,问题大概在哪里心里就有谱了;Agent这边完全不是这样,一个任务挂掉,抛给你的往往只有一个粗错误码,像agent_execution_failed或者model_api_error,看起来像是模型的问题,但真正的原因可能在工具调用参数、上下文长度、记忆检索,甚至上游数据处理逻辑。我之前排查过一起失败率从2%飙升到18%的事故,第一反应也是模型服务不稳定,最后沿着失败链一层层挖下去,发现根因竟然是一个滑动窗口去重参数的配置错误,整个过程和模型本身没有半点关系。这篇文章就用这次经历,聊聊怎么把粗错误码变成一条能定位根因的失败链,以及Agent项目里高频踩坑的事故模式。
1. 先说结论:Agent失败是一条链,不是一个点
1.1 为什么模型不应该是第一责任人
先说一个概念:Agent的每一次执行,本质是一条流水线。用户输入进去,要经过意图识别、任务拆解、工具选择、工具调用、结果解读、上下文整合,最后由模型生成回复。这条链路上任何一个环节出问题,最终呈现的结果都是一个“Agent执行失败”。你把失败归因到模型,相当于把一锅烧糊的菜归因到菜谱。很少有菜谱是真正写错的,更多时候是火候不对、食材不新鲜、锅糊了,甚至是你中途接了十分钟电话导致忘了关火。
我在多个Agent项目里统计过失败样本,真正由模型本身的生成质量导致的失败,占比大概在20%到35%之间。剩下的失败里,工具调用参数错误、上下文长度超限、上游数据异常、并发状态污染各占一席之地,而且它们呈现出来的错误码高度相似。如果一上来就归因模型,一方面会掩盖真正的根因,另一方面还会导致你花大量无效时间去调prompt、换模型,问题却原封不动地待在原地。这也解释了为什么有些项目换了更强的模型之后,整体成功率并没有提升多少——因为瓶颈根本不在模型推理能力这一环。
1.2 用“料理失败”来理解Agent失败链
我经常用一个很生活化的类比来给团队讲Agent的失败链:假设你要做一盘红烧肉,最终肉没做好,可能的原因是什么?菜谱步骤写错了(模型指令理解偏差)、猪肉本身不新鲜(上游输入数据差)、火候不对(工具执行环境异常)、锅糊了(上下文污染)、生抽老抽放反了(工具参数传错)、做到一半煤气停了(基础设施断供)。这些都是完全不同的失败模式,需要完全不同的修复手段。
所以排查Agent失败,核心思路不是“错误码指向谁就修谁”,而是把整条执行链上残留的证据全部挖出来,按照时间线拼成一条完整的失败链。错误码只是告诉你“哪里疼”,失败链才能告诉你“为什么疼、怎么治”。这个方法论听起来简单,但真正落实到每一次事故处理中,需要一整套信息采集和排查的规范来支撑,下面我拆开讲。
2. 粗错误码:定位的起点,而不是归责的终点
2.1 Agent场景下最常见的六类粗错误码
实际操作中,Agent的错误码上报方式千差万别,但归纳起来,最常见的是下面六类。我自己做过一张分类表,排查的时候先看错误码落在哪一类,再决定往哪个方向深挖。
| 粗错误码 | 表面含义 | 背后常见真实原因 |
|---|---|---|
| MODEL_API_ERROR | 模型接口调用失败 | 网络超时、限流、鉴权失效、模型服务自身抖动 |
| OUTPUT_PARSING_FAILED | 模型输出解析失败 | 返回非法JSON、被截断、语言混杂、输出格式漂移 |
| TOOL_CALL_FAILED | 工具调用失败 | 参数类型不符、工具不存在、目标系统异常、权限不足 |
| CONTEXT_LENGTH_EXCEEDED | 上下文超限 | 检索内容过长、历史压缩失效、多轮对话无限叠加 |
| AGENT_LOOP_LIMIT | 循环次数超限 | 规划逻辑死循环、路由判断失效、工具副作用未消除 |
| MEMORY_ACCESS_ERROR | 记忆层异常 | 向量库不可用、索引为空、召回内容不足、embedding异常 |
这张表的价值在于:粗错误码虽然粗糙,但至少能把排查范围缩小到某一两层。比如看到CONTEXT_LENGTH_EXCEEDED,至少能确定问题大概率出在上下文构造而不是工具本身;看到TOOL_CALL_FAILED,就不要急着去调模型温度,先查工具定义。我见过很多团队在告警平台里收到一条TOOL_CALL_FAILED就直接去改工具代码,结果改了三天也没见效,就是因为没有先确认这个错误码是不是被上层包装过的。
2.2 粗错误码的局限性:方向对了,不等于根因找到了
这里必须泼一盆冷水:粗错误码经常撒谎。最常见的一种撒谎方式是“错误码漂移”,也就是底层真实异常在传递过程中被上层捕获后,被包装成了另一个语义。举个例子,我在一个项目里见过TOOL_CALL_FAILED背后其实是上下文超限,原因是工具返回了60k token的超长文本,模型在生成下一次工具调用时因为上下文超出窗口限制,输出的JSON被截断,最终框架把“解析失败”包装成了“工具调用失败”。如果你只盯着粗错误码,就会去修工具,而真正的问题是没有人对工具返回内容做截断。
还有一种更让人头疼的情况是,错误码本身在原框架里被吞掉了。很多Agent框架的异常处理写得非常粗,一个try-except把下面所有的异常全部统一成agent_execution_failed,连原始traceback都没有保留。这等于让你闭着眼睛找人。我在做日志规范的时候,第一条原则就是禁止在Agent的编排层做“裸捕获”,任何catch到异常的逻辑必须重新包装并带上原始错误信息,否则后续排查链直接断掉。
3. 失败链的搭建:把散落的信息串成一条可追溯的线
3.1 三个维度补全失败链信息
排查Agent失败,最怕的是信息不足。我的经验是,只要有完整的信息,90%的问题都能在半小时内定位,缺信息才是最大的时间黑洞。我做失败链分析,永远先看三个维度的数据。
第一,环境快照。这次跑的是哪个Agent版本、用的模型是哪一版、工具库里每个工具的schema是什么、上下文max_tokens配置是多少。很多时候根因就是“昨天有人改了配置忘了通知大家”,如果环境快照缺失,这类问题几乎不可能被发现。
第二,执行轨迹。从用户输入开始,每一步的输入、输出、耗时、模型token消耗、工具调用的请求和响应,按时间顺序全部记录下来。这一步记录得越细,事后还原现场越容易。
第三,决策上下文。模型真正看到了什么,包括完整的prompt、检索回来的片段、历史记录截取策略、工具返回的原始内容。这个维度最容易被人忽略,但恰恰是最关键的信息。同样的失败结果,模型看到脏数据导致乱规划,和模型看到干净数据但自己理解错了,是两种完全不同的修法。只有拿到决策上下文,你才能在事后还原模型的“视野”。
3.2 用trace_id贯穿每一次Agent执行的完整轨迹
要把三个维度的信息串起来,我通常会在每次Agent执行开始时生成一个trace_id,并把ID贯穿到所有环节的日志、工具调用日志、模型调用日志中。传统后端链路追踪的思路,完全可以移植到Agent场景里。
具体操作分两步。第一步,在入口生成trace_id和step_id。每执行一步,生成一个新的step_id,同时记录parent_step_id,这样整条执行路径就变成了一棵树状结构。Agent调用三个工具,就是三棵子树,任何一个子节点的失败都能追溯到它所属的父节点。第二步,统一日志结构,每条日志必须包含trace_id、step_id、parent_step_id、event_type、timestamp,以及当前步骤的输入输出摘要。这一步越早做越省事,我之前接手过一个没有任何链路追踪的项目,排查一个问题要同时开六个终端窗口对着时间戳找日志,那种痛苦我再也不想经历了。
下面是实际通过日志采集到的执行轨迹,我用它来说明一条失败链条上关键信息该怎么组织:
{ "trace_id": "trace_8f3a2b1c", "step_id": "step_005", "parent_step_id": "step_003", "event_type": "tool_call", "tool_name": "search_knowledge_base", "input": {"query": "退货政策"}, "output_summary": "hits=12, total_chars=48230", "duration_ms": 1280, "token_usage": {"prompt_tokens": 5210, "completion_tokens": 87}, "status": "success", "timestamp": "2025-07-01T14:23:45.128Z" }有很多人问,为什么日志里还要存input和output的摘要?因为完整内容可能要几万字,全塞进日志里成本太高。摘要的目的,是让你在快速浏览时能定位到可疑的环节,找准了再按需去拉完整内容,效率高很多。
3.3 结构化日志设计:给后续排查留好后路
讲到日志设计,我再补几个实操细节。结构化日志比free-text日志的排查效率高太多。free-text日志看起来直观,但一旦数据量上来,文本搜索根本跟不上,特别是遇到想对齐时间线的时候,人眼几乎无法处理。结构化日志字段固定,查询又快又准,而且对后面做统计分析和告警很有帮助。
几个容易被忽略但非常值得记录的字段:
- input_hash:输入内容的哈希值,方便找出同一类型问题的所有样本,同时避免在日志里保存太长的原始文本
- error_details:原始异常堆栈,标记是否经过上层包装
- retry_count:当前步骤已经重试了几次,多次重试本身就是一种症状信号
- truncated_flags:记录上下文或工具内容有没有被截断,被截断的位置用符号标注
日志级别也要分级,不建议所有信息都打INFO,那样日志量会大到没人看。我的习惯是:正常情况下只打带摘要的INFO,模型完整prompt和工具原始返回放到DEBUG级别,只有出错的时候才打印完整版本。这样日志量可控,排查时又能拿到完整的现场信息,两全其美。
4. 实操复盘:一次从粗错误码到根因的完整排查
4.1 事故背景:失败率突然从2%飙升到18%
去年我负责一个客户工单分类Agent,流程不算复杂:读取工单文本、从知识库检索相关政策、结合历史处理记录,然后给每个工单输出分类和优先级。这个Agent每天要处理几千个工单,各项指标一直很稳。某天下午,监控群突然开始刷失败告警,失败率从平时的2%左右一路飙到18%。
第一反应和大多数人一样,我先看了一眼错误码分布,MODEL_API_ERROR占比最高。当时模型服务商的状态页显示一切正常,但“一切正常”四个字在故障排查里说明不了任何问题,我还是先去拉了原始日志,准备顺着失败链慢慢往下走。
4.2 第一轮排查:表面上是模型API错误
粗错误码统计结果大致是:MODEL_API_ERROR占六成,OUTPUT_PARSING_FAILED占不到一成,其余散布在各类错误码里。按常规思路,很多人这时候已经开始怀疑模型服务不稳定,甚至准备直接给项目换模型了。我没有急着下结论,而是按照trace_id把失败样本和工单类型做了交叉统计。
告警里展示的错误信息长这样:
=== error report === message: 自定义模型C调用失败 error_code: MODEL_API_ERROR trace_id: 8f3a2b1c tip: 请检查模型服务状态或稍后重试发现了一个很微妙的现象:失败率升高的工单,集中在“附件文本较长”的那一批里,短文本工单几乎没有失败。这就有点意思了,MODEL_API_ERROR里藏着一条和文本长度强相关的线索。如果模型服务大面积出问题,失败率通常只会按时间段分布,不会精准地只打到某类数据上。我立刻开始怀疑,这个错误码很可能不是真正的根因。
4.3 第二轮排查:顺着失败链往下翻,发现上下文超限
随机抽取三个失败样本的完整trace,每一条里都有十几条结构化日志,时间线非常清楚。从日志里看到,工具search_knowledge_base返回的内容长度惊人,一个工单的检索结果文本量超过了40k token,而正常情况平均只有1k左右。继续翻决策上下文,让模型“崩溃”的不是模型能力,而是prompt总长度突破了窗口上限,模型API报了上下文长度超限,框架接住后把它包装成了MODEL_API_ERROR。
也就是说,第一轮看到的粗错误码,本质上是二次包装过的错误,它把“上下文数据问题”伪装成了“模型服务问题”。如果当时只盯着模型API错误去换模型或者调温度,这个问题永远不会被解决,因为无论换什么模型,长文本都会把窗口塞爆。
4.4 第三轮排查:根因在滑动窗口去重参数
那知识库检索返回的文本量,为什么突然暴涨这么多?第三步开始往上游查。知识库检索前的预处理环节,有一段对OCR文本做去重和压缩的逻辑。代码里用的是滑动窗口去重:把文本按固定窗口切分,如果在窗口范围内检测到重复段落,就只保留一份。
排查后发现,发布记录里有一次参数调整,把滑动窗口的窗口大小从64改成了16,同时相似度阈值从0.95调到了0.99。这两个参数一改,效果完全变味:阈值太高导致重复段落不被判定为重复,窗口太小导致跨窗口的重复内容无法被检测出来。结果就是大量重复的OCR文本被当成正常内容返回给Agent,文本量直接爆炸。这里我把参数变化影响整理成了一张表:
| 参数 | 调整前 | 调整后 | 影响 |
|---|---|---|---|
| 窗口大小 | 64 | 16 | 跨窗口重复无法被检测 |
| 相似度阈值 | 0.95 | 0.99 | 重复判定难度大幅提高 |
| 检索输出量 | 平均1k token | 平均40k token | 上下文超限引发全链路失败 |
这个案例非常典型,它说明一条核心规律:Agent的失败链不会止步于Agent本身,它会一直延伸到上游的数据处理、配置调整、模型服务的每个角落。你在Agent层看到的错误,往往只是整条链路最后一环的泛化表达。排查Agent失败,必须把所有信息串起来,不能只看最后一眼的错误码就下结论。
4.5 修复方案与验证效果
修复分三步走。紧急修复是把滑动窗口参数回滚到发布前的值,失败率在半小时内就恢复了,这是短期止血。中期修复是在Agent的服务端加了一层内容截断逻辑,用预设的token预算对检索结果做截断,并打上截断标记。这样即使上游再次出现数据异常,Agent也不至于被塞爆导致全链路失败,最多损失一点检索质量。长期修复则是加了一个监控指标:统计每次检索返回的字符数,用滑动窗口基线和阈值告警,一旦单次检索内容的token估算超过正常阈值的3倍,直接告警到人。
这个监控在后来不到一个月的时间里,又提前发现了两起类似问题,都是上游配置被改导致的。每次排查开会的时候,大家都会感慨一句:如果当初一开始就有这套失败链数据,可能当天下午第一轮就能定位,而不是折腾到晚上。
5. 高频事故模式速查:遇到这些失败链直接对照
5.1 模型输出解析失败:输出合法但解析不了
症状:看错误码是OUTPUT_PARSING_FAILED,把模型原始返回打出来看,明明像JSON,程序却解析不了。常见原因有三类:返回内容被markdown代码块包裹;JSON里带了中文注释或者尾随逗号;输出在超长文本场景下被截断,导致JSON结构不完整。
排查方法:先把模型的原始返回完整打印出来,人眼确认属于哪种情况。如果是代码块包裹,写一个提取函数,优先提取代码块内的内容;如果是截断,马上检查上下文预算,并适当调大max_tokens作为兜底。我自己还会在prompt里加一个严格的Rag示例,要求模型只输出JSON、不带任何解释。一个简单有效的方案是在工具返回层做格式校验,解析失败时自动触发一次带错误提示的重试,告诉模型“你上次的输出格式不合规,请重新生成”,重试一次成功率会提高不少。
5.2 工具调用参数错位:schema和真实签名不一致
症状:TOOL_CALL_FAILED频繁出现,手工调用工具完全正常,只有Agent调用会失败。常见原因是定义工具时使用的schema和实际执行函数的签名不一致,比如参数类型定义错误、必填参数缺失、默认值不匹配。我在一个项目里遇过特别经典的案例:工具函数定义中有一个参数是list类型,但生成的schema里被写成了string。模型按schema传了一个字符串,框架传给函数后直接抛TypeError,最终被统一包装成工具调用失败。
解决这个问题的关键动作是让schema成为唯一事实来源。工具的schema定义和函数签名必须用同一个数据源生成,并在启动时做一次自动校验,用假参数把每个工具跑一遍。这比等运行时报错再排查省心太多。排查时重点对比模型实际传的参数和函数签名里的参数,三样挨个检查:参数类型、参数个数、必填项。
5.3 上下文被静默塞爆:长对话后半程的隐形杀手
症状:CONTEXT_LENGTH_EXCEEDED,或者没有明确报错,但长对话到后半程开始频繁失败。常见原因有三个:知识库检索结果不截断、历史消息无限堆积、系统prompt里嵌入了动态数据且这部分数据偶尔异常变大。
排查方法:追踪prompt的实际token数,观察是哪一轮开始超限。如果是检索内容,检查检索工具输出是否有截断逻辑;如果是历史消息堆积,检查历史压缩策略是否生效。对于检索内容,我强烈建议按token预算提前截断,而不是靠模型自己消化,模型不会帮你变出窗口容量。几个经验参数可以分享:系统prompt建议控制在总上下文的20%以内,检索内容单条控制在800 token以内,历史消息用滑动窗口只保留最近N轮。这些参数不是绝对标准,但按这个基线配置,长对话场景的稳定性会明显好很多。
5.4 并发下的状态污染:偶发失败的第一嫌疑
症状:失败是偶发、间歇性的,单个请求单独复现不出来,并发一高就开始出错。常见原因指向全局变量、模块级buffer、非线程安全的工具实例、请求间共享同一个会话状态。Agent场景里尤其容易出现这类问题,因为Agent天然有“状态”这个属性,如果某个Agent实例被多个请求复用,或者工具类里有一个全局的临时变量存取上一步的结果,并发一高就会互相污染。
我见过一个真实案例:两个用户同时触发同一个Agent,A用户的中间结果被B用户的请求覆盖,最后A用户的数据被错误分类。排查的时候单个请求跑完全正常,并发压测一上就挂。后来把请求级状态全部移到会话内部,禁止任何模块级临时变量,问题直接消失。预防的核心原则很清晰:一个请求一个独立的Agent上下文,工具实例保持无状态,会话状态不可共享。上线前跑一个50并发、每个请求都带不同数据集的压测脚本,能挡掉大部分这类问题。
5.5 记忆层的“薛定谔召回”:时灵时不灵
症状:Agent的记忆时灵时不灵,同一个问题有时能检索到相关历史,有时检索不到。常见原因有向量检索相似度分数受embedding模型波动影响、索引未及时更新、写入时校验不严导致脏数据、召回数量阈值设置不合理。记忆层失败很多时候不会抛异常,而是“静默失败”,表现为召回结果为空或召回无关内容,Agent基于这些内容继续推演,最终得到混乱的结果,错误码什么都看不出来。
排查记忆类问题,需要在记忆写入和读取两个环节都加日志。写入时记录内容哈希和向量维度,读取时记录召回数量和相似度分数。把这两条数据放一起对比,能快速看出是写入失败还是召回漂移。市面上已经有针对Agent记忆防御方向的开源框架,比如我调研过的a-memguard,核心思路就是给记忆读写加校验和审计。虽然这些项目还比较早期,但记忆安全这件事值得提前关注,它比普通缓存数据出问题的影响面大得多。
6. 把排查能力固化到日常:Agent可观测性建设
6.1 日志、指标、告警三步走
前面讲了很多具体案例,但如果你只在出问题的时候才去看日志,每次依然会手忙脚乱。建议平时就把可观测性建设起来,三个步骤缺一不可。
日志这一层,最重要的不是用哪家日志系统,而是格式规范、字段统一、链路完整。没有结构化日志,后面的指标和告警全都无从谈起。指标这一层,需要盯的不只是成功率。建议至少采集五类:执行成功率、P50/P95耗时、token消耗量、工具调用成功率、上下文长度分布。这五类指标覆盖了Agent执行链路的关键位置,任何一个指标出现明显偏离都值得深挖。
告警这一层,不建议用固定阈值硬告警,因为Agent的请求量波动天然很大。更好的做法是用滑动窗口计算基线,比如以过去7天同一个小时的成功率为基线,当前值偏离基线超过3倍标准差时触发告警。这样既不会因为瞬时抖动频繁打扰,也不会漏掉真正的持续恶化。
6.2 用统计模型辅助归因排序
如果失败样本足够多,光靠人工梳理会很累。我每隔一段时间会跑一轮离线分析,把日志里能提取的特征全部向量化,然后用LightGBM这类回归模型在历史数据上拟合失败概率。这样做主要不是为了预测失败,而是为了拿到特征重要性排序,把排查优先级从凭经验变成靠数据。
我实际跑过的特征包括:工单文本长度、检索返回token数、对话轮数、模型输出长度、工具调用次数、并发量、上游配置版本号。特征重要性排序结果通常会重新定义排查优先级。比如在我前面讲的那个案例里,特征重要性排名靠前的字段就是“检索返回token数”,如果我一开始就按重要性去查,第一轮就能定位到问题,根本不用折腾到第三轮。这个思路对样本量有要求,至少几千条日志才有统计意义,但Agent项目的数据量很容易达到这个量级,顺手做个回归,排查效率会提升非常明显。
6.3 每次事故后的复盘清单
最后分享我每次事故处理完都会走一遍的复盘清单,一共三个问题。
第一,这次根因能否转化为某个粗错误码?如果可以,说明错误码体系是有效的;如果不行,就需要考虑错误码是否存在覆盖盲区,或者这个错误码在上层被包装错了。第二,失败链中哪一环信息缺失?是缺少决策上下文,缺少工具返回原文,还是缺少配置版本记录?缺了哪一环,就在日志规范里把它补上。第三,如何让下一次排查更快?可能需要一个新的监控指标、一个自动诊断脚本,或者一个更方便的trace查询面板。
这三个问题看起来很基础,但真正落到行动上,每一次事故处理都会变成一次诊断能力的升级。你会发现,过了几个月之后,团队的Agent排查能力已经远远甩开那些只会看错误码的团队。
我个人在实际处理中的体会是,排查Agent失败,最难的不是技术,而是心态。人本能地会想把责任推给模型,因为模型像一个黑盒,说它不行最省力。但在大多数项目里,模型的稳定性比想象中要好,问题往往出在喂给模型的数据、调用工具的方式,以及承载Agent运行的工程架构上。如果你的Agent还在频繁失败,先别急着换模型,也不要去调那些玄学温度参数,把粗错误码、执行轨迹、决策上下文这三样数据抓齐,顺着失败链完整走一遍,大概率会有惊喜。这也是这篇文章最想传达的东西。