从零搭一套能扛住生产环境的评估循环:lm-evaluation-harness 实战手记
lm-evaluation-harness 是当前社区使用最广的语言模型评测框架之一,核心能力是"少样本评估",即用少量示例引导模型在统一的任务基准上打分。但大多数教程只教你跑通 lm_eval 命令行,一旦你需要在内部模型、私有数据集、自定义指标上构建自己的评估循环,手册之外的空白就暴露出来了。这篇文章不重复官方 README,而是用一次真实的搭建过程,把评估循环拆开揉碎给你看。
先交代一下我自己的处境,后面所有代码都出自这个场景:公司自研了一个 3B 参数的对话模型,我要给它搭一套可持续复用的评测体系,既能跑公开基准,也能跑内部标注的问答集,最后还要接进 CI,每次发版自动回归。听起来不复杂,真正动手才发现,评估循环这件事,框架只给了你方向盘,路还得自己画。
上图是典型的少样本提示结构:任务说明加若干示例,最后留一个待完成的输入。lm-evaluation-harness 的"少样本"核心就在这一张图里,后面所有配置都围绕它展开。
翻车现场:我按 README 跑通后,发现三件事不对
先说我第一次上手时的遭遇,你大概率也踩过类似的坑。
第一件事,我用命令行跑 --tasks mmlu 很顺利,但想把评测嵌进自己的 Python 服务里,文档里却只给了入口函数名,没讲清楚内部怎么流转。第二件事,我加了一个内部数据集,写了个 YAML,结果提示词格式怎么调都不对,模型输出永远是乱的。第三件事,评测结果和我在别处手动算的对不上,找了半天才发现是 Chat 模板在悄悄改变输入格式。
这三件事合起来,指向同一个结论:只把框架当黑盒用,是走不远的。 你需要理解评估循环的骨架——请求怎么构建、输出怎么对齐、指标怎么算——才能让它按你的意志工作。
下面这张图是我后来总结出的评估循环全景,和官方文档的视角不同,我把它画成"数据流"而不是"步骤流":
记住这条链路,后面每一节都在回答同一个问题:在哪个环节插入你自己的逻辑。
两个入口函数,先分清谁干粗活、谁干细活
官方核心源码在 lm_eval/evaluator.py,里面躺着两个关键函数:simple_evaluate 和 evaluate。它们的关系有点像"一键下单"和"手动配菜"。
simple_evaluate 是大多数人的第一站:传入模型名和任务名,它负责实例化模型、加载任务、跑完整流程、返回结果。它的完整签名在源码里有,我挑几个你迟早会用到、但文档着墨不多的参数列出来:
| 参数 | 作用 | 我的建议 |
|---|---|---|
model | 模型名或 LM 实例 | 传名字省事,传实例才能做高级控制 |
model_args | 模型初始化参数,如 pretrained=xxx | 用字典比字符串更不容易写错 |
num_fewshot | 少样本示例个数 | 设成 0 就是零样本,别省略 |
batch_size | 批大小,支持 "auto" | 后面专门讲它挖的坑 |
limit | 每个任务只取前 N 条或比例 | 调试期必备,limit=0.05 能救命 |
use_cache | 模型响应缓存,SQLite 路径 | 复跑不变的数据集时省一半时间 |
cache_requests | 请求构建缓存 | 数据集大、任务多时开 |
gen_kwargs | 生成式任务的生成参数 | 控制 until、temperature 等 |
apply_chat_template | 是否套用模型的对话模板 | 默认 False,改了它分数会变 |
一个能直接跑的最小调用长这样:
from lm_eval import evaluator
result = evaluator.simple_evaluate(
model="hf",
model_args={"pretrained": "your-org/your-3b-model", "dtype": "bfloat16"},
tasks=["arc_easy", "hellaswag"],
num_fewshot=5,
batch_size=16,
limit=0.1, # 先跑 10% 验证流程,再放开
)
而 evaluate 函数接收的是"已经实例化好的模型对象 + 已经加载好的任务字典",适合你在 simple_evaluate 的流程中间插入自定义逻辑的场景——比如你想在请求构建后、推理前做一次输入改写,就必须走这条路。先跑一遍 simple_evaluate,再回头读它内部如何调用 evaluate,你对整个框架的理解会突然清晰。
任务从哪来:一张 YAML 怎么描述一个评测任务
任务配置的核心字段集中在几个地方,我们以内部问答数据集为例,逐步解剖。先看最小可用版:
task: corp_qa_zh
dataset_path: your-org/corp-qa
doc_to_text: "问题:{{question}}\n答案:"
doc_to_target: "{{answer}}"
output_type: generate_until
generation_kwargs:
until: ["\n"]
max_gen_toks: 64
metric_list:
- metric: exact_match
aggregation: mean
higher_is_better: true
逐行说:
dataset_path:数据集在 HuggingFace Hub 上的路径,本地私有数据集也可以用json或csv加载器指到本地文件。doc_to_text/doc_to_target:从数据行中提取提示词和参考答案,花括号里是字段名,这就是上面那张少样本图的程序化版本。output_type:只有两种主类型——loglikelihood(算似然,适合选择题)和generate_until(生成文本,适合问答、翻译)。metric_list:指标定义,exact_match、acc、acc_norm这些是内置的,后面讲怎么造自己的。
如果你想要少样本效果,在 YAML 里直接写 num_fewshot: 3,框架会从数据集中采样示例拼进提示词。想更精细地控制示例的组织方式,fewshot_config 块里可以指定采样器和上下文格式,这块的细节建议读 docs/config_files.md,它是目前最完整的字段字典。
写 YAML 时最容易懵的是 doc_to_text 里的字段到底长什么样。我的调试习惯是:先跑一次带 write_out=True 的评估,框架会把真实构造出的提示词和参考答案落盘,肉眼比对一遍,比猜字段名高效得多。
内置指标不够用?自己注册一个
框架的指标系统比想象中开放。内置指标实现集中在 lm_eval/api/metrics.py,但你完全可以注册自己的。装饰器在 lm_eval/api/registry.py 里定义,签名长这样:
@register_metric(
metric="f1_macro",
higher_is_better=True,
output_type="generate_until",
aggregation="f1_macro",
)
注意它注册的不只是一个函数,而是三样东西:指标函数(怎么算)、聚合函数(跨样本怎么汇总)、方向(数值越大越好还是越小越好)。我写过的一个多标签分类任务就是靠它解决了 exact_match 完全不适配的问题:
from lm_eval.api.registry import register_metric
@register_metric(
metric="subset_accuracy",
higher_is_better=True,
output_type=["loglikelihood", "multiple_choice"],
aggregation="subset_accuracy",
)
def subset_accuracy(items):
"""全对才算对:预测的标签集合必须和参考答案完全一致。"""
import numpy as np
preds = [set(item[0]) for item in items]
golds = [set(item[1]) for item in items]
return np.mean([p == g for p, g in zip(preds, golds)])
注册之后有两种用法。第一种,直接在 YAML 的 metric_list 里写 metric: subset_accuracy。第二种,代码里动态替换,适合"同一任务不同场景要换指标"的情况,用的是 lm_eval/api/task.py 里的 override_metric:
from lm_eval.api.registry import get_task
task = get_task("corp_qa_zh")
task.override_metric(metric_name="subset_accuracy")
一个提醒:注册函数后要确保它在评测进程里被 import 过,否则注册表里找不到。常见的做法是放在自己的工具模块里,评测脚本启动时先 import 它,或者利用框架的 include 机制在 YAML 里声明自定义函数路径。
非 HuggingFace 模型怎么接进来:实现 LM 抽象类
这是很多人的终极需求:公司自研推理引擎不是 HF 格式,跑不了 model="hf"。框架的解法很优雅——所有模型都要实现 lm_eval/api/model.py 里的 LM 抽象类,你只要把自家引擎包一层即可。
抽象类要求实现四个抽象方法:loglikelihood、loglikelihood_rolling、generate_until,以及分词相关的 tok_encode / tok_decode。但对大多数接入场景,你真正要写的是两个底层方法:
from lm_eval.api.model import LM
from lm_eval.api.instance import Instance
class InternalEngineLM(LM):
def __init__(self, endpoint_url: str):
super().__init__()
self.endpoint = endpoint_url
def _loglikelihood_tokens(self, requests, **kwargs):
"""输入是 (上下文, 续写) 的 token 对,输出 (loglikelihood, 是否贪婪匹配)。"""
outputs = []
for context_enc, continuation_enc in requests:
score = self.endpoint.score(context_enc, continuation_enc)
outputs.append((score, True))
return outputs
def _generate_until(self, requests):
"""输入是 (提示词, 生成参数),输出是生成的文本列表。"""
results = []
for context, gen_kwargs in requests:
until = gen_kwargs.get("until", ["\n"])
text = self.endpoint.complete(context, stop=until)
results.append(text)
return results
写完之后,把实例传给 simple_evaluate(model=engine_lm, ...),或者干脆注册成新模型别名,方便命令行直接用。想照抄完整模板,参考官方示例 examples/transformer-lens.py,它展示了如何把一个第三方模型库完整适配进来,包括分词器和 loglikelihood_rolling 的实现套路。
这里有个隐蔽细节:_loglikelihood_tokens 里的请求会尽量按长度分批,以匹配 GPU 的并行能力,所以你返回的顺序必须和传入顺序一一对应,错一个就全线崩盘。我会在返回前做一次长度断言,花两行代码省一晚上排错。
多模态任务:图文输入怎么走评估循环
如果你的模型要吃图,任务类里有个开关叫 MULTIMODAL。在 lm_eval/api/task.py 里,只要配置了 doc_to_image 字段,框架会自动把任务的 MULTIMODAL 置为 True,并启用图像处理管线。
用 YAML 定义一个图文任务并不复杂:
task: chart_reading_zh
dataset_path: your-org/chart-qa
doc_to_text: "请根据图表回答:{{question}}\n"
doc_to_image: "{{image}}"
doc_to_target: "{{answer}}"
output_type: generate_until
generation_kwargs:
until: ["\n"]
metric_list:
- metric: exact_match
对应的模型侧需要支持图像输入。HF 系多模态模型走 lm_eval/models/hf_vlms.py,它会处理图像编码和与文本的拼接;如果你是自研多模态引擎,还是回到上一节的套路——实现 LM 抽象类,在 _generate_until 里从 Instance 中取出图像数据一并发给引擎。
doc_to_image 支持传 PIL 图像对象、图像路径或 base64 字符串,框架内部统一编码。如果图像字段比较大,建议在任务配置里开请求缓存,否则每次跑评测都要重新下载和处理图片。
规模化运行:分布式评估与两级缓存
单卡评测小模型没问题,一旦上 70B 或几百个任务,就要动真格的了。框架原生支持 torch.distributed,一条命令拉起多进程,每个 rank 处理数据分片:
CUDA_VISIBLE_DEVICES=0,1,2,3 python -m torch.distributed.run --nproc_per_node=4 \
-m lm_eval --model hf --model_args pretrained=your-org/your-3b-model \
--tasks mmlu,hellaswag,arc_easy --batch_size auto
所有 rank 会并行构建请求,模型推理各算各的分片,最后聚合结果。注意一点:多进程时每个 rank 会各自加载一份模型副本,显存是按进程数线性增长的,4 卡就是 4 份权重,别想当然以为会自动张量并行。
再谈缓存,框架有两级,别混:
- 请求缓存(
cache_requests=True):缓存"数据集 → Instance"的构建结果。数据集大、fewshot 采样复杂时收益明显,复跑时直接读缓存。 - 模型响应缓存(
use_cache="cache.db"):缓存模型对每个请求的输出。适合模型不变、只改指标或聚合方式的场景,第二次跑能秒出。
result = evaluator.simple_evaluate(
model="hf",
model_args={"pretrained": "your-org/your-3b-model"},
tasks=["corp_qa_zh", "mmlu"],
use_cache="eval_cache.db",
cache_requests=True,
)
缓存文件的哈希键包含了模型配置、任务配置和数据集版本,改了任何一环都会自动失效,基本不用担心脏缓存。真正要防的是缓存文件越来越大,CI 里建议定期清理。
三个最容易翻车的坑,每个我都付过学费
坑一:YAML 里的 \n 被当成两个字符
这是框架文档 docs/footguns.md 里排第一的坑,我完美复现过:生成式任务的 until 里写了单引号 '\n',结果模型永远等不到停止符,输出长得离谱。原因很简单——单引号字符串不做转义,\n 变成了字面量反斜杠加 n。
# 错的:模型收不到换行符
generation_kwargs:
until: ['\n']
# 对的:这才是真正的换行
generation_kwargs:
until: ["\n"]
规则一句话:YAML 里只要想表达转义字符,就用双引号。 同理,模板里要输出换行也得写在双引号包裹的字符串中。
坑二:Chat 模板悄悄改写输入,loglikelihood 对不上
如果你的模型是对话模型,且评估时开了 apply_chat_template=True,提示词会被包成 [system][user][assistant] 这样的对话结构。这本身没错,但它会改变 token 序列,而 loglikelihood 类任务算的是"在给定上下文下续写的似然",上下文变了,分数自然变。
更隐蔽的是 fewshot_as_multiturn 参数:少样本示例是拼成一轮长对话,还是拆成多轮对话,直接影响最终分数。我的经验是:对话模型评测,务必在同一配置下对比不同模板的效果,并在报告里固定记录模板名,否则你没法解释为什么两次跑分不一样。官方对这个话题有专门讨论,见 docs/chat-template-readme.md。
坑三:batch_size="auto" 不是万能的
自动批处理会从 1 开始指数级增大批大小,直到触发 OOM 再回退,听起来很智能。但有两件事它管不了:一是 max_batch_size 不设的话,可能尝试到很大值导致显存抖动甚至崩掉;二是长序列任务(比如长文档问答)即使 batch 很小也可能 OOM,因为显存占用和序列长度平方相关。
result = evaluator.simple_evaluate(
model="hf",
model_args={"pretrained": "your-org/your-3b-model"},
tasks=["long_doc_qa"],
batch_size="auto",
max_batch_size=8, # 显存不够就把它调小
)
我的建议:先在 limit 很小的数据集上分别试 batch_size=1 和 auto,对比耗时和显存峰值,再决定正式配置。别把 auto 当成免检产品。
落地到生产:一套可持续复用的最小评测脚本
把上面的知识点收拢,给你一套我实际在 CI 里用的脚本骨架。它做了三件事:注册自定义指标、实例化模型、按环境变量控制是否全量跑。
import os
import my_metrics # 确保自定义指标被注册
from lm_eval import evaluator
def run_regression(full: bool = False):
tasks = ["corp_qa_zh", "arc_easy", "hellaswag"]
kwargs = {
"model": "hf",
"model_args": {"pretrained": os.environ["MODEL_PATH"], "dtype": "bfloat16"},
"tasks": tasks,
"num_fewshot": 3,
"batch_size": "auto",
"max_batch_size": 8,
"gen_kwargs": {"until": ["\n"], "max_gen_toks": 128},
}
if not full:
kwargs["limit"] = 0.1 # 快速冒烟
return evaluator.simple_evaluate(**kwargs)
if __name__ == "__main__":
result = run_regression(full=os.environ.get("FULL", "0") == "1")
print(result)
这个脚本配合上面讲的多模态任务、分布式命令和缓存参数,已经足够支撑一个内部模型的发版回归了。
下一步行动清单:从会用走向能改
评估循环这件事,光看文章是学不会的,动手才算数。给你一条可执行的路线:
- 跑通最小闭环:用文中的第一个代码块,在
limit=0.1下跑通一个内置任务和一个你自己的数据集。 - 读
evaluate源码:lm_eval/evaluator.py 里evaluate函数的请求构建段(requests字典的填充逻辑)是理解全框架的钥匙。 - 仿写一个模型适配器:对照 examples/transformer-lens.py,把你手头任意一个模型(哪怕是 Dummy 模型)包成
LM子类。 - 提交一个新任务:社区评测基准文件都在 lm_eval/tasks/ 下,每个子目录一个基准。你把自己领域的数据集整理成 YAML 后,完全可以按 docs/new_task_guide.md 的规范提交,既服务社区,也逼自己把配置写规范。
最后给你一张自检表,写代码时对照着过一遍:
- 我的 YAML 里所有转义字符都用双引号了吗?
- 自定义指标被 import 了吗?
higher_is_better方向对了吗? - 开 Chat 模板后,我记录下模板配置了吗?
batch_size="auto"时设max_batch_size了吗?- 复跑实验时,缓存和数据集版本匹配吗?
评测的本质是"可复现的测量"。当你把评估循环的每个环节都握在自己手里,你得到的就不只是一份分数报告,而是一套能支撑模型迭代决策的信任体系。框架给你的是骨架,血肉是你自己长出来的。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考




