Skip to content

필사 모드: 拆解 LLM 基准测试工具 —— 为什么同一个 MMLU 在不同评测框架下分数不一样

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 —— 同一个模型、同一个 MMLU、三个不同的分数

2023 年 6 月,Hugging Face 的 Open LLM Leaderboard 团队收到了大量奇怪的投诉:LLaMA 65B 的 MMLU 分数比原论文的 63.4 低了一大截。调查结果并不是 bug —— 只是三套 MMLU 实现,各自用不同的方式给同一份数据集打分。

模型Original 实现HELMlm-evaluation-harness (2023-01)
llama-65b0.6360.6370.488
llama-30b0.5840.5830.457
llama-13b0.4700.4710.377
llama-7b0.3510.3390.342
falcon-40b0.5580.5710.527

出处是 Hugging Face 的 What's going on with the Open LLM Leaderboard?。这里值得留意的不是差距的大小,而是这个差距因模型而异这个事实。65B 模型上差了将近 15 分,7B 模型上却几乎没差。也就是说,换一套评测框架,分数不是均匀地平移,而是模型之间的排名发生了翻转。这不是一个能靠一个校正常数解决的问题。

三套实现的差别是这样的:Original 只比较 A/B/C/D 四个字母的概率。HELM 让模型生成一个字母,把它当作答案。当时的 harness 比较的是选项整句话的对数似然。提示词本身也不一样 —— Original 加了学科名那一行,HELM 加了"Question:"前缀,harness 则去掉了学科名、加上了"Choices:"。

本文的前提正是从这里来的。基准分数不是模型的属性,而是在特定条件下完成的一次测量结果。 但公开的分数大多省略了这些条件。所以我们该一直追问的问题只有一个:要让这个数字具有它表面上的意义,需要哪些前提为真?

排行榜数字到底能信几分,大局观整理在从 AI 编程模型评测中分辨信号与噪声里;哪个基准测的是什么,地图整理在AI 智能体与 LLM 基准测试 2026里。本文看的是更底下那一层 —— 工具到底在做什么。

评测框架实际在做的六个步骤

"我们跑了一遍 MMLU"这句话,压缩了至少六个步骤。每个步骤都有需要决定的东西,这个决定如果没被记录下来,就没法复现。

1. 加载数据      用哪个 split(test/validation)? 多少条? 顺序是否固定?
        |
2. 构建 few-shot 从哪个池子抽例子、抽几个、什么顺序、随机种子是什么
        |
3. 拼装提示词    模板、分隔符、系统消息、是否套用 chat template
        |
4. 调用模型      温度、top_p、max_tokens、停止字符串、重试、批大小
        |
5. 解析答案      对数似然比较? 正则提取? 取最后一个数字?
        |
6. 评分与汇总    精确匹配、部分给分、模型评分,以及取平均的单位

把每个步骤对分数的影响用一句话概括,如下表所示。

步骤常被省略的设置对分数的影响
加载数据用的是 test 还是 validation每个学科相差好几分,无法比较
构建 few-shot示例个数、示例选取的随机种子0-shot 和 5-shot 相差两位数百分点的任务并不少见
拼装提示词是否套用了 chat template在指令微调模型上影响尤其大
调用模型温度和 max_tokens温度设为 0.7 时,同一条命令每次分数都不一样
解析答案提取用的正则表达式GSM8K 上 strict 和 flexible 分道扬镳的地方
评分与汇总子任务的平均方式宏平均和微平均会得出不同的排名

MMLU-Pro 论文(arXiv 2406.01574,NeurIPS 2024)实际测量了这种敏感度。用 24 种提示词风格跑下来,MMLU 上的分数波动大体在 4-5 个百分点,最高达 10.98 个百分点;把选项扩到 10 个的 MMLU-Pro 上,这个波动缩小到大体 2 个百分点,最高 3.74 个百分点。反过来说,在原版 MMLU 上,光是换个提示词,分数的变动就足以贯穿排行榜的中段。

还有更极端的测量。Sclar 等人的 Quantifying Language Models' Sensitivity to Spurious Features in Prompt Design (arXiv 2310.11324) 报告称,仅仅是含义相同的格式变化,就在 LLaMA-2-13B 上造成了最高 76 分的准确率差异。2025 年的 A Single Character can Make or Break Your LLM Evals (arXiv 2510.05152) 更进一步 —— 报告称仅仅改变分隔示例用的一个字符,MMLU 分数就能变动最多 23%,而且光靠这种操纵就能把想要的模型推上第一名。提示词的格式不是实验设置,而是测量装置本身的一部分

选答案的方式本身就在制造分数

对于选择题任务,实现"模型选了一个答案"这件事至少有三种方式。这三种测的是不同的能力。

对数似然比较。 完全不让模型生成任何东西。把每个选项的字符串接在提示词后面,计算这个字符串的对数概率,取最高的那个作为选中的答案。因为没有生成过程,所以是确定性的、速度快。作为代价,它完全不测量遵循指令的能力 —— 一个无视指令的基础模型照样能拿到正常的分数。

字母概率比较。 把提示词拼到"答案:"为止,只比较下一个 token 是 A、B、C、D 的概率。不受选项长度影响,但会忽视模型实际上可能是以"Answer: B"这种方式来回答的情况。

先生成再解析。 让模型真正作答,再从文本里提取答案。这最接近实际使用场景。作为代价,解析规则本身也成了评分的一部分。

对数似然方式还有一个广为人知的陷阱。字符串越长,token 越多,对数概率之和自然就越小。所以如果不做任何校正直接比较总和,短选项在结构上就占便宜。lm-evaluation-harness 之所以在 acc 之外还报告 acc_norm,原因就在这里 —— acc_norm 把对数似然除以选项长度,用来抵消这种偏差。如果某个任务的这两个数字差距很大,就是在提示你:这个任务对选项长度的分布很敏感。

用中文评测时,这上面还要再叠一层。EleutherAI 的归一化说明文章把这种校正称为字节长度归一化,并解释其优点是不依赖分词方式。但仓库里的 issue 3278 指出,实际代码用的是字符串的字符数来除,而不是字节数。在英文里两者基本等价,但在 UTF-8 下一个字占 3 个字节的中文、日文、韩文里就不一样了。如果评测集里中英文选项混杂,这个差异会直接带进分数里。为什么归一化方式要去代码里确认、而不是看文档,原因就在这。

先生成再解析这一路的代表案例是 GSM8K。lm-evaluation-harness 的 gsm8k 任务,一次运行会产出两个分数。

过滤器提取规则遗漏的东西
strict-match只认严格符合指定格式(比如四个井号后面跟数字)的答案把所有没按格式来的正确答案都判成错误
flexible-extract把输出中最后一个数字当作答案如果计算过程中的中间值排在最后就会判错,货币符号、逗号的归一化也不够充分

同一次运行、同样的输出,却出了两个数字。但论文和模型卡里引用的 GSM8K 分数往往只有其中一个,而且不说明是哪一个。仓库里还有一个 issue,报告 flexible-extract 过滤器没能正确归一化货币符号或逗号,把本来正确的答案判成了错误(issue 3214)。这是一种解析器的 bug 被当成模型分数记录下来的结构。

主流评测框架对比

截至 2026 年 8 月,实务和论文里实际在用的工具整理如下。版本是核实当天的最新发布版本。

工具版本(核实日期 2026-08-02)理念评分方式的特点
lm-evaluation-harness (EleutherAI)lm-eval 0.4.12, 2026-05-11用 YAML 声明任务,60 多个基准对数似然与生成两种都支持,用过滤器链解析
HELM (Stanford CRFM)crfm-helm 0.5.16, 2026-04-30一个任务同时用多个指标衡量除准确率外,还同时衡量校准、鲁棒性、公平性、毒性、效率
Inspect AI (UK AISI, Meridian Labs)inspect-ai 0.3.251, 2026-07-29把智能体和工具调用当作一等公民solver 与 scorer 分离,内置模型评分与沙箱
promptfoonpm 0.121.20, 2026-07-31声明式 YAML,网格化比较提示词与模型基于断言,内置红队扫描器
DeepEval4.1.5, 2026-07-31pytest 风格,面向应用层指标G-Eval 与决策树式判定(DAG)
LightEval (Hugging Face)0.13.0, 2025-11-24与后端无关的轻量级流水线将 Inspect AI 定为首选后端
OpenCompass (Shanghai AI Lab)0.5.3, 2026-06-29大规模综合评测工具内含模型评分工具集
Ragas0.4.3, 2026-01-13RAG 专用指标集合忠实度、答案相关性、上下文精确度
EvalPlus0.3.1, 2024-10-20强化 HumanEval・MBPP 的测试大幅增加测试用例以弥补弱评分
OpenAI simple-evals无发布标签目的是公开参考实现固定使用 0-shot 思维链

有几点值得说明。

HELM 的视角不一样。 HELM 问的不是"这个模型的 MMLU 分数是多少",而是"在这个场景下,这个模型在准确率、校准、鲁棒性、公平性、毒性、效率各方面分别表现如何"。按原论文(arXiv 2211.09110)的说法,这是一种在 16 个核心场景上同时衡量 7 个指标的多指标方法。运行用 helm-run,汇总用 helm-summarize,结果通过 helm-server 在浏览器里查看。

不过有一点状态变化需要留意。HELM 从2026 年 6 月 1 日起进入维护模式。代码和排行榜会继续公开,但不再新增功能和新的评测。如果打算新采用这个工具,要考虑到这一点;反过来,引用过去的 HELM 分数时,不能假设那个时间点的排行榜现在仍在更新。

HELM 处理选择题的方式也值得了解。有让模型看到全部选项再生成一个字母的方式(joint),有给每个选项单独打分的方式(separate),还有再用选项自身的先验概率做校正的方式(separate-calibrated)。随着近期的 API 越来越少暴露 token 概率,业界正在向 joint 倾斜 —— 而这一个选择,正是前面表格里那 15 分差距的根源所在。

Inspect AI 的设计前提就是智能体评测。 它把任务拆成 Dataset、Solver、Scorer 三部分。Solver 可以只是一次简单的生成,也可以是一个用多轮工具调用的完整智能体;Scorer 从字符串比较到模型评分都能接。用于安全运行模型生成代码的 Docker、Kubernetes 沙箱是内置的。它由英国 AI Security Institute 和 Meridian Labs 联合开发,在安全评测这个方向上事实上已经是标准。Hugging Face 的 LightEval 把它定为首选后端,是 2026 年一个值得注意的收敛信号。

OpenAI simple-evals 与其说是工具,不如说是参考实现。 它存在的意义是公开 OpenAI 在发布模型时所附带的准确率数字,究竟是用什么提示词、什么设置跑出来的。它坚持使用 0-shot 思维链,理由是 few-shot 提示词是基础模型时代的遗留做法,不太能反映真实使用场景。这个选择本身,就让它没法和其他评测框架直接比较。还有一个有意思的细节 —— 不同采样器实现的默认温度不一样:面向 chat API 的采样器默认温度是 0.5、最多 1024 个 token,面向 Claude 的采样器默认温度是 0.0、最多 4096 个 token。在同一个仓库里,采样条件会因模型而异。

有两件容易混淆的事要区分一下。GitHub 上的 openai/evals 仓库既没有被归档,也没有弃用公告,但实际上已经处于休眠状态(PyPI 包自 2024 年以来没有更新过)。而 OpenAI 托管的 Evals 仪表盘和 API 已在 2026 年 6 月 3 日宣布下线,计划 2026 年 10 月 31 日转为只读,11 月 30 日彻底关闭。仓库和平台是两码事。

promptfoo 于 2026 年 3 月 9 日被 OpenAI 收购(OpenAI 的公告)。官方公告称开源许可证将维持不变。选工具的时候,维护主体已经变更这件事值得留意。

还有一件事。Hugging Face 的 Open LLM Leaderboard 已于 2025 年 3 月 13 日停止运营 —— 正是本文开头那张 MMLU 表格所依据的那个排行榜。排行榜就算消失了,从它那里产出的分数依然会持续被论文和模型卡引用,所以养成核实被引用分数的出处是否仍然存在这个习惯很有必要。

从头到尾亲手跑一遍

与其解释,不如直接跑一遍更快。这里的规模设计成即便在笔记本电脑的 CPU 上也能跑完。

python3 -m venv .venv
source .venv/bin/activate
pip install "lm-eval[api]==0.4.12"

# 先看看有哪些任务
lm_eval --tasks list | head -40

用小模型跑 ARC-Easy,只跑 200 道题。核心是把随机种子和 few-shot 数量都明确写出来。

lm_eval \
  --model hf \
  --model_args pretrained=EleutherAI/pythia-160m,dtype=float32 \
  --tasks arc_easy \
  --num_fewshot 5 \
  --batch_size 8 \
  --device cpu \
  --seed 1234 \
  --limit 200 \
  --output_path ./results/pythia-160m \
  --log_samples

这里每个参数固定住了什么,很重要。

  • 不加 --num_fewshot 5 跑的话,会用任务 YAML 里的默认值。这个值因任务而异,版本一变也会跟着变。
  • --seed 固定了 few-shot 示例的抽取和洗牌方式。不设的话,每次运行都会拼出不同的提示词。
  • --limit 200 只截取前 200 条。这不是随机样本,所以不能把这个数字当成整体分数来引用,它是用来调试的。
  • --log_samples 真的很重要。它会把提示词原文、模型的原始输出、解析结果全部保存下来。没有它,事后就没法知道这个分数是怎么来的。

跑完之后,结果目录里会生成 JSON。结构大致如下。具体的键名可能因版本而异,建议直接打开来确认。

{
  "results": {
    "arc_easy": {
      "alias": "arc_easy",
      "acc,none": 0.395,
      "acc_stderr,none": 0.0346,
      "acc_norm,none": 0.365,
      "acc_norm_stderr,none": 0.0341
    }
  },
  "configs": {
    "arc_easy": {
      "task": "arc_easy",
      "output_type": "multiple_choice",
      "num_fewshot": 5,
      "metric_list": [{ "metric": "acc" }, { "metric": "acc_norm" }]
    }
  },
  "config": {
    "model": "hf",
    "model_args": "pretrained=EleutherAI/pythia-160m,dtype=float32",
    "batch_size": 8,
    "random_seed": 1234,
    "limit": 200
  },
  "git_hash": "…",
  "date": 1785000000
}

这份 JSON 里真正要看的不是 results,而是 configs 和 config。引用一个分数时应该一并附上的信息,全都在这里。acc 和 acc_norm 相差 3 个百分点这件事,也只有在这里才能看到。

用 API 测量指令微调模型时,命令会不一样。先用 vLLM 或 Ollama 之类的工具起一个兼容 OpenAI 的接口,再这样连接。

lm_eval \
  --model local-chat-completions \
  --model_args model=qwen3-8b,base_url=http://localhost:8000/v1/chat/completions,num_concurrent=8,max_retries=3,tokenized_requests=False \
  --tasks gsm8k \
  --num_fewshot 5 \
  --apply_chat_template \
  --fewshot_as_multiturn \
  --gen_kwargs temperature=0,max_gen_toks=512 \
  --output_path ./results/qwen3-8b \
  --log_samples

测量指令微调模型时,几乎总是需要 --apply_chat_template--fewshot_as_multiturn 这两个参数。前者把提示词包装成模型训练时用的对话格式,后者把 few-shot 示例作为真正的多轮对话回合送进去,而不是拼成一整段长文本。光是这两个参数的有无,就能让指令微调模型的分数产生很大差别。但公开的分数里,这类信息却很少见到。

有些 API 根本不支持对数似然。这种情况下选择题任务没法用对数似然来跑,只能走 generate_until 这一路。也就是说,用 API 测量模型还是用权重测量模型,从一开始就决定了能用哪些评分方式。 光是这一点,就可能让同一个基准上的两个分数无法比较。

直接读并修改任务定义

在 lm-evaluation-harness 里,一个任务就是一个 YAML 文件。如果想用同一套评测框架跑内部数据,只需要写这一个文件。先看选择题。

task: internal_mcq
dataset_path: json
dataset_kwargs:
  data_files:
    test: ./data/internal_mcq.jsonl
output_type: multiple_choice
test_split: test
doc_to_text: "请回答以下问题。\n问题: {{question}}\n答案:"
doc_to_choice: "{{choices}}"
doc_to_target: "{{label}}"
num_fewshot: 5
fewshot_config:
  sampler: default
metric_list:
  - metric: acc
    aggregation: mean
    higher_is_better: true
  - metric: acc_norm
    aggregation: mean
    higher_is_better: true
should_decontaminate: false
metadata:
  version: 1.0

这里哪怕只改动 doc_to_text 里的一个字,分数都会跟着变。前面 MMLU 的案例正是这个现象。所以这份文件必须纳入版本控制,metadata 里的 version 每次改动定义时都要往上加。这个字段存在的意义,就是用来确认"上个月的分数和这个月的分数是不是出自同一个定义"。

生成式任务会带一条过滤器链,这是解析规则被明确写出来的地方。

task: internal_math
dataset_path: json
dataset_kwargs:
  data_files:
    test: ./data/internal_math.jsonl
output_type: generate_until
test_split: test
doc_to_text: "问题: {{question}}\n请写出解题过程,并在最后一行只用「答案: 数字」这种格式作答。\n"
doc_to_target: "{{answer}}"
generation_kwargs:
  until:
    - "\n\n"
  do_sample: false
  temperature: 0.0
  max_gen_toks: 512
filter_list:
  - name: strict-match
    filter:
      - function: regex
        regex_pattern: "答案:\\s*(-?[0-9][0-9,]*(?:\\.[0-9]+)?)"
      - function: take_first
  - name: last-number
    filter:
      - function: regex
        regex_pattern: "(-?[0-9][0-9,]*(?:\\.[0-9]+)?)"
        group_select: -1
      - function: take_first
metric_list:
  - metric: exact_match
    aggregation: mean
    higher_is_better: true
    ignore_case: true
    ignore_punctuation: false
metadata:
  version: 1.0

同一次运行会由两个过滤器分别产出一个分数。这两个分数的差别,测的不是模型的数学能力,而是模型对指定输出格式的遵守程度。养成把这两个数字放在一起看的习惯很重要。如果 strict 低而 last-number 高,说明模型会算但不守格式,这是提示词就能解决的问题;如果两个都低,说明模型不会算,这是必须换模型才能解决的问题。只报告一个数字,这个区别就消失了。

再看看逗号的处理。正则表达式里有 [0-9,]*。如果没有这个,模型回答"1,024"时就只会提取出"1",被判为错误。答案解析规则就是这样一层层决定累积出来的,而这份累积本身就是分数。

可复现所需的最低条件清单

要复现一个分数,下面这些全都需要。哪怕缺一项,得到的都不是复现,而是近似。

项目为什么需要记录在哪里
模型标识符与版本同名的 API 模型可能被悄悄更新权重记 commit 哈希,API 记快照 ID
评测框架版本任务定义和过滤器每个版本都会变精确到像 lm-eval 0.4.12 这样的写法
任务定义版本提示词里的一个字就能移动分数YAML 的 metadata.version 与文件哈希
few-shot 数量与随机种子示例一变就是另一份试卷命令行参数原样记录
采样参数温度不是 0 的话每次分数都不同temperature、top_p、max_tokens、停止字符串
提示词拼装方式是否使用 chat template 影响尤其大apply_chat_template、系统消息原文
答案解析规则解析器本身就是评分的一部分过滤器名称与正则表达式原文
汇总单位宏平均和微平均会得出不同排名子任务加权方式
被评测的子集limit 或抽样不等于全部题目数量与选取方式
运行次数与方差只跑一次的数字会掩盖波动范围重复次数、均值与标准误
运行环境批大小和精度会改变结果dtype、批大小、推理后端与版本

最后一项经常被忽视,但确实有影响。哪怕权重完全相同,float16 和 bfloat16 也可能算出不同的答案;批大小不同会影响 padding 和内核选择;vLLM 和 transformers 面对同一个提示词也可能吐出不同的 token。这一层的差异通常很小,但常常比排行榜上第一名和第三名之间的差距还要大。

实务中处理这份清单最省钱的办法,是把评测的运行方式写成一个文件,而不是让人手敲一条命令。

# evals/run-2026-08-02.yaml —— 把这个文件本身提交进版本库
harness: lm-eval==0.4.12
model:
  provider: local-chat-completions
  base_url: http://localhost:8000/v1/chat/completions
  name: qwen3-8b
  weights_revision: 8f2c1d9
sampling:
  temperature: 0
  max_gen_toks: 512
tasks:
  - name: gsm8k
    num_fewshot: 5
    apply_chat_template: true
    fewshot_as_multiturn: true
    report_filters: [strict-match, flexible-extract]
repeats: 3
seed: 1234

有了这份文件,"那个分数是怎么跑出来的"这个问题,一个 commit 哈希就能回答。没有它,哪怕是再认真负责的人,三个月后也答不上来。

评分器才是基准本身

走到这里,结论会汇聚到一点上。一个基准测试的身份认同,不在于数据集,而在于评分器。哪怕面对同样的 14,000 道题,评分器不同,就是不同的考试。

评分方式大致分三种,各自会以不同的方式悄悄失效。

评分方式工作原理优势悄悄崩溃的地方
精确匹配字符串比较确定性强,可复现答案正确但写法不同会被判错
正则提取用模式只抽取答案允许一定的格式自由度模式抓不住的表达方式全部记 0 分
模型评分由另一个 LLM 判定能给自由文本打分评委自身的偏差会混进分数里
基于执行跑测试来判定语义层面比较扎实测试如果薄弱,错误答案也能通过

正则提取的脆弱之处,前面在 GSM8K 上已经见过。模型评分的偏差会在下一篇文章里详细讨论。基于执行的评分陷阱 —— 测试太弱导致错误的补丁也能通过 —— 已经在 SWE-bench 系列上被实际测量过,这也是下一篇文章的主题。

这里只留一条实务上的经验法则。引入一个新基准之前,先读评分代码,别急着看数据集。 读 30 行评分函数,比翻看几道题目更能准确告诉你这个基准到底在测什么。然后把自己团队的十个失败案例拿去过一遍那个评分器。只要有一个人类看来明显正确的答案被判成 0 分,那这个基准的分数测的就不是你以为的那个东西。

结语 —— 没有条件的数字不是数字

LLaMA 65B 的 MMLU 同时是 63.6 分又是 48.8 分,这件事不是一次例外的事故,而是评测的一个基本属性。基准分数是模型、评测框架、提示词、解析器、采样设置共同产出的一次观测。其中任何一个变了,得到的就是另一次观测。

所以实务中要守住的只有两条。第一,引用外部分数时,先看能不能核实它是在什么条件下得出的。核实不了的话,那就不是数据,而是一种主张。第二,产出自己团队的分数时,把条件写进文件里。敲在命令行里的参数三个月后就会消失,但提交进版本库的 YAML 会留下来。

信任一个数字的方法,不是收集更多的数字,而是把产出这个数字的条件记录下来。

参考资料

현재 단락 (1/254)

2023 年 6 月,Hugging Face 的 Open LLM Leaderboard 团队收到了大量奇怪的投诉:LLaMA 65B 的 MMLU 分数比原论文的 63.4 低了一大截。调查结果...

작성 글자: 0원문 글자: 12,646작성 단락: 0/254