最近排查一个基于 Agent(智能体)的文档问答服务,测试环境跑得很欢,一上生产就不断有任务失败。监控面板上清一色的“Agent execution terminated due to an error.”,点开详情也只是“模型请求失败”这种粗错误码。更诡异的是,手动拿同一段 prompt 去调模型,一切正常;模型服务商的状态页也显示全绿。那天上午,我们几乎所有人都下了一个结论:模型又“抽风”了。
可问题并没有因为“甩锅给模型”消失。后来我从“粗错误码”转向“失败链”的视角,把一次 Agent 运行拆成若干环节逐段验证,最后定位到的根因却是一个再普通不过的问题:工具返回内容过长,把模型生成 function call 参数的余量挤爆了,输出在接近截断位置处被掐断,下游拿到的参数残缺,于是整个任务被框架捕获并包装成了那行通用错误。这篇文章就还原这次排查全过程,并给出一个可以复用的排查框架:遇到 Agent 失败时,怎么从粗错误码一步步摸到真正的根因。
文章主要写给两类人:一是正在做 Agent 应用开发、被各种泛化报错折腾的人;二是刚接触 Agent 项目、还不太清楚函数调用和工具编排内部链路的新手。看完你会发现——Agent 排查和普通后端排查最大的区别,不是步骤更玄,而是中间多了一条由模型输出、工具调用、上下文记忆共同组成的“隐藏链路”。
1. 先还原现场:粗错误码是怎么把我带进坑里的
1.1 现象:只给一排“任务终止”和一句“模型失败”
当时我们的 Agent 走的是比较典型的 ReAct 循环:接收用户问题,由模型决定调用哪些工具,工具返回结果后再由模型整理成最终答案。任务失败后,平台侧返回的错误信息有两层:外层是“Agent execution terminated due to an error.”,内层是“模型请求失败”。这几乎等于什么都没说。
我把当天失败的 200 多条任务拉出来做了个简单统计,表现高度一致:失败点落在模型请求阶段,而且都发生在工具调用比较多的会话里。此时如果按传统后端思路去排查,第一步肯定是看模型服务的调用日志。但我把调用日志翻出来后,发现每次模型请求其实都已经发出去了,有些甚至拿回了完整响应,只是 Agent 框架把后续某个步骤的异常吞掉了,统一包装成了“模型失败”。
这里就暴露出一个关键认知:Agent 框架的错误码经常是“聚合错误”,它是框架对整条失败链的简化抽象,而不是对真实故障点的描述。我们太把它当回事,反而被它带偏了节奏。
1.2 为什么框架更愿意给一个模糊错误码,而不是具体原因
理解粗错误码为什么存在,有助于我们冷静下来。常见原因有三个:
第一,异常被多层包装后丢失了上下文。Agent 的编排层通常会有很多嵌套调用:工具执行器 catch 住异常后转成内部错误,内部错误又被记忆管理模块再包一层,最后到了统一出口只剩下“模型请求失败”。每包一层,原始的 exception class、堆栈、甚至具体是哪一次的 tool_call_id 触发的,都可能被丢掉。
第二,跨服务边界本来就拿不到细节。Agent 服务调用模型服务、模型服务调用工具服务、工具服务又去调外部 API……很多服务在设计时只允许透出最低限度的错误信息,比如“422”“500”“超时”。这不是某个框架的毛病,而是分布式系统的一种默认保守姿态。
第三,日志和追踪没接好。只要我们把 Agent 的每一步 request_id 或 session_id 漂亮地串起来,粗错误码就能快速定位到具体服务。可惜多数项目初期只做了业务日志,没有做全链路追踪,所以到了排查时才发现“链条断了”。
不过也别急着否定粗错误码。它至少帮我们划出了边界:“问题发生在 Agent 内部,而不是任务根本没进入系统”。我们需要做的,是把这条边界往里再推一层,从“错误码”走向“失败链”。
2. 从错误码到失败链:把一次Agent运行拆成七段
2.1 失败链七段法,先给错误定位定边界
我自己常用的做法,是先不理会错误码本身,把一次 Agent 运行的路径完整列出来。我会分成七段:
- 输入入口:用户 query 的接收、会话状态的恢复、上下文初始化。
- 编排决策:Agent 的规划机制(ReAct、plan-and-execute、多 Agent 协作)决定下一步做什么。
- 模型请求:把系统提示词、历史消息、工具定义、当前任务拼装后发给模型服务。
- 工具调用:模型返回 function call 后,框架解析参数,执行工具 API,拿回结果。
- 记忆装配:把长期记忆、短期记忆、检索到的知识塞回新的上下文,供下一轮模型调用使用。
- 输出解析:模型回复转成 JSON 或结构化结果,校验是否符合预期。
- 基础设施:容器网络、DNS、端口、超时策略、WSL2/Docker 环境等横切因素。
这个七段并不一定是严格流水线,很多 Agent 框架会把记忆装配和模型请求合并,但用来做“失败链排查”已经够了。核心思路是:不要把一次失败看成单个节点的事,而是看成一条数据链在某处断掉。七段法的作用,是帮我们一个个排除,而不是在第一段或最后一段上原地打转。
2.2 三段式边界划分:先证明“模型”到底有没有问题
“模型请求失败”这一句粗错误码,最容易让人直接冲去找模型服务商。我的建议是先做三段式边界划分:
- 第一段:模型能力。把完整 prompt、工具定义、历史上下文原封不动地拿出去,直接通过模型服务的 API 或调试页面复现。如果复现成功,说明模型自身没毛病,问题在 Agent 框架或工具侧。
- 第二段:工具能力。把失败任务里最后调用的几个工具单独抽出来,不看 Agent 框架,直接构造参数去调工具 API。如果工具本身响应正常,说明问题出在模型生成参数和工具入参之间的“翻译”。
- 第三段:编排逻辑。写一个最小脚本,只包含“模型→工具→模型”三步,把同样输入喂进去。如果最小链路也失败,再逐步加回记忆、历史、多轮逻辑。
理解这个分层法后,前面“模型全绿但 Agent 还是失败”的矛盾就好解释了:模型没问题,工具没问题,但它们在 Agent 这条链上组合在一起之后出了问题。所以排查要从“单独能力”回到“整条链路”。
3. 实操:一次完整的失败链排查实录
3.1 第一步:让日志重新变成一条可追溯的链
那批失败任务发生在夜间,日志量很大。第一件事当然是去把日志查出来,但我没有只查“错误”关键字,而是按 session_id 拉出整段链路。具体做法:
- 在 Agent 框架的入口处确认 session_id / task_id 是否贯通,如果没有,临时开启 DEBUG 日志,把每个环节的进入、离开时间打出来。
- 用时间窗过滤出失败任务所在时段的全部日志,不要只看 error 级别。因为真正有用的线索(工具返回内容的长度、模型输出的最后几十个 token)往往在 INFO/DEBUG 里。
- 如果日志是 JSON 结构,用 jq 做字段提取。例如
jq 'select(.session_id=="xxx") | {ts, stage, message}',很快就能把同一任务在几十秒内的所有阶段排成一条时间线。
当时我拉完日志,发现失败前最后一个工具调用居然执行成功了,工具也返回了一大堆 HTML 片段,接下来模型回复了一个残缺的 JSON:参数列表在数组中间被硬生生截断。这个现象如果只看最终错误,是永远发现不了的。
3.2 第二步:顺着时间线复现,并记录每一次工具调用
新的问题变成了:为什么工具返回内容后,模型会在生成参数时被截断?
我把 Agent 的时间线打印出来,得到几个量化数据:
- 一次任务平均循环 3~5 轮,每轮模型输入里包含完整对话历史。
- 工具返回的 HTML 片段最长超过 1 万 token,但工具结果的配置项里,我们当时设置的“截断阈值”是 2000 token。
- 2000 token 的截断不是问题,问题在于:截断发生在工具结果返回给模型前,这会导致 Agent 下一轮模型的输入上下文被大段 HTML 填满,而模型需要输出的 function call 参数本身需要足够空间。
这里我又核对了一个常识:模型在生成时经常需要预留 max_tokens,比如我们配的是 1024。如果上下文已经超过模型的上下文窗口上限,框架会按“截断策略”裁掉较早的历史消息;裁掉历史消息还好,但有时会把非常关键的“上一轮工具结果摘要”也裁掉,模型就看不到工具返回的实际内容了。最终模型为了“自救”,会开始生成缺字段的参数请求,落到工具 API 上自然就变成 422 或参数缺失。
第三步我专门做了验证:把同一份 prompt 和工具定义直接调用模型,内容不经过 Agent 框架时,模型完全能生成正确参数。这说明问题不在模型能力,而在“上下文装配策略”——工具结果过长,把模型思考空间挤没了。
3.3 第三步:把模型请求报文原样拉出来比对
这一步很关键,也是很多人会跳过的一步:我们应该把“Agent 实际发给模型服务的请求体”原样保存下来,而不是事后重新拼一个“差不多”的请求。
我在排查时给所有模型请求加了一层报文记录,记录内容包括:
- 请求时间、request_id
- system prompt 的完整内容
- 消息列表每一条的 role、长度(字符数和 token 数)
- 工具定义列表
- 模型侧的 max_tokens、temperature 等参数
- 实际响应内容,尤其是最后一个 token 是否完整
拿到这些之后,再跟“直接调用模型”的输入做 diff,差距立刻暴露:Agent 发的请求里,最后一轮 user 消息竟然是被拼接过的工具 HTML,长度占满了上下文窗口;模型根本没有余量生成完整的参数列表。于是它在接近输出上限的地方被强制截断了。
这里还有个很容易踩的细节:很多工具结果不是模型自己截断的,而是框架的字符串截断逻辑导致的。比如我们存工具返回结果时按“字符”截断,但模型上下文按“token”计费;一个中文字符可能占多份 token,最终 token 数比我们预估的大好多倍。字符截断和 token 截断之间,必须做一次换算,或者直接用模型的 tokenizer 去数。
3.4 第四步:排查工具所在的环境问题(DNS、端口、容器网络)
当 Agent 链路里调用了外部工具时,失败链上还有一个“基础设施”环节不能漏。我们的 Agent 服务是容器化部署的(Docker/WSL2 环境),排查模型报文之后,我还顺手查了工具服务的网络连通性。
当时有几个任务失败得很随机,表现是工具调用超时,触发重试后整体流程就会乱掉。我按下面的顺序检查:
- 先确认端口监听是否正常:
ss -tlnp | grep <port>,看容器端口有没有映射到宿主机。 - 再看 DNS 解析是否稳定:
getent hosts <service-name>,重试几次看返回的 IP 是否稳定。容器网络里如果 DNS 解析偶尔失败,外部工具调用就会超时。 - 看容器网络情况:
docker network inspect <network>、docker logs <container> --since <time>。WSL2 网络模式偶尔也会出现瞬时丢包,尤其在重启机器后残留旧 IP 缓存时。 - 检查是否有 IP 冲突:在局域网部署场景下用
ip neigh查看 ARP 表,避免工具服务的请求被错误路由。
最后我们发现:工具服务所在的容器发生过一次 IP 重新分配,但 Agent 服务里还缓存着旧 IP,导致部分机器访问失败,表现成间歇性超时。把它改成服务名解析并清理了 DNS 缓存后,超时明显减少。这里再次印证:失败链里的任何一段都可能埋雷,不能只看模型报文。
3.5 第五步:修复与验证
修复动作分三层:
第一层:上下文治理。工具结果不再直接全文塞给模型,而是先做结构化摘要;保留原始结果的哈希或索引,模型需要细节时再按需检索。这样既保住了上下文空间,又不至于丢失关键信息。
第二层:工具调用参数校验。模型返回的 function call 参数必须先做 schema 校验,缺字段就触发“补全重启”机制,而不是直接抛给工具 API。补全重启的成本远低于整条任务失败的代价。
第三层:错误码分级。我让框架在出口处不再简单输出“模型请求失败”,而是按阶段输出结构化错误码,比如 tool_timeout、tool_output_truncated、context_overflow、function_args_invalid、model_request_failed。每个码后面必须带 request_id 和阶段名。
修复后我做了连续 50 轮的回归跑测,成功率从 76% 提升到 98%,剩下的 2% 大多是外部服务偶发 5xx,而且现在能根据错误码直接定位到是哪个工具有问题,不用再翻半天链路。这一步让我特别有感触:把粗错误码变细,不是为了好看,而是为了让下一次失败可以被系统自动路由。
4. 常见误区、速查表与工程化沉淀
4.1 粗错误码 → 排查方向速查表
我把常见的“粗错误码”和首选排查方向整理成一张表,方便直接抄作业:
| 错误码/报错 | 容易得出的错误结论 | 建议优先排查方向 |
|---|---|---|
| 422 Unprocessable Entity | “模型收到了坏请求” | 模型生成的 function call 参数、工具输入 schema、上下文里被塞入了非预期字符 |
| 500 Internal Server Error | “模型服务挂了” | Agent 服务自身未捕获异常、上游网关、工具服务崩溃 |
| 502 Bad Gateway / 503 | “网络问题” | Agent 到工具服务、Agent 到模型网关之间的负载均衡或网关状态,服务限流 |
| 504 Gateway Timeout | “模型太慢/超时” | 工具执行时长、模型 max_tokens 过大、外部 API 响应慢、重试策略失效 |
| Agent execution terminated due to an error. | “流程被终止,原因不明” | 框架全局异常捕获;重点查最后 2~3 步工具调用和输出解析 |
| 模型请求失败(泛化文案) | “模型不行” | 把请求报文原文存下来做 diff,核查上下文长度、token 截断、参数生成截断 |
| 间歇性失败 | “偶发,不好查” | DNS 解析、容器 IP 缓存、端口冲突、旧进程占用、限流配额 |
这张表不是标准答案,作用是快速收敛方向。真正常用的不是“查表”,而是表背后的链路段位划分,再加上“保留原始报文”的习惯。
4.2 四个特别常见的误判场景
第一个误判:模型服务商状态页全绿,就觉得模型没责任。状态页只是宏观情况,不代表你的具体请求没问题。我自己就遇到过因为触发内容过滤而返回 4xx,状态页永远是绿的。所以该做的是拿原始请求复现,而不是看状态页。
第二个误判:只用最后一次报错信息,不看失败链。Agent 失败往往不是瞬时发生,而是一步步恶化的。比如第一轮工具返回内容就已经过大,到第二轮模型才开始生成错误参数,第三轮才正式报错。只看最终报错完全无法定位。
第三个误判:优先怀疑模型逻辑,忽略基础设施。工具调用、记忆检索、模型请求之间的网络和环境问题经常被忽略,尤其当项目跑在 WSL2/Docker 组合环境下。DNS 的瞬时失败、容器的 IP 重新分配、端口被占用,这些都会悄悄爬上失败链。
第四个误判:用重试代替排查。偶发失败偶尔重试能恢复,但重试次数太多会让整条 Agent 流程的状态变乱,尤其是工具调用本身有副作用时(比如已经扣了钱的任务,重试造成重复扣款)。重试必须是带了退避和幂等保护的,而不是把同一份坏请求再发一遍。
4.3 把这次教训固化成 Agent 工程规范
从这次排查之后,我给自己团队定了几条带强制性的工程规范:
第一,所有 Agent 任务必须生成可关联的 trace_id,从任务开始到结束,经过的模型请求、工具调用、记忆读取、输出校验全部带上它。做不到全链路追踪,至少也要把 trace_id 写进业务日志和模型网关日志。
第二,错误码必须带阶段前缀。内部代码里逐步重构返回格式:tool_timeout:get_weather:request_id=xxx。这样错误码本身就是一份“故障摘要”,而不只是“又失败了”。
第三,工具结果要分层使用。粗结果、摘要结果、原始结果分别放不同位置;模型默认只吃摘要,必要时再加载原始内容。这个设计不仅解决上下文窗口问题,也明显降低调用成本。
第四,失败样本要入库。把每次失败任务的 session_id、请求报文、上下文大盘、工具返回快照、模型响应结尾都存成回归集,每次改版后跑一遍。这就是 Agent 项目的“故障演练场”。
这些规范看起来会花一些开发成本,但每一个都对应着真实踩过的坑。做 Agent 开发越久,越会觉得:与其让线上任务去暴露问题,不如让工程规范把问题挡在发布之前。
5. 最后分享三个能救命的排查小习惯
最后说几个我自己实打实验证过的小习惯,供参考。
第一个习惯:Agent 框架的错误日志只信三分,原始报文信七分。现在我再看到“模型请求失败”,会直接要求日志模块把真实发送的 prompt、上下文长度、响应末尾 20 个 token 一起打出来。正因为那次失败里模型响应是被硬生生截断的,所以“末尾 20 个 token”往往比中间内容更能说明问题。
第二个习惯:做一个“最小 Agent 复现脚本”。它不用完整实现框架,只要能完成“输入→模型→工具→模型→输出”五步闭环即可。遇到任何泛化报错,先拿最小脚本跑一遍,很多问题在完整框架里千头万绪,在最小脚本里一眼就能看出来。
第三个习惯:给 Agent 加一条定时健康检查任务。我每天会让一个测试任务带着固定问题去跑,故意制造轻微的工具超时或长文本输入,看它能不能正确自愈。这不是无聊的监控,而是用最低成本把失败链中最脆弱的几段暴露在白天。自从加了这条巡检,我半夜被叫醒的次数确实少了很多。
排查 Agent 失败,最难的不是哪个环节的代码复杂,而是我们太容易被一个粗错误码牵着走。把视角从“某个环节报错”切换到“整条失败链”,把工具调用、上下文装配、基础设施这些平时不太起眼的环节都摆到桌面上,大多数疑难杂症其实都能在半小时内锁死范围。希望这次从粗错误码到失败链的完整复盘,能让你下次排查时少踩几个坑。