1. 为什么“模型不行”往往是排查方向跑偏了
做 Agent 开发的人,大概都经历过这种时刻:任务跑了一半突然中断,日志里只留下一行冷冰冰的execution terminated due to error,或者一个422、一个timeout。第一反应通常是——模型是不是又抽风了?于是换模型、调温度、加提示词,折腾半天,问题照旧。
我踩过太多次这种坑。后来复盘发现,真正把 Agent 搞挂的,十次里有六七次根本不是模型推理能力的问题,而是失败链上某个环节先崩了,模型只是最后一个“背锅”的。所谓失败链,就是一次 Agent 执行从入口到出口经过的所有环节:任务编排、工具调用、终端执行、网络请求、容器资源、记忆读写、结果回传。任何一个环节出错,最终都会以“模型请求失败”这种笼统的面目呈现出来。
这篇文章想聊的就是这件事:怎么从一行粗糙的错误码出发,顺藤摸瓜还原出完整的失败链,把锅精准地扣到该扣的地方。核心关键词是 Agent、错误码、失败链、排查、terminalBlocker。适合正在做 Agent 开发、被各种玄学报错折磨、想建立一套系统排查方法的人。不管你是刚上手 Agent 搭建的新手,还是已经在做多 Agent 协作的老手,这套思路都能直接抄去用。
我个人的判断标准很简单:如果一个错误你只能定位到“模型失败”,那说明你的可观测性还没做到位。真正成熟的 Agent 项目,任何一个失败都能追溯到具体的环节、具体的参数、具体的那一次调用。下面就把这套方法拆开讲。
2. 错误码不是终点,而是失败链的入口
2.1 粗错误码为什么总是“骗人”
先说说为什么错误码经常误导人。Agent 系统里最常见的几类粗错误码,无非是timeout、422、500、execution terminated due to error、model request failed这几种。它们有个共同特点:描述的是“结果”,而不是“原因”。
举个我亲身遇到的例子。有一次线上 Agent 批量处理任务,隔三差五报model request failed。按字面意思,肯定是模型服务商那边的问题,我甚至去翻了服务商的错误信息面板。结果查了两小时才发现,真正的原因是容器内存被一个没释放的临时文件撑爆了,进程被系统 OOM 干掉,模型请求根本没发出去。你看,错误码说的是模型,实际根因在资源。
这就是粗错误码的本质问题:它处在失败链的最末端,是层层包装之后抛出来的最终异常。中间任何一层捕获了原始错误、又用统一格式重新抛出,信息就被稀释一次。等传到你能看到的地方,早就面目全非了。
所以我的第一条经验是:看到粗错误码,先别急着信它的字面含义,把它当成一个“入口”,而不是“结论”。
2.2 失败链的五个典型环节
要把错误码还原成失败链,得先知道一条完整的 Agent 执行链路上有哪些环节。我一般把它拆成五段,从外到内依次是:
| 环节 | 典型职责 | 常见故障表现 |
|---|---|---|
| 任务编排层 | 拆解任务、调度子 Agent、管理状态 | 状态机卡死、子任务丢失、死循环 |
| 工具调用层 | 选择工具、组装参数、解析返回 | 参数格式错、工具不存在、返回解析失败 |
| 终端执行层 | 执行 shell、读写文件、跑脚本 | terminalBlocker、权限拒绝、命令超时 |
| 网络与资源层 | 请求模型、访问外部服务、容器资源 | 连接超时、内存溢出、端口冲突 |
| 记忆与回传层 | 读写短期/长期记忆、回传结果 | 记忆污染、上下文超限、结果截断 |
这五层里,越靠内的环节出错,越容易被包装成越靠外的错误码。终端执行层崩了,可能被包装成工具调用失败;工具调用失败,又可能被包装成模型请求失败。层层套娃,最后你看到的那个错误码,离真正的根因可能隔了三层。
我建议你在项目里做一件事:给每一层都打上独立的 trace id,并且保证异常在每一层重新抛出时,把原始错误作为 cause 带上。这样即使最终展示的是粗错误码,你也能顺着 cause 链一路挖到底。这个改动成本很低,但排查效率能提升一个数量级。
2.3 从错误码反推路径的基本方法
有了分层意识,接下来就是具体怎么反推。我的做法是“三步定位法”:
第一步,看错误码的“形状”。422通常是参数校验失败,说明请求发出去了但格式不对;timeout说明请求发出去了但没回来,问题在链路中段或对端;execution terminated这种没有具体状态码的,往往是进程级异常,比如被 kill、崩溃、死锁。
第二步,看错误发生的时间点。是任务刚开始就挂,还是跑到一半挂?刚开始挂,多半是编排层或参数问题;跑到一半挂,多半是资源耗尽或状态污染;快结束时挂,多半是回传或记忆写入问题。
第三步,看错误是否可复现。稳定复现的,基本是逻辑 bug 或配置错误;偶发、跟并发量相关的,多半是资源竞争、端口冲突、内存泄漏这类问题。
这三步走下来,你基本能把范围从“整个系统”缩小到“某一层”。剩下的就是进那一层细查。下面几节我会针对几个高频环节,讲具体的排查手法。
3. 终端执行层:terminalBlocker 是怎么把 Agent 卡死的
3.1 terminalBlocker 的典型成因
terminalBlocker 这个词,指的是 Agent 在终端执行环节被“堵住”了——命令发出去了,但永远等不到返回,整个 Agent 就挂在那里。它和普通的 timeout 不太一样:timeout 是等够了时间主动放弃,terminalBlocker 是连放弃的机会都没有,进程就那么僵着。
我总结下来,terminalBlocker 的成因主要有这么几类:
- 交互式命令等待输入。Agent 执行了一个需要用户确认的命令,比如某些包管理器、某些删除操作,命令在等
y/n,而 Agent 根本没给它输入,于是永久阻塞。 - 子进程没有正确关闭。命令执行完了,但 fork 出来的子进程还活着,父进程在等子进程退出,形成僵尸等待。
- 管道缓冲区写满。命令输出量巨大,管道缓冲区满了,写端阻塞,读端又没在读,死锁。
- 文件锁或资源锁未释放。前一个任务持有锁没放,后一个任务在等锁,而前一个任务其实已经异常退出了。
这几种里,交互式命令等待输入是最隐蔽的,因为从日志上看,命令确实执行了,只是没有输出,你很容易误以为是模型在思考。
3.2 用超时和心跳把阻塞变成可观测
terminalBlocker 最恶心的地方在于它“静默”。我的应对策略是两条:强制超时 + 执行心跳。
强制超时好理解,任何终端命令都必须带一个最大执行时间,超了就 kill。但光有超时不够,因为你不知道它是在正常跑还是已经卡死。所以还要加心跳:命令执行期间,定期往日志里打一个“still running”的标记,带上当前耗时和已产生的输出字节数。
# 一个带超时和心跳的执行封装示例 timeout --signal=KILL 120s bash -c 'your_command' & pid=$! while kill -0 $pid 2>/dev/null; do echo "[heartbeat] still running, elapsed=${SECONDS}s" sleep 10 done这样一旦出现 terminalBlocker,你能从心跳日志里清楚看到:命令在第几秒之后就不再产生新输出了,从而判断是卡在等待输入,还是卡在资源锁。
注意:超时时间不要一刀切。查询类命令给 30 秒够了,编译、训练类命令可能要几十分钟。按命令类型配置不同的超时阈值,比全局一个值靠谱得多。
3.3 非交互模式与资源清理的实操配置
针对交互式命令阻塞,最根本的解法是强制所有命令走非交互模式。不同工具的非交互参数不一样,我整理了一份常用的:
| 工具 | 交互模式风险 | 非交互参数 |
|---|---|---|
| apt | 等待确认 | -y |
| pip | 一般无交互 | 加--no-input更稳 |
| git | 可能等待编辑器 | 设GIT_EDITOR=true |
| ssh | 等待密码 | 用密钥 +BatchMode=yes |
| 自定义脚本 | 可能 read 输入 | 重定向 stdin 为/dev/null |
除了非交互,资源清理同样关键。我习惯在每次命令执行前后做一次“环境体检”:检查有没有残留的子进程、有没有没释放的临时文件、有没有占着不放的文件锁。这些检查本身很轻量,但能提前发现很多隐患。
# 执行前清理残留 pkill -P $$ 2>/dev/null || true # 执行后确认无残留子进程 ps --ppid $$ -o pid,cmd我踩过最坑的一次,是一个 Agent 反复执行同一个脚本,每次都在临时目录建文件但从不删除,跑了几百次之后磁盘满了,后续所有命令都因为写不了临时文件而失败,报出来的却是“模型请求失败”。所以磁盘和 inode 的监控也要纳入终端执行层的健康检查。
4. 网络与资源层:那些伪装成模型问题的故障
4.1 连接超时、端口冲突与内存暴涨的区分
网络与资源层的故障,是最容易被误判成“模型不行”的。因为它们发生在模型请求的“路上”,错误最终都会以模型请求失败的形式冒出来。我一般按这几个特征来区分:
- 连接超时:错误信息里通常带
connect timeout或dial tcp,说明 TCP 握手就没成功,问题在目标地址可达性或防火墙。 - 端口冲突:表现为
address already in use,多见于本地起了多个 Agent 实例抢同一个端口。 - 内存暴涨:进程被 OOM kill,日志里可能只有一句
killed,或者干脆什么都没有,进程直接消失。
这三者的排查入口完全不同。连接超时查网络配置,端口冲突查进程占用,内存暴涨查资源监控。如果你只有一个笼统的“模型请求失败”,根本没法区分,所以一定要在请求层把这几类错误分开捕获、分开上报。
4.2 容器内存占用高的排查路径
热词里有个“java docker 容器占用内存特别高怎么排查”,这个问题在 Agent 场景里同样高频,因为很多 Agent 的执行环境就是容器。我的排查路径是这样的:
第一步,确认是容器内进程占用高,还是容器本身统计口径的问题。容器内存统计包含 page cache,有时候你看到的内存高,其实大部分是可回收的缓存,不是真正的泄漏。
# 看容器内实际进程内存 docker exec <container> ps aux --sort=-%mem | head # 看 cgroup 统计 cat /sys/fs/cgroup/memory/memory.usage_in_bytes cat /sys/fs/cgroup/memory/memory.stat第二步,区分是堆内还是堆外。如果是 JVM 类进程,堆内看 GC 日志,堆外看NMT。如果是 Python 或 Node,重点看有没有大对象没释放、有没有循环引用。
第三步,看内存增长曲线。是阶梯式增长(每次任务涨一截,说明有泄漏),还是锯齿状(正常 GC),还是一次性冲高(某个大任务)。曲线形态直接指向不同的根因。
我遇到过一次典型的内存问题:Agent 每次调用工具都会把完整的历史上下文塞进请求,上下文越滚越大,最后单次请求就把内存顶爆了。解法是给上下文做滑动窗口 + 摘要压缩,而不是无脑全量携带。
4.3 网络故障的快速定位清单
网络问题排查,我有一份自己的速查清单,基本能覆盖八成场景:
| 现象 | 优先检查 | 常用命令 |
|---|---|---|
| 连接超时 | 目标地址、端口、防火墙 | telnet host port、nc -zv host port |
| 间歇性失败 | DNS 解析、连接池 | nslookup、看连接池配置 |
| 大量 422 | 请求体格式、字段类型 | 抓包看原始请求 |
| 响应慢但不断 | 带宽、对端限流 | iftop、看对端文档 |
| 证书相关报错 | 证书有效期、信任链 | openssl s_client |
提示:排查网络问题时,先确认“请求到底有没有发出去”。很多人一上来就查对端,结果发现请求压根没离开本机。在请求发出前打一条日志,能省掉大量无用功。
5. 记忆与编排层:失败链里最容易被忽略的两环
5.1 记忆污染如何引发连锁失败
Agent 的记忆体系一般分短期、长期、永久三层。短期记忆是当前会话的上下文,长期记忆是跨会话的经验,永久记忆是固化的知识。这三层任何一层被污染,都会引发连锁失败。
我见过最典型的记忆污染场景:Agent 在一次失败的任务里,把错误的中间结论写进了长期记忆。之后每次遇到类似任务,它都会先读取这条错误记忆,然后基于错误前提继续推理,越错越离谱。表面上看是模型推理能力不行,实际上是记忆被喂了脏数据。
排查记忆污染,关键是给记忆写入加校验和版本。每条记忆写入时记录来源、时间戳、置信度,读取时按置信度排序,低置信度的记忆不参与推理。同时要能一键回滚到某个时间点的记忆快照。
# 记忆写入时带元信息的简化示例 def write_memory(content, source, confidence): memory = { "content": content, "source": source, "confidence": confidence, "timestamp": time.time(), "version": get_current_version() } # 低置信度记忆先入待验证区,不直接进长期记忆 if confidence < 0.7: pending_store.append(memory) else: long_term_store.append(memory)5.2 编排层死循环与状态丢失的识别
编排层的问题更隐蔽,因为它往往不报错,只是“卡住”或者“跑偏”。死循环是最常见的:子 Agent 之间互相调用,或者任务拆解逻辑有 bug,导致同一个子任务被无限重复。
识别死循环,我靠两个信号:任务计数异常增长和状态机长时间不推进。给每个任务加一个执行计数器,超过阈值就强制中断并上报。状态机则要定期打点,如果某个状态停留时间超过预期,就触发告警。
状态丢失是另一个坑。多 Agent 协作时,如果状态没有持久化,某个 Agent 崩溃重启后,整个协作流程就断了。解法是状态外置:把协作状态存到独立的存储里,每个 Agent 只持有引用,崩溃后能从存储恢复。
5.3 多 Agent 协作下的失败归因
多 Agent 协作时,失败归因最难,因为一个失败可能是多个 Agent 共同作用的结果。我的做法是给每次协作生成一个全局 trace,所有 Agent 的操作都挂在这个 trace 下。这样出问题时,你能看到完整的调用时序,谁先出错、谁把错误传给了谁,一目了然。
归因时我遵循一个原则:找第一个偏离预期的节点,而不是找最后一个报错的节点。最后一个报错的往往只是受害者,真正的元凶在更早的地方。这跟排查分布式系统故障是一个道理。
6. 一套可复用的失败链排查流程
6.1 从错误码到根因的标准动作
把前面几节的内容串起来,我总结出一套标准排查流程,你可以直接照着做:
- 收集原始错误:不要只看最终错误码,把每一层的 cause 链都拉出来。
- 定位失败环节:用“三步定位法”把范围缩小到某一层。
- 检查该层健康度:终端层查阻塞和残留,网络层查连接和资源,记忆层查污染和版本。
- 复现并验证:能复现的写最小复现用例,不能复现的加监控等下次。
- 修复并回归:修完必须回归,确认同类问题不再出现。
这套流程的价值在于,它把“玄学排查”变成了“按图索骥”。哪怕你面对的是一个从没见过的错误码,也能一步步逼近根因。
6.2 常见问题速查表
| 错误现象 | 最可能环节 | 首选排查动作 |
|---|---|---|
| execution terminated | 终端执行层 | 查心跳日志、查残留进程 |
| 422 | 工具调用层 | 抓原始请求、校验字段 |
| 连接超时 | 网络层 | 测连通性、查 DNS |
| 内存暴涨 | 资源层 | 看增长曲线、查上下文大小 |
| 结果越来越离谱 | 记忆层 | 查记忆写入、回滚快照 |
| 任务卡住不动 | 编排层 | 查任务计数、查状态机 |
6.3 把排查经验沉淀成监控
最后说个我特别想强调的点:排查的终点不是修好这一次,而是让下一次不用再排查。每次定位到一个根因,都应该问一句:能不能加个监控,让这个问题下次自动暴露?
比如 terminalBlocker,加个心跳监控就能提前发现;内存泄漏,加个增长速率告警就能预警;记忆污染,加个置信度校验就能拦截。这些监控的成本都不高,但能把你从“救火”变成“防火”。
我现在的习惯是,每解决一个线上问题,就顺手加一条对应的监控或断言。半年下来,Agent 的稳定性肉眼可见地提升,因为大部分问题在爆发前就被拦住了。
7. 我在实际排查中攒下的几条心得
做 Agent 排查这几年,有几个体会是文档里不会写、但特别值钱的。
第一,日志要打“上下文”,不要只打“结果”。一句“请求失败”毫无价值,但“请求失败,trace_id=xxx,环节=工具调用,参数=xxx,耗时=xxx”就能救命。多打几个字段的成本,远低于事后排查的时间。
第二,复现是排查的加速器,但不要执着于复现。有些偶发问题,你花三天也复现不出来,但加个监控可能第二天就抓到了。该加监控的时候别硬刚复现。
第三,别信任何单一信息源。错误码会骗人,日志会缺失,监控会有盲区。多源交叉验证,才能逼近真相。我一般至少看三个地方:应用日志、系统日志、资源监控。
第四,把失败链画出来。遇到复杂问题,我会在白板上把整条链路画一遍,标出每个环节的输入输出。画着画着,问题往往就自己冒出来了。这个方法看着笨,但极其有效。
第五,给每个 Agent 起个能识别的名字。多 Agent 协作时,日志里全是“agent”,你根本分不清谁是谁。给每个 Agent 一个唯一标识,排查效率翻倍。
这套东西说起来都是常识,但真正落地到项目里,能坚持做的人不多。而恰恰是这些常识性的工程实践,决定了你的 Agent 是“能跑”还是“跑得稳”。模型能力固然重要,但把失败链理清楚、把可观测性做扎实,才是让 Agent 真正可用的前提。