开头先交代一下背景。前一阵我负责的一个多 Agent 协作服务频繁出问题,业务方拿着一张截图来找我,上面就一行错误码:AGENT_EXECUTION_TERMINATED。没有堆栈,没有节点信息,没有上下文快照,连是哪个子 Agent 挂的都不知道。我当时的第一反应就是:“模型又抽风了,换个更强的模型试试。”结果换完模型照样失败,调低 temperature 也失败,把 prompt 重写三遍还是失败。
这让我非常挫败,但也逼着我换了思路:不再盯着模型猜,而是把这次失败的完整链路挖出来。后来我才发现,真正的根因根本不在模型,而在一条被我忽略的“失败链”上——从外部 API 的 TLS 握手失败,到工具层返回空列表,再到编排器吞掉异常信息,最后才表现为那个粗错误码。这篇文章就是这次排查的完整复盘,我把每一步的思路、操作、踩坑都写出来,希望能帮你少走几个弯路。文章适合所有在做 Agent 开发、尤其是被各种诡异失败折磨过的朋友。
1. 事故现场:一行粗错误码把我带进了死胡同
1.1 症状与第一反应:换模型、调参、重试
先说事发时的具体场景。服务里有一个主 Agent,负责接收用户请求,拆解任务后派发给两个子 Agent:一个负责调用外部搜索工具获取资料,另一个负责把资料整理成结构化答案。整个链路跑在自研的 Agent 编排框架上,每个子 Agent 内部是“大模型 + 工具调用”的循环。
那天线上开始陆续出现失败请求,日志里只有主 Agent 抛出的AGENT_EXECUTION_TERMINATED,后面啥也没有。我的第一反应和大多数人一样:先重试,重试不行就换模型,从原来的 7B 级模型换到更大参数的模型,还把 temperature 从 0.3 调到 0.1,又给子 Agent 的 system prompt 里塞了好几条 few-shot 示例。
结果完全没有用。同一个请求,无论怎么调,都在同一个位置失败。这时候我才意识到,问题可能压根不在模型身上。
1.2 为什么换模型救不了一个脏链路
我后来想明白了一件事:模型只是 Agent 链路里的一个处理器,它吃进去的是前序节点给的输入,输出的结果又会被后续节点处理。如果上游给的输入本身就是脏的——“搜索成功但没有内容”的空列表、被截断的历史记录、错误的工具返回格式——那模型再强也只能基于垃圾输入生成垃圾输出,甚至直接拒绝生成。
打个比方,餐厅上菜慢,你可能先怪厨师炒得慢,但真正的原因可能是采购没买到菜、传菜员把单子送错了、或者后厨的炉子坏了。厨师只是链路里最后一个动手的人,他看着最可疑,但未必是根因。
Agent 系统的复杂性就在这里:一次最终失败,往往是多个节点的问题叠加出来的。模型输出的异常只是最外层的表现,真正的问题可能藏在工具调用、上下文组装、外部服务依赖甚至编排器的异常处理逻辑里。只盯着模型调参,等于只骂厨师,根本不解决问题。
1.3 破局:从“猜错误”转向“收集链路证据”
意识到这一点之后,我停止了所有“盲试”,开始老老实实收集证据。我给整个链路补了全量日志,把每个关键节点的输入输出、耗时、异常状态全部记录下来。
我梳理了一下,需要采集的信息至少包括这些:
- 主 Agent 收到请求的时间、请求 ID、任务类型。
- 任务拆解后派发了哪些子任务,每个子任务去了哪个子 Agent。
- 每个子 Agent 的模型调用记录:输入 prompt 的摘要、输出内容、finish_reason、token 消耗。
- 工具调用记录:工具名、入参、原始返回结构、异常类型、耗时。
- 上下文组装记录:最终喂给模型的 prompt 长度、内容摘要、是否有被截断或丢失的字段。
- 编排器的聚合逻辑:子 Agent 返回后,主 Agent 如何合并结果、何时触发异常分支。
这些日志加上去之后,链路开始“显形”。我后来发现,之前之所以难排查,不是因为问题有多深,而是因为整个链路几乎是一个黑盒——你只看得到最外层的结果,根本不知道里面发生了什么。把可观测性补上之后,问题很快就浮出来了。
2. 粗错误码为什么会误导人:失败链的底层逻辑
2.1 粗错误码到底丢了多少信息
先说一个扎心的事实:很多 Agent 框架默认抛出的错误码,信息量低到可怜。就拿我遇到的AGENT_EXECUTION_TERMINATED来说,它只是一个布尔级别的信号——“挂了”。至于为什么挂、挂在哪一环、是外部依赖挂了还是模型拒答了、是超时还是上下文溢出了,全都不知道。
我整理了一张对比表,可以直观看到粗错误码和细错误码的信息量差异:
| 信息维度 | 粗错误码(常见默认) | 细错误码(推荐) |
|---|---|---|
| 状态 | 只有成功/失败 | 成功、失败、部分成功、重试中 |
| 失败阶段 | 无 | 意图解析、规划、工具调用、模型生成、聚合 |
| 失败节点 | 无 | 具体是哪个子 Agent、哪个工具 |
| 根因类型 | 无 | 超时、空结果、格式错误、外部依赖不可用 |
| 上下文关联 | 无 | 关联 request_id、trace_id、任务快照 |
| 重试建议 | 无 | 是否可重试、建议的退避策略 |
粗错误码之所以这么“粗”,往往不是技术上做不到,而是编排器在向上传递异常时做了简化处理——子 Agent 返回的详细错误被吞掉,外层只保留一个失败状态。这就像一个传话游戏,传到最后一环只剩一句“出事了”,前面所有关键信息全丢了。
2.2 什么是失败链:一个 Agent 请求要经过的所有节点
我所说的“失败链”,是指一次 Agent 任务从用户请求到最终输出的完整链路上,所有可能引发失败的环节。我根据自己的实战经验,把一条典型的 Agent 执行链路拆成了九个节点:
- 请求接收:网络层、鉴权、参数校验。
- 意图解析:理解用户到底要什么。
- 任务规划:把目标拆解成可执行的子任务。
- 意图路由:决定每个子任务派发给哪个子 Agent 或工具。
- 工具选择与参数构造:决定调用哪个外部 API、参数怎么填。
- 外部调用:网络请求、依赖服务、第三方 API。
- 结果解析与上下文组装:把工具返回的内容整理成模型能理解的上下文。
- 模型生成:LLM 基于上下文生成最终回答。
- 输出校验与聚合:检查输出是否符合预期,把多个结果合并返回。
任何一个节点出问题,都可能让整个任务失败,而且上游节点的问题会像多米诺骨牌一样向下传导。比如第 6 步外部调用超时,第 7 步拿到空结果,第 8 步模型产生幻觉或拒答,第 9 步校验不通过,最终对外抛出一个笼统的错误码。你看到的失败在最后一步,真正的根因却可能在第一步或更早的系统环境里。
2.3 模型为什么总是那个“背锅侠”
既然失败链这么长,为什么大家(包括我)第一时间都去怪模型?我总结了三个原因。
第一,模型是链路上最“像决策者”的节点。其他节点比如工具调用、网络请求,看起来都是机械性的,而模型输出的内容语义丰富,一旦它答得不对,你天然觉得是它的问题。
第二,模型确实有随机性。同一个 prompt,同一组参数,上次成功这次失败,这在大模型时代太常见了。这种不确定性让我们很习惯性地把“不稳定”和“模型有问题”画等号。
第三,也是最关键的,系统设计上往往把模型放在链路的末端。前面所有节点的处理结果都被包装成模型的输入,掩盖了中间发生的异常。比如工具返回空列表后,如果不做显式标注,模型根本不知道“搜索其实失败了”,它只会认为“搜索成功了,但世界上没有相关内容”。这种情况下模型给出的回答再奇怪,也是情有可原的。
所以,把 Agent 失败都算给模型,本质上是用“模型不可解释”来掩盖“链路不可观测”。想让结论可靠,必须先让链路完整暴露在视野里。
3. 实操复盘:把一个粗错误码追成一条完整失败链
3.1 第一步:补可观测性,让链路显形
回到我实际的排查过程。我把全链路日志的框架搭起来之后,第一步是在每个关键节点打点。当时我在代码里加了一个简单的埋点函数,结构大概是下面这个样子:
import json import time import hashlib def trace_node(node_name, fn, request_id, **ctx): start_ts = time.time() status = "ok" error = None try: result = fn(**ctx) return result except Exception as e: status = "error" error = f"{type(e).__name__}: {str(e)[:200]}" raise finally: duration_ms = round((time.time() - start_ts) * 1000, 2) io_hash = hashlib.md5( f"{node_name}:{status}:{duration_ms}:{error}".encode() ).hexdigest()[:8] log_entry = { "event": "trace_node", "request_id": request_id, "node": node_name, "status": status, "duration_ms": duration_ms, "io_hash": io_hash, "error": error, } logger.info(json.dumps(log_entry, ensure_ascii=False))实际项目里可能要用 OpenTelemetry 这类成熟的链路追踪框架,但排查问题的临时场景下,这种轻量打点反而更快见效。关键在于:每个节点都要有唯一标识(request_id 贯穿始终)、有耗时、有状态、有错误摘要。
我加了十几个这样的打点,覆盖了请求接收、意图解析、任务规划、子 Agent 派发、工具调用、上下文组装、模型调用、结果聚合这几个核心环节。日志打出来之后,我第一次看到了这次失败请求的完整生命轨迹。
3.2 第二步:从末端往上游逐环回溯
拿到全链路日志后,我的排查策略是从末端往上游推。这个思路很简单:结果失败在最后,但根因一定在更早的某一步,所以从最后一步开始,一步一步往前看,每一步都问“这一步的输入是否正常”。
我当时的排查记录大概是这样的:
第一环,看主 Agent 的最后输出。失败码是AGENT_EXECUTION_TERMINATED,触发位置在主 Agent 的聚合阶段——它在等待某个子 Agent 返回时超时了。
第二环,追那个超时的子 Agent。日志显示这个子 Agent 的模型调用根本没能正常结束,finish_reason不是stop,而是被上层中断了。看起来像是子 Agent 内部进入了一个死循环式的重试。
第三环,进入子 Agent 内部看工具调用。果然,子 Agent 调用了外部搜索工具,工具返回了一个结构异常的结果——不是预期的数据列表,而是一个空数组,且没有附带任何错误标记。子 Agent 把这个空数组直接放进了上下文。
第四环,看外部搜索工具的实现。工具内部封装了一个 HTTPS 请求,异常处理逻辑写的是“捕获所有异常,返回空数组”。也就是说,无论网络失败、超时还是服务端返回 5xx,工具层都统一转成了“空列表”。
到了这一步,我已经有了大致的方向,但真正的“钉子”还没找到——为什么外部请求会失败。于是我用系统指令去查网络层的情况。
这里插一句,跟热词里提到的ssl_get_error错误码返回 1 相关。我查了工具调用日志里的异常详情,里面赫然写着SSL error code 1。SSL_get_error返回 1 通常意味着SSL_ERROR_SYSCALL,也就是 TLS 握手期间底层 socket 出现了 I/O 错误。这种错误最常见的诱因之一,就是对端服务器主动断开了连接,或者网络中间设备重置了连接。
我开始怀疑是部署环境的网络问题。用ss -tnp查了服务到外部 API 的连接状态,又用dmesg -T看了内核日志,最后还用tcpdump抓了一下访问外部搜索服务的包。结论清晰了:服务所在主机的出口网络在特定时间段内极不稳定,多个外部请求都出现了 TCP 连接重置。这就是整条失败链真正的起点。
3.3 第三步:根因定位与失败链完整还原
到这里,整条失败链终于完整浮出水面。我用文字把它还原出来:
- 外部搜索 API 在 TLS 握手阶段发生连接重置,
SSL_get_error返回 1。 - 工具层捕获异常后,没有区分错误类型,直接返回空列表。
- 子 Agent 拿到空列表,误判为“搜索成功但无相关内容”。
- 汇总 Agent 在后续规划中基于这个错误判断继续行动,最终超时。
- 主 Agent 等待超时后抛出粗错误码,任务终止。
- 外部表现:
AGENT_EXECUTION_TERMINATED。
注意看,这条链上真正的技术根因在第 1 步,而错误被“改头换面”发生在第 2 步,等到第 5 步抛出错误码时,信息已经丢了九成。更要命的是,整个过程中模型几乎没做错任何事——它只是基于被污染的信息做了一个“合理但错误”的下一步决策。这恰恰印证了标题那句话:别再把 Agent 失败都算给模型。
顺便说一句,这次排查让我意识到,很多“模型答得不对”的 case,追到后面其实都是链路污染问题。模型成了替罪羊。想避免这种情况,与其天天调 prompt,不如先把链路的可观测性做扎实。
3.4 第四步:修复方案与回归验证
根因找到之后,修复就不难了。我做了四件事:
第一,重写工具层的错误处理逻辑。所有外部依赖的调用结果,统一包装成结构化返回,明确区分“成功”“失败”“超时”“重试”四种状态。失败时不再返回空数组,而是返回一个带错误码和错误信息的对象。
第二,在子 Agent 的提示词里增加工具失败处理策略。当工具返回ok=false时,要求模型明确输出“工具调用失败,正在尝试替代方案”,而不是沉默地把失败当成成功。
第三,调整编排器的异常传播逻辑。子 Agent 的失败信息不再被吞掉,而是作为结构化结果继续向上传递,最终由主 Agent 决定是否重试、降级或直接向用户返回可解释的错误。
第四,给外部调用层加上熔断和指数退避重试机制。针对 TLS 握手失败这类瞬时网络故障,自动重试三次,间隔分别是 1 秒、2 秒、4 秒,避免一次偶发错误直接带崩整个任务。
修复上线后,我拿之前的失败请求做了回归。同一组用例,修复前错误率大约 13%,修复后降到了 1.2% 左右。剩下的零星失败也多与外部服务的独立故障有关,但不会再因为一次瞬时网络抖动就让整条任务链路终止。
这个结果让我非常感慨:真正的修复工作,和模型完全无关。
4. 以后怎么少踩坑:Agent 失败链排查的通用套路
4.1 先把可观测性基线建起来
一次排查让我彻底明白,Agent 系统里最贵的不是算力,而是“问题的可解释性”。不管你现在用的是哪个 Agent 框架,我都建议你先把可观测性基线建起来,至少要覆盖这些事件:
- 每个 LLM 调用的输入摘要、输出摘要、finish_reason、token 消耗。
- 每个工具调用的入参、返回结构、耗时、异常类型。
- 上下文组装后的 prompt 总长度、是否有截断、是否包含失败标记。
- 每次重试的原因、重试次数、最终状态。
- 编排器的状态转换记录:派发了什么、聚合了什么、哪里触发了异常分支。
- 请求级别的 trace_id,保证所有日志可以通过同一个 ID 串起来。
有了这些数据,排查 Agent 失败就从“盲猜”变成了“看链”,效率完全不是一个量级。我个人的经验是,如果第一天就把 trace 做好,后面能省下十倍的时间。
有一点要提醒:可观测性日志本身也可能造假。我之前踩过一个坑,就是往日志里打了“工具调用成功”,但成功标志是默认值,根本没有反映真实状态。所以埋点的时候,尽量直接记录原始返回值,而不要记录“格式化后的友好信息”——原始值才能还原现场。
4.2 设计一套不甩锅的错误码
除了可观测性,错误码本身也值得重新设计。我后来整理了一版错误码规范,核心是“错误码必须能回答三个问题”:在哪一环失败的、为什么失败的、能不能重试。
具体来说,一个合格的错误码或错误对象应该包含这些字段:
stage:失败发生的阶段,比如PLANNING、TOOL_CALL、LLM_GENERATION、AGGREGATION。node:失败发生的具体节点,比如某个子 Agent 的名字、某个工具的名字。root_cause_type:根因类型,比如TIMEOUT、EMPTY_RESULT、INVALID_SCHEMA、UPSTREAM_UNAVAILABLE、NETWORK。request_id/trace_id:关联整条链路。retryable:是否建议重试。original_error:原始错误信息,但要截断,避免日志被写爆。
这看起来是个小改动,但价值很大。它把“一个布尔级的失败”升级成了“一段可追溯的索引”。我这次排查的教训就是:如果错误码从一开始就带上stage=TOOL_CALL、node=search_tool、root_cause_type=NETWORK,可能十分钟就定位完了,根本不用折腾半天。
4.3 实用的系统排查指令清单
Agent 服务跑在 Linux 主机上时,排查看似在应用层,很多时候根子却在系统层。下面这几个指令是我排查过程中的高频工具,先列出来备用。
先看服务自身的状态。journalctl是我最常用的服务日志工具,排查 Agent 失败时我会这样用:
journalctl -u agent-service --since "10 min ago"再看端口和连接状态。如果怀疑外部依赖或者内部服务之间的网络问题,用ss查看连接状态,比netstat更直接:
ss -tnp | grep -E "agent|ESTAB|SYN-SENT"要看系统层面的网络异常,dmesg会有内核记录,排查 TCP 重置、丢包、链路层问题很有用:
dmesg -T | tail -50如果确认了方向怀疑是外部 API 的握手问题,可以用tcpdump抓包来看具体握手流程。生产环境抓包要注意脱敏,并且只抓访问目标 IP 的流量,不要把业务流量全抓下来:
tcpdump -i eth0 host <目标IP> and port 443 -c 100这一套下来,从应用日志到系统日志再到网络报文,基本能把问题范围从“Agent 失败”缩小到“哪个网络环节、什么协议层面的异常”。再往上报的时候,手里全是证据,不是猜测。
4.4 常见问题速查表
最后,把我这次排查中遇到过的典型问题整理成一个速查表,方便大家按图索骥:
| 现象 | 可能根因 | 排查方向 | 修复建议 |
|---|---|---|---|
| 模型返回空内容 | 上下文溢出、工具返回空、输出校验拦截 | 检查 prompt 长度、工具返回结构、校验层逻辑 | 拆分上下文、工具失败显式标记、放开合法输出 |
| 工具调用超时 | 外部 API 慢、网络抖动、DNS 解析慢 | 看链路耗时分布、ss看连接、dig测 DNS | 增加超时熔断、指数退避重试、超时阈值调优 |
| 外部 API 返回 422 | 请求体 schema 不匹配、上游接口变更 | 对比本地 schema 和线上 OpenAPI 文档 | 用代码生成请求模型、定期跑契约测试 |
| 子 Agent 执行被终止 | 编排器超时、子 Agent 死循环、异常被吞 | 查编排器超时配置、子 Agent 的循环次数日志 | 设置最大重试次数和单任务总时长上限 |
| 服务启动后 IP 冲突 | 多实例部署时端口或 IP 配置重复 | ip addr查本机地址、ss -lntp查监听端口 | 统一用容器网络分配、配置管理工具校验路由 |
| 工具返回空列表但无报错 | 异常被统一吞掉、错误分支只返回默认值 | 检查工具层异常捕获逻辑、打印原始异常 | 结构化返回、区分成功和失败分支 |
这张表不可能覆盖所有场景,但它给了你一个排查框架:现象和根因之间,永远隔着几个中间节点。不要停在现象表面,往前多问几步,答案就在链路上。
我个人的体会是,Agent 排查看似是个技术活,本质上是个“还原现场”的活。你越是能完整还原一条请求从进入到退出的全过程,越不容易被表象带偏。我也曾经把大量时间花在“咒骂模型不稳定”上,后来发现那段时间全白费了。
现在我再遇到 Agent 失败,第一件事永远是打开链路追踪,像读故事一样把每个节点的状态看一遍。第二件事是把错误码拆开,看它的 stage、node、root_cause_type。如果错误码本身信息量不够,我会去补埋点、补日志,而不是急着重试或换模型。
最后分享一个小习惯:我会在 Agent 外层包一个“失败指纹”模块,把每个节点的状态、错误类型、耗时拼成一个短字符串,比如TOOL_CALL|search_tool|NETWORK|3002ms,然后打印在日志首行。出现问题时直接看指纹,十次里有八次一眼就能定位方向,剩下的再进 tracing 系统慢慢查。这个习惯帮我省下了大量时间,希望你也能用上。