数据截至 (上游 commit dd417662e5bd)
LM Evaluation Harness — 架构与原理
30 秒导读: 这是 EleutherAI 的大语言模型评测框架——Hugging Face Open LLM Leaderboard 的后端,学术界引用最多的 benchmark 数字很多是它跑出来的。它回答一个问题:一个 benchmark 分数到底是怎么算出来的? 答案是:每个任务一个 YAML(prompt 格式 + few-shot + 打分规则),每道题变成「算 loglikelihood」或「生成直到停止符」的请求,模型后端批量执行,再抽答案、算指标、聚合。它评的是模型本身,不是 agent——没有环境、没有工具调用、没有多轮交互。
1. 这是什么(零基础也能懂)
一句话定义
lm-evaluation-harness(命令行叫 lm-eval)是一个给语言模型跑标准学术 benchmark 的框架:你给它一个模型(本地权重或 API)和一组任务名(如 arc_easy、mmlu、gsm8k),它输出每个任务的分数和每张卷子的逐题明细。
它解决谁的什么问题
设想你想知道「我的模型 MMLU 多少分」。听起来简单,做起来处处是坑:
- MMLU 的 prompt 怎么写?选项写成
A. xxx还是(A) xxx? - few-shot 用几道题?从哪个 split 抽?随机还是固定前 5 道?
- 怎么算「答对」?让模型自由生成再匹配,还是直接比四个选项的概率?
- 生成的答案文本怎么抽取出「B」这个选项?正则写错了分数就差几个点。
每个选择都会让分数动几个百分点。没有统一的 harness,两个论文里的「MMLU 分数」根本不可比。 这个库的价值就是把所有这些选择固定成公开可查的 YAML 配置,让「分数」变成「某模型 + 某 prompt 配置 + 某 harness 版本」的函数。
它能做什么
| 能力 | 说明 |
|---|---|
| 任务库 | 几百个任务的 YAML 定义(lm_eval/tasks/ 下 200+ 个子目录),覆盖 MMLU、ARC、HellaSwag、GSM8K、HumanEval 等 |
| 四种评测方式 | loglikelihood(给答案算概率)、multiple_choice(选项比概率)、loglikelihood_rolling(整文 perplexity)、generate_until(自由生成) |
| 模型后端 | HuggingFace transformers、vLLM、SGLang、OpenAI/Anthropic 等 API、llama.cpp(GGUF)、NeMo 等 30+ 种(lm_eval/models/) |
| few-shot | 每个任务可配 shot 数、采样策略(随机 / 固定前 N 个)、是否走 chat 模板 |
| 可复现 | 固定种子、记录每个任务的 YAML 配置与版本号、缓存请求与响应 |
| 分布式 | HF 后端支持多 GPU 数据并行(accelerate),vLLM 支持张量并行 |
用起来什 么样
一条命令(摘自 lm_eval/_cli/run.py 的帮助文本):
lm-eval run --model hf \
--model_args pretrained=EleutherAI/pythia-1b \
--tasks arc_easy,gsm8k \
--num_fewshot 5 --batch_size auto
也可以当库用,入口是 simple_evaluate(lm_eval/evaluator.py:55):
# 示意,非源码(参数名来自真实签名)
import lm_eval
results = lm_eval.simple_evaluate(
model="hf",
model_args="pretrained=EleutherAI/pythia-1b",
tasks=["arc_easy"],
num_fewshot=25,
)
# results["results"]["arc_easy"]["acc,none"] 就是 ARC-Easy 准确率
返回的 results 是一个大字典,键包括:results(任务分数)、groups(组分数)、configs(每个任务的完整 YAML 配置)、versions(任务版本号)、n-shot、higher_is_better、n-samples——组装逻辑在 _to_eval_results(lm_eval/evaluator_utils.py:134)。配置和版本号跟分数一起落盘,这是它可复现承诺的落点。
一句话直觉
把它想成「高考的标准化阅卷系统」。 每个 benchmark 是一份试卷(YAML 规定了题干怎么誊写、给不给例题、怎么判卷),模型是考生,框架负责按同一套规则发卷、收卷、判分。换一套阅卷规则,分数就不可比——所以它把阅卷规则公开成配置,钉死在版本号上。
2. 顶层全景(它大概怎么转)
2.1 一张图看全貌
从左到右是一次评测的生命周期:配置 → 请求 → 执行 → 判分 → 聚合。
lm-eval run(命令行/ Python API)
│
▼
① TaskManager
扫 tasks/**/*.yaml
建 Task / Group 对象
│
▼
② Task.build_all_requests
doc → few-shot 上下文
→ Instance 列表
│
▼
③ evaluate() 按请求类型分组
loglikelihood │ generate_until │ rolling
│
▼
④ LM 后端批量执行
HFLM │ VLLM │ OpenAI API │ ...
│
▼
⑤ filter 抽答案
+ Task.process_results 逐题判分
│
▼
⑥ 聚合:任务级 mean / perplexity
+ bootstrap stderr + group 汇总
2.2 部件职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
TaskManager | 扫描任务目录建索引,按名字/路径/dict 加载任务与组 | lm_eval/tasks/manager.py:37 |
TaskIndex / TaskFactory | 遍历 YAML 建 {名字: Entry};把 Entry 造成 Task/Group | lm_eval/tasks/_index.py:36、lm_eval/tasks/_factory.py:25 |
Task / ConfigurableTask | 一个 benchmark:doc → prompt → Instance → 逐题指标 | lm_eval/api/task.py:64、:618 |
TaskConfig | YAML 解析出的任务配置(prompt 模板、split、指标表……) | lm_eval/config/task.py:82 |
Instance | 一条待执行请求:类型 + 参数 + 回收响应的容器 | lm_eval/api/instance.py:11 |
LM / TemplateLM | 模型后端抽象:loglikelihood / loglikelihood_rolling / generate_until | lm_eval/api/model.py:25、:331 |
HFLM / VLLM | 最常用两个本地后端的实现 | lm_eval/models/huggingface.py:62、lm_eval/models/vllm_causallms.py:59 |
Collator | 请求排序、按组分批、执行后还原原顺序 | lm_eval/models/utils.py:238 |
FilterEnsemble | 生成文本的后处理流水线(正则抽答案、取第一条……) | lm_eval/api/filter.py:34 |
evaluate() | 主循环:建请求 → 执行 → 过滤 → 判分 → 聚合 | lm_eval/evaluator.py:429 |
| 指标注册表 | metric / aggregation / higher_is_better 三件套 | lm_eval/api/registry.py:418-457 |
2.3 主线走一遍(以 ARC-Easy 为例)
① 用户说 --tasks arc_easy。TaskManager.load 查到 lm_eval/tasks/arc/arc_easy.yaml,造成一个 ConfigurableTask(lm_eval/tasks/_factory.py:81)。YAML 里写着:output_type: multiple_choice、doc_to_text: "Question: {{question}}\nAnswer:"、metric_list: [acc, acc_norm]。
② evaluate() 调 task.build_all_requests()(lm_eval/api/task.py:268):对每道题,先用 fewshot_context 拼出「描述 + N 个例题 + 本题题干」,再调 construct_requests(lm_eval/api/task.py:1362)。因为是 multiple_choice,每个选项生成一条 loglikelihood 请求:(上下文, " B")、(上下文, " C")…… 一条题扇出成 4 条请求。
③ 所有任务的 Instance 按 request_type 归堆(lm_eval/evaluator.py:564-566),同类型的请求一次性发给模型:resps = getattr(lm, reqtype)(cloned_reqs)(lm_eval/evaluator.py:600)。
④ HFLM.loglikelihood 把每对 (context, continuation) 分词,按长度降序排序分批,一次前向拿回每条 continuation 的 log 概率和「贪心解码是否会产出它」(lm_eval/models/huggingface.py:1329)。
⑤ 响应回到 Instance 后,task.process_results(lm_eval/api/task.py:1489)把 4 个选项的 loglikelihood 取 argmax,和 gold 比,得到 acc;除以字符长度再取 argmax,得到 acc_norm。
⑥ _process_results(lm_eval/evaluator_utils.py:349)对每个指标套聚合函数(acc 就是 mean)并算 bootstrap 标准误;若任务属于 group(如 mmlu),自底向上再加权汇总。
这条线最值得记住的一点:选择题从头到尾没有「生成」。 模型只是给每个选项算了次概率——这就是为什么 harness 分数可复现:解码随机性被整个消掉了(见第 3 章)。
3. 阅读地图(建议顺序)
五章由浅入深。只想抓要害读 01 → 03 就够:01 讲清「任务是什么」,03 讲清「分数怎么算出来」。
| 顺序 | 章节 | 讲什么 | 适合谁 |
|---|---|---|---|
| 1 | 01-task-registry.md | YAML 任务注册表:include 继承、!function 逃生舱、group/tag、TaskFactory 建造 | 所有人;想加自己任务的人必读 |
| 2 | 02-task-fewshot.md | doc_to_text 的四种形态、few-shot 采样器、Message 列表拼 prompt、chat 模板 | 想知道「模型到底看到什么」的人 |
| 3 | 03-requests-execution.md | 四种 output_type 的语义与适用面、Instance 扇出与回收、filter 流水线、逐题判分 | 想搞懂分数语义的人(核心章) |
| 4 | 04-model-backends.md | LM 契约、_encode_pair 的空格处理、HFLM 批处理与缓存、vLLM/API 后端、CachingLM | 要接新后端或抠性能的人 |
| 5 | 05-evaluator-aggregation.md | evaluate() 全流程、分布式切分与 padding、指标注册表、stderr、group 聚合、结果 schema | 要改框架本身、或在乎统计严谨性的人 |
4. 巧妙之处(可借鉴的技术)
每条先白话点题,细节在各章。
-
选择题不比生成、比概率。
multiple_choice扇出成 N 条loglikelihood请求,argmax 即答案(lm_eval/api/task.py:1374-1390)。贪心解码、温度、停止符这些噪声源整个消失,分数变成纯函数。还顺手给出acc_norm(按长度归一,修正「短答案天然概 率高」的偏差)。→ 第 3 章 -
互信息校正只要再加 N 条空上下文请求。 配了
acc_mutual_info就追加("", choice)请求,用log P(choice|ctx) − log P(choice)打分(lm_eval/api/task.py:1393-1405)。实现成本极低,因为请求模型本来就是个列表。→ 第 3 章 -
长度降序排序 = OOM 前移 + 自适应 batch 的前提。
_collate返回-len(toks),最长的请求永远排在最前——OOM 在第一 batch 就炸,而不是跑到 90% 才炸;batch 内 padding 长度也可预测(lm_eval/models/huggingface.py:1338-1348注释里写明了这三点理由)。→ 第 4 章 -
单 token 续写缓存。 选择题的四个选项常常只差最后一个 token;
group_by="contexts"把context + cont[:-1]相同的请求分一组,只跑一次前向,同组共享 logits(lm_eval/models/utils.py:238、lm_eval/models/huggingface.py:1350-1357)。选项越多省得越多。→ 第 4 章 -
YAML
include+!function:配置复用与逃生舱并存。 MMLU 57 个子任务共享一份模板(_default_template_yaml),每个只写 4 行差异;模板表达不了的,!function utils.py 里的函数名直接注入 Python(lm_eval/tasks/_yaml_loader.py:93)。→ 第 1 章 -
指标是「passthrough + 聚合函数」两段式。 逐题值原样攒进列表,
perplexity之类的换算全推到聚合端(math.exp(-mean(items)),lm_eval/api/metrics.py:46-48)。stderr、group 加权都在同一处统一处理。→ 第 5 章 -
响应缓存按请求参数哈希。
CachingLM用 sqlite 存( 方法名, 参数) → 响应,重跑同配置秒回;但do_sample=True的请求故意不缓存——否则repeats>1时每次采样会返回同一条「随机」结果(lm_eval/api/model.py:285-294)。→ 第 4 章
5. 边界与局限
诚实清单——它的设计取舍意味着:
- 分数只在同一 harness 口径下可比。 prompt 模板、target 前有没有空格(
target_delimiter)、few-shot 样本,每一项都能动几个点。它解决的是「同一框架内可比」,跨框架(或与论文手工评测)比数字要非常小心。 - 评的是模型,不是 agent。 没有环境、工具调用、多轮轨迹;「模型在 agent 场景下表现如何」是它刻意不回答的问题。多轮/工具场景评测看书架上的 inspect-ai 等兄弟源。
- few-shot 样本默认随机抽。 种子固定所以可复现,但换
num_fewshot就换一批样本;MMLU 这类任务改用first_n采样器(固定前 5 道)就是为了可比性(lm_eval/api/samplers.py:104)。 generate_until任务的天花板在 filter。 分数依赖正则从自由文本里抽答案(如 GSM8K 的#### (\-?[0-9\.\,]+)),模型换个格式输出就可能抽不到——分数低不一定是模型不会。- loglikelihood 类任务对 chat 模型水土不服。 必须显式开
apply_chat_template;enable_thinking=True直接与 loglikelihood 任务互斥,框架会报错(lm_eval/models/huggingface.py:1216-1224)。 acc_norm按字符数归一,不是 token 数。completion_len = len(choice)是 Python 字符串长度(lm_eval/api/task.py:1494),跨语言/跨分词器时这个「norm」的含义会变(另有按 UTF-8 字节数的acc_bytes)。- 多模态是原型态。 README 自己标注 multimodal 是 in-progress,严肃多模态评测出门左转 lmms-eval(同门 fork)。
6. 横向对比
最大的分野:评模型 vs 评 agent。 本库评「模型这张卷考多少分」——请求是无状态的 loglikelihood/生成,判分是确定性规则。而 inspect-ai 和 openai-evals 评的是「系统在一串交互里干得怎么样」——有环境、有工具、有 solver/scorer 的编排。两类问题不要混用工具:给 base model 跑 MMLU 用本库;给 agent 跑 SWE-bench 用它们。docs 总览的 eval-observability 章 目前主要覆盖 agent 侧那条线,本篇正好补上模型侧。
同一问题(模型 benchmark)内的不同取舍:
| 维度 | lm-evaluation-harness | lighteval |
|---|---|---|
| 出身 | EleutherAI,2019 年起,学术引用最多 | HuggingFace,leaderboard 后起的重写 |
| 任务定义 | YAML 为主 + !function 注入 Python | Python 代码定义任务 |
| 请求模型 | 四种 output_type,Instance 扇出 | 类似(loglikelihood / generative) |
| 生态重心 | 社区任务最多,兼容历 史口径 | 与 HF 生态(dataclasses、nanotron)更紧 |
与 olmo 的关系:OLMo 是「训练栈 + 评测一体」的研究仓库,其评测大量借助本类 harness 跑;要研究「训练里怎么用评测」看 olmo,要研究「评测本身怎么算」看本篇。
7. 代码地图(入口级)
每章末尾有细粒度地图,这里只列从零读源码的五个入口:
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 命令行 / 库入口 | lm_eval/evaluator.py | simple_evaluate、evaluate |
| 任务发现与建造 | lm_eval/tasks/manager.py | TaskManager.load、TaskIndex.build、TaskFactory.build |
| 一个任务的全部行为 | lm_eval/api/task.py | ConfigurableTask.fewshot_context、construct_requests、process_results |
| 模型后端契约 | lm_eval/api/model.py | LM.loglikelihood、TemplateLM._encode_pair、CachingLM |
| 最常用的后端实现 | lm_eval/models/huggingface.py | HFLM._loglikelihood_tokens、generate_until |