1. 为什么“模型不行”往往是排查方向跑偏了
做 Agent 开发的人,几乎都经历过这样的场景:线上告警突然响了,某个 Agent 任务批量失败,日志里只有一行冷冰冰的agent execution terminated due to error,或者一个 422、一个超时、一个terminalBlocker。第一反应是什么?大概率是“模型又抽风了”“换个大模型试试”“是不是 prompt 写崩了”。我早期也这么干过,结果折腾半天换模型、改温度、重写提示词,问题依旧。后来才发现,真正的问题根本不在模型层,而在一条被忽略的失败链上——从工具调用返回的粗错误码,到编排层的状态机,再到终端阻塞,每一环都可能是元凶。
这篇内容就是把我自己踩过的坑和后来总结的一套排查方法完整拆开讲。核心思路只有一句话:别把 Agent 失败都算给模型,先顺着错误码把失败链还原出来。适合正在做 Agent 开发、智能体编排、多 Agent 协作的工程师,也适合刚入门 Agent 学习路线、还在搭第一个 Agent 项目的朋友。你不需要多深的底层功底,只要能看懂日志、会看错误码,就能跟着这套方法把问题定位到具体环节。
我先把结论摆出来:Agent 的失败,按发生位置大致分四层——模型层、编排层、工具层、环境层。模型层失败其实占比没那么高,真正高频的是工具层和环境层,尤其是错误码被“粗化”之后,排查信息在传递过程中被一层层丢掉,最后到你手里只剩一个笼统的失败状态。所以排查的第一步不是换模型,而是把粗错误码还原成失败链。
2. 失败链的整体设计与错误码分层思路
2.1 什么是失败链,为什么粗错误码会误导你
失败链这个词听起来有点玄,其实很朴素:一次 Agent 执行从入口到结束,会经过“接收任务 → 规划 → 选择工具 → 调用工具 → 处理结果 → 生成回复”这么一条链路。任何一个节点出错,都会产生一个错误码。问题在于,很多框架为了对外统一,会把底层五花八门的错误收敛成一个粗错误码,比如统一叫EXECUTION_ERROR或者AGENT_FAILED。
这就好比你家里跳闸了,电工只告诉你“停电了”,但到底是总闸、分闸还是某个电器短路,你完全不知道。粗错误码就是那个“停电了”。它有用,但远远不够。你要做的是在链路的每个关键节点埋上细粒度错误码,并且保证错误码在向上传递时不被吞掉。
我见过太多项目,工具调用失败返回的是tool_error,编排层捕获后直接转成agent_failed,前端再显示成“请求失败”。三层信息衰减下来,你手里什么都没有。所以设计阶段就要定一个原则:错误码只允许被包装,不允许被丢弃。包装的意思是保留原始码,附加一层上下文;丢弃就是直接覆盖。
2.2 错误码分层设计:从 L1 到 L4 的四层结构
我一般把错误码分成四层,对应失败链的四个位置:
| 层级 | 位置 | 典型错误码 | 排查优先级 |
|---|---|---|---|
| L1 | 环境层 | 端口占用、IP 冲突、容器 OOM、连接超时 | 高 |
| L2 | 工具层 | 工具超时、参数校验失败、权限拒绝 | 高 |
| L3 | 编排层 | 状态机死锁、terminalBlocker、循环超限 | 中 |
| L4 | 模型层 | 请求失败、限流、上下文超长、返回格式错误 | 中 |
这个分层的价值在于:当你拿到一个失败任务时,先判断它属于哪一层,再决定往哪个方向查。L1 和 L2 是最高频的,L3 最隐蔽,L4 反而最少见。很多团队把 80% 的精力花在 L4 调模型上,结果 L1 的一个 IP 冲突或者容器内存爆了才是真凶。
提示:分层不是绝对的,一次失败可能跨层。比如工具调用超时(L2)可能是因为容器内存高(L1)导致响应慢。所以失败链要串起来看,不能孤立看单个错误码。
2.3 为什么要在编排层引入 terminalBlocker 标记
terminalBlocker是我在编排层专门加的一个标记,用来表示“这个失败会阻塞整个终端会话,必须人工介入或强制重置”。它和普通错误的区别在于:普通错误可以重试、可以降级,但 terminalBlocker 意味着当前会话状态已经不可信,继续跑只会产生更多脏数据。
举个实际例子:某个 Agent 在调用一个需要保持长连接的终端工具时,连接断了但状态机还认为连接活着,后续所有调用都会挂起。这时候如果只是重试,会一直卡住。加上 terminalBlocker 标记后,编排层会直接终止当前会话并清理状态,避免雪崩。这个标记的引入,是我从一次线上事故里学到的——当时一个卡死的会话拖垮了整个队列。
3. 核心细节解析与实操要点
3.1 错误码采集:在哪些节点埋点最有效
埋点不是越多越好,关键是埋在对的地方。我总结下来,有五个必埋点:
- 任务入口:记录任务 ID、用户输入摘要、时间戳。这是失败链的起点,没有它后面全对不上。
- 工具调用前后:调用前记录工具名和参数,调用后记录返回码和耗时。这是定位 L2 的核心。
- 编排状态切换:每次状态机跳转都记一条,尤其是进入等待、重试、终止状态时。这是抓 terminalBlocker 的关键。
- 模型请求前后:记录请求 ID、token 数、响应状态。L4 问题靠它。
- 环境健康检查:定期采集容器内存、CPU、网络连接数。L1 问题靠它。
埋点的格式要统一,我一般用结构化日志,字段固定为trace_id、layer、code、detail、timestamp。这样后面做失败链还原时,直接按trace_id聚合就能看到完整路径。
3.2 错误码粗化是怎么发生的,如何避免
错误码粗化通常发生在三个地方:
- 异常捕获时直接
except Exception:这是最常见的。捕获后如果不区分类型,统一抛一个通用错误,原始信息就没了。 - 跨进程/跨服务传递时序列化丢失:比如工具是独立服务,返回的错误在 RPC 序列化时只保留了 message,丢掉了 code。
- 前端展示层统一兜底:为了用户体验,把所有错误都显示成“操作失败”,排查时看不到细节。
避免的方法也对应三条:捕获异常时按类型分支处理,保留原始 code;跨服务传递时用结构化错误对象,别只传字符串;前端展示和日志分离,展示可以友好,但日志必须完整。
我踩过最坑的一次是:工具服务返回了一个很明确的CONNECTION_REFUSED,但编排层用except Exception as e捕获后,只把str(e)传上去,结果str(e)是空的,最后只剩一个agent_failed。查了整整一个下午,最后发现是工具服务的端口被另一个进程占了。如果当时保留了原始 code,五分钟就能定位。
3.3 失败链还原的三个关键动作
拿到一个失败任务后,我一般做三个动作:
动作一:按 trace_id 拉全链路日志。把所有埋点的日志按时间排序,先看最后一个成功节点和第一个失败节点之间发生了什么。
动作二:定位第一个异常错误码。注意是“第一个”,不是最后一个。因为后面的错误往往是前面引发的连锁反应。比如容器 OOM 导致工具超时,工具超时导致编排重试,重试导致循环超限。第一个异常是 OOM,后面都是果。
动作三:判断是否 terminalBlocker。如果是,直接走会话重置流程,别在重试上浪费时间。如果不是,再根据层级决定重试还是修复。
这三个动作看起来简单,但顺序不能乱。很多人一上来就看最后一个错误,结果被连锁反应带偏。
4. 实操过程与核心环节实现
4.1 从一条真实失败日志开始还原
我拿一条脱敏后的真实日志来演示。假设某个 Agent 任务失败,前端只显示“请求失败”,日志里最初只有这么一行:
[trace_id=abc123] agent execution terminated due to error第一步,按 trace_id 拉全链路,得到下面这些(按时间排序):
[trace_id=abc123][L1] container_memory_usage=92% [trace_id=abc123][L2] tool_call start: terminal_exec, timeout=30s [trace_id=abc123][L2] tool_call timeout after 30s [trace_id=abc123][L3] state transition: RUNNING -> RETRY [trace_id=abc123][L2] tool_call start: terminal_exec, timeout=30s [trace_id=abc123][L2] tool_call timeout after 30s [trace_id=abc123][L3] state transition: RETRY -> RETRY [trace_id=abc123][L3] retry limit exceeded, mark terminalBlocker [trace_id=abc123][L4] agent execution terminated due to error看到没?最上面那行container_memory_usage=92%才是根因。容器内存快满了,导致终端工具响应极慢,30 秒超时。编排层重试两次后触发 terminalBlocker,最后对外只报了一个笼统错误。
如果只看最后一行,你会以为是模型问题;如果看倒数第二行,你会以为是重试策略问题;只有看到第一行,才知道是 L1 环境问题。
4.2 容器内存高导致工具超时的排查与验证
顺着上面的线索,怎么验证是内存问题?我一般做三步:
第一步,看容器内存趋势。不是看瞬时值,而是看失败前后的曲线。如果失败前内存持续爬升到 90% 以上,基本可以确认。
第二步,看是什么占了内存。Java 容器内存高是常见情况,可能是堆没设上限,也可能是线程池泄漏。用jstat看 GC 频率,用jmap看堆对象分布。如果是 Python 或 Node,看是否有大对象没释放。
第三步,做隔离验证。把工具调用单独拉出来,在内存正常的容器里跑,如果不再超时,就坐实了。
这里有个经验:工具超时不要只调大 timeout。很多人一看超时就加时间,结果内存问题没解决,只是把失败延后了。正确的做法是先查 L1,再决定要不要调 timeout。
4.3 IP 冲突与端口占用的快速定位
L1 里另一类高频问题是 IP 冲突和端口占用。这类问题的错误码通常表现为CONNECTION_REFUSED、ADDRESS_ALREADY_IN_USE或者连接超时。
排查方法很直接:
- 端口占用:
netstat -tunlp | grep 端口号或lsof -i:端口号,看是哪个进程占了。 - IP 冲突:
arping或看交换机日志,确认是否有两个设备用了同一个 IP。 - 连接超时:先
ping通不通,再telnet端口通不通,逐层排除。
我遇到过一次很隐蔽的:工具服务重启后,旧进程没完全退出,新进程绑不上端口,但错误被吞了,表现成随机超时。后来加了启动时的端口检查才解决。
4.4 编排层状态机死锁与循环超限的处理
L3 的问题最隐蔽,因为状态机本身不报错,只是“卡住了”。典型表现是任务一直处于 RUNNING 或 RETRY,但没有任何进展。
我的处理方式是给状态机加两个保护:
- 单状态停留超时:任何状态停留超过阈值(比如 60 秒),强制标记异常。
- 重试次数硬上限:不管什么原因,重试超过 N 次直接 terminalBlocker。
这两个保护加上后,死锁问题从“偶发卡死”变成了“明确报错”,排查难度直线下降。
4.5 模型层失败的识别与区分
L4 虽然占比低,但也要会区分。模型层失败常见几种:
- 请求失败:网络问题或服务端问题,看 HTTP 状态码。
- 限流:429,需要退避重试。
- 上下文超长:请求被拒,需要截断或压缩历史。
- 返回格式错误:模型返回了不符合预期的结构,需要加校验和重试。
关键点:模型层失败通常有明确的状态码,如果状态码正常但结果不对,那大概率不是模型层问题,要往 L2、L3 查。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
| 现象 | 可能层级 | 排查动作 | 解决方向 |
|---|---|---|---|
| 任务批量失败,错误码统一 | L3 | 看是否有 terminalBlocker | 检查重试策略和状态机 |
| 工具随机超时 | L1/L2 | 看容器内存、CPU、连接数 | 扩容或修资源泄漏 |
| 连接被拒 | L1 | 查端口占用、IP 冲突 | 释放端口或改配置 |
| 模型请求失败 | L4 | 看 HTTP 状态码 | 退避重试或换端点 |
| 任务卡住不动 | L3 | 看状态停留时间 | 加超时保护 |
| 返回格式错误 | L4 | 看原始响应 | 加校验和重试 |
5.2 独家避坑技巧
技巧一:错误码永远保留原始值。包装可以,覆盖不行。我现在的做法是错误对象里同时存original_code和wrapped_code,排查时优先看 original。
技巧二:terminalBlocker 要慎用但要用。用多了会误杀正常重试,用少了会雪崩。我的经验是只在“状态不可信”时用,比如连接断了但状态机还认为活着。
技巧三:失败链还原要自动化。手动拉日志太慢,我写了个小脚本,输入 trace_id 自动按层级聚合输出。这个脚本后来成了团队标配。
技巧四:别在高峰期调模型参数。模型层问题很多时候是负载导致的,高峰期调参只会让情况更糟。先看 L1 资源,再看 L4。
技巧五:给每个工具调用加唯一 ID。这样即使日志乱序,也能靠 ID 把调用和返回配对。
5.3 一次完整的排查复盘
最后复盘一次我印象最深的排查。现象是某 Agent 每天下午三点左右批量失败,错误码统一是agent_failed。按老思路查模型,毫无头绪。后来用失败链方法:
- 拉全链路日志,发现失败前都有
container_memory_usage爬升。 - 定位到是某个定时任务在下午三点跑,占用了大量内存。
- 该任务和 Agent 共用容器,导致 Agent 工具调用超时。
- 解决方式是把定时任务隔离到独立容器,并给 Agent 容器设内存上限。
整个过程从“怀疑模型”到“定位资源竞争”,靠的就是不放过任何一个粗错误码背后的细节。模型没背这个锅,资源隔离才是正解。
这套方法我用了大半年,Agent 失败的平均定位时间从几小时降到了十几分钟。核心就一句话:错误码是线索,失败链是地图,别让粗错误码把你带沟里。