通过向LLM输出注入已知缺陷来验证评测grading脚本的可靠性,暴露「通过但实际未检查任何东西」的评测漏洞。
你的测试套件通过了。它真的在检查什么吗?
下面是我曾经上线过的一个评分器。也许你也写过类似的:
assert:
- type: contains
value: approved
它只负责一道题——贷款批准了吗?——只要输出里出现"approved"就通过。绿灯。很好。
"我根本没有批准。'approved' 这一步完全被跳过了。"
approved 这个子串就躺在那里,所以检查通过了——即使输出表达的意思和你的要求完全相反。测试套件在通过。只是什么都没检查到。
我构建 evalmut 就是为了故意发现这类问题,在它上线之前。
代码变异测试(PIT、Stryker、mutmut)会把你的代码里的 > 改成 >=,然后问:有没有测试注意到?如果什么都没变红,那这个测试就是装饰品。
evalmut 在上一层做同样的事——对你的 eval 评分器。它拿一个评分器通过了的用例,往输出里注入一个已知缺陷,然后重新运行评分器。如果评分器依然让一个明显错误的输出通过,那就是一个漏洞:你的 eval 会让它带着这类回归问题绿灯放行。
难点不在于翻转一个布尔值。这里的"变异"是一种语义变更,你需要为它建立ground truth——而且每一个算子都从有文档记录的真实故障中挖掘而来,不是凭空杜撰的。(杜撰的变异只能测到作者自己想象出来的漏洞——而这恰恰是你正在追查的盲区。)
整个工具建立在一个不变式之上:
它从不从判决翻转来推断漏洞。它只从(输出被证明错误 AND 评分器通过)来推断——这里的"错误"是相对于用例自身的 ground truth 建立的,独立于被测试的评分器。
所以一个算子只会在它能证明变异极性的时候应用到用例上:可证明错误(缺陷)或可证明仍然正确(等价)。在无法证明的地方——没有数字可破坏、没有答案片段可截断、评分器不评判的字段——它返回 N/A,不计入分数。报告出来的漏洞从来不是对模糊变异的猜测。没有任何 LLM-as-judge,所以运行结果可以逐字节复现。
evalmut 通过 gradecore 进行评分——一个确定性评分引擎。所以我用 evalmut 运行了针对 gradecore 自身评分器的测试:
$ evalmut run demos/dogfood_gradecore.py
mutation score 91.4% (32 caught / 35 applied)
holes 3 (1 blind spot, 2 coverage gaps)
三个真实的漏洞——最让我自豪的部分是它对它们的定性的公正。它把那个坏掉的检查称为盲区(一个存在但损坏的检查),另外两个 is-json 相关的称为覆盖缺口(一个缺失的检查,而不是坏掉的检查)——因为 is-json 从只承诺检查键是否存在,从未承诺检查它们的值。一个对正确限定作用域的检查喊"坏了!"的工具,是一个你最终会学会忽略的工具。
经历了八轮对抗性自我批判,才把假阳性率降到零并保持住;这几轮发现的每一个假漏洞现在都被一个回归测试钉住了。仓库里有一篇短论文,详细阐述了这个方法和诚实保证。
pip install -e . # depends on gradecore
evalmut run your_suite.py
如果返回 100%,你的评分器是实打实的。如果不是,你就找到了你的 eval 在放行的那些输出。
(我惯常的方式构建的——agent 做了大量敲代码的工作,我读每一处 diff,决定什么可以上线。)