LLM 评测体系
第 10 章 lm-evaluation-harness:学术评测的事实标准
lm-evaluation-harness 之所以成为学术评测的事实标准,靠的不是指标新颖,而是可复现:Task / LM / Instance / Filter 四层抽象把「这个数字是怎么算出来的」变成可以逐层追溯的事。
本章要点
- lm-evaluation-harness(简称 lm-eval)的整体架构:Task / LM / Instance / Filter 四层抽象
- LM 基类的四种
OutputType:loglikelihood / loglikelihood_rolling / generate_until / multiple_choice - Task 子类化与 YAML 配置驱动:3000 多个 task 名是怎么挂载的
- Metrics 库:16 个可写进 YAML 的 metric + 12 个 aggregation,从 mean / perplexity 到 BLEU / chrF / Matthews CC
- 与 OpenAI evals 的对比:为什么学术界几乎没人用 OpenAI evals 报数字
10.1 仓库位置与版本
本章源码引用统一基于 EleutherAI/lm-evaluation-harness 的 v0.4.5 tag(本专栏的基准版本)。读者可通过下面命令获取:
git clone https://github.com/EleutherAI/lm-evaluation-harness.git
cd lm-evaluation-harness
git checkout v0.4.5
仓库的整体规模:核心代码(lm_eval/api/)约 3400 行,task 配置(lm_eval/tasks/)128 个目录、3388 份 YAML,lm_eval/models/ 下 @register_model 注册了 23 个 --model 名字(hf / vllm / local-completions / openai-completions / anthropic-chat / mamba_ssm / neuronx / openvino 等,其中若干是同一实现的别名)。
它的核心地位来自一个事实:大量 LLM 论文报告 MMLU / HellaSwag / ARC / GSM8K / BIG-Bench-Hard 等 benchmark 数字时,背后跑的就是这个仓库。v0.4.5 的 README 自述:它是 Hugging Face Open LLM Leaderboard 的后端,已被数百篇论文使用,NVIDIA、Cohere、BigScience、BigCode、Nous Research、Mosaic ML 等几十家机构在内部使用——在开源模型的横向对比里,它是事实上的"度量衡"。
10.2 四层核心抽象
flowchart TB
subgraph 任务层
T[Task 子类]
YAML[YAML 配置]
DS[HuggingFace Datasets]
end
subgraph 请求层
I[Instance]
end
subgraph 模型层
LM[LM 基类]
MAdapter[Model Adapter<br/>HF/vLLM/OpenAI/...]
end
subgraph 后处理层
F[Filter Pipeline]
M[Metrics]
end
YAML --> T
T --> DS
T -->|生成 requests| I
I --> LM
LM --> MAdapter
MAdapter -->|response| LM
LM -->|结果| F
F --> M
M --> Out[最终分数]
style T fill:#dbeafe
style LM fill:#dcfce7
style F fill:#fef3c7
style M fill:#fce7f3
四层各自职责清晰:
- Task 定义"评什么"——数据集、prompt 模板、评分公式
- Instance 是"一次请求"的最小单元
- LM 是模型调用的抽象——任何推理后端只要实现 LM 协议都能接入
- Filter / Metrics 后处理:抽答案、归一化、聚合
下面逐层解读源码。
10.3 LM 基类:四种 OutputType
lm_eval/api/model.py:20 定义抽象基类 LM(abc.ABC),用 @abc.abstractmethod 标了三个方法——loglikelihood(第 33 行)、loglikelihood_rolling(第 58 行)、generate_until(第 100 行),子类必须全部实现。下面按源码原样摘前两个,省略了 __init__ 的函数体(设置 rank / world_size / cache_hook 三行)和篇幅很长的参数文档:
class LM(abc.ABC):
def __init__(self) -> None:
"""Defines the interface that should be implemented by all LM subclasses.
LMs are assumed to take text (strings) as input and yield strings as output
(inputs/outputs should be tokenization-agnostic.)
"""
...
@abc.abstractmethod
def loglikelihood(self, requests) -> List[Tuple[float, bool]]:
"""Compute log-likelihood of generating a continuation from a context.
Downstream tasks should attempt to use loglikelihood instead of other
LM calls whenever possible.
...
"""
@abc.abstractmethod
def loglikelihood_rolling(self, requests) -> List[float]:
...
注意签名用的是 typing 的 List / Tuple(这个版本还要兼容旧 Python),
而且 requests 没有类型标注——文档字符串里写着它是 list[Instance]。
注意 loglikelihood 返回 (logprob, is_greedy) 二元组——is_greedy 表示 "这个 continuation 是否是 greedy decoding 会产生的"。它和多选题的判分是两回事:multiple_choice 的 acc 是对各选项的 logprob 取 argmax 再与 gold 比(lm_eval/api/task.py:1428 的 pred = np.argmax(lls));is_greedy 用在 loglikelihood 类任务上——例如 LAMBADA 这类"续写对不对"的任务,acc 直接取 int(is_greedy)(lm_eval/api/task.py:1386)。
OutputType 在 lm_eval/api/instance.py:5-7:
OutputType = Literal[
"loglikelihood", "loglikelihood_rolling", "generate_until", "multiple_choice"
]
四种类型对应四种评测形态:
| OutputType | 评测形态 | 典型 task |
|---|---|---|
| loglikelihood | 单段 continuation 的概率 | LAMBADA |
| loglikelihood_rolling | 整篇文本的 perplexity | wikitext、Pile |
| generate_until | 自由生成直到遇到 stop sequence | GSM8K、BBH、IFEval |
| multiple_choice | 多个候选各算 logprob 后取最大 | MMLU、ARC、HellaSwag、Winogrande |
这四种类型几乎覆盖了所有主流 benchmark。值得注意的是 lm-eval 不是基于 chat completion API 设计的——它的核心是 logprob 接口,因为很多 benchmark(特别是 multiple-choice)需要看模型对各选项的相对概率,而不只是文本输出。
对 OpenAI / Anthropic 这种只暴露 chat completion API 不暴露 logprob 的厂商,v0.4.5 并不会自动降级:LocalChatCompletion.loglikelihood(lm_eval/models/openai_completions.py:167)与 AnthropicChat.loglikelihood(lm_eval/models/anthropic_llms.py:359)都直接 raise NotImplementedError。想在 chat 模型上评 MMLU,只能改用仓库另外提供的生成式口径(lm_eval/tasks/mmlu/generative/、flan_cot_*),让模型自由生成 ABCD 再抽取——精度比 logprob 差,且与 logprob 口径的数字不可直接相比。
10.4 Task 子类与 YAML 配置驱动
10.4.1 Task 基类
lm_eval/api/task.py:175 定义 Task(abc.ABC)(第 55 行的 TaskConfig、第 691 行的 ConfigurableTask 是它的配置载体与 YAML 驱动实现),要求子类实现:
has_training_docs()/has_validation_docs()/has_test_docs():声明数据集有哪些 split(数据本身由基类的download()走 HuggingFace datasets 拉)doc_to_text(doc):把数据集 doc 转成 prompt 文本doc_to_target(doc):拿出 ground truthconstruct_requests(doc, ctx, **kwargs):把一条 doc 变成若干Instanceprocess_results(doc, results):从模型 response 算单条样例分数aggregation(self) → dict:把单条样例分数聚合成 task-level metrichigher_is_better(self) → dict:声明每个 metric 的方向
这套接口逼迫 task 作者把"prompt 模板"和"判分逻辑"写得显式、可审查——这是学术评测可复现性的工程基石。
10.4.2 上千个 task 的 YAML 驱动方式
v0.4.5 的 lm_eval/tasks/ 下有 128 个子目录,每个目录是一个 benchmark 家族;家族内再按语言 / 学科 / 难度展开,全仓库共 3388 份 YAML,其中 3173 个不重复的 task 名字可直接 --tasks 调用(另有 123 个 group 名、123 个 tag 名;均按 TaskManager 实际索引计)。绝大部分新 task 不需要写 Python,只需要写 YAML——以 MMLU 为例(tasks/mmlu/default/_default_template_yaml):
dataset_path: hails/mmlu_no_train # a copy of `cais/mmlu` with no auxiliary_train split
test_split: test
fewshot_split: dev
fewshot_config:
sampler: first_n
output_type: multiple_choice
doc_to_text: "{{question.strip()}}\nA. {{choices[0]}}\nB. {{choices[1]}}\nC. {{choices[2]}}\nD. {{choices[3]}}\nAnswer:"
doc_to_choice: ["A", "B", "C", "D"]
doc_to_target: answer
metric_list:
- metric: acc
aggregation: mean
higher_is_better: true
metadata:
version: 1.0
dataset_kwargs:
trust_remote_code: true
这一份十几行的 YAML,定义了一个完整的 MMLU 子任务:
- dataset_path:直接拉 HuggingFace 上的数据集。注意 v0.4.5 用的是
hails/mmlu_no_train(去掉 auxiliary_train split 的cais/mmlu副本),不是cais/mmlu本身——这类"官方数据集的清洗副本"在 lm-eval 里很常见 - doc_to_text:Jinja2 模板,把
{question, choices}渲染成"题目 + ABCD 选项 + Answer:"的 prompt - doc_to_choice / doc_to_target:4 个选项 + ground truth 字段
- metric_list:用
acc指标、mean聚合、higher_is_better - metadata.version:版本号——MMLU 的判分规则若有更新,version bump
这套 YAML 驱动的好处是贡献门槛极低——任何研究者要加新 benchmark 只需提一份 YAML PR,不用懂 Python 代码。这是为什么 lm-eval 仓库能积累上千个 task 名:贡献者不被代码门槛吓退。
10.4.3 MMLU 的子任务展开
仓库的 lm_eval/tasks/mmlu/default/ 下有 57 个 mmlu_*.yaml——MMLU 标准包含 57 个学科子任务(abstract_algebra、anatomy、astronomy、business_ethics、...)。每个学科一份 YAML,统一继承 _default_template_yaml;此外还有 _mmlu.yaml 与 _mmlu_stem/humanities/social_sciences/other.yaml 五份 group 配置,负责把 57 个子任务聚合成 4 大类和 1 个总分。同一个 lm_eval/tasks/mmlu/ 下还并列着 continuation、generative、flan_cot_fewshot 等多套变体 prompt——同一 benchmark 的不同评测口径在这里是显式分开的。这种"模板继承"机制让维护成本极低——共有逻辑只写一次。
10.5 Metrics 库:内置指标与聚合函数
lm_eval/api/metrics.py(570 行)里有 42 个模块级函数,其中 16 个挂了 @register_metric(可在 YAML 的 metric: 字段直接引用)、12 个挂了 @register_aggregation(可在 aggregation: 字段引用):
| 指标 | 用途 | 源码位置(metrics.py) |
|---|---|---|
| mean / median | 基础聚合 | 25 / 30 |
| perplexity / weighted_perplexity / bits_per_byte | 语言模型困惑度 | 37 / 42 / 47 |
| f1_score | F1(用于 SQuAD 这类抽取式 QA) | 52 |
| matthews_corrcoef | Matthews 相关系数(适用极不平衡分类) | 64 |
| bleu / chrf / ter | 翻译评测(走 sacrebleu) | 74 / 92 / 107 |
| brier_score | 概率校准评测 | 123 |
| acc / acc_norm / acc_mutual_info | 准确率 / 归一化准确率 / 互信息归一 | acc_fn 148、acc_norm_fn 158、acc_mutual_info_fn 168 |
| exact_match | 精确匹配(移植自 HuggingFace evaluate 的实现,不调用 evaluate 库) | exact_match_fn 234 → exact_match_hf_evaluate 190 |
| pop_stddev / sample_stddev / mean_stderr | 统计推断 | 278 / 283 / 288 |
| bootstrap_stderr / stderr_for_metric | bootstrap 误差估计 | 459 / 488 |
| pooled_sample_stderr / combined_sample_stderr / aggregate_subtask_metrics | group 聚合与合并误差 | 511 / 529 / 561 |
注意 @register_metric(写进 YAML 的 metric:)和 @register_aggregation(写进 aggregation:)是两套注册表:bleu / chrf / ter / brier_score 这类既注册成聚合、又通过 *_fn 包装成同名 metric;mean / median 只在聚合表里(没有名叫 mean 的 metric);而 pop_stddev / bootstrap_stderr 两张表都不进,只是内部工具函数。
值得专门讨论的是 acc_norm——它做"长度归一化的 accuracy",把每个选项的 logprob 除以选项的字符数,避免短选项有不公平优势。这种细节是学术评测和工业评测的真正差异点:学术评测必须把每一个潜在的 confounder 都排除到底,工业评测往往容忍。
10.6 与 OpenAI evals 的设计对比
学术界几乎没人用 OpenAI 的 evals 仓库报数字。原因是设计哲学的根本差异:
| 维度 | lm-eval-harness | OpenAI evals |
|---|---|---|
| 接口核心 | logprob | text completion |
| 任务定义 | YAML 优先 | YAML + Python 混合 |
| 模型支持 | 23 个注册名(HF/vLLM/OpenAI 兼容/Anthropic 等) | 主要 OpenAI 系 |
| 任务数量 | 3173 个 task 名 | 100+(registry 不全) |
| 输出格式 | 严格 reproducibility | 偏 demo |
| 主要受众 | 研究者 / 模型方 | 模型评测员 |
简言之:lm-eval 是为"复现论文数字"设计的——它的工程方方面面都为可复现性服务(YAML 模板继承、metadata.version、acc_norm 长度归一化、严格的 logprob 接口)。OpenAI evals 是为"快速跑评测"设计的——它的工程方方面面都为开发者迭代速度服务。
工业团队应该这么选:
- 想做"模型横向对比"(如选哪个基础模型)→ lm-eval-harness
- 想做"应用层评测"(如比较两个 prompt 哪个好)→ OpenAI evals 或 promptfoo
- 想做"RAG 系统评测" → ragas(详见第 11 章)
10.7 lm-eval 的工程亮点:5 个值得借鉴的设计
graph TB A[lm-eval 工程亮点] --> B[Filter 流水线<br/>抽答案的可组合管道] A --> C[Cache 层<br/>SqliteDict 持久化中间结果] A --> D[Decontamination 工具<br/>算法齐备但未接主流程] A --> E[Multi-task 合批<br/>多个 task 的同类请求并成一批] A --> F[Few-shot 模板继承<br/>YAML 复用] style A fill:#fef3c7
每个亮点都对应一个可学习的工程模式:
- Filter 流水线(
lm_eval/api/filter.py、lm_eval/filters/):模型输出经过一组可组合的 filter 抽取最终答案。例如gsm8k.yaml的filter_list声明了两条并列流水线——strict-match是regex(#### (\-?[0-9\.\,]+))+take_first,flexible-extract是更宽松的regex+take_first——同一次生成可以同时按两套口径打分 - 两层 Cache:
lm_eval/caching/cache.py用dillpickle 文件缓存构造好的请求实例(--cache_requests开启,Task.build_all_requests里读写,省掉每次重新渲染 prompt、构造Instance;数据集本身的下载缓存则由 HF datasets 负责);模型响应的缓存是另一套——--use_cache会用lm_eval/api/model.py:228的CachingLM把loglikelihood/generate_until的结果按参数哈希存进 SqliteDict,非 greedy 采样(do_sample=True)会被显式跳过不缓存 - Decontamination(
lm_eval/decontamination/):n-gram overlap 查测训污染的算法实现在这里(lm_eval/decontamination/decontaminate.py:37的get_train_overlap、同目录janitor.py的 13-gramJanitor、archiver.py的 zstd 归档),是学术界对 data contamination 问题的工程响应。但 0.4.5 里它没有接进评测主流程——全仓搜from lm_eval.decontamination只命中scripts/clean_training_data/下两个离线脚本和tests/test_janitor.py,lm_eval/evaluator.py与lm_eval/__main__.py里连decontam这个词都没有,CLI 也没有对应开关;同名的decontaminatefilter 更是个pass空壳(见 §10.9.2 的表)。所以它是一套能用但要自己调的离线工具,不是「跑 lm-eval 就自动帮你去污染」 - Multi-task 合批:
lm_eval/evaluator.py先把所有 task 的Instance按request_type归并(第 467-468 行),再按类型整批调用getattr(lm, reqtype)(...)(第 500 行)——不同 task 的同类请求进同一批,提高 GPU 利用率 - Few-shot 模板继承:MMLU 57 个子任务共用一份模板的机制
这五个设计是 lm-eval 多年迭代沉淀的工程财富——内部 fork 改造时这些点都值得保留。
10.8 为什么是 lm-eval:可复现性与定位
10.8.1 为什么这么多论文用它:可复现性的工程含义
如果只看技术接口,lm-eval-harness 似乎只是一个"通用 eval runner"。但它在学术界的地位远超工程价值——开源模型的论文与横向对比大量用它报 benchmark 数字(闭源厂商与不少头部团队则用各自的内部评测框架,模型卡上的数字不一定出自 lm-eval)。
为什么是它?根本原因是可复现性的承诺被工程化兑现:
- YAML 配置 + git tag:每个 task 的版本号写在 metadata 里。论文可以注明 "evaluated using lm-eval-harness commit abc123 on task mmlu version 1.0",读者能精确重跑得出同样数字
- prompt 模板透明:所有 prompt 模板都在公开 yaml 里,没有"内部魔法 prompt"
- 数据集版本可锁定:可在
dataset_kwargs里传revision锁到 HuggingFace 数据集的具体 commit(v0.4.5 自带的 YAML 都没锁,需要时自己加) - logprob 接口标准化:multiple_choice 评测的"取概率最大的选项"逻辑统一,不会因实现方式不同导致数字差异
这种"可复现性"在工业评测里是常被忽视的——团队有时报告"我们的模型在 MMLU 上 85%",但换一个框架、换一套 prompt 跑,数字可能差出好几个点。lm-eval 把这种不确定性压到尽可能低。
工业团队即使不发论文,也应该把 lm-eval-harness 作为"模型横向对比"的事实标准——你切换基础模型时,跑一次 MMLU/HellaSwag/ARC 比无数自家 benchmark 更能让团队达成共识。这是它在工业场景的隐藏价值。
10.8.2 学术 vs 工业评测的张力
读完 lm-eval-harness 与 OpenAI evals 之后会感受到一种张力——学术与工业评测的优先级根本不同:
- 学术:可复现性 > 速度 > 易用性
- 工业:易用性 > 速度 > 可复现性
学术追求"任何人重跑都得到相同数字",所以 prompt 模板锁死,metric 严格定义,版本化到 commit 级别。工业追求"快速迭代得到决策信号",所以 prompt 可以日常微调,metric 可以临时改造,版本化粗放。
这种张力没有"对错"——它们解决不同的问题。一个成熟的 LLM 团队会同时维护两套评测:
- 用 lm-eval-harness 做"基础模型选型"——每次切换底模时跑学术 benchmark
- 用 promptfoo / ragas / OpenAI evals 做"应用层迭代"——每次改 prompt / retriever 跑业务 benchmark
理解这种张力,团队才不会浪费工程时间在"用错的工具解决错的问题"上。
10.8.3 lm-eval 的"task 编年史":哪些 benchmark 是真重要
仓库 3173 个 task 名不是同等重要。从论文引用频率看,工业团队真正应该关心的"top 10 benchmark":
| Task | 测什么 | 工业重要性 |
|---|---|---|
| MMLU / MMLU-Pro | 综合知识 | ⭐⭐⭐⭐⭐ |
| HellaSwag | 常识推理 | ⭐⭐⭐⭐ |
| ARC-Challenge | 科学推理 | ⭐⭐⭐⭐ |
| GSM8K | 数学题 | ⭐⭐⭐⭐⭐ |
| MATH | 高级数学 | ⭐⭐⭐⭐ |
| HumanEval | 代码生成 | ⭐⭐⭐⭐⭐ |
| MBPP | 代码补全 | ⭐⭐⭐ |
| TruthfulQA | 抗幻觉 | ⭐⭐⭐⭐⭐ |
| Winogrande | 指代消解 | ⭐⭐⭐ |
| BIG-Bench-Hard | 综合难题 | ⭐⭐⭐⭐ |
注意其中 HumanEval 与 MBPP 并不在 lm-eval v0.4.5 的内置 task 里——lm_eval/tasks/ 下没有这两个目录,代码生成 benchmark 由姊妹项目 bigcode-evaluation-harness 承担;lm-eval 里与代码沾边的只有 code_x_glue。其余 8 个都能在本仓库直接 --tasks 跑到(MMLU-Pro / BBH 是 group 名,GPQA 的 task 名是 gpqa_diamond_zeroshot 这类带后缀的形式)。
跑这 10 个 benchmark 已经覆盖了大部分主流 LLM 论文报告的能力维度。工业团队选模型时跑这几个就够,不必把 3173 个 task 名全跑一遍——边际信息几乎为零。
10.8.4 lm-eval 与 BIG-bench 关系的"考古"
读者读 lm-eval 时常会问"为什么 EleutherAI 不直接用 Google 的 BIG-bench?"——这个问题的前提其实不成立:lm-eval 并不是 BIG-bench 的重写。仓库 2020-08-27 就有首次提交,最早的 README 写的是 "Currently based on evaluations of GPT3 as mentioned in https://arxiv.org/pdf/2005.14165.pdf",即起步于复现 GPT-3 论文的评测;BIG-bench 论文(arXiv:2206.04615)是 2022 年才发布的。后来 lm-eval 反过来把 BIG-bench 收了进来:v0.4.5 的 lm_eval/tasks/bigbench/ 下有 286 份 YAML。
| 维度 | BIG-bench (2022) | lm-evaluation-harness (2020 起) |
|---|---|---|
| 组织 | Google 牵头,450 位作者、132 家机构 | EleutherAI + 开源社区 |
| 任务数 | 204 个 | v0.4.5 共 3173 个 task 名(含 BIG-bench 子任务) |
| 接入门槛 | JSON spec + 复杂 rendering | yaml + Python class |
| 模型支持 | 主要 Google 系 | HF / OpenAI / vLLM / 任意 |
| 定位 | 一次性的大规模协作 benchmark | 持续维护的评测框架 |
flowchart TB
subgraph "评测框架演化"
LME[2020-08: lm-eval 首次提交<br/>复现 GPT-3 论文评测]
BB[2022: BIG-bench<br/>204 task]
LME1[2023-12: lm-eval v0.4.0<br/>YAML 化任务 / vLLM 后端]
LME2[2024-10: lm-eval v0.4.5<br/>3000+ task 名 / group]
OPEN[Open LLM Leaderboard 后端]
end
LME --> LME1 --> LME2
BB -->|子任务收录进 tasks/bigbench| LME1
LME2 --> OPEN
style BB fill:#fff3e0
style LME2 fill:#e8f5e9
style OPEN fill:#e3f2fd
两者的设计取向差异可以概括为 4 点(本专栏的归纳,不是 EleutherAI 的官方表述):
- 后端解耦:lm-eval 用抽象
LM类把模型后端与任务定义分开,换后端不动 task - 接入成本:新任务写一份 YAML 即可,不必写 BIG-bench 那样的 JSON spec
- 口径显式:
output_type、prompt 模板、filter、metric 都写在 YAML 里,可逐层追溯 - 可审计:
--log_samples把每条样例的输入、输出、中间结果完整落盘
这使 lm-eval 成了开源 LLM benchmark 的"事实标准"——Hugging Face 的 Open LLM Leaderboard 曾直接用它跑分(该榜已于 2025 年 3 月退役归档)。
工程实务的 4 条"BIG-bench 时代教训":
- 不要把 task 当论文做——单 task 应该能被任何团队低成本重跑
- 格式贴近开源生态:用 yaml + jsonl 而非自创格式
- abstract backend 是必须的:让"换底层模型"不需要改 task 定义
- leaderboard 集成是落地关键:能上 leaderboard 的框架自带活跃度
具体例子:BIG-bench 的 "emoji_movie" 任务(用 emoji 串猜电影名),从论文角度有趣,但工业团队基本不用。lm-eval 其实收录了它(lm_eval/tasks/bigbench/multiple_choice/emoji_movie.yaml 与 generate_until/emoji_movie.yaml)——收录不等于值得跑,选哪些 task 是使用者自己的取舍(见 §10.8.3)。
研究背景:
- BIG-bench 论文 (Srivastava et al. arXiv:2206.04615) 是早期大规模协作构建的 benchmark 集
- lm-evaluation-harness 论文 (Biderman et al. 2024, "Lessons from the Trenches on Reproducible Evaluation of Language Models", arXiv:2405.14782) 总结了作者们多年开发维护 lm-eval 的评测经验
- Hugging Face Open LLM Leaderboard 于 2024 年中推出 v2,改用 IFEval / BBH / MATH Lvl 5 / GPQA / MuSR / MMLU-Pro 六项(v0.4.5 的
lm_eval/tasks/leaderboard/即对应这一套),2025 年 3 月退役
理解这段历史能让读者更深刻看到 lm-eval 设计中的"工业取舍"——它不是技术上最强大,但是工程上最稳健的开源评测框架。
10.8.5 lm-eval 的"反误用"提醒
lm-eval 太好用,工程团队容易犯一些误用。一份"反误用"提醒:
- 不要把 MMLU 分数当成"模型整体能力":MMLU 测的是百科知识,与你业务的客服 / 代码 / 创意能力没必然相关
- 不要追求 SOTA 分数而忘了业务:模型在 lm-eval 上+5pp 不一定让你的业务+5pp
- 不要忽视 task version:MMLU v1 与 v2 的题集 / 评分逻辑可能略有差异,跨版本比对要谨慎
- 不要把 lm-eval 当成应用层评测:业务 RAG / Agent 评测请用 ragas / promptfoo
- 不要在生产环境的 LLM Gateway 上随意跑:lm-eval 14k 题一次跑可能撑爆生产配额
每条都对应过去工程团队踩过的真实坑。这种"反误用"清单看起来朴素,但能避免一半的 lm-eval 使用陷阱。
10.9 打分范式与执行引擎
10.9.1 lm-eval 的"Generate-Until vs LogLikelihood"——两种打分范式的根本区别
读 lm-eval 源码时最容易混的概念是它的 2 种 request 类型——generate_until 与 loglikelihood。这两种范式直接决定了任务能不能跑、跑得对不对。lm_eval/api/instance.py:5-7 的 OutputType 里定义了这两种类型(multiple_choice 底层也是发 loglikelihood 请求),下面把它们的区别一次讲透:
| 维度 | generate_until | loglikelihood |
|---|---|---|
| 调用方式 | 模型自由生成 | 给定字符串求 logprob |
| 输入 | prompt | (prompt, target_str) |
| 输出 | str | float(log p(target|prompt)) |
| 速度 | 慢(自回归生成) | 快(单次 forward pass) |
| 评分方式 | 提取后 match | 比较多个 target 的 logprob |
| 适合任务 | 数学题 / 开放问答 / 代码 | 多选题 / 二分类 / 排序 |
| 典型任务 | gsm8k, ifeval, minerva_math | mmlu, arc, hellaswag, winogrande |
| API 模型支持 | ✅ | ⚠ 仅部分(OpenAI 已停) |
| 本地模型支持 | ✅ | ✅ |
# generate_until 范式(gsm8k task.yaml 示例)
"""
task: gsm8k
output_type: generate_until
generation_kwargs:
until: ["Question:", "</s>", "<|im_end|>"]
do_sample: false
temperature: 0.0
filter_list: # 每个条目是一条独立的 filter 流水线
- name: strict-match # name 是这条流水线的名字,不是 filter 名
filter:
- function: regex # function 才是 @register_filter 注册的名字
regex_pattern: '#### (\-?[0-9\.\,]+)'
- function: take_first
"""
# loglikelihood 范式(mmlu task.yaml 示例)
"""
task: mmlu_anatomy
output_type: multiple_choice
doc_to_choice: ["A", "B", "C", "D"]
doc_to_target: answer # 数据集里 answer 字段就是 0/1/2/3 索引
"""
flowchart LR
T[task yaml] --> O{output_type?}
O -->|generate_until| G[模型自由生成]
O -->|multiple_choice / loglikelihood| L[计算每个 candidate 的 logprob]
G --> RX[regex / filter chain]
RX --> M1[与 ideal 比对]
L --> ARGMAX[argmax over candidates]
ARGMAX --> M2[与 gold 比对]
M1 --> A[accuracy]
M2 --> A
style G fill:#fff3e0
style L fill:#e3f2fd
工程实务的 4 条选择经验:
- 多选题用 loglikelihood——只需前向打分、不用自回归生成,通常快得多;也不存在"生成了但抽不出答案"的格式失败
- 数学 / 代码用 generate_until——必须看到完整 reasoning 链
- chat API 模型只能用 generate_until——v0.4.5 里 chat 端点的
LocalChatCompletion.loglikelihood直接抛NotImplementedError(lm_eval/models/openai_completions.py:168);非 chat 的openai-completions也只对babbage-002/davinci-002放行 loglikelihood(断言见lm_eval/models/openai_completions.py:197) - 混合 task 时注意两类请求的 batch 需求不同——loglikelihood 请求可以用更大的 batch,generate_until 受生成长度拖累,合适的 batch 往往小得多(lm-eval 本身就按
request_type分批执行)
具体例子(示意数据,需用你自己的数据复算):跑 MMLU 全集(14k 题):
- 用 multiple_choice / loglikelihood:vLLM 7B 约 8 分钟
- 强行改成 generate_until 让模型选 ABCD:vLLM 7B 约 50 分钟,且准确率低 3-5pp
这个差距对应工业团队选 lm-eval 跑大批量评测的核心理由——loglikelihood 范式让"全 benchmark 跑一遍"在分钟而不是小时级。
研究背景:BIG-bench(Srivastava et al. arXiv:2206.04615)同时提供生成式与多选打分两类任务口径;两种口径跑出的分数不能直接相比,这也是 lm-eval 把 output_type 显式写进每份 YAML 的原因。
理解了 generate_until vs loglikelihood,再读 lm-eval 任何 task.yaml 都能秒判断它的运行模式。这是 §10.9.2 filter pipeline 之外,掌握 lm-eval 的另一个根本性切入点。
10.9.2 lm-eval 的 Filter Pipeline 源码细节
lm-eval 的一个高频踩坑点是 filter pipeline 的执行顺序——同一道题若 filter 配置错,再正确的模型答案也会被判错。本节用一份运行可见的对照例还原其内部机制。
Task.apply_filters(lm_eval/api/task.py:607,ConfigurableTask 在 1130 行覆写)按 yaml 中 filter_list 的每条流水线、流水线内 filter: 的声明顺序依次串联。注意 YAML 里写的是 @register_filter 注册的名字,不是类名——常用的几个:
| 注册名 | 实现类 / 源码位置 | 作用 | 典型场景 |
|---|---|---|---|
regex |
RegexFilter,lm_eval/filters/extraction.py:9 |
用正则从输出抽片段(regex_pattern / group_select) |
抽 "answer is X" 中的 X |
take_first |
TakeFirstFilter,lm_eval/filters/selection.py:12 |
从多次采样取第一个 | n=1 sampling |
majority_vote |
MajorityVoteFilter,lm_eval/filters/selection.py:43 |
多数表决(self-consistency) | n>1 sampling |
multi_choice_regex |
MultiChoiceRegexFilter,lm_eval/filters/extraction.py:74 |
选择题专用规则(继承自 RegexFilter) |
A/B/C/D 抽取 |
remove_whitespace / lowercase / uppercase / map |
lm_eval/filters/extraction.py:54、lm_eval/filters/transformation.py:5/17/29 |
归一化 | 判分前统一格式 |
decontaminate |
DecontaminationFilter,lm_eval/filters/decontamination.py:5 |
0.4.5 里是空壳:apply() 的函数体只有一个 pass(:21-25),什么都不做、返回 None;而且 lm_eval/filters/__init__.py 只导入了 extraction / selection / transformation,这个模块默认根本没被导入注册 |
别在 filter_list 里配它 |
# 一个常见错误配置(导致 0 分)
repeats: 5
generation_kwargs:
do_sample: true
filter_list:
- name: self-consistency
filter:
- function: take_first # ❌ 错:先 take_first 把 5 个采样砍成 1 个
- function: regex
regex_pattern: "answer is (\\d+)"
- function: majority_vote # ❌ 错:此时只有 1 个,多数表决无意义
# 正确配置
filter_list:
- name: self-consistency
filter:
- function: regex # ✅ 先对 5 个采样各抽一次答案
regex_pattern: "answer is (\\d+)"
- function: majority_vote # ✅ 再多数表决
- function: take_first # ✅ 最后落成单值
flowchart LR M[模型输出 5 次采样] --> R[function: regex<br/>每次抽数字] R --> MV[majority_vote<br/>5 个候选选最频繁] MV --> S[最终答案] M2[5 次采样] --> TF[function: take_first ❌] TF -->|信息丢失| LOST[只剩 1 个候选] style LOST fill:#ffebee style S fill:#e8f5e9
Task.apply_filters(lm_eval/api/task.py:607)只是遍历 self._filters(每个元素是一个 FilterEnsemble)逐条调用它。
源码层的关键点在 lm_eval/api/filter.py:33-56 的 FilterEnsemble.apply:
@dataclass
class FilterEnsemble:
"""
FilterEnsemble creates a pipeline applying multiple filters.
Its intended usage is to stack multiple post-processing steps in order.
`task.apply_filters` should use a list of FilterEnsemble classes that it stores, to apply each
pipeline separately.
"""
name: str
filters: List[Callable[[], Filter]]
def apply(self, instances: List[Instance]) -> None:
resps, docs = zip(*((inst.resps, inst.doc) for inst in instances))
resps, docs = list(resps), list(docs)
for f in self.filters:
# apply filters in sequence
resps = f().apply(resps, docs)
# add the end results after filtering to filtered_requests of their respective source instances.
# has key `self.name`: each FilterEnsemble applied in a given run should use a different name.
for inst, resp in zip(instances, resps):
inst.filtered_resps[self.name] = resp
三个容易踩的点:filter 是按 task 整批作用的(resps 是"每道题一份候选列表"的列表,不是单条字符串);Filter.apply(resps, docs) 两个参数都要收;结果按流水线 name 写进 inst.filtered_resps[name]——所以同一次生成能被多条流水线用不同口径打分(gsm8k 的 strict-match / flexible-extract 就是这么来的)。
工程实务的 3 条原则:
- filter 顺序 = 数据流顺序——上游 filter 的输出是下游的输入,错一步全错
- 聚合类 filter(
majority_vote)必须在抽取类(regex)之后 - debug 时把每步 output 打 log——
--log_samples --output_path debug.jsonl能看到每个 filter 后的中间值
读 lm-eval 源码这一段(lm_eval/api/filter.py 全文 56 行)就能把 filter 顺序问题排查清楚。
10.9.3 lm-eval 的并发架构剖析
仓库 lm_eval/api/registry.py(192 行)和 lm_eval/evaluator.py(681 行)一起实现了 lm-eval 的多任务并发架构。核心设计:
- 注册表:模型、metric、aggregation、filter 都注册进
registry.py里的 dict,按 name 查找(YAML 任务则由lm_eval/tasks/__init__.py的TaskManager扫描索引) - request collation:把多个 task 的 logprob requests 合并到同一批
- batch dispatch:合并后的 request 一次发给 model backend
- result demultiplex:response 收到后按 task / request 拆回各自结果
- lazy evaluation:task 的 metric 只在最后聚合阶段才算,不影响中间速度
这种 collate-batch-demultiplex 的并发模式让"跑 10 个 task 不比跑 1 个慢多少"——只要每个 task 的请求规模差异不大,batch 利用率就高。
工业 fork 时这个并发机制要保留——很多团队 fork 后只关注 task / metric 部分,忽略合批机制,结果吞吐明显下降。
10.9.4 多模型并发跑评测:lm-eval 的工程性能优化
lm-eval-harness 在大规模评测里的隐藏价值是性能优化——它不只是"能跑",是"快速跑通几万样例"。这一点对工程团队评估影响很大。
具体优化点(来自源码与官方文档):
- batch inference:把多条样例的 forward pass 打包,HuggingFace transformers / vLLM 后端原生支持
- 缓存重用:
--use_cache把(方法名, 请求参数)哈希后存进 SqliteDict,重跑时命中即跳过(注意默认不开,且非 greedy 采样不缓存) - 数据并行:多 GPU 时按 rank 切分样例(
build_all_requests(rank=lm.rank, world_size=lm.world_size)),各 rank 跑同一批 task 的不同分片,最后gather_object到 rank 0 聚合——不是"每张卡跑不同 task" - 数据集缓存:数据经 HF datasets 下载后落本地缓存,重跑不再下载(lm-eval 0.4.5 自身没有流式加载选项)
- 请求缓存即断点:崩溃重跑时已缓存的请求不再打模型;lm-eval 本身没有独立的 checkpoint/resume 机制
这套优化让"在 vLLM 后端跑完整 MMLU + HellaSwag + ARC + GSM8K"这类组合从"要跑一整天"压到可以当天多次迭代的量级(具体耗时取决于模型规模、few-shot 数与硬件,需自己实测)。
工程团队选 evaluator 时这一点常被忽视——很多自建评测脚本没做并行 / 缓存,跑一次评测要一整天,团队就慢慢不跑了。lm-eval 的"工程级性能"是它能成为学术事实标准的硬实力之一。
工业 fork 时建议保留这套优化机制,特别是 batch + 缓存——它们是单元测试覆盖之外的"无形资产",迁移到内部框架时容易被丢弃。
10.9.5 lm-eval-harness 在多机分布式跑大模型的工程模式
lm-eval 跑 70B+ 大模型时,单机推理能力捉襟见肘。仓库官方支持三种分布式模式(详见仓库 README 的 Multi-GPU 一节):
| 模式 | 核心 flag | 适用场景 | 代价 |
|---|---|---|---|
| HF accelerate | --model hf --model_args parallelize=True(把模型按层切到多卡);数据并行则用 accelerate launch -m lm_eval |
单机多 GPU:模型单卡放不下用 parallelize,放得下用数据并行 |
parallelize 是朴素的层切分,同一时刻只有一张卡在算 |
| vLLM | --model vllm(tensor_parallel_size / data_parallel_size) |
推理吞吐优先、batch 大 | 推理引擎差异需 verify |
| OpenAI compat API | --model local-completions --model_args base_url=... |
后端是 LLM Gateway / vLLM serve | 网络瓶颈、需限速保护 |
import subprocess
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass
class LmEvalLaunchPlan:
tasks: list[str]
n_fewshot: int
batch_size: int
output_path: Path
backend: str
model_args: dict
timeout_h: int
class DistributedLmEvalRunner:
"""多机 lm-eval 启动器:本地 / vLLM / OpenAI-compat 三选一"""
def _build_model_args(self, plan: LmEvalLaunchPlan) -> str:
if plan.backend == "vllm":
kv = {
# lm-eval 的 vllm 后端用 pretrained= 指定模型(与 hf 相同)
"pretrained": plan.model_args["model_name"],
"tensor_parallel_size": plan.model_args.get("tp", 4),
"gpu_memory_utilization": 0.9,
"max_model_len": plan.model_args.get("max_len", 4096),
}
elif plan.backend == "openai":
kv = {
"model": plan.model_args["model_name"],
# base_url 会被原样 POST,要写完整端点,如 http://host:8000/v1/completions
"base_url": plan.model_args["base_url"],
}
else:
kv = {
"pretrained": plan.model_args["model_name"],
"parallelize": True,
"dtype": "bfloat16",
}
return ",".join(f"{k}={v}" for k, v in kv.items())
# --model 只认 @register_model 注册名,"openai" 不是其中之一
BACKEND_TO_MODEL = {"hf": "hf", "vllm": "vllm",
"openai": "local-completions"}
def _build_cmd(self, plan: LmEvalLaunchPlan) -> list[str]:
return [
"lm_eval",
"--model", self.BACKEND_TO_MODEL[plan.backend],
"--model_args", self._build_model_args(plan),
"--tasks", ",".join(plan.tasks),
"--num_fewshot", str(plan.n_fewshot),
"--batch_size", str(plan.batch_size),
# 注意 --output_path 始终当"目录"用:结果落在
# <output_path>/<model_name_sanitized>/results_<时间戳>.json
"--output_path", str(plan.output_path),
"--log_samples",
]
def launch(self, plan: LmEvalLaunchPlan) -> int:
env = os.environ.copy()
env["HF_HUB_ENABLE_HF_TRANSFER"] = "1"
env["TOKENIZERS_PARALLELISM"] = "false"
cmd = self._build_cmd(plan)
result = subprocess.run(cmd, env=env,
timeout=plan.timeout_h * 3600,
check=False)
return result.returncode
def expected_cost_estimate(self, plan: LmEvalLaunchPlan,
cost_per_gpu_hour: float = 2.5) -> dict:
"""根据 task / fewshot / batch 估算 GPU 小时与成本"""
task_size = {"mmlu": 14000, "gsm8k": 1300, "hellaswag": 10000,
"arc_challenge": 1170, "winogrande": 1267}
total_q = sum(task_size.get(t, 5000) for t in plan.tasks)
# 7B / 单 GPU / batch=8 经验值:~4 题/秒
throughput = 4 * plan.batch_size / 8 * (plan.model_args.get("tp", 1))
seconds = total_q * (1 + plan.n_fewshot * 0.5) / max(throughput, 0.1)
gpu_hours = seconds / 3600 * plan.model_args.get("tp", 1)
return {
"total_questions": total_q,
"estimated_gpu_hours": round(gpu_hours, 1),
"estimated_cost_usd": round(gpu_hours * cost_per_gpu_hour, 2),
"estimated_wallclock_hours": round(seconds / 3600, 1),
}
flowchart LR
P[LmEvalLaunchPlan] --> B{backend?}
B -->|hf| H[本地 GPU<br/>parallelize=True]
B -->|vllm| V[vLLM 4-8 GPU<br/>tensor_parallel]
B -->|openai| O[OpenAI-compat<br/>调远端 Gateway]
H --> SUB[subprocess.run lm_eval]
V --> SUB
O --> SUB
SUB -->|结果 jsonl| OUT[output_path]
P --> EST[cost_estimate<br/>GPU 小时 / $$$]
style V fill:#e3f2fd
style O fill:#fff3e0
工程实务 4 条选择规则:
- 70B 模型 + 单机 8×A100 → vLLM(吞吐高)
- 小模型 7B 微调对比 → HF accelerate(启动简单,无需 vLLM 学习)
- 跨团队、跨地域、不能传模型权重 → OpenAI-compat(调远端 Gateway)
- Apple Silicon / mac mini 集群 → HF accelerate +
device=mps(不要用 vLLM,目前不支持 MPS)
成本估算没有通用的 ground truth:同一份 MMLU(14k 题、5-shot)的 GPU 时长随模型规模、推理后端、batch 与硬件差出数倍。expected_cost_estimate 里的吞吐系数是示意值,先用自己的环境跑一个小 --limit 标定,再用它在正式跑之前估预算。
这套 runner 的工程价值:把"哪种 backend、跑多久、要花多少钱"从口口相传变成可计算的预算决策。是公司级 lm-eval 集群运营的最小可行模板。
10.9.6 lm-eval 与 vLLM 的紧密集成
lm-eval 的 vLLM 后端是工业界首选——vLLM(《vLLM 推理内核深度解析》专栏第 4 章详述)的 PagedAttention + 高并发让评测吞吐量远超直接调 transformers。
耗时量级对比(示意值,未实测,只说明量级差异;自己的数字要在自家硬件上测):
| 后端 | MMLU 14k 题完整跑完 | 硬件 |
|---|---|---|
| HuggingFace transformers | ~6 小时 | 1×A100 |
| vLLM | ~30 分钟 | 1×A100 |
| vLLM tensor_parallel=4 | ~10 分钟 | 4×A100 |
差距主要来自 vLLM 的连续批处理与 PagedAttention 显存管理——这也是很多团队评测优先用 vLLM 而非裸 transformers 的原因。
工业 lm-eval 部署的标配:
# 启动 vLLM service
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--tensor-parallel-size 4 \
--max-model-len 8192
# lm-eval 通过 OpenAI 兼容 API 接入
lm_eval --model local-completions \
--model_args base_url=http://localhost:8000/v1/completions,model=meta-llama/Llama-3.1-8B-Instruct \
--tasks mmlu_pro
这种"vLLM service + lm-eval client"的解耦让评测和推理服务独立扩缩——评测高峰期可以临时拉起更多 vLLM 实例。
10.10 Task 的编写与治理
10.10.1 lm-eval 的"复合 task"——把多个子任务用一个 yaml 表达
lm-eval 的 yaml 不仅能定义单 task,还支持"复合 task" / "task group"——把几十个子任务一次启动。MMLU 有 57 个子集(anatomy / biology / chemistry / ...),每个都跑一次很麻烦——lm-eval 用 group 机制让 lm_eval --tasks mmlu 一行跑全 57 个。下面拆解 group yaml 与源码(lm_eval/api/group.py)的工作方式。
MMLU 的 group 是两层的,不是一层平铺。顶层 lm_eval/tasks/mmlu/default/_mmlu.yaml 原文只有 11 行:
group: mmlu
task:
- mmlu_stem
- mmlu_other
- mmlu_social_sciences
- mmlu_humanities
aggregate_metric_list:
- metric: acc
weight_by_size: True
metadata:
version: 2
第二层是 4 份类别 group,例如 _mmlu_stem.yaml:
group: mmlu_stem
group_alias: stem
task:
- mmlu_stem_tasks # 这是 tag,不是 task 名
aggregate_metric_list:
- metric: acc
weight_by_size: True
第三层才是 57 份学科 yaml,例如 mmlu_anatomy.yaml 原文:
"dataset_name": "anatomy"
"description": "The following are multiple choice questions (with answers) about anatomy.\n\n"
"tag": "mmlu_stem_tasks"
"include": "_default_template_yaml"
"task": "mmlu_anatomy"
"task_alias": "anatomy"
三个 schema 细节值得记住:include 让子任务继承 _default_template_yaml,所以学科 yaml 只写差异(数据集子集名 + 学科专属 description);tag 是横切标签——group 的 task: 列表里可以直接写一个 tag,把所有打了该 tag 的任务一网打尽,不必逐个枚举 57 行;聚合字段叫 aggregate_metric_list(配 weight_by_size),不是 aggregation——后者是单 task 内 metric_list 的字段,两者别混。
flowchart TB G[mmlu group yaml] --> A1[mmlu_anatomy] G --> A2[mmlu_astronomy] G --> A3[mmlu_biology] G --> A57[mmlu_world_religions] T[模板 _default_template_yaml] --> A1 T --> A2 T --> A57 A1 --> R[各子集结果] A2 --> R A57 --> R R --> AGG[weighted_mean 聚合] AGG --> M[全 group 综合分] style G fill:#e3f2fd style M fill:#e8f5e9
工程实务的 4 条 group 设计经验:
- 任何"多 sub-domain benchmark"都该用 group:MMLU / BBH / MMLU-Pro 都遵循该模式
- 聚合方式必须显式声明:
aggregate_metric_list里的weight_by_size: True(按子任务样本数加权)vs 默认的False(每子任务平等)在子集大小悬殊时结论会不一样——MMLU 各学科测试题量从 100 道到 1500 多道不等,两种口径给出的总分并不相同 include共享公共字段:description / fewshot 等公共配置提到模板里,子任务只写差异group自身可以嵌套 group:MMLU 就是mmlu→ 4 个类别 group → 57 个学科的嵌套结构(MMLU-Pro、BBH 在 v0.4.5 里则是一层平铺)
具体例子(示意数据,需用你自己的数据复算):跑 lm_eval --tasks mmlu_stem 一行启动 STEM 这一类别下的全部学科子集,结果分别报告 + 类别综合 acc(v0.4.5 提供的四个类别 group 是 mmlu_stem / mmlu_humanities / mmlu_social_sciences / mmlu_other)。这种粒度让团队能精确定位"模型在哪个学科方向退化"——比单一 mmlu 数字有诊断价值。
源码层:lm_eval/api/group.py 只有 117 行,负责的是配置对象而不是聚合算法——AggMetricConfig(lm_eval/api/group.py:8)校验 aggregation 只支持 mean(或传入可调用对象)、把 filter_list 规整成列表;GroupConfig(lm_eval/api/group.py:26)承载 group / group_alias / task / aggregate_metric_list;ConfigurableGroup(lm_eval/api/group.py:87)是运行时包装,lm_eval/tasks/__init__.py 加载 group yaml 时用它构造 group 对象。真正把子任务分数合成 group 分数的是 lm_eval/api/metrics.py:561 的 aggregate_subtask_metrics(metrics, sizes, weight_by_size=True),误差合并则在同文件的 pooled_sample_stderr / combined_sample_stderr。
下面是等价的简化示意(不是仓库里的真实类名):
# 示意:group 聚合在概念上做的事
class TaskGroup:
def __init__(self, group_name: str, tasks: list[str],
aggregation: str = "weighted_mean"):
self.name = group_name
self.tasks = tasks
self.aggregation = aggregation
def aggregate(self, per_task_scores: dict[str, dict]) -> float:
if self.aggregation == "weighted_mean":
total_n = sum(s["n"] for s in per_task_scores.values())
return sum(s["score"] * s["n"] / total_n
for s in per_task_scores.values())
elif self.aggregation == "simple_mean":
return sum(s["score"] for s in per_task_scores.values()) / \
len(per_task_scores)
raise ValueError(f"Unknown aggregation: {self.aggregation}")
读完这段,读者能理解 lm-eval 报告里"mmlu acc = 0.78、subtask=anatomy:0.82, biology:0.85, ..."这种结构是怎么出来的——并且能照着派生新 group(如自家 50 个客服领域的子任务合成 "internal_customer_service" group)。
研究背景:BBH(BIG-bench Hard, Suzgun et al. arXiv:2210.09261)原本就是 BIG-bench 的 23 个 hard 子任务的子集——天然 group 模式。MMLU-Pro(Wang et al. arXiv:2406.01574)把 MMLU 升级为 10 个选项、更偏推理的难题集,在 lm-eval 里按学科拆成 14 个子任务再合成一个 group。
10.10.2 完整的 lm-eval 中文 task 自定义 yaml
整合本章方法学,给一份"自家中文业务 task"的完整 lm-eval yaml 示例:
# my_tasks/cs_policy_qa.yaml
# 评测大模型对客服政策问答的事实一致性
task: cs_policy_qa
dataset_path: my_org/cs-policy-qa # 自家发布到 HuggingFace 的数据集
test_split: test
fewshot_split: dev
fewshot_config:
sampler: first_n
# 输出类型: 多选(A/B/C/D)
output_type: multiple_choice
# Jinja2 模板:把 doc 渲染成 prompt
doc_to_text: |
你是某电商平台的客服, 请根据政策回答问题.
问题: {{question}}
A. {{choice_a}}
B. {{choice_b}}
C. {{choice_c}}
D. {{choice_d}}
Answer:
doc_to_choice: ["A", "B", "C", "D"]
doc_to_target: answer # 数据集字段, 0=A, 1=B, 等
# Few-shot 条数(示例从上面 fewshot_split 指定的 dev 取,格式同 doc_to_text)
num_fewshot: 5
# 评测指标
metric_list:
- metric: acc
aggregation: mean
higher_is_better: true
- metric: acc_norm # 长度归一化版本
aggregation: mean
higher_is_better: true
# 元数据
metadata:
version: 1.0
description: |
客服退换货 / 物流 / 投诉等政策问答, 200 题
标注一致性 Cohen's Kappa = 0.78
覆盖电商场景的高频政策问题
tags:
- chinese
- customer-service
- policy-qa
几十行 yaml + 上传一份数据集到 HuggingFace = 自家的中文 lm-eval task。后续每个 LLM release 都能跑这个 task 拿到客观分数。
工业实务:把团队的核心业务测试集都按这种格式发布——内部公司私有 dataset 也行(不用真的上 HF:dataset_path: json 配合 dataset_kwargs.data_files 指向本地文件即可)。3-5 个核心 task 就能覆盖团队的"模型选型"和"模型升级"决策。
10.10.3 "lm-eval task 自动化巡检"——保证 task 质量长寿
仓库里 3000 多个 task 长期累积,会出现"陈年累月没人维护"的 task:依赖断裂 / 数据不可访问 / 与 lm-eval API 不兼容。下面是一份巡检脚本,每周扫描所有 task 找出"坏掉的":
import yaml
import subprocess
import asyncio
from pathlib import Path
from dataclasses import dataclass
from typing import Iterable
from datetime import datetime
@dataclass
class TaskHealthReport:
task_name: str
task_path: str
status: str # "healthy" | "stale" | "broken" | "deprecated"
last_test_at: str
issue_summary: str | None
sample_count: int | None
quick_run_seconds: float | None
class LmEvalTaskHealthChecker:
"""对 lm-eval 仓库所有 task 自动健康巡检"""
def __init__(self, lm_eval_root: Path):
self.root = lm_eval_root
def discover_tasks(self) -> list[Path]:
return list(self.root.glob("lm_eval/tasks/**/*.yaml"))
def parse_task(self, path: Path) -> dict:
# 注意:带 !function 标签的 yaml 会被 safe_load 拒绝,这里返回 {};
# group yaml 的 task 字段是列表,真实巡检前应先按 task / group 分流
try:
return yaml.safe_load(path.read_text()) or {}
except yaml.YAMLError:
return {}
async def quick_smoke_test(self, task_name: str) -> tuple[bool, float, str]:
"""跑 task 5 题做 smoke check"""
import time
start = time.monotonic()
try:
result = subprocess.run(
["lm_eval", "--model", "hf",
"--model_args", "pretrained=facebook/opt-125m",
"--tasks", task_name,
"--limit", "5",
"--num_fewshot", "0"],
capture_output=True, text=True, timeout=120,
)
elapsed = time.monotonic() - start
if result.returncode == 0:
return True, elapsed, ""
return False, elapsed, result.stderr[-500:]
except subprocess.TimeoutExpired:
return False, 120, "timeout 120s"
def check_dataset_accessible(self, task_spec: dict) -> bool:
"""检查 dataset_path 是否还能下载"""
ds_path = task_spec.get("dataset_path")
if not ds_path:
# 学科 yaml 常经 include 从模板继承 dataset_path,交给 smoke test 判断
return True
# 简化版:实际应试 huggingface_hub.dataset_info
return True
async def assess_task(self, path: Path) -> TaskHealthReport:
spec = self.parse_task(path)
task_name = spec.get("task", path.stem)
# 标记放在 metadata 里:顶层写未知字段会让 TaskConfig(**config) 直接报错
if (spec.get("metadata") or {}).get("status") == "deprecated":
return TaskHealthReport(
task_name=task_name, task_path=str(path),
status="deprecated",
last_test_at=datetime.now().isoformat(),
issue_summary=None, sample_count=None,
quick_run_seconds=None,
)
if not self.check_dataset_accessible(spec):
return TaskHealthReport(
task_name=task_name, task_path=str(path),
status="broken",
last_test_at=datetime.now().isoformat(),
issue_summary="dataset 不可访问",
sample_count=None, quick_run_seconds=None,
)
ok, elapsed, err = await self.quick_smoke_test(task_name)
if not ok:
return TaskHealthReport(
task_name=task_name, task_path=str(path),
status="broken",
last_test_at=datetime.now().isoformat(),
issue_summary=err, sample_count=None,
quick_run_seconds=elapsed,
)
return TaskHealthReport(
task_name=task_name, task_path=str(path),
status="healthy",
last_test_at=datetime.now().isoformat(),
issue_summary=None,
sample_count=spec.get("test_split_size", None),
quick_run_seconds=elapsed,
)
async def full_audit(self) -> dict:
tasks = self.discover_tasks()
# 控制并发避免打爆磁盘
sem = asyncio.Semaphore(5)
async def bounded(task):
async with sem:
return await self.assess_task(task)
reports = await asyncio.gather(*(bounded(t) for t in tasks))
from collections import Counter
status_counts = Counter(r.status for r in reports)
return {
"total": len(reports),
"by_status": dict(status_counts),
"broken_tasks": [r.task_name for r in reports
if r.status == "broken"][:20],
}
flowchart LR
R[lm_eval/tasks/**/*.yaml] --> D[discover_tasks]
D --> P[parse yaml]
P --> DA{dataset 可访问?}
DA -->|否| BK1[broken: dataset 失效]
DA -->|是| SM[5 题 smoke test]
SM -->|exit 0| H[healthy]
SM -->|exit !=0| BK2[broken: runtime error]
SM -->|timeout| BK3[broken: 性能问题]
BK1 --> RP[健康报告]
BK2 --> RP
BK3 --> RP
H --> RP
style H fill:#e8f5e9
style BK1 fill:#ffebee
style BK2 fill:#ffebee
style BK3 fill:#ffebee
工程实务的 4 条巡检规则:
- 每周 cron 触发:周末空闲时跑——避免占用工作时间 GPU
- broken_tasks 自动开 GitHub issue:标 [task-broken] tag,作者按周去修
- deprecated 标记写进 yaml 的
metadata(如metadata: {status: deprecated}):明确"已知坏,不影响整体健康度";别加顶层字段,TaskConfig不认识的键会让任务加载失败 - smoke test 用最小模型:facebook/opt-125m 跑得快,只验证"框架兼容性"
3 类常见 broken 来源:
| 类型 | 表现 | 修法 |
|---|---|---|
| dataset 链接挂 | HuggingFace 上 dataset 删除 | 切镜像 / 改用本地 |
| API 不兼容 | lm-eval 升级后老 task spec 失效 | 按新 API 重写 spec |
| 依赖断裂 | 任务依赖某个 Python 包 yanked | 锁 requirements.txt |
具体例子(示意数据,需用你自己的数据复算):仓库 1000 个 task 巡检——
- healthy: 850 (85%)
- broken: 45 (dataset 60% / API 30% / 依赖 10%)
- deprecated: 80
- 自动开 issue 30 个,剩 15 个由 maintainer 决定退役
研究背景:
- lm-eval 仓库的
.github/workflows/new_tasks.yml已部分实现这套检查:只对本次改动涉及的 task(lm_eval/tasks/**或lm_eval/api/**有变更时)跑测试,不做全量定期巡检
10.10.4 "私有 task 与公开 task 的 bridge"——让团队 task 也能上 leaderboard
读者建了一堆私有 task,但工业团队也想"对标公开 task"——下面给出私有 task 与公开 task 互相 bridge 的工程方案:
import yaml
from pathlib import Path
from dataclasses import dataclass
from typing import Iterable
@dataclass
class TaskBridgeConfig:
private_task: str
public_analog: str # 最相似的公开 task
metric_alignment: str # accuracy / f1 / exact_match
cross_correlation: float | None # 两边 score 的历史相关性
class PublicPrivateTaskBridge:
"""让团队私有 task 与公开 task 双向对照"""
DOMAIN_MAPPINGS = {
"customer_service_intent": {
"public_analog": "banking77", # lm-eval 内置的客服意图分类 task
"metric_alignment": "accuracy",
},
"code_review": {
"public_analog": "humaneval_plus", # 不在 lm-eval v0.4.5 里,需另跑 bigcode-evaluation-harness
"metric_alignment": "pass@1",
},
"medical_qa": {
"public_analog": "medqa_4options",
"metric_alignment": "accuracy",
},
"rag_grounded_qa": {
"public_analog": "nq_open",
"metric_alignment": "exact_match",
},
"agent_tool_calling": {
"public_analog": "bfcl", # 同样不在 lm-eval 里,需用 BFCL 自带评测工具
"metric_alignment": "function_call_accuracy",
},
}
def __init__(self, lm_eval_root: Path):
self.root = lm_eval_root
def bridge(self, private_task: str) -> TaskBridgeConfig:
mapping = self.DOMAIN_MAPPINGS.get(private_task, {
"public_analog": "mmlu", # 兜底
"metric_alignment": "accuracy",
})
return TaskBridgeConfig(
private_task=private_task,
public_analog=mapping["public_analog"],
metric_alignment=mapping["metric_alignment"],
cross_correlation=None, # 实际从历史数据算
)
def emit_combined_yaml(self, private_task: str,
output_path: Path) -> dict:
bridge = self.bridge(private_task)
# group yaml 只认 group / group_alias / task / aggregate_metric_list / metadata,
# 多写 metric_list 之类的键会让 GroupConfig(**config) 直接报错;
# 这里也不写 aggregate_metric_list:私有与公开分数各自报告,不合成一个均值
config = {
"group": f"{private_task}_with_baseline",
"task": [private_task, bridge.public_analog],
"metadata": {"metric_alignment": bridge.metric_alignment},
}
output_path.write_text(yaml.safe_dump(config, allow_unicode=True))
return config
flowchart LR
P[私有 task] --> B[Bridge Config]
PA[公开 task] --> B
B --> Y[combined yaml]
Y --> RUN[lm_eval --tasks group]
RUN --> R1[私有 score]
RUN --> R2[公开 score]
R1 --> CMP[相关性分析]
R2 --> CMP
CMP --> Q{相关性 > 0.7?}
Q -->|是| TR[trustworthy: 私有 task 反映通用能力]
Q -->|否| MISMATCH[警告: 私有 task 测的可能是别的]
style TR fill:#e8f5e9
style MISMATCH fill:#ffebee
工程实务的 4 条 bridge 价值:
- 横向对标同行:私有 task 跑外部模型 + 公开 task 配对——"我们的 customer_service 跟 banking77 做映射"
- 新模型选型快:候选模型在 banking77 跑得好 → 大概率私有也好
- 回答"我们 task 是否合理":与公开 task 高相关说明你 task 真测了能力
- 新人理解快:解释私有 task 用 "类似 banking77" 一句话就够,不必从头讲一遍任务定义
5 类常见公开 task 对应:
| 私有 task 类型 | 推荐公开 analog | 用途 |
|---|---|---|
| 客服意图分类 | banking77 | 通用客服能力 baseline |
| RAG 问答 | nq_open / triviaqa | 开放域问答能力 |
| 代码 | humaneval / mbpp(在 bigcode-evaluation-harness 里,不在 lm-eval) | 编码能力 |
| 推理 | gsm8k / math / gpqa | reasoning 能力 |
| 安全 | toxigen / realtoxicityprompts(lm-eval 内置);越狱类用 JailbreakBench / StrongREJECT 各自的工具 | 安全能力 |
具体例子(示意数据,需用你自己的数据复算):某团队的 customer_service_intent 与 banking77 做 bridge:
- 同跑 5 个候选模型
- 私有分数(依次):0.84 / 0.79 / 0.72 / 0.81 / 0.87
- banking77 分数:0.78 / 0.71 / 0.65 / 0.75 / 0.80
- Spearman 相关性:1.0(两组分数的排序完全一致)→ 私有 task 与 banking77 高度相关
- 结论:选 GPT-4o-mini(私有 0.87)合理——可信任公开 leaderboard 顺序
10.10.5 HuggingFace Datasets 集成的工程价值
lm-eval-harness 的一个被低估的设计——YAML task 的数据都通过 HuggingFace datasets API 加载(ConfigurableTask.download 直接调 datasets.load_dataset)。这不是技术选型偶然,而是有深远工程意义。
# task yaml 里典型的数据加载配置
dataset_path: cais/mmlu # HF datasets 仓库地址
dataset_name: abstract_algebra # 子集名(可选)
test_split: test # 用哪个 split
fewshot_split: dev
HuggingFace datasets 的工程价值:
- 数据集版本锁定:HF 上每个 dataset 有 commit hash,YAML 可以经
dataset_kwargs: {revision: <commit>}锁定到具体版本(v0.4.5 自带 YAML 默认不锁)。论文报告"在 mmlu commit abc123 上跑出 78.3%"读者能精确复现 - 流式加载:datasets 库本身支持
streaming=True,超大数据集不必全量下载(lm-eval 0.4.5 的加载路径没有用到它) - 统一缓存:第一次拉到本地后所有后续运行都用本地缓存,离线能跑
- 跨语言可读:一份 dataset 可以同时被 Python / Rust / 其他语言读取
- 生态工具丰富:filtering / mapping / batching 等操作都是 datasets 库内置
- 预处理可追溯:dataset 的 transform 操作(如 tokenization)会被缓存为新版本
工业 fork 这套框架时,最关键的一步是保持 datasets 集成不变。即使把 dataset 替换成内部数据,也建议把它转成 HuggingFace datasets 格式(用 datasets.Dataset.from_pandas 或 from_dict)——这样能继承 lm-eval 整个数据加载生态。
第 13 章 RAG 评测的 ragas + Dataset.from_list 用的正是这套机制——HuggingFace datasets 已经是 LLM 工程领域事实上的"数据交换格式"。
10.11 中文与本地生态
10.11.1 中文 LLM 评测的特殊挑战与 lm-eval 的中文 task 生态
中文 LLM 评测在 lm-eval-harness 上有一个值得讨论的演化——早期它的中文 task 极少,后来由社区贡献涌入。以本专栏对照的 v0.4.5 为准,lm_eval/tasks/ 下已有 CMMLU(中文版 MMLU)、C-Eval(综合中文评测)、AGIEval(含高考、法考、LogiQA 中文子集)、ACLUE(古汉语)、TMMLU+(繁体中文)等;CLUE、SuperCLUE 不在其中,要用它们各自的评测工具。
中文评测有几条与英文不同的工程难题:
- 分词差异:英文 BPE tokenizer 在中文上效率低(一个汉字可能切 2-3 个 token)
- 同义表达多:英文 "Paris" 唯一,中文"巴黎 / 法国首都 / 法国巴黎"全都对
- 繁简兼容:同一概念在简体 / 繁体下可能是不同 token,影响 logprob 一致性
- 领域知识本地化:法律、医疗、金融等专业领域的中文术语库与英文 benchmark 完全不同
工业团队跑中文 LLM 评测时建议组合:
- 基础能力:跑 CMMLU、C-Eval(覆盖中文学科知识)
- 推理能力:跑 AGIEval(含中文公考、高考、法考题)
- 生成能力:跑 SuperCLUE 的开放式题(不在 lm-eval 里,用 SuperCLUE 自己的评测流程)
- 加自家业务集:每个团队自己的客服 / 政策 / 业务对话集
这套组合能给出完整的"中文 LLM 在你业务上的能力画像"。
10.11.2 lm-eval 与中国大模型生态的对接
国内大模型(豆包 / Qwen / DeepSeek / Kimi / GLM 等)也常被拿到 lm-eval 上横向对比(各家技术报告里的数字则多出自自家评测框架)。通过 API 对接的方式:
# 通过 OpenAI compatible 的 chat 端点接入国内模型
# local-chat-completions 从环境变量 OPENAI_API_KEY 读密钥;base_url 会被原样 POST,要写完整端点
export OPENAI_API_KEY=<你的 DeepSeek API key>
lm_eval --model local-chat-completions \
--model_args base_url=https://api.deepseek.com/chat/completions,model=deepseek-chat \
--apply_chat_template \
--tasks mmlu_flan_cot_zeroshot,gsm8k_cot,ifeval
# chat 端点不支持 loglikelihood,truthfulqa_mc1 这类 multiple_choice task 不能放进来
国内模型在 lm-eval 上跑的几个特殊点:
- API 兼容:大部分国内模型已经提供 OpenAI 兼容 API,直接接入
- rate limit 各异:各家限流策略不同,按其文档设置并发(
--model_args的num_concurrent) - 中文 task:不只跑 MMLU 等英文 benchmark,要跑 CMMLU / C-Eval(注意二者是 multiple_choice,走 chat API 时需要生成式改写)
- Token 计费差异:国内模型 input / output 价格可能不同于 OpenAI,要按各家定价计算
国内大模型公开发布时,model card 上的 "MMLU 85%" 未必出自 lm-eval——各家常用自家框架、自家 prompt 跑分。要和 GPT-4 / Claude / Gemini 的数字真正可比,得自己用同一版本 lm-eval、同一份 task 重跑;这种"统一口径"才是 lm-eval 给国内团队的关键工程价值。
10.11.3 具体的"国内大模型在 lm-eval 上的位置"
把 lm-eval 在国内大模型公开数据上的应用具象化。下面只列各家自己在技术报告里写明的 MMLU 数字,并给出可点击出处——凡是找不到官方原始出处的,本专栏一律不列:
| 模型 | 自报 MMLU | 出处 |
|---|---|---|
| DeepSeek-V3 | 87.1(5-shot, EM) | DeepSeek-V3 Technical Report, arXiv:2412.19437 |
| Qwen2.5-72B | 86.1 | Qwen2.5 Technical Report, arXiv:2412.15115 |
| Llama 3.1 405B | 87.3(5-shot) | The Llama 3 Herd of Models, arXiv:2407.21783 |
这类数字最不该被直接横向相减——各家的 few-shot 数、prompt 模板、答案抽取规则、是 loglikelihood 还是 generation 判分都可能不同,而这些正是 §10.9.2 讲的 filter pipeline 与 §10.4 讲的 YAML 口径决定的。真要比较,唯一可信的做法是自己用同一份 lm-eval 版本、同一份 task yaml 把候选模型重跑一遍。
工业团队的实务:选基础模型时,把官方自报数字 + 自家业务集评测组合看。任何"自家模型比 GPT-4 强 10pp"的宣传,先在 lm-eval 上用统一口径复跑再相信。这种"客观对照"的姿态是评测工程师的基本功。
国内团队特别注意:如果你看到自家模型在 MMLU 上"超越 GPT-4",要警惕数据污染——MMLU 的训练数据可能已经被你模型见过。多跑几个 newer benchmark(Arena Hard / MMLU Pro / GPQA)做交叉验证。
10.12 分数怎么读:偏差、污染与长尾
10.12.1 工程教训:lm-eval 的"benchmark 偏差"
lm-eval 给的 benchmark 数字口径透明、可复现,但有一个工程教训:benchmark 不能完全反映你业务的真实能力。
经典反例:某模型在 MMLU 上 85%,业务团队接入后发现实际客服任务上效果只有 65%。原因:
- MMLU 测的是"百科知识",与"客服礼貌响应 + 准确引用政策"是不同能力
- MMLU 题目格式(多选)与真实开放对话格式差异大
- 模型可能在 MMLU 数据上有过拟合(公开数据集污染)
这意味着:lm-eval 给的是"模型相对位置"信号,不是"绝对业务表现"判断。工业实践:
- 用 lm-eval 把候选模型从 10 个筛到 3 个(节省时间)
- 用 promptfoo / ragas 在自家业务集上跑这 3 个候选(拿到真实表现)
- 综合两个信号做最终选型
跳过任一步都会出问题——只看 lm-eval 选模型经常事后扑街,只看业务集忽略 lm-eval 经常错过更强的候选。
10.12.2 学术评测的根本痛点:Benchmark Contamination
学术 LLM 评测的最大头疼问题是 benchmark contamination(数据污染)——模型在预训练数据里见过 benchmark 题目,跑出虚高分数。
具体表现:
- MMLU、HumanEval 等公开 benchmark 的题目早已被模型方爬取
- 部分模型的训练集甚至包含 benchmark 解析("this is the answer to MMLU question X")
- 跑出 95% 准确率 ≠ 模型真懂,可能只是"背过"
学术界的应对:
- n-gram overlap 检测:把 benchmark 与训练集做 n-gram 重叠度检测,超过阈值的视为污染(Brown et al. 2020 GPT-3 论文用的是 13-gram)
- 私有变体:把 benchmark 题改写成等价但用词不同的私有版
- 动态 benchmark:持续补充新题(如 LiveBench 定期用新近的竞赛题、论文、新闻出题)或改用实时人类投票(如 Chatbot Arena)
- Holdout 题集:保留小部分题不公开,用于真实评估
工业团队的应对:
- 不依赖单一 benchmark:lm-eval 跑出的数字配合自家私有评测集
- 看新发布的动态 benchmark:LiveBench / Chatbot Arena 在 2024-2026 年是更可信的"未污染"选择
- 怀疑反常的高分:某个模型在单一 benchmark 上明显高出同代模型,而在新题上掉得厉害时,先怀疑污染
这是 lm-eval-harness 给的"benchmark 数字"必须配合"业务集验证"的根本原因。学术评测和工业评测不是互替关系——是互补关系。
10.12.3 lm-eval 与"评测训练污染检测"——模型是真懂还是背过 benchmark
行业最隐秘的"虚假高分"来源:模型在训练时见过 MMLU / GSM8K 等 benchmark 的题目(公开数据中混入),评测看似 86 分实则是"背题"。lm-eval 跑出来的高分不能直接采信,必须配套训练污染检测。本节给读者一份「污染检测 + 报告卡」工程方案,让评测分数自带可信度评级。
graph LR
A[lm-eval 评测分数] --> B{污染检测}
B --> C[1. n-gram 重叠]
B --> D[2. 完美匹配率<br/>异常高?]
B --> E[3. 训练数据公开声明<br/>是否覆盖该 benchmark]
B --> F[4. canary 字符串嵌入]
C & D & E & F --> G[污染评分]
G --> H{评分阈值}
H -->|< 0.05| I[clean<br/>分数可信]
H -->|0.05-0.20| J[suspect<br/>需独立测试]
H -->|> 0.20| K[contaminated<br/>分数不可信]
K --> L[报告必须标记 disclaimer]
J --> M[补充 hold-out 测试]
4 类污染检测信号 × 阈值 × 处置(阈值为示意值,需按自己的数据校准):
| 检测信号 | 度量 | warn 阈值 | block 阈值 | 实现 |
|---|---|---|---|---|
| n-gram 重叠 | benchmark 13-gram 在训练数据中比例 | > 0.05 | > 0.20 | 字符串扫描 |
| 完美匹配率异常 | 模型答案 100% 一字不差比例 | > 0.30 | > 0.50 | 比对 ideal |
| 训练数据声明 | 是否公开声明含某 benchmark | 不含 | 含 | 公开声明扫描 |
| canary 字符串 | 故意植入的"咒语"是否被记住 | 部分匹配 | 完全匹配 | canary test |
配套实现:训练污染检测器 + 评测可信度评级:
import re
from dataclasses import dataclass, field
from typing import Literal
ContaminationStatus = Literal["clean", "suspect", "contaminated"]
@dataclass
class ContaminationScore:
benchmark_name: str
ngram_overlap_pct: float
perfect_match_rate: float
training_data_declares_benchmark: bool
canary_match: bool
overall_score: float
status: ContaminationStatus
@dataclass
class ContaminationDetector:
ngram_n: int = 13
overlap_warn: float = 0.05
overlap_block: float = 0.20
perfect_warn: float = 0.30
perfect_block: float = 0.50
def ngram_overlap(self, benchmark_text: str, training_corpus_sample: str) -> float:
"""简化:N-gram 集合交集 / benchmark 总 N-gram 数"""
def ngrams(text: str, n: int) -> set[str]:
tokens = text.split()
return set(" ".join(tokens[i:i+n]) for i in range(len(tokens) - n + 1))
b_ng = ngrams(benchmark_text, self.ngram_n)
t_ng = ngrams(training_corpus_sample, self.ngram_n)
if not b_ng: return 0.0
return len(b_ng & t_ng) / len(b_ng)
def perfect_match_rate(self, model_outputs: list[str], ideals: list[str]) -> float:
if not ideals: return 0.0
matches = sum(1 for m, i in zip(model_outputs, ideals)
if m.strip() == i.strip())
return matches / len(ideals)
def detect(self, benchmark_name: str,
benchmark_text: str,
training_corpus_sample: str,
model_outputs: list[str],
ideals: list[str],
training_data_declares: bool = False,
canary_string: str | None = None,
model_response_to_canary: str | None = None) -> ContaminationScore:
overlap = self.ngram_overlap(benchmark_text, training_corpus_sample)
perfect = self.perfect_match_rate(model_outputs, ideals)
canary_hit = bool(canary_string and model_response_to_canary
and canary_string in model_response_to_canary)
# 综合评分
score = (
min(overlap / 0.20, 1.0) * 0.40 +
min(perfect / 0.50, 1.0) * 0.30 +
(1.0 if training_data_declares else 0.0) * 0.20 +
(1.0 if canary_hit else 0.0) * 0.10
)
if score < 0.05:
status: ContaminationStatus = "clean"
elif score < 0.20:
status = "suspect"
else:
status = "contaminated"
return ContaminationScore(
benchmark_name=benchmark_name,
ngram_overlap_pct=overlap,
perfect_match_rate=perfect,
training_data_declares_benchmark=training_data_declares,
canary_match=canary_hit,
overall_score=round(score, 3),
status=status,
)
def report_disclaimer(self, c: ContaminationScore) -> str:
if c.status == "clean":
return f"[clean] {c.benchmark_name} 分数可直接采信"
if c.status == "suspect":
return (f"[suspect] {c.benchmark_name} 污染分 {c.overall_score};"
f"建议补充 hold-out 测试验证")
return (f"[contaminated] {c.benchmark_name} 污染分 {c.overall_score};"
f"分数不可作为模型能力证明,必须用 fresh benchmark 重测")
举例(示意数据,需用你自己的数据复算):某团队评测一款国产开源模型 MMLU 跑 88 分(高于 GPT-4 的 86):
- detect → ngram_overlap 0.15 / perfect_match 0.40 / 训练数据声明含 MMLU = True
- score = 0.15/0.20×0.4 + 0.40/0.50×0.3 + 1.0×0.2 + 0×0.1 = 0.74 → contaminated
- report_disclaimer:「[contaminated] MMLU 分数不可作为模型能力证明」
- 下一步:改用 fresh benchmark(自建 hold-out 题)复测,再决定是否入选
避免依赖虚假高分做出错误的模型采购决策。
配套行业研究背景:
- Benchmark contamination 的系统讨论可参考 Sainz et al. arXiv:2310.18018
- Canary 字符串:BIG-bench 在任务文件里嵌入 canary GUID,用来检测训练语料是否混入了评测数据
10.12.4 lm-eval 的"长尾任务覆盖率"——leaderboard 高分但生产差的诊断框架
行业常见现象:模型在 MMLU / GSM8K 等头部 benchmark 拿 90+,但接到具体业务后表现"一般"。原因不是模型差,而是"benchmark 与业务任务分布不匹配"——头部 benchmark 集中在 5-10 类任务,而业务可能涉及 50+ 类长尾任务。本节给读者一份"长尾任务覆盖率"诊断框架。
graph LR
A[模型 leaderboard 高分] --> B{部署到业务后}
B -->|实际表现差| C[根因诊断]
C --> D[业务任务分布分析]
C --> E[lm-eval 任务覆盖分析]
D & E --> F[覆盖率矩阵]
F --> G[发现 gap]
G --> H[补充 lm-eval 长尾 task]
H --> I[重测得到真实分数]
I --> J[决策修正]
lm-eval 头部 / 长尾任务分布对照(示意:task 数按 v0.4.5 目录粗数,只取量级;业务占比是虚构的示例公司):
| 任务类型 | lm-eval 内 task 数 | 业务实际占比 | 偏差 | 诊断 |
|---|---|---|---|---|
| 通用知识 (MMLU 系) | 400+(仅 mmlu/ 目录) |
5% | 严重过覆盖 | leaderboard 偏向通用 |
| 数学推理 (GSM8K, MATH) | 20 左右 | 3% | 过覆盖 | 学术热点 |
| 代码 (只有 code_x_glue) | 6 | 8% | 不足 | lm-eval 无 HumanEval/MBPP,要另跑 bigcode-evaluation-harness |
| 中文 (C-Eval, CMMLU) | 100+ | 30% | 学科题多、业务题少 | 中文业务必扩 |
| 多轮对话 | 3 | 25% | 严重不足 | 实际 chat 主流场景 |
| 工具调用 / Agent | 2 | 15% | 严重不足 | RAG / Agent 场景 |
| 安全 / 合规 | 5 | 10% | 不足 | 监管要求 |
| 业务垂直域 | 0 | 4% | 完全无覆盖 | 必须自建 task |
配套实现:长尾任务覆盖率诊断器:
from dataclasses import dataclass, field
@dataclass
class TaskTypeCoverage:
name: str
lm_eval_task_count: int
business_traffic_share: float # 0.0~1.0
def coverage_index(self) -> float:
"""归一化的覆盖偏差:>1 过覆盖,<1 不足"""
denom = max(self.business_traffic_share, 0.01)
return (self.lm_eval_task_count / 100) / denom
def gap_severity(self) -> str:
idx = self.coverage_index()
if idx > 3: return "严重过覆盖(投入浪费)"
if idx > 1.5: return "过覆盖"
if idx >= 0.5: return "适度"
if idx >= 0.2: return "不足(可能漏诊)"
return "严重不足(leaderboard 与业务严重脱节)"
@dataclass
class LongTailDiagnosis:
coverages: list[TaskTypeCoverage]
def by_severity(self) -> dict[str, list[str]]:
groups = {"严重不足": [], "不足": [], "适度": [],
"过覆盖": [], "严重过覆盖": []}
for c in self.coverages:
sev = c.gap_severity()
for key in groups:
if key in sev:
groups[key].append(c.name)
break
return groups
def recommendation(self) -> list[str]:
groups = self.by_severity()
recs = []
for name in groups.get("严重不足", []):
recs.append(f"高优:自建 lm-eval task 补充 {name}(业务占比高但完全无覆盖)")
for name in groups.get("不足", []):
recs.append(f"中优:扩展 {name} task 覆盖")
for name in groups.get("严重过覆盖", []):
recs.append(f"低优:可减少 {name} 评测频次,节省算力")
return recs
def overall_score(self) -> float:
"""加权平均偏差,越接近 1 越对齐"""
total_weight = sum(c.business_traffic_share for c in self.coverages)
weighted = sum(
min(c.coverage_index(), 3.0) * c.business_traffic_share
for c in self.coverages
)
return weighted / max(total_weight, 0.01)
举例(示意数据,需用你自己的数据复算):某中文客服公司诊断 → overall_score = 0.4(远小于理想 1.0),4 类严重不足(中文 / 多轮 / Agent / 业务域)。recommendation 给出 4 条高优行动。
配套行业研究背景:
- HELM(Liang et al. arXiv:2211.09110)用 16 个核心场景 × 7 类指标做"全景式"评测,是"按覆盖面评测"思路的代表
10.12.5 lm-eval 与 OpenLLM Leaderboard 的"分数解读手册"
HuggingFace Open LLM Leaderboard(直接用 lm-eval 后端)在 2024–2025 年间为上万个开源模型各出过一行分数(该榜 2025 年 3 月已退役归档,但历史数据仍常被引用)。读者常常问"这一行的 IFEval=0.823 / GPQA=0.412 到底说明什么?"——下面给出常见 metric 的工程解读。先厘清口径:v0.4.5 仓库里 lm_eval/tasks/leaderboard/leaderboard.yaml 的 group 只含 6 项——leaderboard_mmlu_pro / leaderboard_bbh / leaderboard_gpqa / leaderboard_math_hard / leaderboard_ifeval / leaderboard_musr;表里的 HellaSwag / GSM8K 是 leaderboard v1 时代的项目,HumanEval 则从来不在 leaderboard 上(放在这里只是给一个常用编码指标的档位参照)。
下表的档位是本专栏按 2024–2025 年常见公开分数粗略划分的经验示意,不是官方分级:
| Metric | 评测内容 | 0.0-0.4 含义 | 0.4-0.7 含义 | > 0.7 含义 |
|---|---|---|---|---|
| MMLU-Pro | 多领域知识 | 弱模型 / 早期 7B | 中等 / GPT-3.5 级 | 强 / GPT-4 级 |
| GPQA-Diamond | 物理 / 化学博士题 | 一般 LLM | o1-mini 级 | 顶尖 reasoning |
| HumanEval | Python 编码 | 弱编码 | GPT-3.5 级 | GPT-4o / Claude 级 |
| MATH | 数学推理 | 早期模型 | 中等 | reasoning 模型 |
| IFEval | 指令跟随 | 不听话 | 一般 | 优秀 |
| MuSR | 多步推理 | 推理弱 | 中等 | 强推理 |
| BBH | 综合困难题 | 一般 | 较好 | 优秀 |
| HellaSwag | 常识 | 弱 | 一般 | 已饱和(多数 ≥ 0.85) |
| GSM8K | 小学数学 | 中等 | 良好 | 已饱和(多数 ≥ 0.85) |
from dataclasses import dataclass
from typing import Iterable
@dataclass
class LeaderboardScoreInterpretation:
metric: str
raw_score: float
percentile_estimate: int # 0-100
capability_tier: str
saturation_warning: bool
business_relevance: str
class LeaderboardScoreInterpreter:
"""把 leaderboard 数字转成业务可读的'能力档位'"""
METRIC_PROFILES = {
"MMLU_Pro": {
"tiers": [(0.30, "weak"), (0.50, "middle"),
(0.70, "strong"), (1.0, "frontier")],
"saturated": False,
"business_relevance": "通用 RAG / 客服基础能力",
},
"GPQA_Diamond": {
"tiers": [(0.25, "random"), (0.40, "good"),
(0.60, "expert"), (1.0, "phd")],
"saturated": False,
"business_relevance": "科研 / 法律 / 医疗推理质量",
},
"HumanEval": {
"tiers": [(0.40, "weak"), (0.70, "middle"),
(0.90, "strong"), (1.0, "frontier")],
"saturated": True, # GPT-4o+ 已 ≥ 0.9
"business_relevance": "代码生成 / Agent",
},
"GSM8K": {
"tiers": [(0.50, "weak"), (0.80, "middle"),
(0.95, "saturated"), (1.0, "frontier")],
"saturated": True,
"business_relevance": "已不能区分头部模型",
},
"IFEval": {
"tiers": [(0.50, "weak"), (0.70, "middle"),
(0.85, "strong"), (1.0, "excellent")],
"saturated": False,
"business_relevance": "结构化输出 + Agent tool 调用",
},
}
def interpret(self, metric: str, score: float) -> LeaderboardScoreInterpretation:
profile = self.METRIC_PROFILES.get(metric, {
"tiers": [(0.4, "weak"), (0.7, "middle"), (1.0, "strong")],
"saturated": False,
"business_relevance": "unknown",
})
for threshold, tier in profile["tiers"]:
if score <= threshold:
break
# 简化:用 tier 估 percentile
tier_to_pct = {
"weak": 25, "middle": 55, "strong": 80,
"frontier": 95, "expert": 90, "phd": 98, "saturated": 50,
"good": 50, "excellent": 90, "random": 10,
}
return LeaderboardScoreInterpretation(
metric=metric,
raw_score=score,
percentile_estimate=tier_to_pct.get(tier, 50),
capability_tier=tier,
saturation_warning=profile["saturated"] and score >= 0.85,
business_relevance=profile["business_relevance"],
)
flowchart LR
L[OpenLLM Leaderboard 行] --> P[每 metric 解读]
P --> S{饱和?}
S -->|是, e.g. HellaSwag 0.92| WARN[已不区分头部]
S -->|否| TIER[tier 分档]
TIER --> BR[业务相关性映射]
BR --> DEC[选型决策]
style WARN fill:#fff3e0
style DEC fill:#e8f5e9
工程实务的 4 条解读经验:
- 不要被"饱和指标"骗:HellaSwag / GSM8K 已 saturated, 头部模型差距 < 1pp 没意义
- GPQA-Diamond 曾是 2024–2025 年区分 reasoning 能力的常用试金石:能拉开 GPT-4o 与 o3-mini 这类模型的差距(随着头部模型分数走高,它也在逐步饱和)
- IFEval 对 Agent 业务最关键:结构化输出能力 = tool calling 能力的 proxy
- 业务相关性看 business_relevance 字段:不是所有 metric 对自家业务都重要
具体例子(示意数据,需用你自己的数据复算):选 chatbot 客服底座模型,看几个候选:
| 模型 | MMLU-Pro | GPQA | IFEval | HumanEval | 综合判断 |
|---|---|---|---|---|---|
| Qwen2.5-72B | 0.61 | 0.32 | 0.81 | 0.85 | 客服够用,IFEval 出色 |
| GPT-4o-mini | 0.65 | 0.39 | 0.83 | 0.87 | 平衡好、性价比高 |
| Claude-Haiku | 0.58 | 0.35 | 0.84 | 0.88 | IFEval 最好 |
| 内部 13B 微调 | 0.50 | 0.20 | 0.65 | 0.55 | 编码弱,reasoning 弱 |
客服业务核心是 IFEval(指令跟随)+ MMLU-Pro(通用知识)—— Claude Haiku 与 GPT-4o-mini 都是好选择,看价格 / 延迟决定。
研究背景:
- HuggingFace Open LLM Leaderboard 2024 年 6 月升级到 v2 版本,加入 GPQA / MMLU-Pro / MuSR 等,2025 年 3 月退役
- OpenAI o1 / o3 等推理模型的官方发布材料都把 GPQA Diamond 作为核心对比项之一
10.13 接进工程流程
10.13.1 lm-eval 在工业 LLM Gateway 后的接入位置
工业大型团队通常在 LLM 调用前过一层 LLM Gateway(第 9 章 §9.9.3 给过把 OpenAI evals 接到内部 Gateway 的示例)。lm-eval 接入这个 Gateway 时的工程考量:
flowchart LR Eval[lm-eval client] -->|OpenAI compatible| GW[LLM Gateway] GW -->|路由| M1[OpenAI] GW -->|路由| M2[Anthropic] GW -->|路由| M3[内部模型] GW -->|审计 / 限流| Audit[审计日志] style GW fill:#fef3c7 style Audit fill:#dcfce7
Gateway 给评测带来的好处:
- 统一鉴权:评测共享生产 LLM 配额,账目清晰
- 多 provider 切换:lm-eval 一次跑出多家模型对比
- 审计 trail:评测调用与生产调用同样有日志
- 成本控制:Gateway 层做评测专用的预算上限
这是不少大厂 LLM 工程团队的常见架构。lm-eval 在这个架构下接 Gateway 而非直接接 model,把"评测"和"调用基础设施"解耦——更可控也更可审计。
10.13.2 lm-eval 与 LLM 训练流程的"无缝集成"
最后讨论 lm-eval 在 LLM 训练流程中的"无缝集成"——它是模型训练 pipeline 的关键组件:
flowchart LR Train[模型训练] --> Save[保存 checkpoint] Save --> AutoEval[自动跑 lm-eval] AutoEval --> Compare[与 baseline 比较] Compare -->|涨| Promote[promote 到 staging] Compare -->|跌| Investigate[调查原因] style AutoEval fill:#fef3c7
每个 checkpoint 自动跑 5-10 个核心 benchmark,数字与上一版对比。这种"训练即评测"的集成让模型迭代有了客观信号。
工程实务:
- 训练脚本(DeepSpeed / FSDP 等)在保存 checkpoint 后调用 lm-eval——CLI 或 Python 入口
lm_eval.simple_evaluate都行 - 跑完后结果写到 wandb(lm-eval 自带
--wandb_args)/ mlflow - 数字差异超阈值自动告警
很多大模型团队都用类似流程。lm-eval 在这种"训练 + 评测一体化"管线里是核心环节,不是可有可无的外部工具。
10.13.3 lm-eval 在 fine-tuning loop 的"训练 → 评测 → 反馈"闭环
模型 fine-tuning 时的 lm-eval 用法和"评测某个固定模型"完全不同——它是 training loop 里的一环。下面给出工程化集成:
import asyncio
import subprocess
import json
from pathlib import Path
from dataclasses import dataclass
from typing import Iterable
@dataclass
class CheckpointEvalResult:
checkpoint_step: int
benchmark_scores: dict[str, float]
overall_score: float
is_best_so_far: bool
delta_from_baseline: float
class FineTuningLoopEvalIntegrator:
"""fine-tuning 训练循环中嵌入 lm-eval"""
KEY_BENCHMARKS = ["mmlu", "ifeval", "gsm8k"]
# 各 task 的主指标 key 不同,形如 "<metric>,<filter 流水线名>"
METRIC_KEY = {
"mmlu": "acc,none",
"ifeval": "prompt_level_strict_acc,none",
"gsm8k": "exact_match,strict-match",
}
EVAL_EVERY_STEPS = 500 # 每 500 步评测一次
LIMIT_PER_TASK = 200 # 每 task 抽 200 题(速度优先)
def __init__(self, model_save_dir: Path, baseline_score: float):
self.save_dir = model_save_dir
self.baseline = baseline_score
self.best_score = baseline_score
self.history = []
def should_eval(self, step: int) -> bool:
return step > 0 and step % self.EVAL_EVERY_STEPS == 0
async def eval_checkpoint(self, step: int,
checkpoint_path: Path
) -> CheckpointEvalResult:
scores = {}
for bench in self.KEY_BENCHMARKS:
# --output_path 是目录:lm-eval 会写成
# <dir>/<model_name_sanitized>/results_<ISO 时间戳>.json
out_dir = self.save_dir / f"step_{step}_{bench}"
subprocess.run([
"lm_eval", "--model", "hf", "--model_args",
f"pretrained={checkpoint_path}",
"--tasks", bench,
"--limit", str(self.LIMIT_PER_TASK),
"--output_path", str(out_dir),
], check=False)
files = sorted(out_dir.glob("*/results_*.json"))
if files:
data = json.loads(files[-1].read_text())
scores[bench] = data["results"][bench].get(
self.METRIC_KEY[bench], 0)
overall = sum(scores.values()) / max(len(scores), 1)
is_best = overall > self.best_score
delta = overall - self.baseline
if is_best:
self.best_score = overall
result = CheckpointEvalResult(
checkpoint_step=step,
benchmark_scores=scores,
overall_score=overall,
is_best_so_far=is_best,
delta_from_baseline=delta,
)
self.history.append(result)
return result
def early_stop_signal(self, patience: int = 3) -> bool:
"""连续 N 个 checkpoint 没改善 → early stop"""
if len(self.history) < patience:
return False
recent = self.history[-patience:]
return all(not r.is_best_so_far for r in recent)
flowchart LR
T[fine-tuning train step] --> S{每 500 步?}
S -->|否| T
S -->|是| E[lm-eval 3 benchmark]
E --> SC[overall score]
SC --> CMP{> best?}
CMP -->|是| SAVE[保存为 best checkpoint]
CMP -->|否| HIST[加入 history]
HIST --> ES{连续 3 次没改善?}
ES -->|是| STOP[early stop]
ES -->|否| T
SAVE --> T
style SAVE fill:#e8f5e9
style STOP fill:#fff3e0
工程实务的 4 类训练评测设计:
| 维度 | 训练时评测 | 上线评测 |
|---|---|---|
| 评测集 | 公开 benchmark 抽样 | 完整 benchmark + 私有集 |
| 频率 | 每 500-1000 步 | release 时一次 |
| 速度 | 必须快(≤ 5 min) | 可慢(30 min) |
| 决策 | early stop / 选 checkpoint | 上线 / 不上线 |
具体例子(示意数据,需用你自己的数据复算):一次 fine-tune 训练到第 3000 步:
| step | mmlu | ifeval | gsm8k | overall | best? |
|---|---|---|---|---|---|
| 500 | 0.55 | 0.62 | 0.40 | 0.52 | baseline 0.50 → ✅ |
| 1000 | 0.60 | 0.68 | 0.50 | 0.59 | ✅ |
| 1500 | 0.62 | 0.71 | 0.55 | 0.63 | ✅ |
| 2000 | 0.61 | 0.70 | 0.54 | 0.62 | 否 |
| 2500 | 0.60 | 0.69 | 0.53 | 0.61 | 否 |
| 3000 | 0.61 | 0.70 | 0.52 | 0.61 | 否 → early stop |
best checkpoint = step 1500,避免 over-training 到 step 3000+。
3 类常见 fine-tune eval 错误:
| 错误 | 现象 | 修法 |
|---|---|---|
| 评测频率过低 | 漏掉 best checkpoint | ≤ 1000 步一次 |
| limit 太小 | 50 题统计噪声大 | ≥ 200 题 |
| 不 early stop | 训练完发现 overfit | patience=3 |
10.13.4 lm-eval 在新模型评测中的"快速上手"路径
每次新 LLM 出现,lm-eval 是工业团队快速验证它能力的工具之一。给一份"新模型快速上手验证"路径:
# 1. 安装 lm-eval (如已装跳过;本专栏对照的是 0.4.5)
$ pip install "lm-eval[api]==0.4.5"
# 2. 接入新模型 (假设有 OpenAI compatible 的 completions 端点)
# base_url 写完整端点,如 http://host:8000/v1/completions;
# arc_challenge / truthfulqa_mc1 是 multiple_choice,要求端点能返回 prompt logprobs(vLLM serve 可以);
# 不传 --num_fewshot,沿用各 task yaml 自带的 few-shot 设置
$ lm_eval --model local-completions \
--model_args base_url=$NEW_MODEL_URL,model=$NEW_MODEL_NAME \
--tasks mmlu_pro,gsm8k_cot,arc_challenge,truthfulqa_mc1 \
--output_path results/new-model # 目录,不是 .json 文件名
# 3. 与现有模型对比 (如果之前跑过;compare.py 是自己写的对比脚本)
# 结果文件在 results/<模型名>/<model_name_sanitized>/results_<时间戳>.json
$ python compare.py results/gpt-4o/ results/new-model/
# 4. 看核心数字
# - MMLU Pro: 综合知识
# - GSM8K CoT: 数学
# - ARC-Challenge: 科学推理
# - TruthfulQA: 抗幻觉
通常当天就能拿到新模型在 4 个核心维度的客观数字(代码维度 lm-eval 没有内置 task,要另跑 bigcode-evaluation-harness)。这种"快速验证"是工业团队跟踪 LLM 进展的标准操作——不需要等论文 / 不需要靠官方 PR 数字。
工业实操:每次新模型发布,工程师跑一次 lm-eval 的"4 task 套餐",很快就能拿到决策依据,比"看 Twitter 上的 demo"客观可信得多。
10.13.5 commercial API 后端的限制:lm-eval 的盲区
lm-eval 在开源模型 / 本地部署场景几乎完美。但接 commercial API(v0.4.5 内置的商业 API 后端主要是 OpenAI 与 Anthropic,另有 TextSynth;没有 Gemini 后端)时存在两个工程限制:
限制 1:multiple_choice 评测精度下降
lm-eval 的 multiple_choice 走法依赖 logprob 接口——直接拿 4 个选项的概率比大小。但 chat 端点普遍不暴露 prompt loglikelihood:v0.4.5 的 openai-completions 只对 babbage-002 / davinci-002 放行 loglikelihood,local-chat-completions / openai-chat-completions / anthropic-chat 一律 raise NotImplementedError。
它不会自动降级——要在这些后端上评多选题,得由你显式换成生成式口径的 task(mmlu_generative / mmlu_flan_cot_*),让模型自由生成 "A" / "B" / "C" / "D" 再判分——mmlu_generative 直接对输出做 exact_match,flan_cot 系列则先用 regex 与任务目录自带的 utils.MultiChoiceRegexFilter 抽出选项。这种方式:
- 失去了"看 4 选项相对概率"的细粒度
- 容易被模型的"emit format"差异污染(输出 "Answer: A" vs "(A)" 都是同一类失败模式)
- 论文上无法直接对比开源模型(用 logprob)和商业模型(用 generate)的数字
限制 2:成本与延迟
lm-eval-harness 设计成"批量并行"——HuggingFace transformers / vLLM 后端可以大 batch 推理。但商业 API 有 RPM / TPM 限流(chat 端点在 v0.4.5 里还被强制 batch size 为 1),跑一次完整 MMLU(14k 题)的量级对比如下(示意,未实测):
| 后端 | 时间 | 成本 |
|---|---|---|
| vLLM 本地部署 | 分钟到小时级,取决于模型与硬件 | 只有 GPU 成本 |
| 商业 API | 受限流约束,常以小时计 | 按 token 计费:5-shot 口径下输入约千万 token 量级 × 当期单价 |
对工业团队的工程含义:lm-eval 适合"研究模型本身",不适合"日常应用层评测"。第 9、11、12 章的 OpenAI evals / ragas / promptfoo 才是后者的工具。
10.14 对外产出:模型卡片与决策报告
10.14.1 lm-eval 与"模型卡片"的工程关系
观察开源大模型公开发布的"Model Card",不少 benchmark 数字背后都有 lm-eval 的影子。下面是一个示意形态(数字为虚构):
Model Card v1.0 (示意,仿 Llama / Qwen / DeepSeek 风格)
==================================================
Architecture: Transformer Decoder
Parameters: 70B
Training data: 15T tokens
Benchmark Results (lm-eval-harness):
MMLU: 85.3% (5-shot, acc)
GSM8K: 91.2% (8-shot CoT, exact_match)
TruthfulQA: 62.7% (MC1)
ARC: 95.1% (acc_norm, 25-shot)
Benchmark Results (bigcode-evaluation-harness):
HumanEval: 78.4% (pass@1)
理想情况下,每个数字后面都应附上 "lm-eval-harness commit hash + task version"——读者才能复现(现实中很多模型卡并没有做到)。
工程团队的实务:发布自家 fine-tune 模型时,模型卡片应该按这种格式撰写——所有 benchmark 数字标注 lm-eval 的版本 + commit。这种"可复现"姿态是模型发布的工程伦理。
本专栏的判断是,这种"模型卡片即合规文档"的趋势会持续——EU AI Act 已对通用 AI 模型提供方提出技术文档方面的义务,评测结果可追溯很可能从工程伦理变成合规要求(这是预测)。
10.14.2 lm-eval 在团队"模型卡片自动生成"中的位置
每个新模型上线必出"Model Card"——下面给出 lm-eval 自动产出 model card 的工程方案:
import json
import subprocess
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
from typing import Iterable
@dataclass
class ModelCardSection:
section_name: str
content: str
class ModelCardAutoGenerator:
"""从 lm-eval 输出自动生成 Model Card"""
# 均为 v0.4.5 真实存在的 task / group 名
BENCHMARKS_TO_INCLUDE = [
"mmlu", "mmlu_pro", "gsm8k", "arc_challenge",
"ifeval", "gpqa_diamond_zeroshot", "bbh",
]
# 各 task 的主指标 key("<metric>,<filter 流水线名>"),并不都是 acc
METRIC_KEY = {
"mmlu": "acc,none",
"mmlu_pro": "exact_match,custom-extract",
"gsm8k": "exact_match,strict-match",
"arc_challenge": "acc_norm,none",
"ifeval": "prompt_level_strict_acc,none",
"gpqa_diamond_zeroshot": "acc_norm,none",
"bbh": "exact_match,get-answer",
}
def __init__(self, model_id: str, model_meta: dict):
self.model_id = model_id
self.meta = model_meta
def run_evals(self, output_dir: Path) -> dict:
"""跑全套公开 benchmark"""
results = {}
for bench in self.BENCHMARKS_TO_INCLUDE:
bench_dir = output_dir / bench
subprocess.run([
"lm_eval", "--model", "hf",
"--model_args", f"pretrained={self.model_id}",
"--tasks", bench,
"--output_path", str(bench_dir), # 目录,不是文件名
"--limit", "500",
], check=False)
files = sorted(bench_dir.glob("*/results_*.json"))
if files:
results[bench] = json.loads(files[-1].read_text())
return results
def generate_card(self, eval_results: dict) -> str:
"""合成 model card markdown"""
sections = []
# Header
sections.append(f"# Model Card: {self.model_id}\n")
sections.append(f"**Generated**: {datetime.now().isoformat()}\n")
sections.append(f"**Created by**: {self.meta.get('author', 'unknown')}\n")
sections.append(f"**License**: {self.meta.get('license', 'unknown')}\n\n")
# Intended use
sections.append("## Intended Use\n")
sections.append(self.meta.get("intended_use", "TBD") + "\n\n")
# Benchmark scores
sections.append("## Benchmark Performance\n")
sections.append("| Benchmark | Score | Tier |\n|---|---|---|\n")
for bench, result in eval_results.items():
score = result.get("results", {}).get(bench, {}).get(
self.METRIC_KEY[bench], 0)
tier = self._tier(score)
sections.append(f"| {bench} | {score:.3f} | {tier} |\n")
# Limitations
sections.append("\n## Known Limitations\n")
for lim in self.meta.get("limitations", []):
sections.append(f"- {lim}\n")
# Safety
sections.append("\n## Safety Evaluation\n")
sections.append(self.meta.get("safety_summary",
"见独立 §16 评测") + "\n")
# Reproducibility
sections.append("\n## How to Reproduce\n")
sections.append("```bash\n")
sections.append(f"lm_eval --model hf --model_args pretrained={self.model_id} \\\n")
sections.append(f" --tasks {','.join(self.BENCHMARKS_TO_INCLUDE)} \\\n")
# 上面 run_evals 用了 --limit 500 抽样;正式对外的分数应去掉 --limit 全量重跑
sections.append(f" --output_path ./results\n")
sections.append("```\n")
return "".join(sections)
def _tier(self, score: float) -> str:
if score >= 0.85:
return "frontier"
if score >= 0.70:
return "strong"
if score >= 0.50:
return "middle"
return "weak"
flowchart LR M[新模型] --> R[run lm-eval 7 benchmark] R --> S[7 个 score] S --> T[tier 分类] T --> CARD[Model Card markdown] M --> META[人工填: license / intended_use / limitations] META --> CARD CARD --> PUB[发布 HF / GitHub / 内部 wiki] style CARD fill:#e8f5e9 style PUB fill:#e3f2fd
工程实务的 4 条 model card 设计经验:
- 必含至少 5 个 benchmark:单个 benchmark 没说服力(代码能力要另跑 bigcode-evaluation-harness,lm-eval 里没有 HumanEval)
- 必有 reproducibility 段:让外部能验证
- 限制必坦诚:写 "weak in Chinese 古文" 比"全能"更有信任
- 每个新版本必更新:v2 ≠ v1,必重跑
具体例子(示意数据,需用你自己的数据复算):某团队内部模型 v2.1 自动 model card 片段:
# Model Card: company/internal-llm-v2.1
## Benchmark Performance
| Benchmark | Score | Tier |
|---|---|---|
| mmlu | 0.62 | middle |
| mmlu_pro | 0.41 | weak |
| gsm8k | 0.83 | strong |
| arc_challenge | 0.71 | strong |
| ifeval | 0.78 | strong |
| gpqa_diamond_zeroshot | 0.21 | weak |
| bbh | 0.55 | middle |
## Known Limitations
- Chinese 古文理解能力弱
- 数学题超过中学难度准确率显著下降
- Python 之外的编程语言能力较弱
3 类常见 model card 错误:
| 错误 | 现象 | 修法 |
|---|---|---|
| 只写好的 benchmark | 显得 cherry-picking | 必含 weak 维度 |
| 不写 limitations | 用户惊讶 | 主动声明已知短板 |
| 不更新 | v3 model 用 v1 card | 强制每版重跑 |
研究背景:
- "Model Cards for Model Reporting" (Mitchell et al. 2019, arXiv:1810.03993) 是 model card 的奠基论文
- HuggingFace Model Hub 以仓库里的 README.md 作为 model card,并提供模板鼓励填写
- Anthropic / OpenAI 的 system card 是 model card 的扩展版
10.14.3 lm-eval 的"评测可复现性 + 公开报告 reproducibility 报告卡"——为什么大家跑不出论文里的数
lm-eval 是论文 benchmark 报告的事实标准,但论文里的数和你跑出来的数往往对不上。如果团队不能解释这个 gap,对内对外都会陷入"是不是我们用错了 lm-eval"的怀疑。本节给读者一份可复现性 checklist + reproducibility 报告卡,让团队对每次报告负责,避免被外部质疑。
graph LR
A[论文报 MMLU 86.4] --> B{你跑出 84.1}
B --> C[认知冲突]
C --> D[复现性诊断]
D --> E[1. 模型版本]
D --> F[2. lm-eval 版本]
D --> G[3. prompt 模板]
D --> H[4. few-shot 数 K]
D --> I[5. decoding 参数]
D --> J[6. 截断 / 系统 prompt]
E & F & G & H & I & J --> K[gap 归因]
K --> L[reproducibility 卡]
L --> M[公开报告附带卡]
M --> N[同行可复现 / 可质疑]
6 大复现性维度 × 必填字段 × 排查方式("gap 有多大"高度依赖 task 与模型,本专栏不给统一数字——请在自己的 task 上做单变量对照实验测出来):
| 维度 | 必填字段 | 排查方式 |
|---|---|---|
| 模型版本 | exact model id + revision | huggingface revision hash |
| lm-eval 版本 | git commit | tag / commit(结果 JSON 的 git_hash 字段) |
| prompt 模板 | task yaml hash | lm_eval --show_config;开 --log_samples 时结果 JSON 另有按样例算的 task_hashes |
| few-shot K | k value | --num_fewshot |
| decoding 参数 | temperature, top_p | --gen_kwargs |
| 截断 / 系统 prompt | max_seq_len, system | 配置文件 |
配套实现:lm-eval 复现性报告卡生成器:
import hashlib
import json
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
@dataclass
class ReproducibilityCard:
task_name: str
model_id: str
model_revision: str # huggingface revision hash 或 API version
lm_eval_commit: str
task_yaml_hash: str
num_fewshot: int
temperature: float
top_p: float
max_seq_len: int
system_prompt_used: bool
score: float
n_samples: int
bootstrap_std: float | None = None
timestamp: datetime = field(default_factory=datetime.now)
def card_id(self) -> str:
canonical = f"{self.task_name}|{self.model_id}|{self.model_revision}|{self.lm_eval_commit}|{self.task_yaml_hash}|{self.num_fewshot}|{self.temperature}"
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
def render_markdown(self) -> str:
return f"""
## Reproducibility Card
| 字段 | 值 |
|------|------|
| task | `{self.task_name}` |
| model | `{self.model_id}@{self.model_revision[:8]}` |
| lm_eval commit | `{self.lm_eval_commit[:8]}` |
| task yaml sha256 | `{self.task_yaml_hash}` |
| num_fewshot | {self.num_fewshot} |
| temperature | {self.temperature} |
| top_p | {self.top_p} |
| max_seq_len | {self.max_seq_len} |
| system_prompt | {'yes' if self.system_prompt_used else 'no'} |
| samples | {self.n_samples} |
| **score** | **{self.score:.4f}**{f' ± {self.bootstrap_std:.4f}' if self.bootstrap_std else ''} |
| card_id | `{self.card_id()}` |
| run_at | {self.timestamp.isoformat()} |
任何人凭这张卡可以精确复现这个分数。
""".strip()
@dataclass
class ReproducibilityComparator:
@staticmethod
def diagnose_gap(card_a: ReproducibilityCard, card_b: ReproducibilityCard) -> dict:
diffs = []
if card_a.model_revision != card_b.model_revision:
diffs.append(("model_revision", card_a.model_revision[:8], card_b.model_revision[:8]))
if card_a.lm_eval_commit != card_b.lm_eval_commit:
diffs.append(("lm_eval_commit", card_a.lm_eval_commit[:8], card_b.lm_eval_commit[:8]))
if card_a.task_yaml_hash != card_b.task_yaml_hash:
diffs.append(("task_yaml_hash", card_a.task_yaml_hash, card_b.task_yaml_hash))
if card_a.num_fewshot != card_b.num_fewshot:
diffs.append(("num_fewshot", card_a.num_fewshot, card_b.num_fewshot))
if card_a.temperature != card_b.temperature:
diffs.append(("temperature", card_a.temperature, card_b.temperature))
if card_a.system_prompt_used != card_b.system_prompt_used:
diffs.append(("system_prompt", card_a.system_prompt_used, card_b.system_prompt_used))
return {
"score_a": card_a.score,
"score_b": card_b.score,
"delta_pp": (card_a.score - card_b.score) * 100,
"differing_fields": diffs,
"verdict": "fully_reproducible" if not diffs else "configuration_drift",
}
@dataclass
class ReproducibilityRequirementChecker:
"""检查报告是否携带了完整的复现性卡"""
REQUIRED_FIELDS = ["model_revision", "lm_eval_commit", "task_yaml_hash",
"num_fewshot", "temperature", "n_samples"]
def check(self, card_dict: dict) -> dict:
missing = [f for f in self.REQUIRED_FIELDS if not card_dict.get(f)]
return {
"complete": len(missing) == 0,
"missing_fields": missing,
"verdict": "ok_to_publish" if not missing else "must_complete_before_publish",
}
举例(示意数据,需用你自己的数据复算):某团队报告 MMLU 86.4,外部用户跑出 84.1:
- diagnose_gap → differing_fields: num_fewshot 5 vs 0(论文用 5-shot,外部用 0-shot,差 2.3pp)
- 修正复现 → 84.1 → 86.5(与论文对齐)
- 之后对外报告都附 reproducibility card,外部复现时对照字段即可
配套行业研究背景:
- "Model card" 来自 Mitchell et al. FAT* 2019(arXiv:1810.03993)
- 评测可复现性的系统讨论见 Biderman et al. 2024(arXiv:2405.14782)
10.14.4 lm-eval 与"模型市场购买决策"——给采购看的评测报告模板
很多公司从供应商买 LLM 时需要评测对比——下面给出"采购决策评测报告"工程化模板:
import json
from dataclasses import dataclass, field, asdict
from typing import Iterable
@dataclass
class VendorModelEvalReport:
vendor: str
model_name: str
benchmarks: dict[str, float]
weighted_score: float
cost_per_1m_tokens_in: float
cost_per_1m_tokens_out: float
annual_cost_estimate_usd: float
p99_latency_ms: int
license_compliance: str
recommended: bool
rationale: str
class VendorModelComparisonReport:
"""供应商模型比较的工程化评测报告"""
BUSINESS_WEIGHTS = {
"mmlu_pro": 0.20,
"ifeval": 0.25, # 客服业务最重要
"humaneval": 0.10,
"gpqa_diamond": 0.05,
"gsm8k": 0.10,
"safety": 0.20, # 必须高
"latency": 0.10,
}
def evaluate(self, vendor: str, model_name: str,
benchmark_scores: dict, pricing: dict,
monthly_query_volume: int) -> VendorModelEvalReport:
# 综合加权
weighted = sum(
benchmark_scores.get(b, 0) * w
for b, w in self.BUSINESS_WEIGHTS.items()
)
# 年成本估算
avg_in_tokens = 500
avg_out_tokens = 200
annual_cost = (
monthly_query_volume * 12 *
(avg_in_tokens / 1_000_000 * pricing.get("in", 0) +
avg_out_tokens / 1_000_000 * pricing.get("out", 0))
)
# 推荐判定(综合 score / 成本 / 合规)
license_ok = pricing.get("license", "Unknown") in (
"Commercial-Use", "Apache-2.0", "MIT", "Custom-Approved"
)
score_ok = weighted >= 0.65
cost_ok = annual_cost < 50_000_000 # 上限可调
recommended = score_ok and cost_ok and license_ok
if recommended:
rationale = f"通过:score={weighted:.2f}, "\
f"cost=${annual_cost:.0f}, license=OK"
else:
issues = []
if not score_ok: issues.append("score 不达标")
if not cost_ok: issues.append("成本超预算")
if not license_ok: issues.append("license 不合规")
rationale = "拒绝:" + " + ".join(issues)
return VendorModelEvalReport(
vendor=vendor,
model_name=model_name,
benchmarks=benchmark_scores,
weighted_score=round(weighted, 3),
cost_per_1m_tokens_in=pricing.get("in", 0),
cost_per_1m_tokens_out=pricing.get("out", 0),
annual_cost_estimate_usd=round(annual_cost, 0),
p99_latency_ms=pricing.get("p99_latency_ms", 0),
license_compliance="OK" if license_ok else "REVIEW_NEEDED",
recommended=recommended,
rationale=rationale,
)
flowchart LR C[3 候选模型] --> E[Comparison Report] E --> A[Anthropic Claude] E --> O[OpenAI GPT-4o] E --> Q[阿里 Qwen-Max] A --> S1[加权 score] O --> S2[加权 score] Q --> S3[加权 score] S1 --> D[决策矩阵] S2 --> D S3 --> D D --> WIN[推荐 + rationale] D --> REJ[淘汰 + 原因] style WIN fill:#e8f5e9 style REJ fill:#ffebee
工程实务的 4 类采购评测维度:
| 维度 | 权重 | 说明 |
|---|---|---|
| 业务能力 (MMLU/IFEval) | 45% | 核心评测 |
| 安全 | 20% | 红线 |
| 成本 | 20% | 预算约束 |
| 延迟 | 10% | 用户体验 |
| 合规 / license | 5% | 合规底线 |
具体例子(示意数据,需用你自己的数据复算):客服 chatbot 选 LLM 报告:
| 候选 | weighted score | 年成本 | license | 推荐 |
|---|---|---|---|---|
| GPT-4o | 0.78 | $48k | OpenAI ToS | ✅ |
| Claude-Sonnet | 0.81 | $52k | Anthropic ToS | ✅ |
| Qwen-Max | 0.71 | $32k | 阿里商用 | ✅(首选 - 最便宜) |
| Gemini | 0.76 | $40k | Google ToS | ✅ |
| 内部 13B | 0.55 | $8k | 自有 | ❌ score 不达标 |
最终决策:Qwen-Max(性价比最高 + 合规无问题)。
3 类常见采购评测错误:
| 错误 | 现象 | 修法 |
|---|---|---|
| 只看 benchmark 分 | 选了贵但能力没区别的 | 必加成本权重 |
| 不算业务权重 | MMLU 重要 vs IFEval 重要混淆 | 业务方定权重 |
| 不验 license | 商用拿不到 license | 必查 ToS |
10.14.5 lm-eval 与"开源模型 vs 闭源模型对照"——技术决策中的 4 维平衡矩阵
企业 LLM 选型最反复出现的辩论:用 GPT-4 / Claude(闭源 API)还是用 Llama / Qwen / DeepSeek(开源自托管)?lm-eval 的角色不只是"打个分"——而是给出"在你的场景下,开源是否真的足够"的工程化决策依据。本节给读者一份 4 维平衡矩阵 + lm-eval 跑分自动决策器,避免"凭情怀选开源"或"凭便利选闭源"两类常见误判。
graph LR
A[选型决策] --> B[4 维评估]
B --> C[1. 任务能力<br/>lm-eval 跑分]
B --> D[2. 数据合规<br/>必须自托管?]
B --> E[3. 成本结构<br/>API vs 自托管]
B --> F[4. 工程能力<br/>能否运维?]
C & D & E & F --> G[决策矩阵]
G --> H[开源自托管<br/>合适场景]
G --> I[闭源 API<br/>合适场景]
G --> J[混合方案<br/>主备策略]
4 维平衡矩阵 + 推荐路径:
(下表数值为示意量级,权重是本专栏给的起点,需按自家约束调整。)
| 维度 | 开源(Llama/Qwen) | 闭源(GPT-4/Claude) | 决策权重 |
|---|---|---|---|
| lm-eval 任务覆盖能力 | 头部开源模型已接近闭源(§10.11.3 的自报 MMLU 在 86-87) | 顶端 | 30% |
| 数据合规 / 隐私 | ✓ 完全本地 | ✗ 需 BAA / DPA | 25%(金融 / 医疗高) |
| 单次请求成本 | $0.001-0.005 | $0.01-0.05 | 20% |
| 运维工程能力 | 高(GPU 集群 / 推理优化) | 极低(API 调用) | 25% |
配套实现:开源 vs 闭源决策器:
from dataclasses import dataclass, field
from typing import Literal
DeploymentMode = Literal["open_self_hosted", "closed_api", "hybrid"]
@dataclass
class ModelCandidate:
name: str
is_open_source: bool
lm_eval_avg_score: float # 0.0~1.0
self_hostable: bool
cost_per_1k_request_usd: float
requires_dedicated_ops_fte: float # 需要专职 ops 多少人力
@dataclass
class SelectionCriteria:
data_compliance_hard: bool # 数据必须不出公司
monthly_request_volume: int
available_ops_fte: float # 公司能投入的运维 FTE
quality_threshold: float = 0.75 # lm-eval 最低分
@dataclass
class OpenVsClosedDecider:
candidates: list[ModelCandidate]
criteria: SelectionCriteria
def filter_eligible(self) -> list[ModelCandidate]:
eligible = []
for c in self.candidates:
if self.criteria.data_compliance_hard and not c.self_hostable:
continue
if c.lm_eval_avg_score < self.criteria.quality_threshold:
continue
if c.requires_dedicated_ops_fte > self.criteria.available_ops_fte:
continue
eligible.append(c)
return eligible
def score_candidate(self, c: ModelCandidate) -> dict:
# 4 维加权打分
quality_score = c.lm_eval_avg_score
compliance_score = 1.0 if (not self.criteria.data_compliance_hard or c.self_hostable) else 0.0
monthly_cost = c.cost_per_1k_request_usd * (self.criteria.monthly_request_volume / 1000)
cost_score = max(0, 1 - monthly_cost / 50_000) # $50K/月作为基准
ops_score = max(0, 1 - c.requires_dedicated_ops_fte / max(self.criteria.available_ops_fte, 0.1))
weighted = (quality_score * 0.30 + compliance_score * 0.25
+ cost_score * 0.20 + ops_score * 0.25)
return {
"name": c.name,
"is_open_source": c.is_open_source,
"weighted_score": weighted,
"quality": quality_score,
"compliance": compliance_score,
"monthly_cost_usd": monthly_cost,
"ops_fte": c.requires_dedicated_ops_fte,
}
def recommend(self) -> dict:
eligible = self.filter_eligible()
if not eligible:
return {"verdict": "no_match", "reason": "无候选满足硬性约束"}
scored = sorted(
(self.score_candidate(c) for c in eligible),
key=lambda x: x["weighted_score"], reverse=True
)
top = scored[0]
# 混合策略:top 1 (开源) + top 1 (闭源) 都进 short-list 时建议混合
# scored 已按分数重排,不能再和 eligible 按位置 zip
opens = [s for s in scored if s["is_open_source"]]
closeds = [s for s in scored if not s["is_open_source"]]
if opens and closeds and abs(opens[0]["weighted_score"] - closeds[0]["weighted_score"]) < 0.1:
return {"verdict": "hybrid",
"primary": opens[0]["name"],
"fallback": closeds[0]["name"],
"reason": "开源 / 闭源得分相近,建议主备双模"}
return {"verdict": "single",
"winner": top["name"],
"all_scores": scored}
举例(示意数据,需用你自己的数据复算):某金融客服系统选型:
- 候选 = [闭源 API A, 闭源 API B, Llama-3.1 70B, Qwen2.5 72B, DeepSeek V2]
- criteria:data_compliance_hard=True / 月 5M 请求 / 2 FTE 运维
- filter_eligible → 两家闭源 API 直接被淘汰(数据出公司)
- filter_eligible 同时淘汰 Llama-3.1(运维 2.5 FTE 超出 2 FTE 预算)→ score 后 Qwen2.5(合规 ✓ + 运维 1.5 FTE)居首
- recommend → "single" winner = Qwen2.5
非合规场景比如内部知识助手,则可能 verdict = "hybrid"(Qwen 主 + 闭源 API fallback)。
配套行业研究背景:
- 这套 4 维矩阵是本专栏的工程归纳;开源 / 闭源与自托管 / API 的成本拐点高度依赖请求量、GPU 价格与团队运维能力,务必用自家数据测算
10.14.6 lm-eval 的真实使用场景:模型选型决策
工程团队选基础模型时(比如从 GPT-4o-mini 切到 Claude Haiku 4.5),怎么决定?lm-eval 提供的是"客观对比"路径——而非"我们自己跑两条 query 看感觉"。
具体做法:
# 跑 GPT-4o-mini(chat 端点不支持 loglikelihood,
# 所以 mmlu 要换成生成式口径的 mmlu_flan_cot_zeroshot;密钥读 OPENAI_API_KEY)
lm_eval --model openai-chat-completions \
--model_args model=gpt-4o-mini \
--apply_chat_template \
--tasks mmlu_flan_cot_zeroshot,gsm8k_cot,ifeval \
--output_path results/gpt-4o-mini
# 跑 Claude Haiku 4.5(Claude 3.5 Haiku 已于 2026-02-19 退役;密钥读 ANTHROPIC_API_KEY)
lm_eval --model anthropic-chat \
--model_args model=claude-haiku-4-5 \
--apply_chat_template \
--tasks mmlu_flan_cot_zeroshot,gsm8k_cot,ifeval \
--output_path results/claude-haiku
# 跑你自己 fine-tune 的模型(vLLM 部署,本地权重能走 loglikelihood)
lm_eval --model vllm \
--model_args pretrained=./my-finetuned-model,tensor_parallel_size=2 \
--tasks mmlu,gsm8k,truthfulqa_mc1,arc_challenge \
--output_path results/my-model
跑出 3-4 个 task × 3 个模型的横向对比,几小时内得到决策依据(注意 chat API 与本地权重的口径不同,跨列比较前先统一 task)。这比"找 5 个 SaaS 跑一遍业务集"的传统做法可信得多——因为 lm-eval 上的题是固定 benchmark、跑出的数字可与论文 / model card 直接对照。
工业团队通常的模型选型流程:
- lm-eval 跑 4-5 个核心 benchmark:MMLU / GSM8K / ARC-Challenge / TruthfulQA / HellaSwag
- 选出 top 3 候选:在你的预算 / 延迟约束内综合评分最高的 3 个
- 在自己的业务集上跑:用 promptfoo / ragas 做最终选型
第 1、2 步用 lm-eval 客观对标,第 3 步用应用工具做业务相关验证——两者结合是模型选型的正确流程。
10.15 演进与认知地图
10.15.1 lm-eval 与"动态 benchmark"的演进
为应对 §10.12.2 的 benchmark contamination 问题,2024-2025 年评测领域涌现了一批更抗污染的 benchmark——有的持续更新题目(真正的"动态 benchmark"),有的靠更难、更新的静态题集拉开差距:
| Benchmark | 类型 | 防污染机制 | 来源 |
|---|---|---|---|
| Chatbot Arena | 实时人类投票 | 题目永远变 | LMSYS |
| LiveBench | 滚动更新 | 每月新增题目 | LiveBench Team |
| MMLU-Pro | 增强 MMLU | 10 个选项 + 更偏推理 + 剔除琐碎/噪声题 | TIGER-Lab |
| Arena Hard | 从 Arena 挖 hard | 基于真投票 | LMSYS |
| GAIA | Agent 任务 | 真实工具使用 | Meta + HuggingFace 等 |
| BigCodeBench | 代码 | 不断扩充 | BigCode |
| GPQA Diamond | 专业知识 | 极难 + 不易污染 | Rein et al.(NYU 等) |
这些动态 benchmark 的工程价值:
- 更接近真实能力:不易被训练污染
- 持续可用:动态类定期更新题目,不容易过时
- lm-eval 部分支持:v0.4.5 已接入 MMLU-Pro / GPQA;Arena Hard、LiveBench 等不在其中,要用各自的官方工具
工业团队的实务:
- 评估"基础模型能力"用静态 benchmark + 动态 benchmark 双重对照
- 静态分数高 + 动态分数低 → 可能有训练污染
- 两个分数都高 → 真实能力强
10.15.2 lm-eval 与"模型实战"的认知地图
最后给一个跨章节的认知地图——lm-eval 在 LLM 工程师"模型实战"中的位置:
flowchart TB
Decision[模型选型决策] --> Q1{是研究 / 论文报数字?}
Q1 -->|是| Acad[lm-eval-harness<br/>学术 benchmark 主场]
Q1 -->|否| Q2{是应用层评测?}
Q2 -->|是| App[promptfoo / ragas<br/>第 11/12 章]
Q2 -->|否| Q3{是大规模模型对比?}
Q3 -->|是| Acad
Q3 -->|否| Q4{是 fine-tune 验证?}
Q4 -->|是| Acad
Q4 -->|否| Custom[自家黄金集<br/>第 3 章方法]
style Acad fill:#dbeafe
style App fill:#dcfce7
style Custom fill:#fef3c7
这张图的用处是避免"用错工具"——很多团队拿 lm-eval 跑应用评测(不合适),或拿 promptfoo 报学术 benchmark(不合规)。
10.16 跨专栏关联
- 本专栏第 4 章 §4.7 讨论的统计推断,对应
metrics.py的pop_stddev / sample_stddev / mean_stderr - 本专栏第 9 章 OpenAI evals 的
bootstrap_std,与本章mean_stderr是同一个统计意图的两种实现 - 本专栏第 11 章 ragas:会展示 RAG 评测专用框架与"通用 benchmark 评测"的设计差异
- 《vLLM 推理内核深度解析》专栏第 17 章讨论的生产部署与吞吐优化,正是 lm-eval 调用 vLLM 后端的目标——快速跑完 MMLU 的 14k 题
- 《Claude Code 源码深度解析》:lm-eval 不直接适用,但其 YAML 配置思想可借鉴
10.17 本章小结
- lm-evaluation-harness 是开源大模型报告 benchmark 数字的事实标准——MMLU / GSM8K / HellaSwag / ARC 都能在它上面按统一口径跑
- 四层抽象 Task / Instance / LM / Filter 清晰职责分离,
lm_eval/api/核心代码约 3400 行 - LM 基类强制 logprob 接口,因此能精确做 multiple_choice 评测——这是它和 OpenAI evals 最根本的差异
- 3388 份 YAML、3173 个 task 名,贡献门槛低;MMLU 57 个学科共享模板继承机制
- Metrics 库 16 个 metric + 12 个 aggregation 覆盖所有主流学术评测;
acc_norm长度归一化体现学术严谨度 - 5 个工程亮点(Filter 流水线 / Cache / 去污染 / Multi-task 合批 / 模板继承)是 fork 改造时该保留的财富——其中「去污染」要连着 §10.7 的提醒一起记:算法在
lm_eval/decontamination/,但 0.4.5 没把它接进评测主流程
下一章我们看 ragas——RAG 评测的工程范式,与 lm-eval 完全不同的思路。