Published on

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

Authors
  • avatar
    Name
    hpoenixf
    Twitter

做 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

光看时间线,很容易脑补出一个故事:

第一次回答被拒了,模型根据反馈修了一次,然后成功提交并交付。

这个故事听起来完全合理。

但真正去问证据,会发现里面至少有三个跳跃:

  1. 后一个模型回合真的看到了前一次拒绝反馈吗?
  2. 后面提交的 block 真的是在修前面被拒的那个 block 吗?
  3. 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。

反馈进入上下文,也不是模型理解了反馈。

听起来都是在给结论加限制。

但实际排障时,这些限制反而很省时间。

因为一个准确的“不知道”,通常比一个错误但完整的故事更接近真正的下一步。