当 judge 们吵起来时,别再投票了:用执行结果给 code eval 一个 ground truth
上一篇把数据集扩到了30个case、5个领域、3个judge、3个被测模型。结果比预想更有结构:judge分歧不是均匀的噪声,它集中在某些领域。

最典型的是code。固定被测模型为deepseek,只换judge,看同一批HumanEval输出,三个judge给出的code域均分如下:
| judge | code 域 pass-rate |
|---|---|
| deepseek | 0.80 |
| qwen | 0.00 |
| glm | 0.93 |
逐题看更扎眼:
| case | deepseek judge | qwen judge | glm judge | spread |
|---|---|---|---|---|
he-humaneval-151 | 1.00 | 0.00 | 1.00 | 1.00 |
he-humaneval-28 | 0.40 | 0.00 | 1.00 | 1.00 |
he-humaneval-163 | 0.80 | 0.00 | 0.80 | 0.80 |
he-humaneval-108 | 0.80 | 0.00 | 0.80 | 0.80 |
he-humaneval-62 | 0.80 | 0.00 | 1.00 | 1.00 |
he-humaneval-70 | 1.00 | 0.00 | 1.00 | 1.00 |
这是很难靠“多挂几个judge”解决的分歧。
如果deepseek说0.80,qwen说0.00,glm说0.93,我们当然可以做majority vote。问题是,vote出来的不是事实,只是另一个聚合规则。三个人站在一段代码旁边争“能不能过”,最直接的方法不是开会,是运行。
这就是v0.8的动机。
文章4的panel解决了前半个问题:发现judge之间在吵。v0.8要解决后半个问题:在能客观判定的领域,别让judge继续吵。
二、为什么code域不该继续用LLM裁判
LLM judge评代码时经常混在一起看三件事:
- 代码是不是语法正确。
- 代码是不是覆盖了题意。
- 代码风格、解释、边界情况看起来是不是像一个“好答案”。
第三件事有时有用,但HumanEval这种题真正关心的是前两件事。函数能不能处理docstring里的输入,返回值对不对,边界条件过不过。它不是作文题。
上一篇里qwen judge把deepseek的code输出全部打成0。也许qwen对代码质量更严,也许它过度惩罚了风格和边界描述,也许deepseek和glm太宽。单看judge理由,根本没有办法裁决。
但HumanEval自己带测试:
def check(candidate):
assert candidate([5, 4]) == 25
assert candidate([0.1, 0.2, 0.3]) == 0
assert candidate([-10, -20, -30]) == 0
这类题有一个朴素到不性感的判据:把模型输出当成函数实现,拼上测试,跑一下。全过就是过,assert挂了就是挂。
这不是说单测等于全部真理。HumanEval的测试也可能不完备,隐藏边界也可能漏。但它至少比“另一个LLM觉得像不像对”多了一层可复现的物理约束。代码真的执行了,异常真的抛了,返回值真的错了。这个事实不依赖judge的口味。
三、v0.8做了什么
v0.8加了两个hard-metric scorer:
| scorer | 解决的问题 |
|---|---|
code_exec | 把模型输出的Python代码放进子进程,跑HumanEval式单测 |
numeric_match | 从输出中抽最后一个数字,按rel_tol / abs_tol和expected比对 |
numeric_match是math域的补丁。以前用exact_match时,“3.14”、“3.1400”、“答案约为3.14”会被字符串格式牵着走。数值题应该比数值,不该比标点。
code_exec是这篇的主角。它的配置长这样:
scorers:
- type: code_exec
params:
timeout: 5
memory_mb: 256
用例不需要改TestCase模型,只把HumanEval的entry_point和test放到metadata:
- id: he-humaneval-151
domain: code
input: "补全下面的 Python 函数..."
metadata:
entry_point: double_the_difference
test: |
def check(candidate):
assert candidate([]) == 0
assert candidate([5, 4]) == 25
scorer做四步:
- 从模型输出里抽第一段fenced code;没有围栏就取整段输出。
- 拼成
模型代码 + test + check(entry_point)。 - 放进受限子进程执行。
- exit code为0就pass,否则fail,并把timeout / AssertionError / stderr尾部写进detail。
这条路径没有改engine、report、diff。它仍然只是一个普通scorer,产出普通Score。这点很重要,因为Evalith的核心抽象不应该因为一个新评分器变复杂。
四、为什么要有EVALITH_ALLOW_CODE_EXEC=1
执行模型代码不是普通scorer。contains最坏只是误判,code_exec是真的在本机跑不可信代码。
所以v0.8没有让它静默启用。配置里写了code_exec,但没显式设置环境变量时,build_scorer会直接报错:
EVALITH_ALLOW_CODE_EXEC=1 evalith run examples/eval.code-exec.yaml
这个开关有点啰嗦,但宁愿它啰嗦。一个eval工具如果在用户没意识到的情况下执行模型生成的代码,那就是设计错误。
sandbox这层做了几件防护:
- 每次执行都起独立Python子进程。
- 用
-I隔离Python环境,减少用户site/env的影响。 - 在子进程内注入resource limit:CPU、地址空间、文件大小。
- 在前导代码里禁掉一批危险调用,比如
os.system、os.remove、os.kill、subprocess.run、shutil.rmtree。 - 设定wall-clock timeout,死循环会被主进程杀掉。
有一个实现细节值得单独说:没有用preexec_fn。
Evalith的engine会在线程池里并发跑case。Python文档明确警告,多线程程序里用preexec_fn有死锁风险。传统写法是在subprocess.Popen(..., preexec_fn=set_limits)里给子进程设RLIMIT,但这条路在这里不合适。
v0.8的做法是把resource.setrlimit注入到子进程代码最前面。它仍然发生在用户代码之前,但不经过preexec_fn。这不是为了炫技,是为了避开一个真实的多线程坑。
当然,这不是Docker,不是强安全沙箱。它是一个面向本地eval的最小隔离层。边界要说清楚:别拿它跑恶意对抗样本,别把它当云端代码执行服务。它解决的是“模型偶尔写死循环、乱分配内存、误调危险函数时不要拖死整轮eval”,不是解决所有安全问题。
五、验收数据集:复用文章4的同一批HumanEval题
为了让v0.8和文章4接上,没有另选题,而是把文章4的6个code case原样映射回HumanEval:
| article 4 case | entry point |
|---|---|
he-humaneval-151 | double_the_difference |
he-humaneval-28 | concatenate |
he-humaneval-163 | generate_integers |
he-humaneval-108 | count_nums |
he-humaneval-62 | derivative |
he-humaneval-70 | strange_sort_list |
新的文件是:
examples/code.humaneval.yaml
docs/blog/article4/build_code_exec_dataset.py
docs/blog/article4/configs/eval.code-exec-accept.yaml
这组数据的意义不是“又多了一个demo”。它让文章4的judge分歧有了一个后续裁判面板:
| 层次 | 问的问题 |
|---|---|
| v0.7 judge consensus panel | judge之间是否分歧,分歧落在哪些领域 |
| v0.8 code_exec | 对code题,模型输出到底能不能通过单测 |
之前只能写:
现在可以继续写:
这一步还没有把article 4的历史raw outputs全部重跑成执行表,所以这篇不伪装成“最终实验结论”。目前仓库里已经有的是数据集、scorer、验收配置和全量测试。真正的judge-vs-exec对照表,应该作为下一轮实验跑出来,而不是在文章里脑补。
这点要老实。没有跑过的表,不写。
六、TDD结果:这次更像补地基,不像加功能
v0.8的实现拆成8个小任务:
extract_code剥围栏纯函数。sandbox.run_program隔离子进程。CodeExecscorer。NumericMatchscorer。build_scorer接线和环境变量闸门。- engine端到端测试,证明生产引擎零改动。
- 用真实HumanEval重建article 4同题号验收集。
- 版本、示例和README。
更让人欣赏的是,engine没动。报告也没动。diff也没动。
这说明scorer抽象承住了新能力。code_exec看上去是一个很不一样的东西,实际上对外仍然是:
score(case, output) -> Score
一个eval工具如果每加一种评分方式都要改engine,后面很快会变成一锅汤。v0.8没有走到那一步。
测试覆盖也基本沿着风险来:
- fenced code、裸代码、多代码块、空输出。
- assert失败、死循环timeout、内存冲击波、危险调用。
- 缺metadata时优雅失败。
EVALITH_ALLOW_CODE_EXEC未开启时拒绝构建scorer。- engine通过echo provider做一次完整end-to-end。
numeric_match覆盖exact、容差内、容差外、无数字、expected非数字。
服务器上那次全量回归跑到134 passed,只剩一个旧版本号smoke test。这个测试后来从0.1.0同步到0.8.0。发布前还应该再用服务器环境跑一遍全量,这是最后的门闩。
七、这篇真正想说的不是“我们支持代码执行了”
如果只把v0.8理解成“Evalith加了code_exec scorer”,那它有点小。
它真正补上的,是这条eval工作流:
先用LLM judge / panel找到可疑分歧
再在有客观判据的领域切换到hard metric
最后只把没有客观判据的部分留给judge
这比“多找几个judge投票”稳得多。
对code,跑测试。
对math,比数字。
对事实题,尽量用可检索答案或结构化gold。
只有开放式解释、审美、语气、安全边界这类确实没有单一答案的问题,再交给LLM judge,而且最好挂panel看分歧。
LLM judge不是不能用。文章2、3、4其实都在用它。但它应该放在合适的位置:处理那些hard metric覆盖不到的语义判断。能不用它的地方,就别用它。
这是从前四篇里越来越确定的一点。
八、给团队的工程判断
如果你的eval set里有code题,不要只用LLM judge。至少为关键case加一层code_exec。
如果你的eval set里有数值题,不要只用字符串匹配。至少用numeric_match抹平格式差异。
如果你的eval set里混着code、math、knowledge、safety、open-ended explanation,不要幻想一个judge criteria能公平量所有题。先按领域拆,再决定每个领域该用什么scorer。
一个比较实用的组合是:
| 领域 | 首选scorer | 辅助信号 |
|---|---|---|
| code | code_exec | judge panel看可读性/解释,但不做主裁判 |
| math | numeric_match | llm_judge只看推理过程质量 |
| knowledge | contains / regex / gold answer | llm_judge看解释完整性 |
| safety | llm_judge panel | 人审抽样 |
| concept explanation | llm_judge panel + expected_concepts | bootstrap / adaptive sampling |
这样做会让eval配置更复杂一点,但复杂度是诚实的。问题本来就不是同一种题,硬压成一个scorer只是把复杂度藏进误判里。
九、局限和下一步
这次v0.8还有几个边界:
code_exec只支持Python/HumanEval式函数补全,不支持stdin/stdout竞赛题。- sandbox是本地最小隔离,不是强安全容器。
- HumanEval单测不是形式化证明,测试覆盖不到的bug仍然可能漏。
- 文章4的历史模型输出还没有全部通过
code_exec生成judge-vs-ground-truth对照表。 - Windows上没有
resource模块,当前实现的RLIMIT路径主要面向Linux发布/CI环境。
下一步最值得做的不是继续加scorer,而是把article 4的code raw outputs全部接到code_exec上,生成一张表:
| case | deepseek judge | qwen judge | glm judge | code_exec |
|---|---|---|---|---|
he-humaneval-151 | 1.00 | 0.00 | 1.00 | ? |
he-humaneval-28 | 0.40 | 0.00 | 1.00 | ? |
这张表才会真正回答上一篇最想回答的问题:
如果qwen全打0但code_exec大多通过,说明qwen在这批code题上过严。
如果qwen全打0且code_exec也大多失败,说明deepseek/glm在自评或宽松judge上放水。
如果三者都和execution有系统偏差,那就更有意思,说明LLM judge对代码正确性的口味和真实执行之间存在结构性错位。
这会是下一篇实验文最有价值的表。
十、结论
前四篇一路走下来,Evalith做了几层防护:
- 文章1:不要点对点看回归,要用bootstrap CI处理LLM抖动。
- 文章2:LLM judge自己也在抖,工具之间的判定语义会分叉。
- 文章3:统计方法影响有限,judge identity才是核心变量。
- 文章4:judge分歧有领域结构,code分歧最大,safety更容易共识。
文章5的结论更朴素:
能执行就执行,能算数就算数,能查gold就查gold。LLM judge留给那些真的需要语义判断的地方。
v0.7的panel像烟雾报警器。它告诉你哪里烧起来了。
v0.8的code_exec是灭火器的一种。它不负责所有火情,但在code这个房间里,比继续开会有效。
pip install -U evalith
EVALITH_ALLOW_CODE_EXEC=1 evalith run examples/eval.code-exec.yaml
代码、验收数据集和复现实验都在:
github.com/dominciyue/…
