返回文章

确定性评测:同一份代码,两次评测必须给同一个答案

这是 Harness 系列的 skill 拆解篇,拆的这一份叫 deterministic-eval。它在评测阶段 里当评判者的工作纪律:凡是能算出答案的检查,就不许靠"看"得出结论。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记 》讲整套系统的形状,这个系列逐份拆 skill:为什么有它,具体怎么干。我用的系统是开源的 frontend-harness-cli ,文中所有规则都能在仓库里找到对应实现。

周一 88 分,周二 71 分

先看一个具体的场景。

同一个功能,代码一行没动。周一让 AI 评测一遍:88 分,结论写得有理有据——结构清晰,边界处理得当,只剩几个小问题。周二换一个新上下文再评一遍:71 分。这次它盯上的是完全不同的几处:命名不一致、某个错误分支没处理、测试覆盖不够。

两次评测都没有撒谎,每条意见单独看都成立。但你拿这两个分数做不了任何决定。88 和 71 之间差的那 17 分,差的不是代码,是两次评测时模型的注意力落在了哪里。这把尺子量出来的其实是评测者当时的状态,不是代码的状态。

一把每次量都给不同读数的尺子,读数精确到小数点后两位也量不出任何东西。分数只要不可复现,它就不是测量,是占星——看着像数字,本质是随机的。

deterministic-eval 要消灭的就是这种评测。

这一份 skill 换掉的是什么

skill 的第一句话是这么写的:

AI 评审给 AI 的产出打分,天生偏向放行:它看见一个真问题,会自己说服自己「大概没事」,然后给过。

评测篇讲过位置问题:写代码的人不能给自己打分,所以要换一个全新上下文的评判者。但换人只解决了一半。新评判者依然是个模型,它"通读代码、下判断"这个动作本身还是软的:同一段代码,这次它注意到 A 处,下次注意到 B 处;这次觉得某个取舍可以接受,下次觉得不行。

这份 skill 换掉的不是人,是得出结论的方式:把判断换成测量。它的原话是——每条检查都是一个命令或脚本,结果只有二值,且带 file:line 出处。

它用在四个场合:接受 AI 生成的代码进分支或 PR 之前;一轮生成/修复循环之后,判断这轮是不是真的结束了;当评审意见只剩下"我看没问题"的时候;给新项目或新功能定验收标准的时候。四个场合的共同点是:结论要拿来做决定——合不合、停不停、过不过。做决定的结论,必须先能重跑。

第一步:清单先写死

流程的第一步不是跑检查,是在看结果之前,把检查清单写下来。

清单上是这样的条目:类型检查通过、lint 通过;禁用模式不存在——比如 any、被 @ts-ignore 压制掉的类型错误;必需模式必须存在——比如每个存储 key 都带命名空间、每条用户可见文案都已外置。每一条都必须写明确切的命令或查询,不许写"检查一下有没有偷懒的写法"这种句子。

顺序和粒度都不是形式主义,各有各的道理。

先写,是为了让标准不能随结果移动。先看到代码再定检查,定检查的人(或模型)会不自觉地把标准定在代码恰好能过的位置——考完试再出考卷,考卷必然满分(这个病在验收标准那篇 里讲过)。清单写在看代码之前,它对代码没有迁就的机会。

写到命令级,是为了让检查可以原样重跑。一条 grep 命令、一条 tsc 命令,今天跑和明天跑、这个上下文跑和那个上下文跑,问的是同一个问题。换成"看看类型有没有问题",每跑一次都是一次新的理解——随机性就是从这里漏进来的。

跑,不要读

清单定死之后,第二步是执行:跑,不要读。

逐条执行检查,拿的是硬结果:编译器和 linter 的退出码;grep/AST 查询某个模式在不在;测试套件真实的通过/失败计数。产出是原始结果,不是印象。

配套的证据纪律和评测篇是同一条,只是落到了每条检查的粒度:每条标"通过"的检查,都要记录证据在哪——命中的 file:line、执行的命令及其退出码。没有出处的检查不许标通过。 出处的作用不只是留痕:它让任何第三方能拿着同一条命令、同一个位置,把这条检查原样再跑一遍,当场对账。

这一步还点名了一种假通过:报告"测试通过",而那次运行其实只收集了测试没有执行、用的是上次的缓存结果、或者悄悄跳过了失败的套件。这三种都产生一个绿色的结论和一个没被量过的现实。所以"测试通过"这四个字,必须能指回一次真实运行的计数——跑了多少、过了多少、失败多少。

为什么对"读"这么不信任?skill 里有一句反模式说得很直白:读一遍 diff,因为代码风格看起来像模像样就写 LGTM——看起来可信,恰恰是生成代码最擅长的事。 读,读到的是可信度;跑,跑到的才是事实。

查不了的,标“未验证”

不是每条检查都能在每个环境里跑。有的需要浏览器,有的需要凭据,有的需要起真实服务。清单里难免有几条在当前环境跑不了。

这份 skill 对这种情况的规定是:标**「未验证」**——永远不许标通过,也不许悄悄从清单里删掉。最后产出一份明确的未覆盖清单,把没量到的部分摆在明面上。

这一条对可复现同样关键。分数要可比,两次评测的覆盖面必须一致:周一清单 20 条,周二只剩 17 条,那消失的 3 条本身就能制造分数差异,而且没人会注意到差异从哪来。标"未验证",每条检查就永远在账上,三种状态——通过、失败、未验证——口径固定。分数描述的是"量到的那部分怎么样",没量到的部分单独列出来,不许它混进分数里冒充地基。

只报告,不许顺手改

报告的纪律有三条,一条比一条反直觉。

只报告,不动手。 产出报告,然后停手。不许顺手改你刚评过的代码,哪怕是"一眼就 obvious 的一行修复"。原因在评测篇说过:报告必须描述检查当时那份代码的样子;你一边改一边报,最后那个分数不属于任何一版真实存在的代码。

失败不许软化。 截止时间近了,把一条失败的检查改口成"警告"——不许。skill 的原话是:如果检查本身错了,就公开改清单,不要弯曲结果。改清单是光明正大的版本变更,下一次评测全体按新清单跑;改口是私下交易,只有这一次的分数受益,代价是所有分数从此不可比。

失败要路由回去,而且排好序。 报告完,把每条失败连同证据交给负责修的人——生成者或人类——并且按依赖顺序排列:编译错误排在风格问题之前。先修地基,再修墙面;顺序反了,修墙面的人会在塌着的地基上白干。

这份 skill 的完成判据就是这四条落在纸面上:有一份报告,每条检查都标为通过、失败或未验证;每条通过都附 file:line 或命令退出码;评测过程中被评的代码没有被修改过;失败按修复顺序排列并附证据。

为什么“能重跑”才算数

把上面的规则收拢,核心其实只有一个词:可复现。它值这么多规矩,是因为分数的全部用途都建立在它上面。

归因。 一次评测 71 分,修了一轮,再评 88 分——如果评测本身不可复现,你根本不知道这 17 分是代码变好了,还是评测者这次没注意到老问题。涨跌不可归因,分数就不能用来判断"这轮修复有没有用",生成/修复循环也就没有可靠的停止条件。

比较。 清单固定、命令固定、三值口径固定之后,两次评测之间只剩一个变量:代码本身。这时跨功能比才成立——A 功能 92 分、B 功能 85 分,先修 B;跨时间比才成立——重构之后从 78 涨到 90,说明重构真的改善了什么。不可复现的分数之间做减法,减出来的是噪声。

对账。 每条通过都带命令和出处,意味着结论可以被第三方复核,而不是只能信任评测者的自述。这和实现阶段验真提交是同一种思想:自述是主张,证据才是事实。

最后剩一个诚实的问题:不是所有事都能算。空状态文案有没有说清下一步、页面布局合不合设计意图——这类事情没有退出码,算不出来。这份 skill 的态度不是假装它们不存在,而是把它们隔离出去:在这套系统里,它们被关进 rubric 的 judge 条目——必须引用质量愿景里的锚点(REF-n)才能打分,证据格式被钉死(当场采集的截图、固定路径的判定文件)。品味没法编译,那就用锚点和证据格式把判断的活动范围压到最小;围栏之外能算的,全部算掉。

它的边界

把边界说清。

第一,它只保证"同一套检查给出同一个答案",不保证检查问对了问题。清单漏了一整类问题——比如没人把"接口出错时返回形状一致"写成一条检查——评测会稳定地、精确地漏检下去。确定性放大的是清单的质量;清单错了,它错得很稳定。问对问题是写验收标准那一步的事,不是这一步的。

第二,它替代不了 judge 条目和留给人的 manual 条目。产品取舍对不对、文案好不好,最终有需要人判断的部分。它能做的,是把机器能干的从判断里剥干净,让人的判断只花在真正需要判断的地方——而不是耗在"这个文件里有没有 any“这种本该由命令回答的问题上。

按这份 skill 跑完,产出的是一份带证据的失败清单。接下来怎么修——只修点名的条、修完拿同一份清单原样重跑、涨分才算数——是下一篇 fix-from-report 的事。