当检查本身必须动手。
打开任何一个桌面智能体基准,你都会发现评分器不是在「读」那台机器,而是在操作它——OSWorld 的表格任务会通过 pyautogui 按下 Ctrl+S,等上半秒再去比对文件,而在这之前还要求一个标题为 MaturityDate.xlsx - LibreOffice Calc 的窗口以 strict: true 处于聚焦状态。在一台智能体把活干对了的机器上,上面每一步都可能失败。会动手的校验器有它自己的失败率,而那个失败率被报成了你智能体的失败率;解法不是写一个更好的检查,而是多出第三种结果。
步骤 1校验器分两类,而只有一类是被动的。
被动校验器读取已经存在的状态。它打开一个数据库、给一个文件算哈希、解析一段响应体——这些操作没有副作用,除了「产物不存在」之外基本没有自己的失败模式,而那本身就是一次正当的失败。单元测试是被动的,把模型的最终文本与标准答案比对也是。
主动校验器必须先改变系统,才能观察系统。它得聚焦一个窗口才能读到标题栏,得先把应用内存里的缓冲刷到磁盘上、那些字节才真正存在,得重跑一次查询、因为结果从未被持久化,或者得导航到设置页、才能看到某个开关是否被拨动过。一旦你的环境是一台真实的桌面、浏览器或手机,而不是一次函数调用,大多数有意思的检查就都成了主动的——因为应用把状态留在内存里,而文件系统还不知道。
下面是一个真实的例子,来自 OSWorld 的 LibreOffice Calc 任务集——一道要求智能体加一列 Maturity Date 的题。在任何比对发生之前,evaluator 块会先跑这些:
# OSWorld task 4172ea6e — the evaluator's postconfig, in order activate_window window_name: "MaturityDate.xlsx - LibreOffice Calc", strict: true sleep 0.5 execute python -c 'import pyautogui; pyautogui.hotkey("ctrl","s")' sleep 0.5 # only now: func compare_table expected cloud_file # fetched from a HuggingFace dataset repo result vm_file /home/user/MaturityDate.xlsx
在智能体的成果被看上一眼之前,有四件事必须顺利。窗口标题必须精确匹配——而智能体可能完全正当地重命名或另存了那个文件。保存对话框不能弹出来,否则那次击键落进了它里面。半秒必须够 LibreOffice 写完。标准文件必须下载成功。以上任何一条不成立,一个正确的智能体就在这道题上失败了。
步骤 2这个失败率之所以看不见,是因为它被记在了智能体那一栏。
这是让团队付出好几个月的那一部分。主动校验器的失败不会被记成基础设施错误,而是被记成任务失败——而智能体真的犯了错,产生的也正是同一样东西。一个 38% 的分数里装着三个群体:智能体做对的题、智能体做错的题,以及没人能对其发言的题;而第三类被无声地折进了第二类。
这个偏差的方向值得说准确,因为它不只是「分数偏低」这么简单。主动校验器的噪声与智能体的能力是反相关的,其方式恰好偏袒差的智能体、惩罚好的智能体。校验步骤只有在 harness 走到它那里时才会跑,而一个在第三步就崩掉的智能体根本到不了 postconfig——它干净地失败了,而且是因为一个真实的原因。真正把表格做完的那个智能体,才是暴露在 Ctrl+S 竞态、聚焦检查和那次下载之下的那一个。一套系统越接近把任务做完,它剩下的失败里属于评分器的那一份就越大。
规模在这里也有关系,因为每一步的风险会叠加。在 OSWorld 的任务文件里,setup 与 evaluator 两个阶段一共用了 358 次 execute、307 次 sleep、303 次 launch、293 次 download 和 200 次 activate_window,而 test_all.json 里只有 368 道题。这不是任务集一个偏僻的角落,这就是任务集本身。一个评分路径要发出几百次有时限的 GUI 操作和几百次网络抓取的基准,对它自己的可靠度有一条下限——而没有任何一个公开分数会告诉你那条下限在哪里。
评分路径里的两个固定 sleep 值,是一台「出题时那台机器」上校准出来的测量仪器。在更慢的 VM 上保存还没落盘;在更快的 VM 上某个应用已经把焦点抢回去了。这是「在 CI 里过了的任务集,在同事的硬件上一行代码没改就挂了」最常见的单一原因,也是为什么只按智能体采样算出来的方差预算会低估真实的离散程度。
步骤 3标准态自检:拿一台已知良好的机器去跑那个检查。
解法很便宜,而几乎没人把它做进去。对每道题,你需要一份按定义就是正确的终态记录——那份存好的 .xlsx、那个配置好的应用、那张提交了的表单——以及一种模式:把那个状态恢复出来,然后让校验器去跑它,整个过程里完全没有智能体。
校验器必须通过。如果它没通过,那就是这道题坏了;而你是在把任何事情归因到某个模型之前知道这件事的。
# one task, two runs, three outcomes run_agent(task) -> artifact_A restore(task.gold) -> artifact_G # the known-good end state verify(artifact_G) == FAIL -> INDETERMINATE # the check is broken; drop the task verify(artifact_G) == PASS -> trust the check, then: verify(artifact_A) == PASS -> PASS verify(artifact_A) == FAIL -> FAIL # now attributable to the agent # report all three. denominator = PASS + FAIL, never PASS + FAIL + INDETERMINATE.
有两个性质让这份工程投入值得。第一,它是一次按运行的检查,不是一次性的审计:标准态重放发生在同一个 VM 镜像上、同一天、同一张网络后面,与被打分的那次运行一样——于是它抓得住今早挂掉的那个 CDN,也抓得住上周改掉某个窗口标题的那次镜像更新。第二,它不需要人就能把责任定位下来。标准态重放失败的题会被从分母里拿掉、并作为任务集自身的一个 bug 立案——而这正是一套评测集如何保持为测量工具、而不是慢慢变成一个悲观常数的办法。
代价是每道题每次 sweep 多跑一次环境,这会让你最便宜的那次 sweep 的基础设施账单大致翻倍,而在一次昂贵的 sweep 上只是一个舍入误差。把它摊销掉:标准态重放不必在你对比的每个模型上都跑,每个环境镜像每天跑一次就够——因为它测的是环境,不是模型。请按预算对齐评估里对待采样的方式来记这笔账:它是「拿到一个你守得住的数字」的固定成本。
步骤 4三种结果,而不是两种——而第三种要写进报告。
大多数 harness 只有两种结果,原因是 reward 是一个浮点数,而「未知」没地方放。BrowserGym 的任务接口对此很直白:validate(page, chat_messages) 返回 (reward, done, message, info),而一个无法判定的校验器只有一种方式说出这件事,就是返回 0.0——看上去与一次失败一模一样。AndroidWorld 是同样的形状:is_successful(env) -> float。两者都不是设计得差;对训练来说,标量奖励就是对的接口。它只是对汇报来说是错的接口,而两者被混为一谈,是因为同一个函数同时服务两件事。
所以把第三种状态带到带外去——放进那个本来就有的 info 字典里——并让它出现在汇总里。一个守得住的智能体结果长这样:
Suite: osworld-verified Image: ubuntu-2026-10-14 Sweep: 3 seeds
attempted 368
indeterminate 17 # gold replay failed; excluded
scored 351
pass 134
fail 217
success rate 38.2% (134/351, 95% CI 33.1-43.5)
NOT 36.4% (134/368) — the number a two-outcome harness prints
在 4.6% 的不可判定率下,这两个百分数差了 1.8 个点——听起来还能忍,直到你去比较两套相隔一周、跑在不同镜像构建上的系统。那时它们之间被报出来的差距里,就包含了它们各自评分器的健康状况之差,而这个差没有任何理由是小的或稳定的。这与多轮评估里那个没被报出来的用户模拟器是同一个可识别性问题:测量里含着一套你并不打算评估的第二个系统,而不提它并不会让它消失,只会让它没有名字。
请把这件事与评分器博弈区分开。评测完整性问的是智能体能不能碰到评分器、并影响它。本页问的是:在一台智能体从未碰过的机器上,那个评分器究竟还能不能正常工作。两者要求的补救恰好相反——前者要隔离,后者要自检——而一套任务集完全可以在其中一头无懈可击、在另一头是坏的。
步骤 5哪些东西能搬到你自己的产品上——那里没有基准。
上面没有一条是桌面基准特有的;它们只是问题最容易被看清的地方,因为那些代码是公开的。一旦你自己对「完成」的定义需要一次动作才能观察,同样的结构就会出现。「那张发票过账了吗?」要靠查一个有延迟的读副本来回答。「那张工单升级了吗?」要靠一个 API 来回答,而它会在限流你的智能体之前先限流你的检查器。「那个文件写进去了吗?」要靠一个最终一致的存储层来回答,于是在智能体结束后 200 毫秒就跑的检查,量的是传播延迟、却把它叫作智能体失败。
有三条规则可以干净地搬过去:
- 先写检查器,再写智能体,并拿一次手工造出来的成功去测它。如果你没法手工做出那个正确的终态、并让你的检查器确认它,那你手上还没有一个「完成」的定义——你手上只有一个期望。这是搭一套智能体评估时回报最高的半天,而它几乎总被跳过,因为检查器感觉像测试基建、而不像产品。
- 宁可要那个被动的读,哪怕它更弱。读一份只追加的审计日志,表达力不如重跑一次智能体的查询——而这份损失是值得的:它没有副作用、没有焦点、没有时序,也没有「不同时也是真实失败」的失败模式。凡是你能安排智能体自己的动作留下一份持久产物的地方,你就把一个主动校验器换成了被动的——这是智能体评估里最便宜的那份可靠性收益。
- 绝不要让一次不可判定的运行无声地变成一次失败。把它作为独立的一类发出来,并对它的比率设告警。不可判定率上升,是你的环境发生漂移最早的信号,而它会比任何人注意到「它将被误认成的那次质量回归」早好几周到来——而这恰恰是失败分类法存在的意义:防住那次误归因。
步骤 6诚实的取舍。
主动校验不是一个该被设计掉的错误。有些任务真的无法在不触碰机器的情况下打分,而拒绝写那些检查,就意味着拒绝去测量智能体真正被派去做的工作——那比带噪声地测量它更糟。错的不是那个主动的检查,错的是那份把它的失败藏进智能体失败里的两结果报告。
没人回答好的那个开放问题是:一次不可判定的运行,相对一次做错的运行,该怎么定价。一套丢掉 5% 任务(因评分器失败)的题集,和一套把它们全都打了分、带着 5% 评分器噪声的题集,给出的数字不同,而前者更诚实、后者统计功效更好。公开的智能体基准大多两者都不做;它们在所有尝试过的任务上报一个比率,然后留给你去假定评分器是完美的。在这一点改变之前,请为你读到的任何桌面或浏览器智能体数字预设几个点的评分器噪声下限,并把小于这个幅度的差距当作未决——这与读懂基准所要求的纪律是同一条,只是用在了比平常低一层的地方。
这周就做:取你题集里失败率最高的十道题,手工构造出每一道的正确终态,然后拿你的校验器去跑它。每一个在手工造出的成功上失败的校验器,都代表一道你一直当成模型缺陷在打分的题。然后给你的 harness 加上 INDETERMINATE 这一类结果、把它从分母里排除,并在每份报告里把它的比率放在成功率旁边。如果只做一件,就做第一件——在大多数题集里,它第一个下午就能找出东西。