从零搭一套能扛住生产环境的评估循环:lm-evaluation-harness 实战手记

从零搭一套能扛住生产环境的评估循环:lm-evaluation-harness 实战手记

【免费下载链接】lm-evaluation-harness A framework for few-shot evaluation of language models. 【免费下载链接】lm-evaluation-harness 项目地址: https://gitcode.com/GitHub_Trending/lm/lm-evaluation-harness

lm-evaluation-harness 是当前社区使用最广的语言模型评测框架之一,核心能力是"少样本评估",即用少量示例引导模型在统一的任务基准上打分。但大多数教程只教你跑通 lm_eval 命令行,一旦你需要在内部模型、私有数据集、自定义指标上构建自己的评估循环,手册之外的空白就暴露出来了。这篇文章不重复官方 README,而是用一次真实的搭建过程,把评估循环拆开揉碎给你看。

先交代一下我自己的处境,后面所有代码都出自这个场景:公司自研了一个 3B 参数的对话模型,我要给它搭一套可持续复用的评测体系,既能跑公开基准,也能跑内部标注的问答集,最后还要接进 CI,每次发版自动回归。听起来不复杂,真正动手才发现,评估循环这件事,框架只给了你方向盘,路还得自己画。

少样本提示示例

上图是典型的少样本提示结构:任务说明加若干示例,最后留一个待完成的输入。lm-evaluation-harness 的"少样本"核心就在这一张图里,后面所有配置都围绕它展开。

翻车现场:我按 README 跑通后,发现三件事不对

先说我第一次上手时的遭遇,你大概率也踩过类似的坑。

第一件事,我用命令行跑 --tasks mmlu 很顺利,但想把评测嵌进自己的 Python 服务里,文档里却只给了入口函数名,没讲清楚内部怎么流转。第二件事,我加了一个内部数据集,写了个 YAML,结果提示词格式怎么调都不对,模型输出永远是乱的。第三件事,评测结果和我在别处手动算的对不上,找了半天才发现是 Chat 模板在悄悄改变输入格式。

这三件事合起来,指向同一个结论:只把框架当黑盒用,是走不远的。 你需要理解评估循环的骨架——请求怎么构建、输出怎么对齐、指标怎么算——才能让它按你的意志工作。

下面这张图是我后来总结出的评估循环全景,和官方文档的视角不同,我把它画成"数据流"而不是"步骤流":

mermaid

记住这条链路,后面每一节都在回答同一个问题:在哪个环节插入你自己的逻辑。

两个入口函数,先分清谁干粗活、谁干细活

官方核心源码在 lm_eval/evaluator.py,里面躺着两个关键函数:simple_evaluateevaluate。它们的关系有点像"一键下单"和"手动配菜"。

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生成式任务的生成参数控制 untiltemperature
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 上的路径,本地私有数据集也可以用 jsoncsv 加载器指到本地文件。
  • doc_to_text / doc_to_target:从数据行中提取提示词和参考答案,花括号里是字段名,这就是上面那张少样本图的程序化版本。
  • output_type:只有两种主类型——loglikelihood(算似然,适合选择题)和 generate_until(生成文本,适合问答、翻译)。
  • metric_list:指标定义,exact_matchaccacc_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 抽象类,你只要把自家引擎包一层即可。

抽象类要求实现四个抽象方法:loglikelihoodloglikelihood_rollinggenerate_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=1auto,对比耗时和显存峰值,再决定正式配置。别把 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)

这个脚本配合上面讲的多模态任务、分布式命令和缓存参数,已经足够支撑一个内部模型的发版回归了。

下一步行动清单:从会用走向能改

评估循环这件事,光看文章是学不会的,动手才算数。给你一条可执行的路线:

  1. 跑通最小闭环:用文中的第一个代码块,在 limit=0.1 下跑通一个内置任务和一个你自己的数据集。
  2. evaluate 源码lm_eval/evaluator.pyevaluate 函数的请求构建段(requests 字典的填充逻辑)是理解全框架的钥匙。
  3. 仿写一个模型适配器:对照 examples/transformer-lens.py,把你手头任意一个模型(哪怕是 Dummy 模型)包成 LM 子类。
  4. 提交一个新任务:社区评测基准文件都在 lm_eval/tasks/ 下,每个子目录一个基准。你把自己领域的数据集整理成 YAML 后,完全可以按 docs/new_task_guide.md 的规范提交,既服务社区,也逼自己把配置写规范。

最后给你一张自检表,写代码时对照着过一遍:

  • 我的 YAML 里所有转义字符都用双引号了吗?
  • 自定义指标被 import 了吗?higher_is_better 方向对了吗?
  • 开 Chat 模板后,我记录下模板配置了吗?
  • batch_size="auto" 时设 max_batch_size 了吗?
  • 复跑实验时,缓存和数据集版本匹配吗?

评测的本质是"可复现的测量"。当你把评估循环的每个环节都握在自己手里,你得到的就不只是一份分数报告,而是一套能支撑模型迭代决策的信任体系。框架给你的是骨架,血肉是你自己长出来的。

【免费下载链接】lm-evaluation-harness A framework for few-shot evaluation of language models. 【免费下载链接】lm-evaluation-harness 项目地址: https://gitcode.com/GitHub_Trending/lm/lm-evaluation-harness

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值