[控场AI]
· 6 分钟阅读· 3,210 字

Agent链路全绿却什么都没做:五种静默失败模式

Agent链路全绿却什么都没做:五种静默失败模式

最危险的 Agent Bug 不抛异常:失败被恢复逻辑吞掉,trace 全绿而任务空转。

文章通过一个真实案例展开:一个数据同步 Agent 报告完成了 1,400 条记录核对,trace 从头到尾全绿,实际上什么都没核对——某个工具连续失败三次,重试封装把第三次失败当作普通返回值交出去,Agent 在空载荷上写出了信心十足的总结。作者指出,Tracing 只在异常逃出 span 时才标记失败,而在 try 块内被处理掉的错误会让 span 保持默认的绿色状态。他归纳出五种可预测的静默失败:重试返回最后尝试、步数上限伪装完成、异常字符串化进上下文、解析器强制转换、模型静默降级。修复核心是把恢复与上报分离,靠评估环境中的运行形状断言来发现,而不是靠生产看板。

一个数据同步 Agent 报告称它在一个早晨完成了 1,400 条记录的核对。Trace 从头到尾全绿:没有异常,耗时在正常区间,每个 span 都干净地关闭了。但它实际上什么都没核对。其中一个工具连续失败了三次,包在它外面的重试封装把第三次失败的结果当作普通返回值交了出去,于是 Agent 在一份空载荷上写出了一段信心十足的执行总结。

据原文作者在 rss 平台分享的经历,这条链路里没有任何一环抛出错误。而这正是最值得反复提醒自己的点:最糟糕的 Agent Bug 不会抛异常。框架替你把失败接住了,这是它的职责,而接住这个动作本身,恰恰把失败埋掉了。

RSS 来源截图

被捕获的错误不等于被记录的错误

机制其实平淡到无聊。Tracing 会在异常逃出某个 span 的代码块时,把该 span 标记为失败。这部分是自动的,也是所有人依赖的行为。可一旦你把有风险的调用包进 try 并在块内部处理掉错误,就没有任何东西逃出来,也就没有任何东西被标记。span 保持默认状态——那个既非成功也非失败的状态——而几乎所有 trace 界面都把默认状态涂成绿色。

所以那个吞掉工具失败的重试封装,严格来说并没有撒谎。它只是从未记录下出过问题。基于 span 状态构建的错误率看板,会在事故上画出一条笔直的干净曲线。失败是真实的,信号在到达你之前就已经消失了。

这里涉及可观测性领域的一个基础区分:Span 状态与语义正确性是两回事。OpenTelemetry 等追踪规范里,span 的默认状态是 UNSET(未设置),只有在异常逃逸出该 span 的代码块、或代码显式调用 setStatus(ERROR) 时才会变成 ERROR。所谓"绿色"并不是框架主动判定"这次调用成功了",而只是"没有人来告诉我它失败了"。多数 trace 界面把 UNSET 渲染为绿色,是为了减少视觉噪音,却无意中把"未知"和"成功"画成了同一种颜色。

这也解释了为什么在 span 内部 catch 掉异常是危险动作:它同时抑制了两个独立信号——异常本身,以及本该随之而来的状态标记。要修复这一点,需要在 catch 块里主动重新标记,例如调用 span.recordException(e) 与 span.setStatus({ code: SpanStatusCode.ERROR }),再决定是否恢复执行。

五种可预测的静默失败

当作者开始追模式而不是追症状时,同样的几种恢复习惯每次都出现。每一种单独看都合理,每一种都删掉了你本可以用来定位问题的那条信号。

重试封装返回最后一次尝试

一个失败三次的调用变成了普通返回值,而尝试次数通常存放在 trace 看不到的地方。这是原文作者说自己最常踩的坑。

步数上限把半成品报告为完成

一次耗尽了步数预算的运行,返回的结构和真正跑完的运行一模一样。于是“它提前停了”和“它成功了”在下游看起来毫无区别。

异常被字符串化后塞进模型上下文

错误变成一行文本,模型把它当作数据来阅读和推理,运行继续推进,而你的监控读到的是健康状态。

这种做法之所以危险,根源于 LLM 上下文窗口的"无类型"特性。当异常对象被 str(e) 或 JSON.stringify 转成一段文本塞回对话历史时,它对模型来说与用户输入、检索到的文档没有任何区别——都是可以阅读、可以推理、可以继续基于它作答的普通 token 序列。模型并不知道这行文字代表"系统出故障了",它会像处理其他上下文一样,从中提取信息并接着往下走。

由此产生一个连锁后果:错误信息不仅没能触发告警,反而变成了模型的新增知识。有时模型甚至会基于这段错误文本编造出一段看似合理的解释,写进最终总结里。更稳妥的做法是把异常走结构化通道(如独立的 error 字段、退出码或返回对象类型),让它对下游代码可见,而不是混进模型可自由解读的自然语言流里。

输出解析器做强制转换而不是报错

一个格式错误的响应被填上默认值,下游代码无法区分某个字段是模型产出的、还是解析器凭空造出来的。

回退悄悄降级模型

主模型出问题时,一个更弱的模型静默接管,质量下滑看起来就像一个提示词问题,你白白花一天去改那些根本不是问题所在的 prompt。

修复思路:先记录,再恢复

作者并不主张框架应该停止捕获错误——让运行活下去正是它存在的理由。问题在于,恢复和上报是两件独立的工作,而上面几乎每一种默认做法都把二者合并成一件,然后丢掉了上报的那一半。

于是作者改的是默认值,而不是行为本身。封装照旧重试,但在最终失败返回任何东西之前,记录尝试次数并标记该 span。步数上限交回一个独立的结束原因,而不是一个成功形状的对象。工具调用外面的宽泛 catch 收窄成窄 catch,先记录异常再恢复。解析器跑在严格模式下,未知或缺失字段大声报错而不是被悄悄补上。回退发生时记录下真正作答的是哪个模型。这些都不会阻止 Agent 继续跑,它阻止的是 Agent 就自己做过什么撒谎。

靠评估发现,而不是靠看板

最终奏效的做法,是放弃“看板总有一天会暴露这个问题”的念头。如果输出是你唯一检查的东西,那么只要模型在坏结果上写出一段像模像样的总结,被吞掉的失败就能畅通无阻地走过去。检查输出是必要的,而且远远不够。

真正能抓住这些问题的,是对运行本身的形状做断言,而这只能在你事先知道运行本该长什么样子的地方做。也就是说,靠评估,而不是靠生产环境。三个检查承担了大部分工作:把实际步数和预期区间对比,撞上步数上限的运行会和四步就跑完的运行清晰分开;检查工具调用,被调用却没返回可用结果的工具、以及本该触发却从未触发的工具,都无处藏身;检查轨迹即动作序列,你能抓住重跑、跳步和循环,而这些是最终答案检查看不到的。

这里有一个真实的天花板,作者也很坦诚:如果框架已经把信号抹掉,没有任何下游检查能还原出原始错误。这些断言给你的是症状,响亮且及时,足以把你送回 trace 去诊断那个被吞掉的异常。作者追过的每一个静默失败都是同一种方式被发现的——某个人终于注意到一个奇怪的回答,从来不是测试,也从来不是告警,其中几个在被人发现时已经跑了好几个星期。运行形状检查的价值,就是让你不必再依赖那个人。

"运行形状断言"是评估驱动开发在 Agent 场景下的具体应用。它与传统的输出评估最大的区别在于:检查的对象不是 Agent 说了什么,而是 Agent 做了什么。这里的"形状"(shape)指的是单次运行的宏观结构性特征——调用了哪些工具、按什么顺序、各调用了几次、总步数落在哪个区间、是否触发了预期的分支。

这类断言之所以在评估环境中可行,而在生产环境监控中往往不可行,是因为做形状断言的前提是你事先知道正确运行长什么样。生产流量里任务千变万化,没有稳定的参照系;而评估集是由你自己构造的,每一道题都对应着一个预期执行路径。一旦把形状写进断言,任何偏离——多调了一次、少调了一次、步数越界——都会立刻失败。代价是评估集的维护成本,收益是把发现问题的时刻从"几周后有人偶然注意到"提前到了 CI 阶段。

该改掉的五个默认行为

如果只改五件事,就改这些。让重试封装记录尝试次数,并在最后一次失败时标记 span;给步数上限一个独立的结束原因,让预算耗尽永远不穿成功的形状;在工具调用周围做窄捕获,先记录异常再恢复;让解析器跑严格模式,缺失字段大声失败而不是被编造;回退静默接管时,记录真正作答的是哪个模型。

#AI Agent 静默失败#Agent trace 全绿#重试封装吞掉错误#Agent 可观测性#Agent 评估#span 状态#Agent 失败模式
分享:

相关推荐