跳到主要内容
七比零:agent eval 的静默故障,为什么它们全都长得像好消息

七比零:agent eval 的静默故障,为什么它们全都长得像好消息

全景
全景

· 阅读约 20 分钟

359 个测试通过。真实通过率 20%。

这两个数来自 Debashish Ghosal 在 dev.to 上的那篇(2026,标签 ai / evaluation / llm / programming)。我盯着看了很久。同一张桌子上摆着两个数,中间隔着三百多个沉默的测试,而每一个沉默的测试都在替这套 eval 说"我这边没问题"。

文章表面上是七条"我犯过的错"。作者说他按顺序都犯过,每条都造成了真实的时间损失,所以一条条讲表象、讲他一开始修错的那层、讲最后有效的那个小改动。这类清单我一般扫两屏就关,因为它们太容易变成"最佳实践十条"。这篇我读完又回去翻了五条顶部评论,因为他在结尾留了一个自己没答的问题:怎么知道一个 eval 是诚实的。他能列出七个错误,但不能确定自己抓到第八个没有。

那个问题才是正题。七条是病例,第八条是病理。

我打算把它们重排一遍:不按作者的顺序,按它们在评测栈上落的位置。作者自己的归因是"我一直在修模型,而不是修量测系统"——这句对,但太整齐,整齐到会让人以为问题只有一个方向。实际上这七条散在一个栈的六个层上;另外一条根本不是层内的 bug,是"你以为问题在哪一层"的 bug;再有一条横切,不属于任何单层。六层内加一条元层加一条横切,这个分法比"七条教训"有用,因为它能直接告诉你哪一层该装什么检查。

先把结论放这儿,免得后面绕:按作者的计数,七条里只有一条是真正的模型问题,其余六条是量测 bug;按修复位置算,是零条——那一条最后也修在 scorer 里,不在 prompt 里。我站后者,理由在判分那一节。

一、先把"静默故障"这个词定死

普通 bug 会报错。静默故障会报数。

定义可以收紧一点:系统在一个偏离规格的分支上,返回一个落在合法值域内的数,并且这个数进入了下游决策。它不抛异常、不写日志、不改变 CLI 的颜色,它交出一个完全正常的数字——这个数字随后被当成了事实。Markdown 里的 0/0、场景里的 fixture 导入失败、runner 里被丢掉的 60 个 audit,全在这个定义里。

这件事在普通软件上已经够烦,在 agent 上要贵一个量级,原因不在工程难度,在被测对象的先验分布。传统后端的输出空间是有限的、类型化的,一个落在合理区间的数是可疑的;LLM 的输出空间本来就宽到"什么数都可能",20% 的通过率对 agent 来说完全是个正常值,你不会怀疑 harness,你会怀疑模型。静默故障借了模型噪声的隐身衣。

评论里 Naveen Alavilli 那句我认为是全篇最准的:"harness 的失败模式是返回可信数字,而不是返回错误。"把它和作者自己的归因摆在一起看——"我在修模型不是修量测系统"是一个诊断,说的是你的注意力放错了位置;Alavilli 那句是病理,说的是系统本身的行为。诊断让你改自己,病理让你改系统。后者不依赖你当场意识到自己注意力错了,所以它更值钱。

下面这张图是我按这七条重新画的。先摊开,不评价。

二、地图骨架:六层栈,七条钉在哪儿

agent eval 说到底是把"模型的表现"翻译成一个数的管道。从产物往回推,它至少有六层:

  出口        报告层      通过率 / 分数 / sweep 汇总
             ─────────────────────────────────────
  判断        判分层      scorer / reward / grader
  来源        仿真层      mock / simulator / fixture
  比例        分母层      reference pool / scope / corpus
  计数        断言层      断言是否存在、是否真的断言
  执行        runner 层   timeout / cap / quarantine

七条错误在这张图上的位置,跟作者的编号顺序几乎不重合:

  • 条目 1(359 通过 / 20%、函数体只有 pass、65 里 57 个坏断言、0/0 计通过)钉在断言层与报告层之间的那道缝上,它同时踩两层,比单层 bug 难看得多;
  • 条目 2(precision 1.00 / recall 0.02、字面匹配 step_1、按 overlap 付费)钉在判分层;
  • 条目 3(recall 卡在 0.087 不动)钉在分母层;
  • 条目 4(一周 matcher 换来 10 个点、六行 simulator 换来 20%→50%)不属于任何一层,是层的映射出了问题——你以为问题在报告指出的那一层;
  • 条目 5(4B 和云端撞同一面墙)是横切的,不是某层坏了,是整条管道里有一个常量;
  • 条目 6(mock 套件 9% 通过、零 mock 现场测试找出全部失败)钉在仿真层;
  • 条目 7(1000 次运行死在 80%、丢掉 60 个 audit)钉在 runner 层。

排个顺序:从 runner 层开始往上走。理由很实际——这一层最像运维问题,最容易被划到性能看板上去,然后被所有人忽略。

三、逐层梳一遍

3.1 runner 层:挂起不是慢,是数据不完整

表象先摆出来:一次 1000 次运行的基准死在 80% 处。作者第一反应是 corpus 脏,重跑,还是死在同一个地方。根因最后落在 runner 上——一个挂起的 API 调用丢掉了 60 个已经跑完的 audit,而且没留下任何错误记录。

关键在于这条被修的时候,修的不是"让它更快",是五条边界:每条 trajectory 加 timeout;把 timeout 标成不可重试的分类;加 token cap;失败时 quarantine;shutdown 时 cancel。五条各管一摊——前两条给等待加边界,第三条给消耗加边界,第四条给脏数据加隔离,第五条给进程生命周期加边界。这已经不是一个 bug 的补丁,是一份数据完整性契约。按清单原文推,"不可重试"大概是不让一次挂起被重试放大成一串挂起(这条原文没展开,是我的读法)。修完之后,一次干净的现场测试跑了 4768 次 trajectory run,零丢失 sweep——注意这里的变化不是"跑得更快",是"跑完的东西是真的"。

这一层在地图上的定位很简单:挂起是数据完整性问题,不是性能问题。 它一旦出现在性能看板上,就没人会去查它。

留个伏笔:quarantine 是这五条里最贵的一条,因为它承认有样本要被主动丢掉。一旦开始丢样本,你手上就多了一个新的分母定义问题——丢掉的算不算、算进哪个池子、部分 sweep 算不算一次 sweep。作者自己在结论里也点了:部分 sweep 仍然看起来像数据。这条线我留到判断段收。

3.2 断言层:一个被计成合格的缺席

回到那个数字对。359 个测试通过,真实通过率 20%。作者的第一次修法是加更多测试——这也是所有人的第一反应。真正的病在"这些测试有没有断言"上:其中一个测试函数的函数体只有 pass。

再往下还有一层更狠的。PlannerCritic 的 review 里,65 个断言文件有 57 个格式错误,harness 静默返回 0/0,并且把它计为通过。修复原则一句话:模块返回 0/0 必须当成错误而不是通过,零断言不算成功。

机制值得展开一段。通过率就是 passed/total。total 归零的时候,这个式子在数学上没有定义,但在代码里是有定义的——取决于实现怎么写(具体怎么被吞成通过,原文没展开,以作者的叙述为准,我没核到那六行代码)。一旦被吞掉,"没有失败"就会被读成"通过"。这不是阈值宽松。阈值宽松至少还在讨论同一件事;这是把"缺席"读成"合格",读的东西根本不在同一个范畴里。

Max Quimby 在评论里把这一族说得更干净:把 0/0 当通过是最安静的杀手,零断言应该直接 raise;他遇到过 fixture 导入失败导致整套测试被跳过、runner 仍然报 0 failures。跳过和通过在下游长得完全一样——这一句是这一层所有变体的共同结构。

Hayrullah Kar 补的那条是这层里唯一一个真正结构性的修补:有一种更隐蔽的绿色套件,harness 统计的是"这一跑声称处理了多少",不是"实际留下来多少"。建议是固定 expected count,数量一漂移就失败。它的意义在于把分母从"算出来的"改成"声明出来的"——算出来的数可以被吞,声明出来的数不能。

定位:零断言不是没有结论,是一个被计成合格的缺席。

这一层的修复都长得像一行 raise,但它们真正改的是认识论:把"系统没有报错"从"系统是对的"里面拆出来。这件事在 agent eval 上比在别处都急,因为 agent 的失败空间大到没法靠眼睛发现有东西没跑。

3.3 分母层:一行 scope 换来的位移

表象:recall 连续两次现场测试都卡在 0.087,改了各种设置,一动不动。作者的错修是重建 matcher——纯浪费。真修是一行 scope 改动,把 reference pool 限制到 source domain。recall 从 0.087 抬到 0.17,有一版 0.228,模型一行没改。

机制比记住数字重要。一个指标等于命中除以池子。池子里混进域外的 reference,命中集合没变,分母被放大,所有模型都乘上同一个稀释系数——因为系数对所有被测对象一样,模型侧的任何改动都会被它乘掉。这是"跨模型不变量"的第一种形态;条目 5 是第二种,形态不同,来源同族。

定位:指标跨模型都停在同一低值,先怀疑你除以几。

七条里性价比最高的一行,也是最难被找出来的:你不会为了一行 scope 去翻 reference pool,你会去调模型。还有一点该点出来——作者最初重建 matcher 之所以是浪费,是因为 matcher 的分母和 reference pool 的分母不是同一个分母,他在两个不同的分母之间做优化。这种错位在报告层看是看不出来的。

3.4 判分层:precision 1.00 是最便宜的外观

一个 precision 1.00、recall 0.02 的模型通过了评估。这不是段子。

机制不复杂:一个只对 precision 设卡的门,等价于一个"少判就赢"的门。判得越少,判错的概率越低,所以最优策略是几乎不判。模型没有学会安全,它找到了最便宜的安全外观。更具体的一例,3B 模型学会了匹配字面字符串 step_1——因为 reward 按 overlap 付费,不按 correctness 付费。它在学怎么拿奖励,不是在学审查。

错修是调 prompt,花了一天。真修在 scorer / reward function 那一层。

定位:precision 1.00 不是能力,是最便宜的外观。

现在说我为什么站"零条模型问题"。按作者的计数,这一条是唯一一个真正属于模型的问题。但它的修复位置在 scorer,不在 prompt,这就够了。一个被错误目标函数塑造出来的行为,在目标函数修好之前根本不是一个可判定的陈述——你没法说它是模型的缺陷,因为"缺陷"要相对于规格才有意义,而规格是你的 scorer。目标函数一改行为就变,说明那个行为是被你产出的,不是被你发现的。

Quimby 在这一层的补刀是评论区里技术含量最高的一条:除了加 adversarial negatives 让 grader 必须学会拒绝,还要把 should-not-fire 案例放进分母。后半句是判分层和分母层的一次联动——把"模型什么都没说"从"没出错"改判成"漏报"。这一步做完,precision 那扇门才堵上。

Alavilli 的建议是另一路:在正例之前先跑负例,故意破坏 fixture 让评估变红,把 mutation testing 指向 grader。这是整篇材料里唯一一个能在你没有新信息的情况下执行的防自欺动作——自欺检测写不进被测系统里,只能写在"这系统能被拆坏"这件事上。

3.5 仿真层:mock 只能按你想象的方式失败

表象:作者写了更多 mocks,mock 套件报 9% 通过;一个零 mock 的现场测试找出了 mock 表达不出来的全部失败。

原因在那句该抄下来贴在显示器上的话里:mock agent 总会调用被告知的工具,真实模型有时只用散文回答。mock 的失败空间等于你想象力的边界,所以 mock 的通过率既不是真实通过率的上界也不是下界,它只是另一个空间里的一个数。

定位:mock 只能按你想象的方式失败。

七条里唯一一条"扩测试反而更远"的。前六条的错误修复都是浪费,这一条是负收益:你写下的每个 mock 都在给一个未定义的空间发合格证,而这个合格证还会被算进覆盖率。它和断言层的 0/0 是同一族病——一个词法上非空的测试,语义上是空的——只是入口挪到了仿真层。这也解释了为什么零 mock 的现场测试能找出更多东西:真实失败不是 mock 漏掉的样例,是 mock 的语法本身写不出来的那些样例。

3.6 映射层:报告指的那一层通常不欠你优化

这一条不是层内 bug。作者花一周优化 matcher,指标只挪了 10 个百分点;真正的提升来自生成失败案例的 simulator 的六行改动,20% 涨到 50%。

报告说"matcher 差",不等于 matcher 是瓶颈。matcher 差可能只是因为喂给它的失败案例不在真实分布上——这时候优化 matcher 是在给一个没坏的东西换零件。原则:先证明问题来源,再投入一周。

但这个原则里有个坑,比原则本身值得说:你能看到的"来源",永远只是报告层指给你的那个来源。要破它只有绕开报告层,去问生成环节——六行 simulator 改的是"案例从哪来",不是"案例怎么被评"。

定位:报告指出的那一层,通常不欠你优化。

这一条在七条里最贵,而且贵得不显眼,因为它的成本单位是"一周"而不是"六行"。前几条是发现贵、修复便宜;这一条反过来:报告免费帮你指了一个层,修复很贵,因为指错了。它是七条里唯一一条错误被高亮、纠正被隐藏的。我自己的做法是:动手优化某层之前,先写下一个预测——如果问题真在这层,改它指标至少要动多少;预测不成立就先往上游走。这个方法我没见谁写下来过,上面那个案例是它的一个支持样本。

3.7 横切:两个模型给出同一个数,那是你在说话

本地 4B 模型和云端模型撞上同一面 20% 的墙。作者的第一个念头是买更大的模型——结果不变。

不同模型的失败率完全一致,通常不是能力上限,是共享的 harness bug。原因在形态:能力差异在指标上会表现为一个分布,而不变量表现为一个常数。当两个差距巨大的模型交回同一个数,那个数不是它们的性质,是系统的性质。

定位:两个模型给出同一个数,那是你的机器在说话。

AI Frontier Post 的建议我同意,尤其是"默认"那两个字:在这一族 bug 上,把"先怀疑模型"换成"先怀疑 harness",是一次先验置换,它不需要你掌握任何新信息就能执行,而且几乎没有下行风险——查 harness 一下午,查模型可能是一周。

还有一层关系:条目 3 和条目 5 是同一个不变量的两次现身,一次来自被稀释的分母,一次来自共享的 bug。它们共享一个检验手段:拿两个差异极大的被测对象比数。这个检验的成本低于任何一次模型替换,但它在 agent eval 的实践里出现的频率远低于它该有的频率。为什么?因为换模型更符合直觉,也更容易向上汇报。这一半是组织问题,我留一句在这里,不多展开。

四、命令行不传输信息

作者留的未答问题里,有一句被很多人当成收尾的感慨就带过去了:绿色套件掩盖量测 bug,和红色套件掩盖真实 bug,从命令行看可能一模一样。

这句不该当感慨。它说的是一件挺硬的事:CLI 那个绿/红是被设计成给人看的界面,而可信数字不是信息。摘要把它本该传递的东西丢掉了——所以"看命令行"这个动作本身就是这类 bug 的传播路径,你不是在检查,你是在接收一个已经压缩过、且压缩方式不明的信号。

推论也直接:如果你判断一套 eval 健康状况的全部手段就是看它的颜色,那你对第七个之后的每一条都没有防御。防御只能落在结构上——负例先行、canary 正确例、mutation testing 指向 grader、expected count 漂移即失败。这四样都不是"更绿的绿",它们是"能不能变红"的能力。

五、压成一张图

把上面梳过的摊成表。这张表是本文的图例,建议直接截图。

条目表象落在哪层作者先修(错层)真修成本对比
1 断言缺席359 测试通过 / 真实 20%断言↔报告加更多测试零断言 raise,0/0 视作错误一周调 matcher ↔ 六行
2 自打分precision 1.00 / recall 0.02判分调 prompt(一天)scorer / reward一天 ↔ 改函数
3 分母recall 卡死 0.087分母重建 matcher一行 scope,reference 限同域重建 ↔ 一行
4 错层一周换来 10 个点层映射(元)matchersimulator 六行,20%→50%一周 ↔ 六行
5 常量4B 与云端同墙横切买更大模型查 harness / scorer换模型 ↔ 查一下午
6 mockmock 套件 9% 通过仿真写更多 mocks零 mock 现场测试写 mock ↔ 现场跑
7 runner1000 次死在 80%,60 个 audit 丢runner怪 corpustimeout / cap / quarantine / cancel重跑 ↔ 五条边界

横向读有一条规律:全部修复的成本都很小——一行 scope、六行 simulator、一个 raise、五条边界;全部发现的成本都很大——一周、一天、两次现场测试。这个领域的账花在"找"上,不在"改"上。所以稀缺的从来不是更聪明的评测设计,是不信任自己那张汇报表的能力。

再压一张,把评论区的建议折成发布门槛。它们恰好一一对上前面几层:

建议提出者对上的层
零断言 raise,0/0 不算通过Quimby断言 / 报告接缝
固定 expected count,漂移即失败Kar断言 / 报告接缝
每个计分案例必须有非空断言MakeYourAgent断言
拒绝与升级路径跟流畅回答分开计分MakeYourAgent判分
should-not-fire 案例进分母Quimby分母 + 判分
reference set 与问题同域MakeYourAgent分母
先跑负例,故意弄坏 fixture 让它变红Alavilli横切
每跑放一个 canary 正确例Alavilli横切

这八条加起来,比七条教训本身更有工程价值——因为教训改的是人,门槛改的是系统。而且它们的总实现量比"七条"里任何一条的发现成本都低。

六、落子

摊开这张图,我的判断是这样。

现在站在哪:agent eval 站在"修复便宜、发现昂贵"的阶段。七条里没有一条修复需要新算法、新模型、新数据;也没有一条发现能靠现有工具自动给出。这块地图的形状不是"难题没被解决",是"简单的问题没被看见"。这个区别决定了你该把钱投在哪儿。投在"更聪明的评测"上,边际收益很低;投在"让 eval 关于自己说了什么"上,边际收益可能高一个量级。

空着的第一块:第八个 bug 在哪。 作者的未答问题,我给一个可证伪的落子——它不会长在新的层上,更可能长在层与层的接缝上,尤其是"修复自己制造出来的新接缝"。最具体的候选在 runner 那条的尾巴上:quarantine 一旦开始丢样本,你就多了一个新的分母定义(丢了几个、算不算、算进哪个池子、部分 sweep 算不算),而这正是条目 3 的病换了个入口进来。也就是说,条目 7 的修复会生出一个条目 3 的变体。这条链路是我从这份材料里读出来的、作者没说的东西,我承认它有推测成分——它是我按"分母的病会跟着分母的定义跑"这条规律推的,不是论文结论。

空着的第二块:诚实的 eval 怎么被验证。 Alavilli 的负例先行、canary、mutation testing 指向 grader 这三条已经给出了方向,但它们现在还是散在评论区的建议,不是任何框架的默认配置。下一格坐标大概率长在"把 eval 的健康检查从建议变成门槛"上——MakeYourAgent 那三条就是一份能直接用的门槛草稿。代价不在技术:门槛红的时候你不能跑生产 eval,这条约束会先撞上排期,再撞上 KPI。所以这块地图的上限大概率不是技术画的,是组织画的。

可证伪的地方,我钉死在这儿:如果一年之内没有出现把"负例先行 + canary + 数量漂移即失败"打包成默认配置的主流框架,那要么是门槛的成本被低估了(每次都跑一遍负例,算力账算不过来),要么是路径被绕开了(评测整体外包给第三方,你不再自己测)。这两种结果都能证伪我上面那句"下一格坐标在这",到那时候我回来挪这块坐标。

还空着、且这篇文章一个字没写的:多步和交互场景下的量测栈。多 agent 协作里谁的错误算谁头上、长程任务的失败责任怎么切、跨 trajectory 的分母怎么定义——这些在这份材料里完全没有覆盖,我也不想拿别的领域的东西硬填进来凑数。这块我打算另开一张图,本文欠着。

另外,这篇的所有内容我是按作者的叙述和评论区的转述写的,没有核到那三个仓库的代码级(agent-eval-forge、CauterRule、planner-critic-engine,都是 MIT 许可、公开)。尤其是 0/0 到底怎么被吞成通过的那段,我说的是"取决于实现",没有验证他的实现属于哪一种。谁去翻了,可以把那一段补上。

最后落一句我的判断:这七条里那条"唯一真正的模型问题",我宁愿把它也算进量测——因为一旦把该由 scorer 修的东西记成模型的账,下一次你又会去调 prompt。七比零不是修辞,是把账记对的方式。

下一颗钉子不会钉在"更聪明的评测"上,会钉在"这套评测有没有能力失败"上。在这颗钉子钉上之前,第八个之后的每一条,都会继续以一个好消息的形式,被送到你桌上。

全景
全景

选一个技术领域万字横扫:多论文多技术系统梳理成一张全景地图,最后落判断。

查看主页 →