1. 为什么“模型不行”往往是排查中最偷懒的结论
做 Agent 开发这两年,我参与过不少线上故障的复盘。一个反复出现的现象是:只要 Agent 的行为不符合预期——任务中断、工具调用失败、回复驴唇不对马嘴——第一反应几乎都是“模型能力不够”“换个更强的模型试试”。这个结论之所以流行,是因为它最省事:不用看日志、不用追链路、不用理解编排层到底发生了什么,把锅甩给模型就结束了。
但真实情况是,绝大多数 Agent 失败压根不是模型推理能力的问题。模型在整条链路里只是其中一个环节,它前面有输入组装、上下文裁剪、工具描述注入,后面有输出解析、工具执行、结果回填、状态机推进。任何一环出问题,最终表现都可能是“Agent 答错了”或者“Agent 卡住了”。你看到的失败现象和真正的失败原因之间,隔着一整条失败链。
这篇文章想聊的就是这件事:怎么从最表层、最粗糙的错误码出发,一层层把失败链拆开,定位到真正的故障点。核心关键词是Agent、错误码、失败链、排查、PROVIDER_UNAVAILABLE。我会用一个贯穿全文的案例——线上频繁出现PROVIDER_UNAVAILABLE报错——来演示整套排查思路。适合正在做 Agent 开发、被线上问题折磨、或者正准备给 Agent 加监控的同学参考。哪怕你刚接触 Agent 不久,只要跟着这条链走一遍,也能建立起“不轻易甩锅给模型”的排查直觉。
先说结论:PROVIDER_UNAVAILABLE这个错误码,字面意思是“服务提供方不可用”,但它背后可能是网络抖动、可能是上游限流、可能是密钥过期、可能是请求体超长被拒、也可能是编排层自己拼错了参数。如果你只盯着这个错误码去重试,大概率会一直重试一直失败。真正要做的,是把它当成失败链的入口,而不是终点。
2. 从粗错误码到失败链:先搞清楚错误码是怎么被“造”出来的
2.1 错误码的三个来源层级
很多人对错误码有个误解,以为它是某个权威标准统一定义的。实际上在 Agent 系统里,错误码至少来自三个不同的层级,每一层的语义和可信度都不一样。
第一层是底层传输层。比如 HTTP 状态码、TCP 连接错误、TLS 握手失败。这一层的错误码相对标准化,502、503、504、连接超时这些含义比较明确。但问题是,很多 Agent 框架会把这一层的错误统一包装成一个自定义错误码,把细节吞掉了。
第二层是模型服务提供方(Provider)。不同的模型服务商有自己的错误码体系,有的用 HTTP 状态码,有的在响应体里塞一个业务错误码,有的干脆返回 200 但 body 里写着错误信息。这一层是最容易产生歧义的,因为同一个“不可用”,可能是限流、可能是余额不足、可能是模型下线。
第三层是Agent 编排框架自己。框架为了统一处理,往往会定义一套自己的错误码枚举,比如PROVIDER_UNAVAILABLE、TOOL_EXECUTION_FAILED、CONTEXT_OVERFLOW。这一层错误码的好处是统一,坏处是丢失了原始信息——如果框架没有把底层错误透传出来,你看到的就只是一个笼统的标签。
提示:排查任何 Agent 故障前,先确认你看到的错误码是哪一层抛出来的。如果是框架自定义的,第一件事就是去找框架有没有保留原始错误(raw error / cause / underlying error)。
2.2 为什么框架喜欢“吞掉”原始错误
这不是框架作者偷懒,而是有现实考量。原始错误往往包含大量底层细节:具体的 URL、请求头、内部 IP、堆栈信息。直接暴露给上层业务代码,一是安全风险,二是业务代码没法处理这么多分支。所以框架做了一层归一化,把几十种底层异常映射成几个业务错误码。
问题在于,归一化做得好不好,直接决定了排查难度。做得好的框架,会在自定义错误码之外,附带一个cause字段或者details字段,保留原始错误。做得不好的,就只剩一个PROVIDER_UNAVAILABLE,你完全不知道是网络问题还是鉴权问题。
我踩过的一个坑是:某次线上大量PROVIDER_UNAVAILABLE,团队花了两天怀疑是模型服务商挂了,最后发现是框架在组装请求时,把某个可选参数拼成了null字符串,导致上游直接拒绝。如果当时框架把原始响应透传出来,五分钟就能定位。
2.3 失败链的通用结构
把上面这些串起来,一条典型的 Agent 失败链长这样:
用户请求 → 编排层组装上下文 → 注入工具描述与历史消息 → 调用 Provider 接口 → Provider 返回(成功/失败) → 编排层解析响应 → 执行工具调用 → 回填工具结果 → 再次调用 Provider → 生成最终回复任何一个箭头处都可能断掉,而断掉之后向上抛出的错误码,往往只反映了最靠近抛出点的那一层。PROVIDER_UNAVAILABLE出现在“调用 Provider 接口”这一步,但它可能是前面上下文组装就埋下的雷,也可能是后面解析逻辑误判导致的。
所以排查的核心方法论就一句话:不要相信错误码的字面意思,要沿着失败链把每一环的证据都拿到手。
3. 拆解 PROVIDER_UNAVAILABLE:五种典型成因与区分方法
3.1 成因一:真实的网络层不可达
这是最符合字面意思的情况。Agent 服务到 Provider 服务之间的网络确实断了,或者 DNS 解析失败,或者 TLS 握手超时。这种情况下,底层通常会伴随连接超时、connection refused、no such host之类的原始错误。
区分方法很简单:看原始错误里有没有网络层的痕迹。如果有,基本可以确认。这时候要排查的是网络配置、DNS、出口带宽、目标服务是否真的宕机。
但要注意,网络层不可达在容器化环境里经常是间歇性的。我遇到过一种情况:Agent 跑在容器里,Provider 走的是内网域名,平时都正常,但每到整点批量任务触发时就会零星报PROVIDER_UNAVAILABLE。最后定位是 DNS 缓存在高并发下失效,容器内的 resolver 配置有问题。这种问题只看错误码永远看不出来,必须结合时间分布和并发量分析。
3.2 成因二:上游限流或配额耗尽
Provider 侧对请求频率、并发数、token 消耗都有配额。一旦触发限流,Provider 会返回429或者类似的限流错误。如果框架把429也归一化成了PROVIDER_UNAVAILABLE,你就会误以为是服务挂了,实际上是自己在疯狂打请求。
这种情况的典型特征是:错误集中在特定时间段,且和请求量正相关。比如白天业务高峰期错误率飙升,凌晨几乎为零。或者某个批量任务一启动,错误就来了。
排查时重点看两个指标:单位时间内的请求数,以及 Provider 返回的限流相关 header(很多 Provider 会在响应头里告诉你剩余配额和重置时间)。如果框架没透传这些 header,就得去 Provider 的控制台看用量。
3.3 成因三:鉴权失败被错误归类
密钥过期、密钥权限不足、密钥被禁用,这些鉴权问题在底层通常是401或403。但有些框架的归一化逻辑写得粗糙,把所有非 2xx 都归成了PROVIDER_UNAVAILABLE。于是你看到的是“服务不可用”,实际是“你没资格用”。
这类问题的特征是:错误率 100%,且从某个时间点开始突然出现。因为鉴权是全局的,一旦密钥失效,所有请求都会失败,不会时好时坏。
我印象很深的一次:某团队凌晨做了一次密钥轮换,新密钥少配了一个 scope,结果第二天早上所有 Agent 请求全挂,报的就是PROVIDER_UNAVAILABLE。大家一开始以为是 Provider 故障,直到有人去看了原始响应,发现是403。
3.4 成因四:请求体本身有问题
请求体超长、格式不合法、包含 Provider 不支持的字段,这些都会导致 Provider 拒绝请求。底层可能是400,也可能是413。同样,如果框架归一化做得不好,也会变成PROVIDER_UNAVAILABLE。
这类问题的特征是:和具体请求内容相关,不是所有请求都失败。比如某些长对话会失败,短对话正常;或者某些带特定工具描述的请求会失败。
排查方法是把失败请求的完整 body 抓出来,和成功请求对比。重点看:token 数是否超限、是否有非法字符、工具描述 schema 是否符合 Provider 要求。
3.5 成因五:框架自身的组装 bug
这是最隐蔽的一种。Provider 其实好好的,网络也通,鉴权也没问题,但框架在组装请求时出了 bug——比如把某个字段序列化错了、把超时时间设成了 0、把并发连接池耗尽了。结果请求根本没发出去,或者发出去就是坏的,框架捕获到异常后统一报PROVIDER_UNAVAILABLE。
这类问题的特征是:错误码和实际原因完全对不上,且往往和框架版本、配置相关。升级框架或改配置后错误消失,就是典型信号。
区分这五种成因,最有效的办法是建立一张对照表,把错误码、原始错误、时间分布、影响范围、请求特征这几个维度组合起来看。
| 成因 | 原始错误特征 | 时间分布 | 影响范围 | 请求特征 |
|---|---|---|---|---|
| 网络不可达 | 连接超时/refused | 间歇或持续 | 可能部分节点 | 无规律 |
| 限流配额 | 429/限流 header | 与请求量正相关 | 高峰期集中 | 高频请求 |
| 鉴权失败 | 401/403 | 某时间点后持续 | 全局 100% | 所有请求 |
| 请求体问题 | 400/413 | 与内容相关 | 特定请求 | 长/特殊请求 |
| 框架 bug | 无底层错误或异常堆栈 | 与版本/配置相关 | 视 bug 而定 | 视 bug 而定 |
注意:这张表是排查的起点,不是终点。真实故障经常是多种成因叠加,比如限流触发了重试风暴,重试风暴又耗尽了连接池,最后表现成网络不可达。所以一定要按时间线把事件串起来看。
4. 实操:一次完整的失败链排查过程
4.1 现场:错误率突然从 0.5% 飙到 18%
假设我们面对这样一个线上场景:某个 Agent 服务,平时PROVIDER_UNAVAILABLE错误率稳定在 0.5% 左右(属于正常抖动),某天下午两点突然飙到 18%,持续了四十分钟后回落。用户反馈是“Agent 有时候不回消息”。
第一步不是急着改代码,而是固定现场。把这段时间的日志、指标、trace 全部拉出来,尤其是失败请求的完整链路。很多团队一上来就重启服务,结果现场没了,只能靠猜。
4.2 第一步:确认错误码来源和原始错误
先看日志里PROVIDER_UNAVAILABLE是怎么抛出来的。找到抛出点的代码,确认它捕获了什么异常。如果框架支持,打开 debug 日志,把原始错误打出来。
这一步的关键是回答:这个错误码背后,到底有没有原始错误?如果没有,说明框架把信息吞了,得先去改框架的日志级别或者加埋点。如果有,直接看原始错误是什么。
在我们的假设场景里,打开 debug 日志后发现,原始错误是context deadline exceeded,也就是超时。这就排除了鉴权和请求体问题,方向转向网络和限流。
4.3 第二步:看时间分布和并发量
把错误发生的时间段和请求量曲线叠在一起看。如果错误率和 QPS 高度相关,那大概率是限流或者资源耗尽。如果错误率在 QPS 平稳时也高,那更可能是网络或 Provider 侧问题。
我们的场景里,错误率飙升的时间段,QPS 确实比平时高了 3 倍——因为刚好有个批量任务在跑。但奇怪的是,QPS 高的时候错误率也不是线性上升,而是有个陡峭的拐点。这个拐点提示我们:可能触碰到了某个硬性上限。
4.4 第三步:检查连接池和超时配置
超时错误最常见的两个原因:连接池不够用,或者超时时间设得太短。
先看连接池。Agent 到 Provider 的连接池如果设得太小,高并发时请求会排队,排队超过超时时间就报context deadline exceeded。我们检查配置发现,连接池最大连接数是 50,而峰值并发请求是 200。这就是典型的池子太小。
再看超时时间。如果 Provider 在高峰期响应变慢,而客户端超时设得比较激进(比如 5 秒),也会大量超时。我们查了 Provider 侧的 P99 延迟,高峰期确实从 2 秒涨到了 6 秒,而客户端超时是 5 秒。两个因素叠加,错误率就爆了。
4.5 第四步:验证假设
定位到“连接池过小 + 超时过短”后,不要直接改生产配置。先在预发环境复现:把连接池调到 50,超时设成 5 秒,用压测工具打到 200 并发,看是否复现context deadline exceeded。复现成功后,把连接池调到 200、超时调到 15 秒,再压测,确认错误消失。
这个验证过程很重要,因为线上故障往往是多因一果,你以为是 A,实际是 A+B。只有通过控制变量复现,才能确认根因。
4.6 第五步:修复与回归
确认根因后,修复分两部分:止血和根治。
止血是立刻调整生产配置:连接池扩大、超时放宽。但要注意,超时放宽会增加慢请求的堆积,所以要配合连接池一起调。根治则是从架构上解决:比如给批量任务做限流,避免它和在线请求抢资源;比如给 Provider 调用加熔断,避免雪崩;比如把超时时间做成动态配置,根据 Provider 的实际延迟自适应。
回归验证时,重点看三个指标:错误率是否回到基线、P99 延迟是否可接受、连接池利用率是否健康。如果错误率降了但延迟涨了,说明只是把问题从超时转移到了排队,还得继续调。
5. 常见问题速查与避坑清单
5.1 排查时最容易犯的三个错
第一个错是只看错误码不看原始错误。前面反复强调过,PROVIDER_UNAVAILABLE是个筐,什么都往里装。不看原始错误就动手,等于蒙眼开车。
第二个错是重启了事。重启能解决一部分问题(比如连接池泄漏),但会把现场清掉。如果问题会复现,重启只是拖延;如果问题不复现,你永远不知道根因,下次还会踩。
第三个错是只盯一个环节。Agent 失败链很长,Provider 调用只是其中一环。有时候 Provider 调用失败,是因为前面的上下文组装把请求撑爆了;有时候 Provider 调用成功,但后面的解析逻辑把结果丢了,表现也是“Agent 失败”。所以排查要有全局视角。
5.2 一张速查表
| 现象 | 优先排查方向 | 关键动作 |
|---|---|---|
| 错误率 100% 且突然出现 | 鉴权、配置变更 | 查密钥有效期、查最近发布 |
| 错误率与 QPS 正相关 | 限流、连接池、超时 | 查配额、查池大小、查延迟 |
| 错误率间歇性抖动 | 网络、DNS | 查网络监控、查 resolver 配置 |
| 特定请求失败 | 请求体、工具 schema | 抓完整 body 对比 |
| 升级后开始失败 | 框架 bug、兼容性 | 回滚、查 changelog |
5.3 几个实操心得
心得一:给错误码加维度。不要只上报一个PROVIDER_UNAVAILABLE,至少带上 Provider 名称、原始错误类型、请求耗时、重试次数。这些维度在排查时能省大量时间。
心得二:保留失败请求的完整快照。不是所有请求都存,但失败请求一定要存完整 body 和响应。存储成本可控,排查价值极高。
心得三:重试要带退避和上限。很多PROVIDER_UNAVAILABLE是重试风暴自己造成的。无脑重试会把限流问题放大成雪崩。指数退避加最大重试次数是基本要求。
心得四:区分“可重试”和“不可重试”错误。网络抖动可以重试,鉴权失败重试一万次也没用。在错误码归一化的时候就要打好这个标记,否则重试逻辑会帮倒忙。
6. 从失败链反推 Agent 的可观测性建设
6.1 日志、指标、Trace 三件套怎么落地
排查能力的天花板,取决于可观测性建设。Agent 系统的可观测性,核心是三样东西:日志、指标、Trace。
日志要解决“发生了什么”。关键是结构化,每条日志带上 trace_id、span_id、错误码、原始错误、关键参数。不要打一堆自由文本,那样搜都搜不了。
指标要解决“趋势如何”。核心指标包括:请求量、错误率、P50/P99 延迟、重试次数、连接池利用率、token 消耗。这些指标要能按 Provider、按工具、按用户维度下钻。
Trace 要解决“链路哪里慢/断”。Agent 一次请求可能涉及多次 Provider 调用和多次工具执行,Trace 能把它们串成一棵树,一眼看出哪一环耗时最长、哪一环报错。
6.2 错误码体系的设计建议
如果让我重新设计一套 Agent 错误码,我会遵循三个原则。
原则一:分层但不丢信息。业务层用统一错误码方便处理,但必须附带原始错误。可以用code+cause的结构,code是归一化的,cause是原始的。
原则二:可重试性显式标记。每个错误码明确标注是否可重试,重试逻辑直接读这个标记,不靠猜。
原则三:错误码要稳定。不要频繁改错误码含义,否则监控告警和历史数据都会乱。新增场景就新增错误码,不要复用。
6.3 告警怎么设才不扰民
告警设得太敏感,天天响,大家就麻木了;设得太迟钝,真出事发现不了。我的经验是分两级:基线告警和突增告警。
基线告警针对绝对阈值,比如错误率超过 5% 持续 5 分钟。这能抓住持续性故障。
突增告警针对变化率,比如错误率在 10 分钟内从 0.5% 涨到 5%。这能抓住突发故障,哪怕绝对值还没到阈值。
两级告警配合,既能抓住慢性的、也能抓住急性的。同时一定要设告警收敛,同一个根因引发的多个告警合并成一条,否则一次故障能把你手机震没电。
7. 我个人的一点体会
排查 Agent 故障这件事,技术含量其实不在“会不会用某个工具”,而在“愿不愿意往下挖”。PROVIDER_UNAVAILABLE这种错误码,最大的危害不是它本身,而是它给了人一个偷懒的借口——看到“不可用”就以为是别人的问题,看到“模型答错”就以为是模型的问题。
我自己的习惯是,每次遇到 Agent 失败,先问三个问题:这个错误码是谁抛的?它背后有没有原始错误?这个错误在失败链的哪一环?把这三个问题回答清楚,八成的问题都能定位。剩下两成,才是真正需要深入框架源码或者 Provider 文档的硬骨头。
最后分享一个小技巧:给 Agent 的每一次失败都生成一个“失败指纹”——把错误码、原始错误类型、调用栈关键帧、请求特征哈希组合起来。相同指纹的失败归为一类,这样你面对一万条错误日志时,看到的不是一万个问题,而是可能只有三五个问题。这个思路在排查大规模故障时特别管用,能让你从噪音里快速找到信号。