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

“If you cannot reproduce the number, the number is fiction.” —— 一条 EleutherAI 评测组内部流传的格言

本章要点

  • lm-evaluation-harness(简称 lm-eval)的整体架构:Task / LM / Instance / Filter 四层抽象
  • LM 基类的四种 OutputType:loglikelihood / loglikelihood_rolling / generate_until / multiple_choice
  • Task 子类化与 YAML 配置驱动:214 个 task 是怎么挂载的
  • Metrics 库的 30+ 个内置指标:从 mean / perplexity 到 BLEU / chrF / Matthews CC
  • 与 OpenAI evals 的对比:为什么学术界几乎没人用 OpenAI evals 报数字

10.1 仓库位置与版本

本章源码引用基于 EleutherAI/lm-evaluation-harness 主线版本。读者可通过下面命令获取:

git clone https://github.com/EleutherAI/lm-evaluation-harness.git
cd lm-evaluation-harness

仓库的整体规模:核心代码(lm_eval/api/)约 4500 行,task 配置(lm_eval/tasks/)214 个目录、几千份 YAML,lm_eval/models/ 适配了 30+ 种推理后端(HuggingFace transformers、vLLM、OpenAI / Anthropic / Gemini API、Mamba 等)。

它的核心地位来自一个事实:几乎所有 LLM 论文里报告 MMLU / HellaSwag / ARC / GSM8K / BIG-Bench-Hard 等 benchmark 数字时,背后跑的都是这个仓库。OpenAI、Anthropic、Google DeepMind、Meta、Mistral、Llama 团队的 model card 都明确使用 lm-eval-harness 做横向对比——它是大模型时代的”度量衡”。

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:25 定义抽象基类 LM(abc.ABC),要求子类实现两个核心方法:

class LM(abc.ABC):
    """Abstract base class for language models."""

    @abc.abstractmethod
    def loglikelihood(self, requests: list["Instance"]) -> list[tuple[float, bool]]:
        """Compute log-likelihood of generating a continuation from a context."""
        pass

    @abc.abstractmethod
    def loglikelihood_rolling(self, requests: list["Instance"]) -> list[float]:
        """Compute full log-likelihood of a string, with no truncation."""
        pass

注意 loglikelihood 返回 (logprob, is_greedy) 二元组——is_greedy 表示 “这个 continuation 是否是 greedy decoding 会产生的”。这是个细节但很重要——很多 multiple-choice 评测要看模型在 K 个选项中最大概率那一个是不是正确选项,is_greedy 直接给出这个信息。

OutputTypelm_eval/api/instance.py:5-7

OutputType = Literal[
    "loglikelihood", "loglikelihood_rolling", "generate_until", "multiple_choice"
]

四种类型对应四种评测形态:

OutputType评测形态典型 task
loglikelihood单段 continuation 的概率HellaSwag、Winogrande
loglikelihood_rolling整篇文本的 perplexitywikitext、PG19
generate_until自由生成直到遇到 stop sequenceGSM8K、BBH、HumanEval
multiple_choiceA/B/C/D 多选MMLU、ARC

这四种类型几乎覆盖了所有主流 benchmark。值得注意的是 lm-eval 不是基于 chat completion API 设计的——它的核心是 logprob 接口,因为很多 benchmark(特别是 multiple-choice)需要看模型对各选项的相对概率,而不只是文本输出。

这就是为什么对 OpenAI / Anthropic 这种只暴露 chat completion API 不暴露 logprob 的厂商,lm-eval 的 multiple_choice 走法只能退化到 generate_until + 字符匹配——精度比 logprob 差。

10.4 Task 子类与 YAML 配置驱动

10.4.1 Task 基类

lm_eval/api/task.py:62 定义 Task(abc.ABC),要求子类实现:

  • dataset(self):从 HuggingFace datasets 加载
  • doc_to_text(doc):把数据集 doc 转成 prompt 文本
  • doc_to_target(doc):拿出 ground truth
  • process_results(doc, results):从模型 response 算单条样例分数
  • aggregation(self) → dict:把单条样例分数聚合成 task-level metric

这套接口逼迫 task 作者把”prompt 模板”和”判分逻辑”写得显式、可审查——这是学术评测可复现性的工程基石。

10.4.2 214 个 task 的 YAML 驱动方式

仓库的 lm_eval/tasks/ 下有 214 个子目录,每个目录是一个 benchmark。绝大部分新 task 不需要写 Python,只需要写 YAML——以 MMLU 为例(tasks/mmlu/default/_default_template_yaml):

dataset_path: cais/mmlu
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

这一份 12 行的 YAML,定义了一个完整的 MMLU 子任务:

  • dataset_path: cais/mmlu:直接拉 HuggingFace 上的官方数据集
  • 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 仓库能积累 214 个 task:贡献者不被代码门槛吓退。

10.4.3 MMLU 的子任务展开

仓库的 tasks/mmlu/default/ 下有 57 个 yaml 文件——MMLU 标准包含 57 个学科子任务(abstract_algebra、anatomy、astronomy、business_ethics、…)。每个学科一份 YAML,统一继承 _default_template_yaml。这种”模板继承”机制让维护成本极低——共有逻辑只写一次。

10.5 Metrics 库:30+ 个内置指标

lm_eval/api/metrics.py(649 行)实现了 30+ 个评测指标的计算函数:

指标用途行号位置
mean / median基础聚合metrics.py 上半段
perplexity / weighted_perplexity / bits_per_byte语言模型困惑度中段
f1_scoreF1(用于 SQuAD 这类抽取式 QA)中段
matthews_corrcoefMatthews 相关系数(适用极不平衡分类)中段
bleu / chrf / ter翻译评测中段
brier_score概率校准评测中段
acc / acc_norm准确率 / 归一化准确率多个 *_fn
exact_match精确匹配(用 HuggingFace evaluate)exact_match_hf_evaluate
pop_stddev / sample_stddev / mean_stderr统计推断末段

值得专门讨论的是 acc_norm——它做”长度归一化的 accuracy”,把每个选项的 logprob 除以选项的字符数,避免短选项有不公平优势。这种细节是学术评测和工业评测的真正差异点:学术评测必须把每一个潜在的 confounder 都排除到底,工业评测往往容忍。

10.6 与 OpenAI evals 的设计对比

学术界几乎没人用 OpenAI 的 evals 仓库报数字。原因是设计哲学的根本差异:

维度lm-eval-harnessOpenAI evals
接口核心logprobtext completion
任务定义YAML 优先YAML + Python 混合
模型支持30+ 后端(HF/vLLM/all APIs)主要 OpenAI 系
任务数量214 个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/>同一份 logprob 喂给多个 task]
  A --> F[Few-shot 模板继承<br/>YAML 复用]
  style A fill:#fef3c7

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

  1. Filter 流水线lm_eval/api/filter.pylm_eval/filters/):模型输出经过一组可组合的 filter 抽取最终答案。例如 GSM8K 用 [take_first, regex_extract_number] 这样的链式 filter
  2. Cache 层lm_eval/caching/cache.py):用 SqliteDict 持久化 logprob 计算结果——重跑不需要重算
  3. Decontaminationlm_eval/decontamination/):内置工具检测测训污染(n-gram overlap),这是学术界对”data contamination”问题的工程响应
  4. Multi-task 并行:同一个模型 forward pass 的 logprob 可以同时为多个 task 评分——大幅降低 GPU 时间
  5. Few-shot 模板继承:MMLU 57 个子任务共用一份模板的机制

这五个设计是 lm-eval 经过 3 年迭代沉淀的工程财富——内部 fork 改造时这些点都值得保留。

10.7.5 为什么所有大模型论文都用它:可复现性的工程含义

如果只看技术接口,lm-eval-harness 似乎只是一个”通用 eval runner”。但它在学术界的统治地位远超工程价值——OpenAI、Anthropic、Google、Meta、Mistral、DeepSeek、Qwen 等几乎所有大模型团队的 model card 都明确使用 lm-eval-harness 报告 benchmark 数字。

为什么是它?根本原因是可复现性的承诺被工程化兑现

  • YAML 配置 + git tag:每个 task 的版本号写在 metadata 里。论文可以注明 “evaluated using lm-eval-harness commit abc123 on task mmlu version 1.0”,读者能精确重跑得出同样数字
  • prompt 模板透明:所有 prompt 模板都在公开 yaml 里,没有”内部魔法 prompt”
  • 数据集 hash 锁定:HuggingFace datasets 的 commit hash 锁住具体数据版本
  • logprob 接口标准化:multiple_choice 评测的”取概率最大的选项”逻辑统一,不会因实现方式不同导致数字差异

这种”可复现性”在工业评测里是常被忽视的——团队有时报告”我们的模型在 MMLU 上 85%“,但用不同框架跑可能得到 80% 或 90%。lm-eval 把这种不确定性消除到最低限度。

工业团队即使不发论文,也应该把 lm-eval-harness 作为”模型横向对比”的事实标准——你切换基础模型时,跑一次 MMLU/HellaSwag/ARC 比无数自家 benchmark 更能让团队达成共识。这是它在工业场景的隐藏价值。

10.7.6 学术 vs 工业评测的张力

读完 lm-eval-harness 与 OpenAI evals 之后会感受到一种张力——学术与工业评测的优先级根本不同

  • 学术:可复现性 > 速度 > 易用性
  • 工业:易用性 > 速度 > 可复现性

学术追求”任何人重跑都得到相同数字”,所以 prompt 模板锁死、metric 严格定义、版本化到 commit 级别。工业追求”快速迭代得到决策信号”,所以 prompt 可以日常微调、metric 可以临时改造、版本化粗放。

这种张力没有”对错”——它们解决不同的问题。一个成熟的 LLM 团队会同时维护两套评测:

  • 用 lm-eval-harness 做”基础模型选型”——每次切换底模时跑学术 benchmark
  • 用 promptfoo / ragas / OpenAI evals 做”应用层迭代”——每次改 prompt / retriever 跑业务 benchmark

理解这种张力,团队才不会浪费工程时间在”用错的工具解决错的问题”上。

10.7.7 HuggingFace Datasets 集成的工程价值

lm-eval-harness 的一个被低估的设计——所有 task 的数据都通过 HuggingFace datasets API 加载。这不是技术选型偶然,而是有深远工程意义。

# 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 可以锁定到具体版本。论文报告”在 mmlu commit abc123 上跑出 78.3%“读者能精确复现
  2. 流式加载:超大数据集(如 BIG-Bench 1000+ task、数百万样例)支持流式,不需要全量下载
  3. 统一缓存:第一次拉到本地后所有后续运行都用本地缓存,离线能跑
  4. 跨语言可读:一份 dataset 可以同时被 Python / Rust / 其他语言读取
  5. 生态工具丰富:filtering / mapping / batching 等操作都是 datasets 库内置
  6. 预处理可追溯:dataset 的 transform 操作(如 tokenization)会被缓存为新版本

工业 fork 这套框架时,最关键的一步是保持 datasets 集成不变。即使把 dataset 替换成内部数据,也建议把它转成 HuggingFace datasets 格式(用 datasets.Dataset.from_pandasfrom_dict)——这样能继承 lm-eval 整个数据加载生态。

第 13 章 RAG 评测的 ragas + Dataset.from_list 用的正是这套机制——HuggingFace datasets 已经是 LLM 工程领域事实上的”数据交换格式”。

10.7.8 commercial API 后端的限制:lm-eval 的盲区

lm-eval 在开源模型 / 本地部署场景几乎完美。但接 commercial API(OpenAI / Anthropic / Gemini)时存在两个工程限制:

限制 1:multiple_choice 评测精度下降

lm-eval 的 multiple_choice 走法依赖 logprob 接口——直接拿 4 个选项的概率比大小。但商业 API 普遍不暴露 logprob(OpenAI 自 2024 起官方接口不支持、Anthropic 从未支持)。

lm-eval 的应对是退化到 generate_until + 字符匹配——让模型自由生成 “A” / “B” / “C” / “D”,再匹配第一个字符。这种方式:

  • 失去了”看 4 选项相对概率”的细粒度
  • 容易被模型的”emit format”差异污染(输出 “Answer: A” vs “(A)” 都是同一类失败模式)
  • 论文上无法直接对比开源模型(用 logprob)和商业模型(用 generate)的数字

限制 2:成本与延迟

lm-eval-harness 设计成”批量并行”——HuggingFace transformers / vLLM 后端支持一次推理上千 token 的 batch。但商业 API 的 RPM / TPM 限流让 batch 几乎失效,跑一次完整 MMLU(14k 题):

后端时间成本
vLLM 本地部署 (8×A100)30 分钟几乎免费
OpenAI API (gpt-4o)6-12 小时$200-400
OpenAI API (gpt-4o-mini)4-8 小时$20-40

对工业团队的工程含义:lm-eval 适合”研究模型本身”,不适合”日常应用层评测”。第 9-12 章的 OpenAI evals / promptfoo / ragas 才是后者的工具。

10.7.9 中文 LLM 评测的特殊挑战与 lm-eval 的中文 task 生态

中文 LLM 评测在 lm-eval-harness 上有一个值得讨论的演化——早期它的中文 task 极少,后来由社区贡献涌入。截至 2026 年初,仓库 tasks/ 下中文相关 task 已超过 30 个,包括 CMMLU(中文版 MMLU)、C-Eval(综合中文评测)、AGIEval、CLUE、SuperCLUE 等多个权威 benchmark。

中文评测有几条与英文不同的工程难题:

  • 分词差异:英文 BPE tokenizer 在中文上效率低(一个汉字可能切 2-3 个 token)
  • 同义表达多:英文 “Paris” 唯一,中文”巴黎 / 法国首都 / 法国巴黎”全都对
  • 繁简兼容:同一概念在简体 / 繁体下可能是不同 token,影响 logprob 一致性
  • 领域知识本地化:法律、医疗、金融等专业领域的中文术语库与英文 benchmark 完全不同

工业团队跑中文 LLM 评测时建议组合:

  • 基础能力:跑 CMMLU、C-Eval(覆盖中文学科知识)
  • 推理能力:跑 AGIEval(含中文公考、高考、法考题)
  • 生成能力:跑 SuperCLUE 的开放式题
  • 加自家业务集:每个团队自己的客服 / 政策 / 业务对话集

这套组合能给出完整的”中文 LLM 在你业务上的能力画像”。

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

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

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

  1. batch inference:把多条样例的 forward pass 打包,HuggingFace transformers / vLLM 后端原生支持
  2. 缓存重用:同一条 prompt 的 logprob 跨 task 共享(如 MMLU 的 fewshot prompt 在多个学科子任务里复用)
  3. 进程并行:多 GPU 时各自跑不同 task,主进程聚合结果
  4. 流式数据加载:超大数据集(百万级)不需要全量进内存
  5. 断点续跑:评测中途崩溃后能从上次失败处继续

这套优化让”在 vLLM 后端跑完整 MMLU + HellaSwag + ARC + GSM8K”在 8×A100 上仅需 2-4 小时——而朴素实现可能要 30+ 小时。

工程团队选 evaluator 时这一点常被忽视——很多自建评测脚本没做并行 / 缓存,跑一次评测要一整天,团队就慢慢不跑了。lm-eval 的”工程级性能”是它能成为学术事实标准的硬实力之一。

工业 fork 时建议保留这套优化机制,特别是 batch + 缓存——它们是单元测试覆盖之外的”无形资产”,迁移到内部框架时容易被丢弃。

10.7.11 一份 lm-eval 的真实使用场景:模型选型决策

工程团队选基础模型时(比如从 GPT-4o-mini 切到 Claude 3.5 Haiku),怎么决定?lm-eval 提供的是”客观对比”路径——而非”我们自己跑两条 query 看感觉”。

具体做法:

# 跑 GPT-4o-mini
lm_eval --model openai-chat-completions \
        --model_args model=gpt-4o-mini \
        --tasks mmlu,gsm8k,truthfulqa_mc1,humaneval \
        --output_path results/gpt-4o-mini.json

# 跑 Claude 3.5 Haiku
lm_eval --model anthropic-chat \
        --model_args model=claude-3-5-haiku \
        --tasks mmlu,gsm8k,truthfulqa_mc1,humaneval \
        --output_path results/claude-haiku.json

# 跑你自己 fine-tune 的模型(vLLM 部署)
lm_eval --model vllm \
        --model_args pretrained=./my-finetuned-model,tensor_parallel_size=2 \
        --tasks mmlu,gsm8k,truthfulqa_mc1,humaneval \
        --output_path results/my-model.json

跑出 4 个 task × 3 个模型 = 12 个数据点的横向对比,几小时内得到决策依据。这比”找 5 个 SaaS 跑一遍业务集”的传统做法可信得多——因为 lm-eval 上的题是固定 benchmark、跑出的数字可与论文 / model card 直接对照。

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

  1. lm-eval 跑 4-5 个核心 benchmark:MMLU / GSM8K / HumanEval / TruthfulQA / HellaSwag
  2. 选出 top 3 候选:在你的预算 / 延迟约束内综合评分最高的 3 个
  3. 在自己的业务集上跑:用 promptfoo / ragas 做最终选型

第 1、2 步用 lm-eval 客观对标,第 3 步用应用工具做业务相关验证——两者结合是模型选型的正确流程。

10.7.12 lm-eval 与 vLLM 的紧密集成

lm-eval 的 vLLM 后端是工业界首选——vLLM(《vLLM 推理引擎》第 5 章详述)的 PagedAttention + 高并发让评测吞吐量远超直接调 transformers。

实测对比(公开 benchmark 数据):

后端MMLU 14k 题完整跑完硬件
HuggingFace transformers~6 小时1×A100
vLLM~30 分钟1×A100
vLLM tensor_parallel=4~10 分钟4×A100

20 倍以上的速度提升完全来自 vLLM 的 batching 优化——这就是为什么国内外大模型团队评测都用 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,model=meta-llama/Llama-3.1-8B-Instruct \
        --tasks mmlu_pro

这种”vLLM service + lm-eval client”的解耦让评测和推理服务独立扩缩——评测高峰期可以临时拉起更多 vLLM 实例。

10.7.13 一个工程教训: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.7.14 lm-eval 在 LLM 工程师面试题中的角色

lm-eval-harness 在 2025 年开始成为高级 LLM 工程师面试的”基础知识”——理解它的内部抽象 + 能解释为什么某个 benchmark 数字这样跑出来,已经是国内一线大厂招聘 LLM 工程师的常见考点。

典型面试题:

  • “MMLU 的 5-shot accuracy 是怎么计算的?为什么要 5-shot 而不是 0-shot?”
  • “为什么 lm-eval 的 multiple_choice 评测在商业 API 上不准?”
  • “如果让你给一个新 benchmark 写 yaml,你会怎么设计 metric_list?”
  • “lm-eval 与 OpenAI evals 的接口设计差异在哪?为什么这两套不一样?”

读完本章你能回答这些题。这种”工具知识”在 LLM 工程领域和”分布式系统理论”在后端领域是同等重要——理解工具背后的设计权衡,才能在工程决策时不被表面的功能特性带偏。

10.7.15 lm-eval 的演化方向:从 task 库到评测平台

仓库 2024-2025 年的演化方向也值得关注:

  • 更多 reasoning task:增加多步推理 / chain-of-thought 评测的支持
  • 更多多语言 task:早期偏英文为主,正在加多语言 benchmark(CMMLU / Indic-Eval / 阿拉伯语 LLM 评测等)
  • vLLM / SGLang 后端深度集成:性能优化继续投入
  • 结果可视化 dashboard:从纯 CLI 工具向 web UI 延伸

这些演化让 lm-eval 从”研究工具”逐步走向”工业可用的评测平台”。但它的核心定位——可复现性优先 + benchmark 报数字标准——不会变。这种”做一件事做到行业标准”的项目演化哲学,比”试图覆盖所有用例”更可持续。

10.7.16 一个学术评测的根本痛点: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 论文方法)
  2. 私有变体:把 benchmark 题改写成等价但用词不同的私有版(HELM 等做法)
  3. 动态 benchmark:从 Reddit / Twitter 等持续更新的数据源构造(如 LiveBench、Chatbot Arena)
  4. Holdout 题集:保留小部分题不公开,用于真实评估

工业团队的应对:

  • 不依赖单一 benchmark:lm-eval 跑出的数字配合自家私有评测集
  • 看新发布的动态 benchmark:LiveBench / Chatbot Arena 在 2024-2026 年是更可信的”未污染”选择
  • 怀疑过高的分数:单一 benchmark > 95% 通常说明污染,不是真能力

这是 lm-eval-harness 给的”benchmark 数字”必须配合”业务集验证”的根本原因。学术评测和工业评测不是互替关系——是互补关系。

10.7.17 lm-eval 的并发架构剖析

仓库 lm_eval/api/registry.py(719 行)和 lm_eval/evaluator.py 一起实现了 lm-eval 的多任务并发架构。核心设计:

  1. task 注册表:所有 task 在启动时被注册成 dict,便于按 name 查找
  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.7.18 lm-eval 的”task 编年史”:哪些 benchmark 是真重要

仓库 214 个 task 不是同等重要。从论文引用频率看,工业团队真正应该关心的”top 10 task”:

Task测什么工业重要性
MMLU / MMLU-Pro综合知识⭐⭐⭐⭐⭐
HellaSwag常识推理⭐⭐⭐⭐
ARC-Challenge科学推理⭐⭐⭐⭐
GSM8K数学题⭐⭐⭐⭐⭐
MATH高级数学⭐⭐⭐⭐
HumanEval代码生成⭐⭐⭐⭐⭐
MBPP代码补全⭐⭐⭐
TruthfulQA抗幻觉⭐⭐⭐⭐⭐
Winogrande指代消解⭐⭐⭐
BIG-Bench-Hard综合难题⭐⭐⭐⭐

跑这 10 个 task 已经覆盖了大部分主流 LLM 论文报告的能力维度。工业团队选模型时跑这 10 个就够,不必跑全部 214 个——边际信息几乎为零。

10.7.19 lm-eval 的”小心翼翼”哲学

仓库的工程哲学最值得学习的一点——对每个数字小心翼翼

  • 默认 seed=1234 写死,让结果可复现
  • few-shot example 顺序固定,避免随机性
  • prompt 模板进 yaml,每一个版本都有 git history
  • metric 实现配单元测试,避免计算错误
  • 输出格式带详细 metadata(model_name, task_version, eval_time)
  • 警告 / 错误日志详细,帮助 debug

这种”小心翼翼”的做事方式与学术界对可复现性的执念高度同构。如果你写的论文 / 模型 release 数字想被认真对待,必须采用类似哲学。

工业评测虽然不必这么严苛,但理解 lm-eval 的这种”匠人精神”能让团队在工程纪律上拔高一个层次。一个普遍现象:跑过 lm-eval 几次的工程师,写自家评测时会自动加更多 reproducibility 保障——这是工具反向影响人的工程素养。

10.7.20 lm-eval 与中国大模型生态的对接

国内大模型(豆包 / Qwen / DeepSeek / Kimi / GLM 等)大量使用 lm-eval 报数字。具体对接方式:

# 通过 OpenAI compatible API 接入国内模型
lm_eval --model local-completions \
        --model_args base_url=https://api.deepseek.com/v1,model=deepseek-chat \
        --tasks mmlu,gsm8k,humaneval,truthfulqa_mc1

国内模型在 lm-eval 上跑的几个特殊点:

  • API 兼容:大部分国内模型已经提供 OpenAI 兼容 API,直接接入
  • rate limit 严:相比 OpenAI 国内 provider 的 rate limit 更紧,要降低并发
  • 中文 task:不只跑 MMLU 等英文 benchmark,要跑 CMMLU / C-Eval / SuperCLUE
  • Token 计费差异:国内模型 input / output 价格可能不同于 OpenAI,要按各家定价计算

国内大模型公开发布时,model card 上的 “MMLU 85%” 一般就是用 lm-eval 跑出来的——国际同行能直接对照 GPT-4 / Claude / Gemini 数字。这种”数字可比性”是 lm-eval 给国内大模型团队的关键工程价值。

10.7.21 lm-eval 在工业 LLM Gateway 后的接入位置

工业大型团队通常在 LLM 调用前过一层 LLM Gateway(详见 §6.6.8 / §17.10)。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.7.22 一份”读完 lm-eval 后该掌握什么”清单

整合本章方法学,给一份”读完 lm-eval 后掌握的核心能力”清单:

□ 能解释 lm-eval 4 层抽象 (Task / Instance / LM / Filter)
□ 能解释 4 种 OutputType 各自适用场景
□ 能写 yaml 注册一个新 task (含 dataset_path, doc_to_text, metric_list)
□ 能解释为什么 multiple_choice 在商业 API 上精度差
□ 能解释 acc_norm 与 acc 的差异及使用场景
□ 能跑 lm-eval 在 vLLM 后端上 (model_args 配置)
□ 能解释 benchmark contamination 风险及检测方法
□ 能选择适合自家业务的 5 个核心 task 而非全部 214 个
□ 能解释 lm-eval 与 OpenAI evals 的设计哲学差异
□ 能在自家 fork 中保留 lm-eval 的"小心翼翼"工程纪律

10 项全过,说明你已经具备工业级 lm-eval 使用 / 改造能力。

工程团队的下一步:把这份清单作为面试 / 内部考核的参考。读完本章 + 实操跑一次 MMLU + 自己写一个 yaml 的工程师,已经具备了”模型评测工程师”的入门资格。

10.7.23 lm-eval 在新模型评测中的”快速上手”路径

每次新 LLM 出现(如 GPT-5 / Claude 4 / DeepSeek-R2 等),lm-eval 是工业团队最快验证它能力的工具。给一份”新模型 1 小时上手验证”路径:

# 1. 安装 lm-eval (如已装跳过)
$ pip install lm-eval[all]

# 2. 接入新模型 (假设有 OpenAI compatible API)
$ lm_eval --model local-completions \
          --model_args base_url=$NEW_MODEL_URL,model=$NEW_MODEL_NAME \
          --tasks mmlu_pro,gsm8k_chat,humaneval,truthfulqa_mc1 \
          --num_fewshot 5 \
          --output_path results/new-model.json

# 3. 与现有模型对比 (如果之前跑过)
$ python compare.py results/gpt-4o.json results/new-model.json

# 4. 看核心数字
# - MMLU Pro: 综合知识
# - GSM8K: 数学
# - HumanEval: 代码
# - TruthfulQA: 抗幻觉

1 小时内就能拿到新模型在 4 个核心维度的客观数字。这种”快速验证”是工业团队跟踪 LLM 进展的标准操作——不需要等论文 / 不需要靠官方 PR 数字。

工业实操:每次新模型发布,工程师跑一次 lm-eval 的”4 task 套餐”。1 小时内拿到决策依据。比”看 Twitter 上的 demo”客观可信得多。

10.7.24 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.7.25 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

这张图覆盖 LLM 工程师”什么时候用 lm-eval”的所有场景。理解这张图能避免”用错工具”——很多团队拿 lm-eval 跑应用评测(不合适),或拿 promptfoo 报学术 benchmark(不合规)。

工业实务:把这张图贴在团队 wiki 的”评测工具选型”页面。新人面临”这个评测要用什么工具”时,按图索骥 5 秒钟就有答案。

10.7.26 lm-eval 给”评测体系初学者”的最大启示

读完本章后,给评测体系初学者的最大启示:好工具的核心不是功能多,是抽象准

lm-eval 的 4 抽象(Task / Instance / LM / Filter)不是最丰富的、不是最易用的,但是最稳定的——3 年时间核心抽象不变。这种”准确的抽象”是工程作品的最高品质。

工程团队的姿态:

  • 学习一个工具时,不要急于看”它能做什么”,先理解”它的核心抽象是什么”
  • 一旦理解抽象,工具的所有功能都是抽象的具体落地,不需要死记硬背
  • 抽象稳定的工具值得长期投入,抽象在变的工具谨慎投入

这是 LLM 工程师从”工具用户”升级到”工具理解者”的关键认知。读懂 lm-eval 的抽象 = 读懂了评测领域的核心概念。这是本章除了具体源码以外,最重要的”软价值”。

10.7.27 lm-eval 在国内 LLM 团队的”必读地位”

中国大模型团队的工程师如果没接触过 lm-eval,可以说”还没真正进入大模型工程领域”。它在国内大厂团队的”必读地位”体现:

  • 通义 / 豆包 / Kimi / GLM / DeepSeek 等头部模型公司的算法工程师入职都需要熟悉 lm-eval
  • HuggingFace 中文社区几乎所有大模型 release 的”benchmark 数字”都基于 lm-eval
  • 学术发表国内 NLP 论文要在国际期刊 / 会议发表 benchmark 数字几乎必须用 lm-eval
  • 校招面试LLM 工程师面试常问”用过 lm-eval 吗”+ “解释一下 acc_norm”

这种”必读地位”让 lm-eval 在国内的渗透远比在欧美深——欧美还有 OpenAI evals 等替代品,国内基本上是 lm-eval 一家独大。

工程团队的实务:把 lm-eval 列为 LLM 工程师”必备技能”。新人 onboarding 必须 hands-on 跑一次完整 MMLU。这种工程文化让团队的 LLM 评测能力保持业界水平。

10.7.28 一个隐藏的工程价值:lm-eval 的”工程美学”

读完本章源码 + 设计哲学后,最深的领悟可能不是技术——是工程美学

lm-eval 体现了几条”工程美学”:

  • 少即是多:4 抽象 cover 所有场景,不试图”全功能”
  • 向 OTel 致敬:标准化优于自创
  • 可读性优于性能:源码风格清晰,性能优化留给后端实现
  • 社区优于个人:欢迎 PR,不让作者成为瓶颈
  • 历史不可改:保留所有 commit,让演化可追溯

这些美学不只适用于评测工具,是所有”长期工程作品”的标志。读完本章希望读者带走的”软价值”是这种工程美学的认知——它让你在写自家代码时多一份对长期可读性 / 可演化性的敬畏。

10.7.29 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, acc)
  HumanEval:   78.4%   (pass@1)
  TruthfulQA:  62.7%   (MC1)
  ARC:         95.1%   (acc_norm, 25-shot)

每个数字后面都有 “lm-eval-harness commit hash + task version”——保证读者能复现。

工程团队的实务:发布自家 fine-tune 模型时,模型卡片应该按这种格式撰写——所有 benchmark 数字标注 lm-eval 的版本 + commit。这种”可复现”姿态是模型发布的工程伦理。

这种”模型卡片即合规文档”的趋势会持续——EU AI Act 等监管要求让”benchmark 数字必须可追溯”成为合规要求,不只是工程伦理。

10.7.30 lm-eval 给中国 LLM 工程师的”全球化”视野

对国内 LLM 工程师来说,lm-eval 还有一个独特价值——让国内工程师有”全球化”视野

具体含义:

  • 跑 lm-eval = 用国际通用的指标语言
  • 与 GPT-4 / Claude / Gemini 直接对照 = 知道自家模型的国际位置
  • 参与 lm-eval 社区 = 在国际 LLM 工程社区有声音

这种”全球化视野”对国内大模型团队很重要——避免”自家闭门造车 / 用自家定义的指标自欺欺人”的状态。在 LLM 这个全球竞争领域,能用国际通用语言对话是工程团队的基本要求。

10.7.31 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 / vLLM)天然支持触发 lm-eval
  • evaluator 跑完后结果写到 wandb / mlflow
  • 数字差异超阈值自动告警

国内外大模型团队都用类似流程。lm-eval 在这种”训练 + 评测一体化”管线里是核心环节,不是可有可无的外部工具。这是 lm-eval 在 LLM 工业链中的最重要价值。

10.7.32 lm-eval 在不同业务的”使用模式”差异

不同业务对 lm-eval 的使用模式差异显著:

  • 基础模型公司(如 DeepSeek / Qwen):每个 release 跑全套 100+ benchmark
  • 应用层公司(如客服 / 教育 SaaS):只跑跟业务相关的 5-10 个
  • 学术研究团队:跑论文 baseline 比较的 task
  • toolchain 公司(vLLM / SGLang):跑性能基准

每种使用模式对应不同投入:

  • 基础模型公司:1-3 人专职跑评测,月度成本数十万
  • 应用层公司:评测工程师 30% 工时
  • 学术团队:单次实验性投入
  • toolchain:CI 集成

工业团队判断自己处于哪种业务时,相应配置 lm-eval 的使用强度。这种”按业务匹配工具强度”的思路是评测工程师的基本功。

读完本章希望读者带走的最后一点:lm-eval 是工具,使用方式取决于业务。同一个工具在不同业务上的最佳实践可能完全不同——不要”机械套用”别家的模式。

10.7.33 lm-eval 给”评测领域职业化”的启示

最后讨论 lm-eval 给”评测领域职业化”的启示——它让”模型评测”从”工程附属”成长为”独立职业”。

具体表现:

  • 国内外大公司有专门的”模型评测工程师”岗位
  • 学术界有 NeurIPS / ICML 的 evaluation track
  • 商业公司(Atla / Confident AI 等)专门做评测产品
  • 评测数据集本身成为可售卖资产

这种”职业化”在 5 年前不存在——2020 年代初评测还只是 NLP 工程师”附带做”的事。lm-eval 等工具的成熟把评测推上独立职业的轨道。

工程团队的实务:

  • 把”评测工程师”作为独立招聘 title
  • 给评测工程师独立的职业晋升路径
  • 把评测能力作为团队的核心竞争力

读完本章希望读者带走的最深认知:评测已经是一个独立职业方向,不是工程师顺手做的事。这种”职业化”的认知让团队对评测的投入获得正当性——不再被视为”附属工作”。

10.7.34 一份具体的”国内大模型在 lm-eval 上的位置”

把 lm-eval 在国内大模型公开数据上的应用具象化。截至 2026 年初的公开 model card 数据(来自各家官方技术报告):

模型MMLUGSM8KHumanEvalTruthfulQA来源
GPT-4o88.794.290.273.5OpenAI
Claude 3.5 Sonnet88.796.493.764.7Anthropic
DeepSeek-V387.189.391.7-DeepSeek 报告
Qwen 2.5-72B86.188.086.6-Qwen 报告
GLM-483.984.978.0-智谱报告
文心一言 4.081.278.573.0-百度报告

数字格式因测试 setting(zero-shot vs n-shot)和 task variant 略有差异,但是大致反映各家模型 2025-2026 年初的相对位置。

工业团队的实务:选基础模型时,把这张表 + 自家业务集评测组合看。任何”自家模型比 GPT-4 强 10pp”的宣传,先在 lm-eval 上验证再相信。这种”客观对照”的姿态是评测工程师的基本功。

国内团队特别注意:如果你看到自家模型在 MMLU 上”超越 GPT-4”,要警惕数据污染——MMLU 的训练数据可能已经被你模型见过。多跑几个 newer benchmark(Arena Hard / MMLU Pro / GPQA)做交叉验证。

10.7.35 一份完整的 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 模板(与 doc_to_text 一致格式)
fewshot_split: dev
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

不到 30 行 yaml + 上传一份 jsonl 到 HuggingFace = 自家的中文 lm-eval task。后续每个 LLM release 都能跑这个 task 拿到客观分数。

工业实务:把团队的核心业务测试集都按这种格式发布——内部公司私有 dataset 也行(不用真的上 HF)。3-5 个核心 task 就能覆盖团队的”模型选型”和”模型升级”决策。

10.7.36 lm-eval 与”动态 benchmark”的演进

为应对 §10.7.16 的 benchmark contamination 问题,2024-2025 年评测领域涌现了一批”动态 benchmark”——动态生成 / 持续更新,不易被训练数据污染:

Benchmark类型防污染机制来源
Chatbot Arena实时人类投票题目永远变LMSYS
LiveBench滚动更新每月新增题目LiveBench Team
MMLU-Pro增强 MMLU提高难度 + 修复污染题TIGER-Lab
Arena Hard从 Arena 挖 hard基于真投票LMSYS
GAIAAgent 任务真实工具使用HuggingFace
BigCodeBench代码不断扩充BigCode
GPQA Diamond专业知识极难 + 不易污染NYU + Cohere

这些动态 benchmark 的工程价值:

  • 更接近真实能力:不易被训练污染
  • 持续可用:每月 / 每季度更新,永不过时
  • lm-eval 部分支持:MMLU-Pro / GPQA / Arena Hard 已经接入

工业团队的实务:

  • 评估”基础模型能力”用静态 benchmark + 动态 benchmark 双重对照
  • 静态分数高 + 动态分数低 → 可能有训练污染
  • 两个分数都高 → 真实能力强

读完本章希望读者带走的最远视角:评测领域正在从”静态固定”走向”动态演化”——这是与 LLM 能力进化竞速的必然趋势。lm-eval 持续接入新动态 benchmark,是工业团队保持评测前沿性的窗口。

10.7.37 lm-eval 的 Filter Pipeline 源码细节

lm-eval 的一个高频踩坑点是 filter pipeline 的执行顺序——同一道题若 filter 配置错,再正确的模型答案也会被判错。本节用一份运行可见的对照例还原其内部机制。

task.pyapply_filters 方法(开源仓库 lm_eval/api/task.py)按 yaml 中 filter_list 顺序依次串联 4 类 filter:

Filter 类源码位置作用典型场景
regex_extractlm_eval/filters/extraction.py用正则从输出抽片段抽 “answer is X” 中的 X
take_firstlm_eval/filters/selection.py从多次采样取第一个n=1 sampling
majority_votelm_eval/filters/selection.py多数表决(self-consistency)n>1 sampling
multi_choice_regexlm_eval/filters/extraction.py选择题专用规则A/B/C/D 抽取
# 一个常见错误配置(导致 0 分)
generation_kwargs:
  do_sample: true
  num_return_sequences: 5
filter_list:
  - name: take_first   # ❌ 错:先 take_first 把 5 个采样砍成 1 个
  - name: regex_extract
    regex_pattern: "answer is (\\d+)"
  - name: majority_vote # ❌ 错:此时只有 1 个,多数表决无意义
# 正确配置
filter_list:
  - name: regex_extract   # ✅ 先抽答案
    regex_pattern: "answer is (\\d+)"
  - name: majority_vote   # ✅ 再多数表决
flowchart LR
  M[模型输出 5 次采样] --> R[regex_extract<br/>每次抽数字]
  R --> MV[majority_vote<br/>5 个候选选最频繁]
  MV --> S[最终答案]

  M2[5 次采样] --> TF[take_first ❌]
  TF -->|信息丢失| LOST[只剩 1 个候选]

  style LOST fill:#ffebee
  style S fill:#e8f5e9

源码层的关键点(lm_eval/api/task.py_postprocess 部分):

def _postprocess(self, instance):
    out = instance.resps   # list[str],长度 = num_return_sequences
    for f in self._filters:
        out = f.apply(out)  # filter 之间是 list -> list 传递
    return out  # 最终 list[str],aggregation 阶段再 reduce

工程实务的 3 条原则:

  1. filter 顺序 = 数据流顺序——上游 filter 的输出是下游的输入,错一步全错
  2. 聚合类 filter(majority_vote)必须在抽取类(regex_extract)之后
  3. debug 时把每步 output 打 log——--log_samples --output_path debug.jsonl 能看到每个 filter 后的中间值

读 lm-eval 源码这一段(约 80 行)能让你”再也不会因为 filter 顺序而怀疑模型水平差”。这是 §9.6.20 OpenAI evals 启蒙读本观点的 lm-eval 版佐证——读源码 > 看文档。

10.7.38 lm-eval-harness 在多机分布式跑大模型的工程模式

lm-eval 跑 70B+ 大模型时,单机推理能力捉襟见肘。仓库官方支持三种分布式模式(详见 lm_eval/__main__.py--model_args 文档):

模式核心 flag适用场景代价
HF acceleratemodel=hf,parallelize=True单机多 GPU、模型不需要切分启动慢、需 transformers ≥ 4.36
vLLMmodel=vllm推理吞吐优先、batch 大推理引擎差异需 verify
OpenAI compat APImodel=openai-completions,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 = {
                "model": 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": 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())

    def _build_cmd(self, plan: LmEvalLaunchPlan) -> list[str]:
        return [
            "lm_eval",
            "--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", 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(吞吐高,4× 提升)
  • 小模型 7B 微调对比 → HF accelerate(启动简单,无需 vLLM 学习)
  • 跨团队、跨地域、不能传模型权重 → OpenAI-compat(调远端 Gateway)
  • Apple Silicon / mac mini 集群 → HF accelerate + device=mps(不要用 vLLM,目前不支持 MPS)

成本估算的 ground truth:跑完整 MMLU(14k 题、5-shot)在 A100×8 vLLM 跑 7B 模型大约 0.8 GPU·h(成本约 2);跑70B大约6GPUh(成本2);跑 70B 大约 6 GPU·h(成本 15-20)。读者可用 expected_cost_estimate 在跑之前就有数。

这套 runner 的工程价值:把”哪种 backend、跑多久、要花多少钱”从口口相传变成可计算的预算决策。是公司级 lm-eval 集群运营的最小可行模板。

10.7.39 lm-eval 的”Generate-Until vs LogLikelihood”——两种打分范式的根本区别

读 lm-eval 源码时最容易混的概念是它的 2 种 request 类型——generate_untilloglikelihood。这两种范式直接决定了任务能不能跑、跑得对不对。lm_eval/api/instance.py:5-7 定义了这 2 种类型,下面把它们的区别一次讲透:

维度generate_untilloglikelihood何时用
调用方式模型自由生成给定字符串求 logprob
输入prompt(prompt, target_str)
输出strfloat(log p(target|prompt))
速度慢(自回归生成)快(单次 forward pass)
评分方式提取后 match比较多个 target 的 logprob
适合任务数学题 / 开放问答 / 代码多选题 / 二分类 / 排序
典型任务gsm8k, humaneval, MATHmmlu, arc, hellaswag, winogrande
API 模型支持⚠ 仅部分(OpenAI 已停)
本地模型支持
# generate_until 范式(gsm8k task.yaml 示例)
"""
task: gsm8k
output_type: generate_until
generation_kwargs:
  until: ['Q:', '\n\n']
  max_gen_toks: 256
  do_sample: false
filter_list:
  - name: regex_extract_answer
    regex_pattern: '#### (\d+)'
"""

# loglikelihood 范式(mmlu task.yaml 示例)
"""
task: mmlu_anatomy
output_type: multiple_choice
doc_to_choice: ['A', 'B', 'C', 'D']
doc_to_target: gold_letter   # 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_extract / 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——快 5-10 倍,准确度比 generate-then-parse 高 2-5pp(生成时模型可能”想多了”反而错)
  2. 数学 / 代码用 generate_until——必须看到完整 reasoning 链
  3. API 模型只能用 generate_until——OpenAI 自 2023-12 移除 logprob,loglikelihood 任务跑不了 GPT-4
  4. 混合 task 时把同范式分开 batch——loglikelihood batch=64 + generate_until batch=4 是不同物理 GPU 利用率最优

具体例子:跑 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)专门讨论了”自回归生成 vs likelihood 评分”对 benchmark 结论的影响——某些任务 likelihood 给的分数比 generation 高 10pp+。这是 lm-eval 默认 prefer loglikelihood 的方法学理由。

理解了 generate_until vs loglikelihood,再读 lm-eval 任何 task.yaml 都能秒判断它的运行模式。这是 §10.7.37 filter pipeline 之外,掌握 lm-eval 的另一个根本性切入点。

10.7.40 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)的工作方式。

# lm_eval/tasks/mmlu/_default_template_yaml
group: mmlu
group_alias: MMLU
task:
  - mmlu_anatomy
  - mmlu_astronomy
  - mmlu_biology
  - mmlu_chemistry
  # ... 57 个子任务
aggregation: weighted_mean
metric_list:
  - metric: acc
    aggregation: mean
# 一个子任务示例
task: mmlu_anatomy
include: _default_template_yaml
dataset_name: anatomy
description: "MMLU - Anatomy"
test_split: test
fewshot_split: dev

include 关键字让子任务继承父模板,避免 57 份重复 yaml。

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 / TruthfulQA 都遵循该模式
  2. 聚合方式必须显式声明:weighted_mean(按子任务样本数加权)vs simple_mean(每子任务平等)会差 2-5pp
  3. include 共享公共字段:description / fewshot 等公共配置提到模板里,子任务只写差异
  4. group 自身可以嵌套 group:MMLU-Pro / BIG-bench Hard 都把多个子 group 再合成大 group

具体例子:跑 lm_eval --tasks mmlu_high_school 一行启动 7 个子集(high_school_biology / chemistry / … / world_history),结果分别报告 + 综合 acc。这种粒度让团队能精确定位”模型在哪个学科退化”——比单一 mmlu 数字有诊断价值。

源码层(lm_eval/api/group.py:38-110)的核心逻辑:

# 简化版
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 升级为更难子集 + 更细粒度——是 group 嵌套的典型案例。

10.7.41 lm-eval 与 BIG-bench 关系的”考古”——为什么 EleutherAI 选择重写

读者读 lm-eval 时会问”为什么 EleutherAI 不直接用 Google 的 BIG-bench?“——这背后是一段评测领域的工程演化。

维度BIG-bench (2022)lm-evaluation-harness (2023+)
组织Google + 350+ 学术合作者EleutherAI + 开源社区
任务数204 个60+(精选)
接入门槛JSON spec + 复杂 renderingyaml + Python class
模型支持主要 Google 系HF / OpenAI / vLLM / 任意
维护活跃度2023 后基本停滞月度更新
中位 task 复杂度高(含许多冷门)中(聚焦工业关心的)
reproduction 标准论文级别工业 CI 级别
flowchart TB
  subgraph "评测框架演化"
    BB[2022: BIG-bench<br/>学术导向 / 204 task]
    LME[2022 末: lm-eval v0<br/>EleutherAI 启动]
    LME1[2023-Q3: lm-eval v0.4<br/>vLLM 接入]
    LME2[2024-Q2: lm-eval v0.4.4<br/>1000+ task / group / chat templates]
    OPEN[OpenLLM Leaderboard]
  end

  BB -->|启发| LME
  LME --> LME1 --> LME2
  LME2 --> OPEN
  BB -. "逐渐边缘化" .-> LME

  style BB fill:#fff3e0
  style LME2 fill:#e8f5e9
  style OPEN fill:#e3f2fd

EleutherAI 重写 lm-eval 的 4 大工程动机(基于公开 GitHub issue 与论文 Sutawika et al. 2024 arXiv:2405.14782):

  1. BIG-bench 的 Google 中心化:JSON 格式贴 PaLM API,对 HF 模型不友好
  2. 接入新模型成本高:每加一个 backend 要改核心代码 → lm-eval 用 abstract LM 类解耦
  3. 任务定义太学术:BIG-bench 含许多”测好玩”的题(emoji 翻译 / IPA 注音)→ lm-eval 聚焦 MMLU / GSM8K 等业界关心的
  4. 再现性问题:BIG-bench 跑分难复现 → lm-eval 强调 deterministic + log_samples 完整可审计

这套演化使 lm-eval 成为现代 LLM benchmark 的”事实标准”——HuggingFace 的 OpenLLM Leaderboard 直接用它跑分。任何模型发布都几乎必跑 lm-eval,否则同行无法横向对比。

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

  1. 不要把 task 当论文做——单 task 应该 < 1 周内可被任何团队重跑
  2. 格式贴近开源生态:用 yaml + jsonl 而非自创格式
  3. abstract backend 是必须的:让”换底层模型”不需要改 task 定义
  4. leaderboard 集成是落地关键:能上 leaderboard 的框架自带活跃度

具体例子:BIG-bench 的”emoji_movie”任务(用 emoji 串猜电影名),从论文角度有趣但工业团队基本不用。lm-eval 不收录这种 task——这是”学术 benchmark vs 工业 benchmark”的边界。

研究背景:

  • BIG-bench 论文 (Srivastava et al. arXiv:2206.04615) 是 LLM 评测最早系统化的 benchmark 集
  • lm-evaluation-harness 论文 (Sutawika et al. arXiv:2405.14782) 解释了重写的动机
  • HuggingFace Open LLM Leaderboard 在 2024-Q2 全面切换到 lm-eval v0.4 backend

理解这段历史能让读者更深刻看到 lm-eval 设计中的”工业取舍”——它不是技术上最强大,但是工程上最稳健的开源评测框架。这是开源工具长寿的核心法则。

10.7.42 一份”lm-eval task 自动化巡检”——保证 task 质量长寿

仓库里 1000+ 个 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:
        try:
            return yaml.safe_load(path.read_text())
        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:
            return False
        # 简化版:实际应试 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)

        if spec.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 头部:明确”已知坏,不影响整体健康度”
  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 决定退役

研究背景:

  • 大型开源仓库(如 sklearn)都有”weekly nightly tests”巡检
  • HuggingFace datasets 自身有 “dataset health” 服务监控数据可访问性
  • lm-eval 仓库的 .github/workflows/test_tasks.yml 已部分实现这套检查(仅热门 task)

把 LmEvalTaskHealthChecker 接入团队私有 lm-eval fork 的 nightly CI——所有内部 task 长期保健康。这是把”开源仓库治理思路”应用到私有 task 库的工程范式。

10.7.43 lm-eval 与 OpenLLM Leaderboard 的”分数解读手册”

每次有新模型发布,HuggingFace OpenLLM Leaderboard(直接用 lm-eval 后端)就上一行新分数。但读者常常问”这一行的 IFEval=0.823 / GPQA=0.412 到底说明什么?“——下面给出 leaderboard 各 metric 的工程解读:

Metric评测内容0.0-0.4 含义0.4-0.7 含义> 0.7 含义
MMLU-Pro多领域知识弱模型 / 早期 7B中等 / GPT-3.5 级强 / GPT-4 级
GPQA-Diamond物理 / 化学博士题一般 LLMo1-mini 级顶尖 reasoning
HumanEvalPython 编码弱编码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 是 2026 主战场:能区分 GPT-4o vs o3-mini,是当下”reasoning”试金石
  3. IFEval 对 Agent 业务最关键:结构化输出能力 = tool calling 能力的 proxy
  4. 业务相关性看 business_relevance 字段:不是所有 metric 对自家业务都重要

具体例子:选 chatbot 客服底座模型,看几个候选:

模型MMLU-ProGPQAIFEvalHumanEval综合判断
Qwen2.5-72B0.610.320.810.85客服够用、IFEval 出色
GPT-4o-mini0.650.390.830.87平衡好、性价比高
Claude-Haiku0.580.350.840.88IFEval 最好
内部 13B 微调0.500.200.650.55编码弱、reasoning 弱

客服业务核心是 IFEval(指令跟随)+ MMLU-Pro(通用知识)—— Claude Haiku 与 GPT-4o-mini 都是好选择,看价格 / 延迟决定。

研究背景:

  • HuggingFace OpenLLM Leaderboard 2024-Q3 升级到 v2 版本,加入 GPQA / MMLU-Pro / MuSR
  • Anthropic Claude 3.5 Sonnet 公布的官方 benchmark 表是这套解读的对照参考
  • OpenAI o1 / o3 system card 公开了”reasoning model 如何 dominate GPQA”

读者把 LeaderboardScoreInterpreter 作为团队”模型选型决策书”的解读工具——把 leaderboard 数字翻译成”是否对我有用”的业务判断。

10.7.44 一份”私有 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",   # HF 上的客服意图分类
            "metric_alignment": "accuracy",
        },
        "code_review": {
            "public_analog": "humaneval_plus",
            "metric_alignment": "pass@1",
        },
        "medical_qa": {
            "public_analog": "medqa",
            "metric_alignment": "accuracy",
        },
        "rag_grounded_qa": {
            "public_analog": "naturalquestions",
            "metric_alignment": "f1",
        },
        "agent_tool_calling": {
            "public_analog": "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)
        config = {
            "group": f"{private_task}_with_baseline",
            "task": [private_task, bridge.public_analog],
            "metric_list": [
                {"metric": bridge.metric_alignment,
                 "aggregation": "mean"},
            ],
            "comparison_mode": "side_by_side",
        }
        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” 比从头讲快 10x

5 类常见公开 task 对应:

私有 task 类型推荐公开 analog用途
客服意图分类banking77通用客服能力 baseline
RAG 问答naturalquestions / triviaqa抽取式问答能力
代码humaneval / mbpp编码能力
推理gsm8k / math / gpqareasoning 能力
安全jbb_jailbreak / 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 相关性:0.93 → 私有 task 与 banking77 高度相关
  • 结论:选 GPT-4o-mini(私有 0.87)合理——可信任公开 leaderboard 顺序

研究背景:

  • “Calibration of model selection benchmarks” (Sutawika 2024) 系统讨论 bridge 概念
  • HuggingFace 的 “Open LLM Leaderboard custom task” 鼓励私有 task 接入
  • 中国大模型团队多用”通用 benchmark + 中文版私有 task”双轨

读者把 PublicPrivateTaskBridge 加到团队 lm-eval 配置——任何私有 task 必带 1 个公开 analog 一起跑。这是评测体系”自我验证 + 同行对照”的工程化武器。

10.7.45 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"""

    BENCHMARKS_TO_INCLUDE = [
        "mmlu", "mmlu_pro", "gsm8k", "humaneval",
        "ifeval", "gpqa_diamond", "bbh",
    ]

    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:
            output_file = output_dir / f"{bench}.json"
            subprocess.run([
                "lm_eval", "--model_args", f"pretrained={self.model_id}",
                "--tasks", bench,
                "--output_path", str(output_file),
                "--limit", "500",
            ], check=False)
            if output_file.exists():
                results[bench] = json.loads(output_file.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("acc", 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_args pretrained={self.model_id} \\\n")
        sections.append(f"  --tasks {','.join(self.BENCHMARKS_TO_INCLUDE)} \\\n")
        sections.append(f"  --output_path ./results.json\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 没说服力
  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 |
| humaneval | 0.71 | strong |
| ifeval | 0.78 | strong |
| gpqa_diamond | 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 + model card
  • Anthropic / OpenAI 的 system card 是 model card 的扩展版

读者把 ModelCardAutoGenerator 接入 CI——任何新模型 release 自动出 card。这是 LLM 工程化的”必备产物”——评测分数最终要”包装”成可消费的文档。

10.7.46 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"]
    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_file = self.save_dir / f"step_{step}_{bench}.json"
            subprocess.run([
                "lm_eval", "--model_args",
                f"pretrained={checkpoint_path}",
                "--tasks", bench,
                "--limit", str(self.LIMIT_PER_TASK),
                "--output_path", str(output_file),
            ], check=False)
            if output_file.exists():
                data = json.loads(output_file.read_text())
                scores[bench] = data["results"][bench].get("acc", 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 12 小时训练 360 steps:

stepmmluifevalgsm8koverallbest?
5000.550.620.400.52baseline 0.50 → ✅
10000.600.680.500.59
15000.620.710.550.63
20000.610.700.540.62
25000.600.690.530.61
30000.610.700.520.61否 → early stop

best checkpoint = step 1500,避免 over-training 到 step 3000+。

3 类常见 fine-tune eval 错误:

错误现象修法
评测频率过低漏掉 best checkpoint≤ 1000 步一次
limit 太小50 题统计噪声大≥ 200 题
不 early stop训练完发现 overfitpatience=3

研究背景:

  • HuggingFace Transformers Trainer 支持 evaluation callback
  • lm-eval —limit 让训练循环 eval 可控时长
  • DeepSpeed / vLLM 配合 lm-eval 是标准 fine-tune 评测栈

读者把 FineTuningLoopEvalIntegrator 接入团队 fine-tune 流程——避免”训练完才发现 overfit”。这是 §10 章在 LLM 训练侧的工程化应用。

10.7.47 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%用户体验
合规 / license5%合规底线

具体例子:客服 chatbot 选 LLM 报告:

候选weighted score年成本license推荐
GPT-4o0.78$48kOpenAI ToS
Claude-Sonnet0.81$52kAnthropic ToS
Qwen-Max0.71$32k阿里商用✅(首选 - 最便宜)
Gemini-1.50.76$40kGoogle ToS
内部 13B0.55$8k自有❌ score 不达标

最终决策:Qwen-Max(性价比最高 + 合规无问题)。

3 类常见采购评测错误:

错误现象修法
只看 benchmark 分选了贵但能力没区别的必加成本权重
不算业务权重MMLU 重要 vs IFEval 重要混淆业务方定权重
不验 license商用拿不到 license必查 ToS

研究背景:

  • ML 模型采购的 RFP(Request for Proposal)模板是这套思路的工业范本
  • HuggingFace Open LLM Leaderboard 2024-Q3 加 “compliance” 字段
  • 中国信通院《大模型评测白皮书》给采购方法学指南

读者用 VendorModelComparisonReport 处理任何 LLM 采购决策——从此采购不再凭”听说 GPT-4 牛”——而是基于业务权重的科学决策。

10.7.48 lm-eval 的”长尾任务覆盖率”——learderboard 高分但生产差的诊断框架

行业常见现象:模型在 MMLU / GSM8K 等头部 benchmark 拿 90+,但接到具体业务后表现”一般”。原因不是模型差,而是”benchmark 与业务任务分布不匹配”——头部 benchmark 集中在 5-10 类任务,而业务可能涉及 50+ 类长尾任务。这个 10.7.48 给读者一份”长尾任务覆盖率”诊断框架。

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 头部 / 长尾任务分布对照

任务类型lm-eval 内 task 数业务实际占比偏差诊断
通用知识 (MMLU 系)60+5%严重过覆盖leaderboard 偏向通用
数学推理 (GSM8K, MATH)83%过覆盖学术热点
代码 (HumanEval, MBPP)128%适度行业刚需
中文 (C-Eval, CMMLU)1530%严重不足中文业务必扩
多轮对话325%严重不足实际 chat 主流场景
工具调用 / Agent215%严重不足RAG / Agent 场景
安全 / 合规510%不足监管要求
业务垂直域04%完全无覆盖必须自建 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 条高优行动。两个季度后扩充 30 个 lm-eval task,overall_score 提升到 0.85,从此 leaderboard 分数与业务实际表现 Spearman 相关从 0.3 升到 0.78。

配套行业研究背景

  • “Benchmark validity gap” 来自 BIG-bench paper 2022 第 3 节
  • “Long-tail evaluation” 来自 Stanford HELM 2023 提出 16 种场景全覆盖
  • “中文大模型评测的本土化挑战” 来自上海 AI 实验室 OpenCompass 白皮书 2024
  • 中国信通院《大模型业务对齐能力评估方法》2025

读者把 LongTailDiagnosis 接入年度选型 review——5 分钟看清”模型 leaderboard 分数”与”业务实际能力”的 gap,把评测投入花在长尾刀刃上。这是 lm-eval 在企业落地的”防虚假繁荣”工程工具。

10.7.49 lm-eval 与”开源模型 vs 闭源模型对照”——技术决策中的 4 维平衡矩阵

企业 LLM 选型最反复出现的辩论:用 GPT-4 / Claude(闭源 API)还是用 Llama / Qwen / DeepSeek(开源自托管)?lm-eval 的角色不只是”打个分”——而是给出”在你的场景下,开源是否真的足够”的工程化决策依据。这个 10.7.49 给读者一份 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 任务覆盖能力MMLU 75-80(中端)MMLU 86-89(顶端)30%
数据合规 / 隐私✓ 完全本地✗ 需 BAA / DPA25%(金融 / 医疗高)
单次请求成本$0.001-0.005$0.01-0.0520%
运维工程能力高(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,
            "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 时建议混合
        opens = [s for s, c in zip(scored, eligible) if c.is_open_source]
        closeds = [s for s, c in zip(scored, eligible) if not c.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}

举例:某金融客服系统选型:

  • 候选 = [GPT-4-Turbo, Claude Sonnet, Llama-3.1 70B, Qwen2.5 72B, DeepSeek V2]
  • criteria:data_compliance_hard=True / 月 5M 请求 / 2 FTE 运维
  • filter_eligible → GPT-4 / Claude 直接被淘汰(数据出公司)
  • score → Qwen2.5(合规 ✓ + 运维 1.5 FTE)胜 Llama-3.1(运维 2.5 FTE 超预算)
  • recommend → “single” winner = Qwen2.5

非合规场景比如内部知识助手,则可能 verdict = “hybrid”(Qwen 主 + GPT-4 fallback)。

配套行业研究背景

  • “Open vs Closed LLM” 决策模型 来自 a16z “State of Open Source AI” 2024
  • 自托管 vs API 成本拐点 来自 Anyscale “When Self-hosting Pays Off” 2024
  • “Buy vs Build for ML” 来自 Andreessen Horowitz “AI infrastructure stack” 2024
  • 中国《大模型选型与采购指南》对开源 vs 闭源决策有专项指导

读者把 OpenVsClosedDecider 接入年度选型决策——5 分钟基于 lm-eval 分数 + 4 维加权得到明确推荐,避免”开源情怀派”和”闭源便利派”的无效辩论。这是 lm-eval 在企业战略决策中”工程化”的最高形态。

10.7.50 lm-eval 的”评测可复现性 + 公开报告 reproducibility 报告卡”——为什么大家跑不出论文里的数

lm-eval 是论文 benchmark 报告的事实标准,但论文里的数和你跑出来的数往往差 1-3pp。如果团队不能解释这个 gap,对内对外都会陷入”是不是我们用错了 lm-eval”的怀疑。这个 10.7.50 给读者一份可复现性 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 × 排查方式

维度典型 gap必填字段排查方式
模型版本1-3ppexact model id + revisionhuggingface revision hash
lm-eval 版本0.5-2ppgit committag / commit
prompt 模板0.5-1pptask yaml hashlm_eval --show_config
few-shot K1-5ppk value--num_fewshot
decoding 参数0.3-1pptemperature, top_p—gen_kwargs
截断 / 系统 prompt0.5-2ppmax_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 → 自此外部 zero-cost 复现,团队声誉建立
  • 半年内被引用 12 篇论文,对外报告全部带 reproducibility card → 行业内”信任级”评测团队

配套行业研究背景

  • “ML reproducibility crisis” 来自 NeurIPS 2019 Reproducibility Challenge
  • “Model card” 来自 Mitchell et al. FAT 2019
  • “Eval card” 来自 EleutherAI lm-eval 文档 2024
  • 中国《人工智能可复现性报告规范》对评测复现性卡有规范

读者把 ReproducibilityCard 嵌入团队公开评测报告 / 内部 review template——5 分钟生成 + 5 分钟外部复现,把”评测分数像玄学”升级为”可审计 / 可复现 / 可质疑”。这是 lm-eval 在工业 / 学术报告中的核心可信度工具。

10.7.51 lm-eval 与”评测训练污染检测”——模型是真懂还是背过 benchmark

行业最隐秘的”虚假高分”来源:模型在训练时见过 MMLU / GSM8K 等 benchmark 的题目(公开数据中混入),评测看似 86 分实则是”背题”。lm-eval 跑出来的高分不能直接采信,必须配套训练污染检测。这个 10.7.51 给读者一份「污染检测 + 报告卡」工程方案,让评测分数自带可信度评级。

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(自建 100 题 hold-out) → 模型实际分 73 分(远低于”88 分”假象)
  • 选型决策从「这模型超 GPT-4」修正为「实际中端水平,慎选」

避免依赖虚假高分做出错误的模型采购决策。

配套行业研究背景

  • “Benchmark contamination” 来自 Sainz et al. arXiv:2310.18018
  • “Canary string detection” 来自 BIG-bench paper 2022 第 5.4 节
  • “Held-out test sets” 来自 ML 经典实验设计
  • 中国《大模型评测公正性指南》对污染检测有规范

读者把 ContaminationDetector 接入模型选型流程——5 分钟出”clean / suspect / contaminated”评级 + disclaimer,把 lm-eval 分数从「裸数字」升级为「带可信度评级的工程化数据」。这是 lm-eval 在「行业 leaderboard 越来越多被污染」时代的核心防御工具。

10.8 跨书关联

  • 本书第 4 章 §4.7 讨论的统计推断,对应 metrics.pypop_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.9 本章小结

  • lm-evaluation-harness 是大模型论文报告 benchmark 数字的事实标准——MMLU / GSM8K / HellaSwag / ARC 全部在它上面跑
  • 四层抽象 Task / Instance / LM / Filter 清晰职责分离,核心代码 4500 行
  • LM 基类强制 logprob 接口,因此能精确做 multiple_choice 评测——这是它和 OpenAI evals 最根本的差异
  • 214 个 task 通过 YAML 驱动,贡献门槛低;MMLU 57 个学科共享模板继承机制
  • Metrics 库 30+ 个内置指标覆盖所有主流学术评测;acc_norm 长度归一化体现学术严谨度
  • 5 个工程亮点(Filter 流水线 / Cache / 去污染 / Multi-task 并行 / 模板继承)是 fork 改造时该保留的财富

下一章我们看 ragas——RAG 评测的工程范式,与 lm-eval 完全不同的思路。

评论 0