LLM 评测体系

第 10 章 lm-evaluation-harness:学术评测的事实标准

作者 杨艺韬 · 23,533 字 · 发布于 · 更新于

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 truth
  • construct_requests(doc, ctx, **kwargs):把一条 doc 变成若干 Instance
  • process_results(doc, results):从模型 response 算单条样例分数
  • aggregation(self) → dict:把单条样例分数聚合成 task-level metric
  • higher_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

每个亮点都对应一个可学习的工程模式:

  1. 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——同一次生成可以同时按两套口径打分
  2. 两层 Cache:lm_eval/caching/cache.py 用 dill pickle 文件缓存构造好的请求实例(--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)会被显式跳过不缓存
  3. Decontamination(lm_eval/decontamination/):n-gram overlap 查测训污染的算法实现在这里(lm_eval/decontamination/decontaminate.py:37 的 get_train_overlap、同目录 janitor.py 的 13-gram Janitor、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 也没有对应开关;同名的 decontaminate filter 更是个 pass 空壳(见 §10.9.2 的表)。所以它是一套能用但要自己调的离线工具,不是「跑 lm-eval 就自动帮你去污染」
  4. Multi-task 合批:lm_eval/evaluator.py 先把所有 task 的 Instance 按 request_type 归并(第 467-468 行),再按类型整批调用 getattr(lm, reqtype)(...)(第 500 行)——不同 task 的同类请求进同一批,提高 GPU 利用率
  5. 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 的官方表述):

  1. 后端解耦:lm-eval 用抽象 LM 类把模型后端与任务定义分开,换后端不动 task
  2. 接入成本:新任务写一份 YAML 即可,不必写 BIG-bench 那样的 JSON spec
  3. 口径显式:output_type、prompt 模板、filter、metric 都写在 YAML 里,可逐层追溯
  4. 可审计:--log_samples 把每条样例的输入、输出、中间结果完整落盘

这使 lm-eval 成了开源 LLM benchmark 的"事实标准"——Hugging Face 的 Open LLM Leaderboard 曾直接用它跑分(该榜已于 2025 年 3 月退役归档)。

工程实务的 4 条"BIG-bench 时代教训":

  1. 不要把 task 当论文做——单 task 应该能被任何团队低成本重跑
  2. 格式贴近开源生态:用 yaml + jsonl 而非自创格式
  3. abstract backend 是必须的:让"换底层模型"不需要改 task 定义
  4. 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 太好用,工程团队容易犯一些误用。一份"反误用"提醒:

  1. 不要把 MMLU 分数当成"模型整体能力":MMLU 测的是百科知识,与你业务的客服 / 代码 / 创意能力没必然相关
  2. 不要追求 SOTA 分数而忘了业务:模型在 lm-eval 上+5pp 不一定让你的业务+5pp
  3. 不要忽视 task version:MMLU v1 与 v2 的题集 / 评分逻辑可能略有差异,跨版本比对要谨慎
  4. 不要把 lm-eval 当成应用层评测:业务 RAG / Agent 评测请用 ragas / promptfoo
  5. 不要在生产环境的 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 条选择经验:

  1. 多选题用 loglikelihood——只需前向打分、不用自回归生成,通常快得多;也不存在"生成了但抽不出答案"的格式失败
  2. 数学 / 代码用 generate_until——必须看到完整 reasoning 链
  3. 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)
  4. 混合 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 条原则:

  1. filter 顺序 = 数据流顺序——上游 filter 的输出是下游的输入,错一步全错
  2. 聚合类 filter(majority_vote)必须在抽取类(regex)之后
  3. 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 的多任务并发架构。核心设计:

  1. 注册表:模型、metric、aggregation、filter 都注册进 registry.py 里的 dict,按 name 查找(YAML 任务则由 lm_eval/tasks/__init__.py 的 TaskManager 扫描索引)
  2. request collation:把多个 task 的 logprob requests 合并到同一批
  3. batch dispatch:合并后的 request 一次发给 model backend
  4. result demultiplex:response 收到后按 task / request 拆回各自结果
  5. lazy evaluation:task 的 metric 只在最后聚合阶段才算,不影响中间速度

这种 collate-batch-demultiplex 的并发模式让"跑 10 个 task 不比跑 1 个慢多少"——只要每个 task 的请求规模差异不大,batch 利用率就高。

工业 fork 时这个并发机制要保留——很多团队 fork 后只关注 task / metric 部分,忽略合批机制,结果吞吐明显下降。

10.9.4 多模型并发跑评测:lm-eval 的工程性能优化

lm-eval-harness 在大规模评测里的隐藏价值是性能优化——它不只是"能跑",是"快速跑通几万样例"。这一点对工程团队评估影响很大。

具体优化点(来自源码与官方文档):

  1. batch inference:把多条样例的 forward pass 打包,HuggingFace transformers / vLLM 后端原生支持
  2. 缓存重用:--use_cache 把 (方法名, 请求参数) 哈希后存进 SqliteDict,重跑时命中即跳过(注意默认不开,且非 greedy 采样不缓存)
  3. 数据并行:多 GPU 时按 rank 切分样例(build_all_requests(rank=lm.rank, world_size=lm.world_size)),各 rank 跑同一批 task 的不同分片,最后 gather_object 到 rank 0 聚合——不是"每张卡跑不同 task"
  4. 数据集缓存:数据经 HF datasets 下载后落本地缓存,重跑不再下载(lm-eval 0.4.5 自身没有流式加载选项)
  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 设计经验:

  1. 任何"多 sub-domain benchmark"都该用 group:MMLU / BBH / MMLU-Pro 都遵循该模式
  2. 聚合方式必须显式声明:aggregate_metric_list 里的 weight_by_size: True(按子任务样本数加权)vs 默认的 False(每子任务平等)在子集大小悬殊时结论会不一样——MMLU 各学科测试题量从 100 道到 1500 多道不等,两种口径给出的总分并不相同
  3. include 共享公共字段:description / fewshot 等公共配置提到模板里,子任务只写差异
  4. 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 条巡检规则:

  1. 每周 cron 触发:周末空闲时跑——避免占用工作时间 GPU
  2. broken_tasks 自动开 GitHub issue:标 [task-broken] tag,作者按周去修
  3. deprecated 标记写进 yaml 的 metadata(如 metadata: {status: deprecated}):明确"已知坏,不影响整体健康度";别加顶层字段,TaskConfig 不认识的键会让任务加载失败
  4. 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 价值:

  1. 横向对标同行:私有 task 跑外部模型 + 公开 task 配对——"我们的 customer_service 跟 banking77 做映射"
  2. 新模型选型快:候选模型在 banking77 跑得好 → 大概率私有也好
  3. 回答"我们 task 是否合理":与公开 task 高相关说明你 task 真测了能力
  4. 新人理解快:解释私有 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 的工程价值:

  1. 数据集版本锁定:HF 上每个 dataset 有 commit hash,YAML 可以经 dataset_kwargs: {revision: <commit>} 锁定到具体版本(v0.4.5 自带 YAML 默认不锁)。论文报告"在 mmlu commit abc123 上跑出 78.3%"读者能精确复现
  2. 流式加载:datasets 库本身支持 streaming=True,超大数据集不必全量下载(lm-eval 0.4.5 的加载路径没有用到它)
  3. 统一缓存:第一次拉到本地后所有后续运行都用本地缓存,离线能跑
  4. 跨语言可读:一份 dataset 可以同时被 Python / Rust / 其他语言读取
  5. 生态工具丰富:filtering / mapping / batching 等操作都是 datasets 库内置
  6. 预处理可追溯: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 给的是"模型相对位置"信号,不是"绝对业务表现"判断。工业实践:

  1. 用 lm-eval 把候选模型从 10 个筛到 3 个(节省时间)
  2. 用 promptfoo / ragas 在自家业务集上跑这 3 个候选(拿到真实表现)
  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% 准确率 ≠ 模型真懂,可能只是"背过"

学术界的应对:

  1. n-gram overlap 检测:把 benchmark 与训练集做 n-gram 重叠度检测,超过阈值的视为污染(Brown et al. 2020 GPT-3 论文用的是 13-gram)
  2. 私有变体:把 benchmark 题改写成等价但用词不同的私有版
  3. 动态 benchmark:持续补充新题(如 LiveBench 定期用新近的竞赛题、论文、新闻出题)或改用实时人类投票(如 Chatbot Arena)
  4. 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 条解读经验:

  1. 不要被"饱和指标"骗:HellaSwag / GSM8K 已 saturated, 头部模型差距 < 1pp 没意义
  2. GPQA-Diamond 曾是 2024–2025 年区分 reasoning 能力的常用试金石:能拉开 GPT-4o 与 o3-mini 这类模型的差距(随着头部模型分数走高,它也在逐步饱和)
  3. IFEval 对 Agent 业务最关键:结构化输出能力 = tool calling 能力的 proxy
  4. 业务相关性看 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 设计经验:

  1. 必含至少 5 个 benchmark:单个 benchmark 没说服力(代码能力要另跑 bigcode-evaluation-harness,lm-eval 里没有 HumanEval)
  2. 必有 reproducibility 段:让外部能验证
  3. 限制必坦诚:写 "weak in Chinese 古文" 比"全能"更有信任
  4. 每个新版本必更新: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 直接对照。

工业团队通常的模型选型流程:

  1. lm-eval 跑 4-5 个核心 benchmark:MMLU / GSM8K / ARC-Challenge / TruthfulQA / HellaSwag
  2. 选出 top 3 候选:在你的预算 / 延迟约束内综合评分最高的 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 完全不同的思路。