- Published on
别再把 Agent 失败都算给模型:一次从粗错误码到失败链的排查
- Authors

- Name
- hpoenixf
做 Agent 一段时间以后,我越来越不相信简单的一个错误码。
尤其是这种:PROVIDER_UNAVAILABLE
它看起来很明确:模型服务不可用。
于是大模型很自然地会做几件事:
- 重试;
- 换模型;
- 调大 timeout;
- 查 provider 状态;
- 再跑一遍。
但后来我们遇到过一种很尴尬的情况:
模型请求其实已经正常结束了。
HTTP 是 200,流也走完了。
真正失败的地方,是后面的发布校验。
也就是说,我们一直在重试一个没有坏的东西。
这件事让我意识到,Agent 里“没拿到答案”只是结果,不是原因。
同一个现象,可能坏在:
请求根本没发出去
模型服务失败
流中途断了
结构化输出解析失败
工具调用失败
发布校验拒绝
持久化失败
内容没有真正交付
如果这些最后都被压成一句 PROVIDER_UNAVAILABLE,错误码虽然简单,排障反而会越来越盲。
这篇文章就是这次改造的复盘。
不是教系统自动找“根因”,而是先做一件更实际的事:
至少先把“最后确定卡在哪一站”说清楚。
最开始的问题,不是日志太少,而是日志没有关系
Agent 其实从来不缺日志。
模型调用有日志。
工具有日志。
发布有日志。
数据库有日志。
前端流也有日志。
问题是,这些日志经常是散的。
一次请求失败后,能看到很多事情发生过:
14:03:01 model request started
14:03:07 provider 200
14:03:08 candidate parsed
14:03:08 publication rejected
14:03:09 another model turn started
14:03:15 block committed
14:03:15 stream closed
光看时间线,很容易脑补出一个故事:
第一次回答被拒了,模型根据反馈修了一次,然后成功提交并交付。
这个故事听起来完全合理。
但真正去问证据,会发现里面至少有三个跳跃:
- 后一个模型回合真的看到了前一次拒绝反馈吗?
- 后面提交的 block 真的是在修前面被拒的那个 block 吗?
- block committed 之后,用户真的拿到了它吗?
时间上前后相邻,不代表它们有因果关系。
这就是我们后来真正想解决的问题:
不只是记录“发生了什么”,还要记录“哪些事实可以被连起来”。
第一个改动:先别急着找根因,先找最后一个确定阻塞点
一开始很容易把这个字段叫 rootCause 或者 primaryFailure。
后来没有这么做。
因为大多数时候,我们根本没有资格说“这是根因”。
例如:
provider 返回 200
→ JSON 解析成功
→ publication validation 拒绝
→ 最终没有内容交付
这时我们能确定的是:
当前这条链最后明确阻止完整交付的阶段,是 publication validation。
但为什么发布校验会拒绝?
可能是模型输出有问题。
可能是规则太严。
可能是上游给错数据。
也可能是某个版本之间的协议不一致。
这些都需要继续调查。
所以我们最后更愿意用:
terminalBlocker
而不是:
rootCause
这个命名差别其实挺重要。
terminalBlocker 只承诺一件事:
从当前已经确认的运行事实看,任务最后明确停在这里。
它不假装知道更深层的原因。
例如:
{
"terminalBlocker": "publication_validation",
"delivery": "none"
}
这个信息已经足够把排障范围缩小很多。
至少这一次,不应该第一反应去换模型 provider。
HTTP 200 以后,失败才刚刚开始有可能发生
做普通 API 时,很容易对 HTTP 状态形成肌肉记忆。
200,成功。
4xx,调用问题。
5xx,服务问题。
但 Agent 的 200 只能说明其中一段路走通了。
后面可能还有:
provider response
→ stream assemble
→ JSON parse
→ schema validation
→ runtime orchestration
→ tool execution
→ publication validation
→ publication commit
→ delivery
所以后来我们把失败按阶段记录。
不是为了造一套特别漂亮的分类标准,而是为了避免这种情况:
output schema 不合法
→ 记成 provider unavailable
业务 deadline 到期
→ 记成 provider timeout
用户主动取消
→ 记成 provider timeout
publication validation 拒绝
→ 还是 provider unavailable
一旦都落到 provider,上游就成了垃圾桶。
最典型的是 timeout。
以前一个 deadline 很难知道是谁的 deadline。
后来我们补了一个很小的事实:
timeoutOwner =
provider
| business
| user
| unknown
它由真正执行 abort 的那一层记录。
不是让模型推断。
也不是看到“差不多超时了”,再根据时间邻近猜。
这个字段本身一点都不聪明,但排障价值很高。
因为“provider 自己超时”和“我们的业务总时限到了”,修法完全不同。
一条诊断记录,不需要保存答案正文
做到这里以后还有一个顾虑:
如果为了排障,把用户问题、模型正文、工具结果全部复制进诊断系统,最后很可能得到另一个更麻烦的系统。
所以我们后来刻意把诊断和内容分开。
诊断记录大概只需要这种东西:
{
"runId": "...",
"timeline": [
{
"stage": "provider_dispatch",
"status": "ok"
},
{
"stage": "response_parse",
"status": "ok"
},
{
"stage": "publication_validation",
"status": "rejected",
"reasonCodes": ["..."]
}
],
"summary": {
"terminalBlocker": "publication_validation",
"delivery": "none"
}
}
它回答的是:
- 哪一阶段成功;
- 哪一阶段失败;
- 哪些原因码是允许记录的;
- 最终有没有交付;
- 哪些事实仍然未知。
它不需要知道正文是什么。
这也是我后来比较认同的一条原则:
可诊断,不等于把所有原始数据都存下来。
很多排障问题靠身份、阶段和状态已经足够定位。
正文只有在非常受限的临时调查窗口里才可能有必要,而且它应该和长期诊断记录分开。
第二个误判:A 被拒了,后来 C 成功了,不代表 A 被修好了
这一类问题比粗错误码更容易骗人。
假设时间线是:
A rejected
→ 模型继续运行
→ C committed
人很容易读成:
A rejected
→ 模型修复 A
→ 修复后的结果成功提交
但中间缺了一条最关键的关系:
C 到底是不是 A 的后继修订?
如果没有这个关系,C 可能只是另一个独立内容块。
也可能模型直接放弃 A,转去回答别的部分。
甚至可能 C 在 A 被拒之前就已经存在,只是提交发生得晚。
所以:
A rejected → C committed
只能证明顺序。
不能证明修复。
后来我们要求,想说“A 被修复”,至少要有明确对象关系。
概念上类似:
A revision 1 rejected
→ feedback consumed by model turn 3
→ A revision 2 proposed
→ A revision 2 validated
→ A revision 2 committed
→ A revision 2 delivered
这里每一步都不是另一件事的同义词。
特别是:
后一个模型回合发生了,不等于它看过反馈。
如果我们真的想证明反馈进入了下一轮模型输入,就得有明确的消费关系。
例如:
{
"actionId": "...",
"proposedByModelTurnId": "turn-2",
"consumedByModelTurnId": "turn-3"
}
更严格一点,还会检查后一轮真正使用的 transcript,是否绑定了前面的那份反馈。
这样做听起来有点较真。
但如果没有它,所谓“self-repair 成功率”很容易只是根据时间顺序脑补出来的数字。
“模型看到了反馈”也不等于“模型理解了反馈”
这又是一个需要克制的地方。
就算我们证明:
前一次拒绝反馈确实进入了下一轮模型请求。
也只能说明:
模型有机会看到它。
不能继续推导:
模型理解了原因。
更不能直接推导:
后一个答案就是根据反馈正确修出来的。
这和前一篇里讲“输入完整不等于回答正确”很像。
系统能证明的事实,最好停在系统真正能观察的位置。
例如:
反馈进入 request:可以证明
后一版本和 A 有 revision 关系:可以证明
后一版本通过校验:可以证明
后一版本已提交:可以证明
后一版本已发送:可以证明到对应层级
模型是否真正理解:不能从这些事实直接推出
这种边界有点烦,但很重要。
否则诊断系统会从“记录事实”,慢慢长成“替模型解释心理活动”。
第三个误判:commit 不等于 delivered
这个问题在做流式 Agent 时特别容易发生。
一个答案 block 成功写入数据库,通常是很强的成功信号。
但它仍然不是“用户已经收到”。
至少有几个阶段:
validated
→ committed
→ projected / emitted
→ client received
→ rendered
具体产品可能没有这么多层,但意思一样。
所以如果我们只有:
publication committed
最好只说:
内容已提交。
不要写:
内容已交付。
更不要写:
用户已经看到了。
如果服务端还有真实发送记录,可以进一步说:
服务端已发送。
如果客户端还有 ACK、sequence 或渲染回执,再继续往后证明。
这是我这几篇复盘里反复碰到的一个共同问题:
系统总喜欢把“下一步很可能发生”写成“下一步已经发生”。
诊断系统尤其不能这么做。
因为它本来就是用来纠正这种乐观推断的。
后来我们把一次失败看成一条“有界调查链”
最后形成的东西,其实没有最开始想象得那么复杂。
对一次失败,我们希望能回答:
1. 请求有没有真正派发?
2. provider 有没有返回?
3. 返回有没有通过解析?
4. Runtime 有没有正确执行?
5. 工具有没有实际完成?
6. 发布有没有通过?
7. 内容有没有提交?
8. 内容有没有进入交付链?
9. 哪些关系是明确证明的?
10. 哪些地方只能写 unknown?
最终 summary 只需要保留少量信息:
{
"terminalBlocker": "publication_validation",
"contributingFailures": [
"provider_dispatch:http_permanent"
],
"delivery": "none",
"classificationConfidence": "partial"
}
这里 partial 也很重要。
如果逐块交付身份还没有接通,就不要因为总计数看起来一致,写成 confirmed。
比如:
summary: delivered = 3
receipt count = 3
数字一样,不代表三个 summary 对象和三个 receipt 是一一对应的。
也可能:
A B C expected
A B B receipts
总数还是 3。
所以真正要确认交付,最终还是得落到对象身份和修订身份。
这和前一篇的数据完整性其实是同一个问题:
aggregate count 很有用,但它不能替代 identity。
诊断不能为了诊断,把业务链路搞坏
做到这里还有一个很现实的问题。
日志和 tracing 经常被当成“旁路”。
于是很容易写成:
await writeDiagnosticEvent()
await executeTool()
或者:
const result = await executeTool()
await writeDiagnosticEvent()
return result
如果诊断存储变慢,它就开始挡住真正的业务执行。
这次 review 里,我们确实发现另一条 lifecycle 日志路径存在这种风险:等待日志 start 可能拖住工具派发,等待 settle 可能拖住 wrapper 返回。
这不代表线上已经发生了数据库锁竞争。
但它至少说明:
“这是观测代码,所以不会影响业务”不是事实,只是一种愿望。
对于真正的 side-channel 诊断,应该专门验证一件事:
diagnostics off
diagnostics on
diagnostic sink slow
diagnostic sink failed
这几种情况下,业务终态是不是一致。
只有测过,才有资格说“非干扰”。
而且要限定范围。
某一条 diagnostics projection 被证明非干扰,不等于整个项目所有日志 writer 都自动获得这个性质。
这也是我后来很警惕的一类措辞:
observability is non-blocking
最好改成:
这条具体路径,在这些测试条件下没有改变业务 trace。
后一句虽然没那么漂亮,但更接近事实。
有些 unknown,比错误归因更有价值
这一轮改造里一个挺反直觉的结果是:
诊断系统增加以后,unknown 反而变多了。
例如:
delivery = unknown
repairOutcome = unknown
usage = unknown
cost = unknown
以前这些地方可能会被默认填成:
0
false
failed
或者根据上下文猜一个最像的状态。
现在更愿意保留 unknown。
因为:
没采集到成本,不代表成本是 0。
没看到交付收据,不代表一定没交付。
后续有成功内容,不代表前一次修复成功。
没有 provider availability 的肯定证据,也不能随便叫 provider unavailable。
诊断系统真正有价值的地方,不是让所有问题都有答案。
而是把:
知道
不知道
推测
这三件事分开。
如果一个诊断系统最后几乎没有 unknown,我反而会有点怀疑它是不是替我们脑补了太多。
三个我现在会特别警惕的“顺手推断”
这次做完以后,我给自己留下了三个非常简单的检查。
1. HTTP 200 → 成功?
不一定。
它最多证明 provider 那一跳按对应协议完成。
后面仍然可能解析失败、工具失败、发布失败、交付失败。
2. 后面成功了 → 前面的问题修好了?
不一定。
要看是不是同一个对象、同一个修订链,以及反馈是否真的进入下一轮。
时间顺序不是因果关系。
3. commit 了 → 用户收到了?
不一定。
commit、server emit、client receive、render,是不同事实。
有什么证据,就说到哪一层。
这三条其实没什么高深的。
但 Agent 链路一长,人特别容易把它们连成一个顺滑故事。
而诊断系统的工作,恰恰是阻止我们讲得太顺。
这次做完以后,什么算已经证明,什么还没有
在受控 fixture 和 provider-free 验证里,我们已经能验证一些比较确定的机制:
- 失败可以按阶段分类,而不是全部归到 provider;
terminalBlocker可以由确定性事实归约;- timeout 可以保留 owner,而不是统一写成 provider timeout;
- 后一模型回合是否消费前一反馈,可以通过明确身份关系验证;
- commit 和 delivery 可以分开记录;
- 诊断 projection 可以只保存安全元数据,不保存正文;
- 部分诊断路径在受控测试里可以做到 sink 失败不改业务终态。
但也还有几件事情不能说完成:
- 另一条 native tool lifecycle 日志路径的完整非干扰集成验证还没补齐;
- summary 和逐 block、逐 revision 的真实交付 identity 还没有完全闭环;
- PostgreSQL 真机重启和迁移场景没有在这轮环境里完整验证;
- 真实 provider、真实 Langfuse endpoint 没有在这组验证中运行;
- 真实 usage / cost delta 没有数据;
- 所谓“修复恢复率”还不能从 fixture 关系直接推出。
这些东西我不想放进正文做成一大张验收表。
但对外分享时还是应该明确写出来。
因为“诊断机制写完了”和“整套生产诊断已经验证完了”,差得很远。
写在最后
以前遇到 Agent 不回答,我经常先看模型。
现在顺序有点变了。
我会先问:
请求到底发出去了吗?
provider 到底成功了吗?
解析过了吗?
工具执行了吗?
发布为什么被拒?
提交了吗?
发出去了吗?
我们是在看事实,还是在根据顺序讲故事?
模型当然会失败。
但 Runtime、协议、工具、发布和交付同样会失败。
如果最后所有问题都叫“模型不行”,不仅模型背了锅,工程上也失去了修复方向。
所以这次诊断改造最后留下来的原则,我觉得可以压成一句:
不要急着自动找根因,先把每一跳真实发生了什么记录清楚。
terminalBlocker 不是根因分析。
timeline 也不是因果证明。
commit 不是 delivered。
反馈进入上下文,也不是模型理解了反馈。
听起来都是在给结论加限制。
但实际排障时,这些限制反而很省时间。
因为一个准确的“不知道”,通常比一个错误但完整的故事更接近真正的下一步。