训练能跑通只是入场券,评测才是把模型从“能跑”拽到“能用”的那道关卡。第七个作业落到书生浦语配 OpenCompass 做 OpenCompass大模型评测上,我起初也以为无非是敲一条命令、看一眼榜单分数,真跑起来才发现坑全在细节里:chat 模板对不上、答案抽取正则没命中、分数出来是 0 却不知道错在哪、八张卡只吃到一张。这篇就把我从环境准备到结果解读的完整路径摊开讲,包含配置结构拆解、显存与并行的估算方法、产物目录怎么读,以及一份我自己踩出来的排查表。适合刚接触大模型评测、手里有书生浦语权重想跑个基准的同行,也适合已经把流程跑通但分数总是对不上官方榜单的人对照找差异。全文以常见实践为准,不同版本的 OpenCompass 目录结构会有出入,命令以 0.2.x 到 0.3.x 区间为参考。
1. 评测这件事为什么比训练更容易翻车
1.1 一次“跑分”背后真正想解决的问题
很多人对评测的理解停在“打个分、发个榜”,这在工程视角下是极其危险的简化。训练阶段的错误往往很响,loss 炸了你立刻知道;评测阶段的错误却很安静,配置少写一行,模型照样跑完、照样出分,只是那个分数毫无意义。我见过最典型的情况是 prompt 模板用了 base 模型的续写格式去测 chat 模型,模型输出一堆自我解释,抽取器抓不到选项字母,最终准确率贴着地板,然后有人得出“这个模型中文能力不行”的结论。
评测真正要回答的是四类问题:这个版本比上个版本强在哪、弱在哪;换一套微调数据之后,原来的能力有没有被“遗忘”;模型输出的格式是否符合业务侧解析要求;以及上线前的门槛线有没有踩住。这四个问题里,只有第一个是“打分”,后三个本质上是回归测试。所以我一直建议把评测当成 CI 的一部分来设计,而不是当成一次性的仪式。
分工上也要说清楚:训练负责让模型见到更多数据,评测负责告诉你这些数据到底有没有用。少了后半段,调参就变成盲猜,超参搜索出来的最优解很可能只是过拟合了某个验证集。
1.2 工具选型的取舍:为什么最后落到 OpenCompass
自写评测脚本在只有一个数据集时是最省事的,二十行代码搞定。但作业要求的多数据集横向对比一上来,自写脚本的维护成本就爆炸了:每个数据集的字段名不一样、答案格式不一样、prompt 风格不一样、指标算法不一样,你很快就不是在评测模型,而是在写数据清洗流水线。
我把当时考虑的三个方向列了个对比,实际选型时这张表帮了大忙:
| 方案 | 上手成本 | 数据集覆盖 | 多模型多后端 | 中文任务覆盖 | 适合场景 |
|---|---|---|---|---|---|
| 自写脚本 | 极低 | 取决于你自己写 | 需自己实现 | 自己找 | 单任务、快速验证 |
| 通用评测框架(英文生态为主) | 中等 | 英文任务丰富 | 支持较好 | 相对薄弱 | 英文基座模型、论文复现 |
| OpenCompass | 中等偏上 | 覆盖广,中英文都有 | 内置多种推理后端 | 中文任务较完整 | 中英文混合、需要体系化对比 |
决定性因素是中文任务和主观评测这两块。书生浦语本身中文能力是重点,只跑纯英文基准等于避重就轻;而主观题(对话质量、指令遵循)必须靠裁判模型打分,这套机制得有人帮你把 prompt、打分维度、聚合方式都定好。OpenCompass 在这两点上省了我大量时间,代价是它的配置体系有一层抽象,需要花半天读懂。
1.3 评测系统的三层结构,理解了就不会乱改
OpenCompass 的配置看起来复杂,本质上是三层解耦:模型接入层负责“怎么把权重加载起来并生成文本”,数据与提示层负责“喂什么问题、以什么格式喂”,指标与后处理层负责“怎么从一段自由文本里把答案抠出来并判定对错”。这三层各有一套配置,互不干扰。
解耦带来的直接好处是:换模型不用动数据集,换数据集不用动模型,改判分规则不用重跑推理。我在实操中就吃过没理解这层结构的亏——第一次调 prompt 的时候直接去改了模型配置里的max_out_len,结果长度够了但格式还是不对,因为真正的问题出在数据集的prompt_template上。想清楚分层,就知道该动哪个文件。
提示:判断问题出在哪一层有个笨办法但很有效。把
predictions目录里的原始输出打开看,如果输出本身合理只是判分错,问题在第三层;如果输出格式就跑偏了,问题在第二层;如果连输出都没有或者乱码,问题在第一层。
2. OpenCompass 的配置骨架怎么读
2.1 三段式配置与继承机制
一个典型的评测入口配置是纯 Python 文件,因为它用了 mmengine 的 config 机制,可以import和变量覆盖。骨架大致长这样:
from mmengine.config import read_base with read_base(): # 数据集 from .datasets.ceval.ceval_gen import ceval_datasets from .datasets.mmlu.mmlu_gen import mmlu_datasets from .datasets.gsm8k.gsm8k_gen import gsm8k_datasets # 模型 from .models.internlm.internlm_chat_7b import models as internlm_chat_7b_models # 结果汇总方式 from .summarizers.medium import summarizers datasets = ceval_datasets + mmlu_datasets + gsm8k_datasets models = internlm_chat_7b_modelsread_base的语义是“把被引用文件里的变量拉进当前命名空间”,所以你写datasets = a + b就是把多个数据集列表拼接。这个机制的好处是复现性极强,配置文件本身就是完整的实验记录,跑完会有一份拷贝落在outputs/<工作目录>/configs/下面,半年后回头查“当时到底用的哪个 prompt 版本”,答案就在那里。
容易踩的坑是命名冲突。如果你引了两个文件,里面都有叫models的变量,后引入的会把前面的覆盖掉,而且不报错。我现在的习惯是引入模型时一律as xxx_models起别名,多花三秒,省掉半小时的“为什么只有一个模型跑了”。
2.2 数据集配置的四件套,重点在 reader 和 infer
单个数据集配置里真正需要关注的是四块:元信息、reader_cfg、infer_cfg、eval_cfg。元信息是abbr(缩写,决定输出目录名)和path(数据路径)。reader_cfg告诉框架从原始数据里取哪些列当输入、哪一列是标准答案。infer_cfg是重头戏,包含 prompt 模板、检索器和推理器。eval_cfg决定怎么判分。
reader_cfg的写法值得单独说一句,因为它是最容易配错又最难发现的地方:
reader_cfg = dict( input_columns=['question'], output_column='answer', train_split='test', )input_columns里的列名必须和数据集实际字段严格一致,一个字母都不能差。有些数据集里字段叫question,有些叫input,有些叫prompt,你从 A 数据集抄配置到 B 数据集,最容易漏改这里。表现是推理阶段拿到空字符串,模型对着空白输出一段废话,然后准确率 0。我在第一次跑的时候就中招了,日志里全是空 question,排查了快四十分钟才回头看到这行。
infer_cfg里的prompt_template决定了对话格式。对书生浦语这类 chat 模型,模板必须带角色标记:
infer_cfg = dict( prompt_template=dict( type=PromptTemplate, template=dict( round=[ dict(role='HUMAN', prompt='{question}\n请只回答选项字母。'), dict(role='BOT', prompt='{answer}'), ], ), ), retriever=dict(type=ZeroRetriever), inferencer=dict(type=GenInferencer, max_out_len=512), )这里的 role 写法在不同版本里有HUMAN/BOT和user/assistant两种,取决于后端 tokenizer 的 chat 模板实现。如果后端会自动套 chat template,那配置里只要给出纯文本内容即可;如果不会,就得靠模板里显式的角色标记来构造。这一点必须在接模型那一步确认清楚,否则会出现“模型回答得很好但格式完全不对”的诡异现象。
2.3 后处理与指标:0 分的一大半来自这里
eval_cfg里通常有两个东西:evaluator和pred_postprocessor。后者是把模型输出的一段自由文本规整成可比对答案的函数,比如“取第一个大写字母”“提取第一行”“取最后一个数字”。
举个具体场景。C-Eval 这类选择题,标准答案是A,模型输出是“根据上述分析,正确答案应该是A,因为……”。这时候如果后处理只做了 strip,字符串比对必然失败,这道题就判错。而实际上模型答对了。所以准确率低到离谱时,第一反应应该是去看后处理,而不是怀疑模型。
我后来养成一个固定动作:跑完先随机抽十条predictions里的样本,人工对一遍标准答案,估算一个“人眼准确率”,再和报告里的数字比。两者差距大,说明是判分链路的问题;两者接近,说明模型真的就是这个水平。这个动作只要五分钟,能挡掉后面所有的自我怀疑。
3. 环境准备与模型接入的实操细节
3.1 显存怎么估,别靠试
显存估算有个够用的经验公式:权重占用量约等于参数量乘以每参数字节数,fp16/bf16 是 2 字节,int4 量化约 0.5 到 0.6 字节。所以 7B 模型 bf16 加载大约 14GB,int4 大约 4 到 5GB,20B 模型 bf16 大约 40GB。
然后是 KV cache,它跟批大小和序列长度线性相关,粗略公式是:2(K 和 V)× 层数 × 隐藏维度 × 序列长度 × 批大小 × 字节数。以 7B 模型、32 层、隐藏维度 4096、max_seq_len=2048、batch_size=8、bf16 计算,单条样本的 KV cache 约 2×32×4096×2048×2 字节,约等于 1GB,八条就是 8GB。加上权重 14GB,再加上框架和激活的几百 MB 到 1GB 开销,24GB 卡跑batch_size=8是有余量的,但余量不大,序列长度再往上加就会 OOM。
| 模型规模 | 精度 | 权重大致占用 | batch=8 时 KV cache 估算 | 推荐单卡显存 |
|---|---|---|---|---|
| 7B | bf16 | 约 14GB | 约 8GB(seq 2048) | 24GB 起步 |
| 7B | int4 | 约 5GB | 约 8GB(seq 2048) | 16GB 可试 |
| 20B | bf16 | 约 40GB | 约 12GB(seq 2048) | 80GB 或多卡 |
| 20B | int4 | 约 12GB | 约 12GB(seq 2048) | 40GB 可试 |
注意表里 KV cache 是按batch_size=8单批次算的,推理框架如果开了分页注意力再省一些,如果开了连续批处理则会动态涨落。显存不够时优先级是:先降batch_size,再降max_seq_len,最后才考虑量化——量化会引入精度损失,评测场景下这会污染结论。
3.2 安装与目录摸底
环境上我一般用独立虚拟环境,避免和训练环境里的 torch、transformers 版本打架。装完之后第一件事不是跑,而是摸目录。configs/下按datasets/、models/、summarizers/分好,run.py是入口,tools/下有几个救命的小脚本,其中列表查询那个最常用:
# 查有哪些与 internlm、ceval 相关的现成配置 python tools/list_configs.py internlm python tools/list_configs.py ceval这个脚本会打印出配置名和对应的文件路径,字段里有缩写和数据集名。别小看它,我第一次全靠猜文件名,浪费了很多时间。用它可以确认当前版本库里到底支持哪些数据集,避免抄了一份不存在的配置名然后 import 报错。
依赖层面有两个隐性要求值得提前确认:一是 transformers 版本,太老会不认最新的模型实现;二是分词器的trust_remote_code,书生浦语的权重走的是自定义实现,配置文件里必须显式打开,否则会在加载阶段直接抛错。这两点如果不提前处理,会在你以为是评测逻辑问题时被误导很久。
3.3 接入本地权重:一份可复用的模型配置
作业环境里权重通常在本地磁盘上,直接写绝对路径最省事。下面这份配置我用了很多次,改动点只有三个:路径、显存切分方式、输出长度。
from opencompass.models import HuggingFace models = [ dict( type=HuggingFace, abbr='internlm-chat-7b-hf', path='/path/to/internlm-chat-7b', tokenizer_path='/path/to/internlm-chat-7b', model_kwargs=dict( trust_remote_code=True, device_map='auto', torch_dtype='bfloat16', ), tokenizer_kwargs=dict( trust_remote_code=True, padding_side='left', truncation_side='left', ), max_out_len=512, max_seq_len=2048, batch_size=8, run_cfg=dict(num_gpus=1, num_procs=1), generation_kwargs=dict( do_sample=False, temperature=1.0, ), ), ]几个参数值得展开。padding_side='left'在批推理里几乎必须,因为自回归生成从左往右,右边 padding 会导致生成位置错位,输出乱码或者重复。do_sample=False是评测的基本要求,贪心解码保证同一份配置跑两遍结果一致,任何带采样的评测都是在给自己制造噪声。num_gpus=1和num_procs=1要根据实际卡数改,这个值和命令行传的--num-gpus对不上时,就会出现“只用了一张卡”的浪费。
如果权重比较大,单卡放不下,可以把device_map改成auto让 accelerate 自动切分,或者改用支持张量并行的推理后端。后者的吞吐明显更高,配置里换一下type和几个字段就行,数据侧完全不用动,这正是前面说的分层解耦带来的便利。
注意:路径里不要带中文和空格。我遇到过因为目录名里有空格导致子进程解析参数失败的情况,表现是任务启动后立刻退出,日志里只有一行语焉不详的报错。
4. 从冒烟测试到全量评测的完整流程
4.1 先跑通一条,再谈规模
直接上全量是最常见的自杀式操作。一个中等规模的数据集加七 B 模型,单卡跑几个小时很正常,等你发现配置有错,时间已经喂进去了。所以第一步永远是冒烟。
大多数版本支持--debug之类的开关,作用是每个数据集只取极少量样本,把整条链路走一遍。命令结构大致是:
python run.py configs/eval_internlm_chat_7b.py \ -w outputs/smoke \ --debug冒烟阶段我关注三件事,而不是分数:第一,predictions里的输出是不是像人话,有没有格式跑偏;第二,每道题的预测和标准答案是否肉眼可比对;第三,整条链路耗时是否在可接受范围,据此推算全量要多久。这三件事花十分钟,能省掉后面几小时的重跑。
冒烟通过之后再逐步放大:先单个数据集全量,确认这个数据集的判分逻辑没问题,再上组合。如果组合跑出来某个数据集分数异常,你至少知道不是全局配置的锅。
4.2 并行策略:模型并行和任务并行是两回事
这一点必须掰开讲,因为混淆它会导致严重的资源浪费或 OOM。模型并行是把一个大模型切成几份放到多张卡上,解决“单卡装不下”的问题;任务并行是同时跑多个数据集或多个模型,解决“总时长太长”的问题。前者要模型真的装不下才用,后者在卡够多的时候收益极大。
# 场景一:单卡装得下,多任务并行 python run.py configs/eval_internlm_chat_7b.py \ -w outputs/full \ --num-gpus 8 \ --max-num-workers 8 # 场景二:大模型单卡装不下,单任务多卡切分 python run.py configs/eval_internlm_20b.py \ -w outputs/full_20b \ --num-gpus 4max-num-workers控制的是任务级并发数,每个 worker 会占一组卡。如果模型配置里run_cfg写的是num_gpus=1,那么 8 张卡配max-num-workers=8就是八个任务同时跑,效率最高。但如果你把num_gpus写成 2,实际只有 4 组能用,另外 4 张卡闲着,而且框架不会提醒你。
还有一个容易被忽略的参数是复用。全量评测跑到一半崩了,或者你只想改判分规则不想重跑推理,这时候开启复用开关就能直接读已有的predictions重新算分,几秒钟出结果。我第一次不知道有这个,改一个后处理正则重跑了两小时,现在想起来还觉得亏。
4.3 产物目录怎么读,报告看哪几列
跑完之后工作目录里一般有这几块:
outputs/full/ ├── configs/ # 本次实验的完整配置快照 ├── logs/ # 推理与评测日志 ├── predictions/ # 每个模型每个数据集的原始生成结果 │ └── internlm-chat-7b-hf/ │ └── ceval/ │ └── ceval.json ├── results/ # 每个数据集的判分细节 │ └── internlm-chat-7b-hf/ │ └── ceval/ │ └── score.json └── summary/ # 汇总表,CSV 和 Markdown 两份读报告的顺序我总结成“三看”:先看summary里的总分列,建立整体印象;再看results里按学科或按子任务拆分的细分行,找短板,比如理工类明显低于人文类,往往指向训练数据配比问题;最后回到predictions抽样本,验证判分是否正确。顺序不能反,一上来就抽样本会淹没在细节里。
score.json里通常除了准确率还有样本数,这两个要一起看。如果一个子集只有十几道题,那它的准确率波动极大,90% 和 70% 之间可能只是三道题的差别,不能当结论。做横向对比时,我只对样本数超过一定阈值的子集下判断,小样本子集只在方向性上参考。
configs目录的价值在于归档。做版本对比时,先把两次实验的配置快照 diff 一遍,能立刻确认到底改了模型还是改了 prompt。我有一次兴冲冲地比较两个版本的提升,最后发现只是数据集版本从 v1 换到了 v2,白高兴一场。
5. 常见问题与排查实录
5.1 一份我自己的排查速查表
下表是几次作业里踩坑汇总,按“现象—根因—处置”组织,遇到问题先对号入座,比漫无目的地翻日志快得多:
| 现象 | 最可能的根因 | 处置方式 |
|---|---|---|
| 准确率接近 0,但输出内容看着像在答题 | 后处理抽取规则与输出格式不匹配 | 打开 predictions 看原始输出,调整 pred_postprocessor 的正则或截取逻辑 |
| 输出是空白,或者只有标点 | reader_cfg 的 input_columns 字段名错了 | 打印一条原始数据字典,核对字段名 |
| chat 模型输出自我解释、长篇大论 | prompt 模板未带角色标记,或后端未套 chat template | 确认后端是否自动套模板,否则在 template 里显式写角色 |
| 启动即 OOM | batch_size 或 max_seq_len 过大 | 先降 batch_size,再降 max_seq_len |
| 八张卡只吃到一张 | 模型配置的 run_cfg.num_gpus 与命令行参数不一致 | 两边对齐,模型装得下就都设 1,靠 max-num-workers 并发 |
| 任务卡住长时间无输出 | 数据路径不可达或数据加载阻塞 | 本地放一份小数据先验证路径,再看日志是否停在加载阶段 |
| 结果里出现乱码或重复词 | padding_side 设置错误 | 左侧 padding,且 attention mask 正确传递 |
| 同一配置两次跑分数不同 | 开启了采样解码 | 关闭采样,用贪心解码保证可复现 |
| 与官方榜单分数差距明显 | few-shot 数量、prompt 版本、数据版本不同 | 逐项比对配置,一次只改一个变量 |
| 改一处配置后全部重跑 | 未开启结果复用 | 启用复用开关,只重跑受影响的数据集 |
5.2 三个值得单独讲的案例
第一个是“后处理把对的判成错的”。当时 C-Eval 准确率只有个位数,我第一反应是模型有问题,因为输出看起来确实在认真答题。打开 predictions 一看,模型输出“正确答案是 A”,标准答案是A,但后处理器做了严格的相等比较,带着前面的中文前缀自然不相等。改成正则提取第一个独立的大写字母之后,准确率直接跳到正常区间。这件事之后我形成一个原则:任何异常的分数,先验证判分链路,再怀疑模型。
第二个是“多进程之间的数据竞争”。同时跑多个数据集时,偶尔会有一个任务挂掉,日志里没有明确错误。后来定位到是数据加载在同一个共享目录里产生了冲突,把工作目录按任务分开之后就消失了。这类问题很隐蔽,因为它不复现——同样的命令重跑一次可能就好了,容易让人以为是偶发。判断依据是:如果失败总是发生在并发度高的时候、且失败的任务不固定,那大概率是共享资源冲突。
第三个是“量化后分数掉了但不知道该不该信”。为了省显存用了 int4,跑出来分数比 bf16 低了一截。这到底是量化损失还是评测噪声?我的处理方式是固定数据集和 prompt,只切精度,跑两遍看差距是否可重复。结果是差距可重复且集中在需要多步推理的任务上,那就说明确实是量化影响了推理链条,而不是随机波动。这个结论直接影响了后续部署选型。
5.3 我的几条避坑心得
第一,日志一定要留着。每次实验的logs目录别删,出问题时那是唯一的现场。第二,一次只改一个变量。同时改 prompt 和 batch_size,跑出来分数变了,你没法归因。第三,小数据先验证。任何新的数据集配置,先用几十条样本把链路跑通,再上全量。第四,把耗时记下来。知道“这个数据集单卡要跑四十分钟”之后,你才能合理安排并行度和等待时间,不至于一晚上都在盯进度条。
提示:如果某个任务耗时远超预期,先别急着加卡。看
max_out_len有没有设得过大,模型对每道题都生成几百个 token,时间全耗在无意义的续写上。这个参数调小往往比加卡有效。
6. 结果到手之后怎么用才不算白跑
6.1 分数波动的归因路径
同一份权重、同一份数据,两次跑出来的分数不一致,是很常见的事,盲目相信任何一次结果都会误导决策。归因我按这个顺序查:先看解码方式,有没有采样、温度是不是 0;再看 prompt 版本,有没有改过措辞或者 few-shot 数量;然后看数据版本,评测集有没有更新;接着看后处理,正则改没改;最后才看环境,依赖版本、硬件差异。
实测下来,波动的大头在 prompt 和数据版本,环境的影响反而小。所以做版本对比时,我会把 prompt 文件和数据路径都写进实验记录,跟配置快照放一起。这样一旦发现差异,五分钟就能定位到是哪个变量动了。
6.2 把评测接进日常迭代的节奏
全量评测又慢又贵,不适合每次都跑。我的做法是分两层:一层是“回归集”,从各个数据集里抽几百条,覆盖面尽量广,跑一次十几分钟,每次模型有改动都跑;另一层是“全量集”,只在里程碑节点跑,比如完成一轮微调之后。回归集的作用是快速发现退化,全量集的作用是给出对外可比的数字。
回归集的构建也有讲究。不能随机抽,要保证每个能力维度都有样本,否则某个能力退化了却抽不到对应的题。我的做法是按子任务分层抽样,每个子任务至少留几十条。这套机制建立起来之后,模型迭代的节奏明显顺畅了,因为不用每次都等几小时才敢确认改动没搞坏东西。
还有一点是主观评测。客观题能自动判分,但对话质量、指令遵循这类没法靠字符串比对。这时候需要裁判模型来打分,配置上多一个裁判模型的指定。裁判模型本身的选择会影响结论,所以同一批对比实验里裁判必须固定,不能这次用这个下次用那个,否则分数之间没有可比性。
我个人在实际操作中的体会是,评测最大的价值不在于那个最终的总分,而在于它逼你把“模型到底擅长什么”这个问题想清楚。为了配一份可信的评测,你必须去读数据集的构造方式、去理解每道题在考什么、去判断一个错误是模型的问题还是流水线的问题。这套思考过程走一遍,比看十份榜单都有用。后面如果还想往下走,可以试着把评测结果按能力维度做可视化归因,或者干脆自己按业务场景补一个小型私有评测集——官方基准告诉你模型行不行,私有评测集才告诉你它在你这件事上行不行。