1. 评测这件事,远比想象中更容易翻车
做LLM训练的人,几乎都经历过这样的场景:模型在训练集上loss降得漂漂亮亮,生成样例看着也像模像样,结果一上公开榜单,分数比预期低一截;或者更尴尬的是,自己评测出来的分数比社区里别人报的高出一大截,仔细一查,发现是评测集和训练数据撞了。这类问题在圈子里太常见了,常见到很多人已经默认“评测分数看看就好”。
但评测恰恰是训练闭环里最不能糊弄的一环。你调了学习率、换了数据配比、加了新的SFT阶段,最终判断这些改动到底有没有用,靠的就是评测。如果评测本身不可信,那整个训练迭代就是在盲人摸象。这篇内容围绕LLM Training Lab15这个主题,把评测模型好坏这件事拆开来讲:基准怎么选、数据污染怎么查、评测流程怎么做到可复现。适合正在做LLM训练、微调、或者需要定期给模型做体检的从业者参考,也适合刚入行、对评测体系还没有完整认知的朋友。
我自己的经验是,评测体系搭建的投入产出比极高。花两天时间把评测流程规范化,后面每次训练迭代省下来的排查时间可能是几十个小时。下面从整体设计思路开始,一步步展开。
2. 评测体系的整体设计与基准选择逻辑
2.1 为什么不能只看一个榜单
刚接触LLM评测的人,最容易犯的错误就是盯着一个榜单看。比如只刷Open LLM Leaderboard,或者只看MMLU分数。这种做法的问题在于,单一榜单的覆盖面有限,而且不同榜单的评测方式、题目分布、评分标准差异很大。一个模型在MMLU上表现好,不代表它在中文理解、代码生成、长文本推理上同样出色。
更合理的做法是建立一个分层评测体系。我通常把它分成三层:
- 通用能力层:覆盖语言理解、常识推理、数学推理等基础能力,常用的有MMLU、ARC、HellaSwag、GSM8K等。
- 领域能力层:根据你的模型实际应用场景选择,比如代码用HumanEval、MBPP,中文用C-Eval、CMMLU,医疗用MedQA等。
- 业务定制层:自己构造的评测集,直接对应产品的真实使用场景,这部分往往最能反映模型的实际价值。
这三层的权重不是均等的。如果模型有明确的落地场景,业务定制层的权重应该最高,通用能力层作为底线参考即可。我见过太多团队在通用榜单上刷得很高,结果上线后用户反馈一塌糊涂,原因就是业务定制层缺失。
2.2 基准选择的几个硬性标准
选基准不是看哪个火就用哪个,有几个标准需要认真考量。
第一,评测集是否与训练数据重叠。这是最关键的。很多公开评测集的题目在互联网上广泛流传,而LLM的训练数据又来自互联网,重叠几乎不可避免。选基准之前,一定要做污染检测,后面会详细讲怎么做。
第二,评测指标是否合理。比如有些生成任务用BLEU、ROUGE这类指标,和人类判断的相关性其实不高。现在更推荐用LLM-as-Judge或者人工评估,虽然成本高,但可信度好很多。
第三,评测集规模是否足够。一个只有几百道题的评测集,分数波动会很大,不同模型之间的差异可能被噪声淹没。一般来说,至少要有上千道题,统计上才比较稳定。
第四,是否支持可复现。有些榜单不公开评测脚本,或者评测环境不透明,你没法在本地复现同样的分数。这种榜单的参考价值要打折扣。
下面这张表是我在实际项目中常用的基准组合,按场景分类:
| 场景 | 推荐基准 | 题量级 | 指标 |
|---|---|---|---|
| 通用英文 | MMLU, ARC-Challenge, HellaSwag | 千到万 | Accuracy |
| 通用中文 | C-Eval, CMMLU | 千级 | Accuracy |
| 数学推理 | GSM8K, MATH | 千级 | Exact Match |
| 代码生成 | HumanEval, MBPP | 百到千 | Pass@1 |
| 长文本 | LongBench, RULER | 千级 | 多指标 |
| 指令跟随 | IFEval | 百级 | 规则匹配 |
2.3 自建评测集的必要性
公开基准有一个根本性的局限:它们测的是“通用能力”,而你的模型要解决的是“具体问题”。一个在MMLU上拿到70分的模型,在你的客服场景里可能连基本的意图识别都做不好。
自建评测集不需要一开始就很大。我的做法是先从真实业务日志里采样200到500条,人工标注标准答案,形成一个最小可用评测集。随着业务迭代,逐步扩充。这个评测集的题目不要公开,避免后续被训练数据污染。
自建评测集的关键是标注质量。如果标注本身有歧义,评测结果就没有意义。建议至少两个人独立标注,分歧部分讨论解决,计算一下标注一致性。一致性低于80%的话,说明题目定义有问题,需要重新设计。
3. 数据污染:评测里最大的隐形杀手
3.1 数据污染是怎么发生的
数据污染这个词听起来很技术,但原理很简单:评测集的题目出现在了训练数据里。模型在训练时“见过”这些题目和答案,评测时自然表现好,但这个好成绩是虚假的,不代表模型真的具备相应能力。
污染的来源主要有几个:
- 评测集本身在网上公开,爬虫抓取时一并进了训练语料。
- 训练数据里包含了评测集的解析、讨论、答案,比如论坛帖子、博客文章。
- 数据清洗不彻底,去重只做了精确匹配,没有做模糊匹配。
我遇到过最隐蔽的一种污染:训练数据里没有原题,但有大量和评测题高度相似的变体,模型通过模式匹配也能答对。这种污染用简单的字符串匹配查不出来,需要更细致的检测。
3.2 污染检测的实操方法
检测污染,从简单到复杂,有几层手段可以用。
第一层:精确匹配。把评测集的题目和训练数据做精确字符串比对。这个方法最快,但只能查出完全一样的题目。实现上用哈希或者后缀自动机都行,训练数据量大的话建议用后者。
第二层:n-gram重叠。把题目拆成n-gram(通常n取8到13),检查训练数据里是否有相同n-gram。这个方法能查出轻微改写的题目。阈值设置很关键,太低会误报,太高会漏报。我的经验是n=13时,重叠超过50%就值得警惕。
第三层:语义相似度。用embedding模型把评测题和训练数据都编码,计算余弦相似度。相似度超过某个阈值(比如0.9)的,人工复核。这个方法能查出换词但语义相同的题目,但计算成本高,适合在精确匹配和n-gram之后做补充。
第四层:影响函数。这是最严格的方法,通过计算训练样本对评测样本loss的影响,判断哪些训练样本可能导致了评测分数虚高。计算量大,一般只在关键评测上做。
下面是一个n-gram重叠检测的简化实现,用Python写:
from collections import Counter def get_ngrams(text, n=13): tokens = text.split() return set(zip(*[tokens[i:] for i in range(n)])) def contamination_check(eval_text, train_texts, n=13, threshold=0.5): eval_ngrams = get_ngrams(eval_text, n) if not eval_ngrams: return 0.0 max_overlap = 0.0 for train_text in train_texts: train_ngrams = get_ngrams(train_text, n) if not train_ngrams: continue overlap = len(eval_ngrams & train_ngrams) / len(eval_ngrams) max_overlap = max(max_overlap, overlap) return max_overlap实际使用时,训练数据可能有几十GB,逐条比对太慢。可以先用MinHash或者SimHash做粗筛,把可能重叠的候选集缩小,再做精细比对。
3.3 污染之后怎么办
查出污染之后,处理方式取决于污染程度。
如果污染比例很低(比如不到1%的评测题有污染),可以把这些题从评测集里剔除,用剩下的题评测。如果污染比例很高,那这个评测集基本就废了,需要换一个,或者自己重新构造。
还有一种情况是,污染无法完全避免,因为评测集和训练数据都来自同一个互联网。这时候可以做去污染训练:在训练前,把评测集从训练数据里剔除。这个操作要在数据准备阶段就做,不能等到评测时才发现。
我个人的习惯是,每次准备训练数据时,都维护一个“评测集黑名单”,把所有计划使用的评测集放进去,数据清洗时统一过滤。这样虽然不能100%避免污染,但能大幅降低风险。
4. 可复现评测流程的搭建
4.1 为什么可复现这么难
评测不可复现,原因通常有这几个:
- 随机性:生成任务的采样有随机性,同样的模型同样的输入,两次输出可能不同。
- 环境差异:不同的推理框架、不同的精度(fp16 vs bf16 vs int8),结果会有差异。
- 版本漂移:评测脚本、依赖库、模型权重更新了,但没记录。
- 提示词差异:同一个评测集,不同的prompt模板,分数可能差好几个点。
这些问题单独看都不大,但叠加起来,就导致“别人复现不出你的分数”成为常态。
4.2 固定随机性
对于生成任务,评测时要把随机性控制住。具体做法:
- 设置固定的随机种子。
- 解码策略用贪心搜索(greedy)或者beam search,不用top-p、top-k这类随机采样。
- 如果必须用采样,固定种子并多次运行取平均。
import torch import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意,完全确定性在某些算子上是做不到的,比如某些CUDA核函数。但把能固定的都固定,已经能大幅提升复现性。
4.3 记录一切
可复现的核心是记录。每次评测,至少要记录以下信息:
| 记录项 | 说明 |
|---|---|
| 模型版本 | 权重哈希或commit id |
| 评测集版本 | 评测集的哈希或版本号 |
| 评测脚本版本 | git commit id |
| 依赖环境 | Python版本、关键库版本 |
| 推理配置 | 精度、batch size、解码参数 |
| 随机种子 | 所有用到的种子 |
| 原始输出 | 模型的原始生成结果,不只是分数 |
| 评测时间 | 时间戳 |
原始输出特别重要。分数只是一个数字,出了问题没法排查。把原始输出存下来,后面可以重新算分、做错误分析、对比不同版本的输出差异。
我通常会把每次评测的结果存成一个目录,结构大概是这样:
eval_results/ └── 2024-01-15_model_v3_mmlu/ ├── config.json ├── raw_outputs.jsonl ├── scores.json └── logs.txt这样任何时候都能回溯到某次评测的完整信息。
4.4 评测流程的标准化
把评测流程标准化,可以减少人为失误。我的做法是写一个评测脚本,输入是模型路径和评测集路径,输出是分数和原始结果。所有评测都通过这个脚本跑,不允许手动操作。
脚本里要包含:
- 环境检查:确认依赖版本符合要求。
- 模型加载:按指定精度加载。
- 评测集加载:校验哈希,确保版本正确。
- 推理:按固定配置生成。
- 评分:按评测集定义的指标算分。
- 结果保存:保存所有记录项。
这个脚本本身也要版本控制,每次修改都要记录改了什么、为什么改。
4.5 交叉验证与置信区间
单次评测的分数是有噪声的。尤其是题量小的评测集,分数波动可能达到几个点。为了判断两个模型的差异是否真实,需要做统计检验。
简单做法是bootstrap:从评测结果里有放回地采样,重复多次,计算分数的分布,得到置信区间。如果两个模型的置信区间重叠,那差异可能不显著。
import numpy as np def bootstrap_ci(scores, n_bootstrap=1000, ci=0.95): n = len(scores) bootstrapped = [] for _ in range(n_bootstrap): sample = np.random.choice(scores, size=n, replace=True) bootstrapped.append(np.mean(sample)) lower = np.percentile(bootstrapped, (1-ci)/2 * 100) upper = np.percentile(bootstrapped, (1+ci)/2 * 100) return lower, upper这个操作成本很低,但能避免很多“看起来有提升其实没提升”的误判。
5. 常见问题与排查技巧实录
5.1 分数异常高或异常低
现象:评测分数明显偏离预期,比如一个7B模型在MMLU上拿到80分,或者一个训练充分的模型分数低得离谱。
排查思路:
- 先查污染。分数异常高,第一反应就是污染。用前面讲的n-gram方法快速筛查。
- 查评测脚本。是不是评分逻辑写错了,比如把accuracy算成了别的。
- 查prompt模板。模板和评测集预期的不一致,会导致模型答非所问。
- 查数据格式。评测集的字段解析错了,模型收到的输入是乱的。
我遇到过一次,分数比预期高了20个点,查了半天发现是评测集的答案字段和模型输出字段对错了位,导致评分时把正确答案当成了模型输出。
5.2 复现不出别人的分数
现象:社区里别人报的分数,自己怎么跑都差几个点。
排查思路:
- 确认模型版本一致。同名模型可能有多个版本,权重不同。
- 确认评测集版本一致。评测集可能更新过,题目有增减。
- 确认推理配置一致。精度、batch size、解码参数都会影响结果。
- 确认prompt模板一致。这是最容易被忽略的。
- 确认评分脚本一致。不同的评分实现可能有细微差异。
如果这些都确认了还是对不上,那可能是硬件差异导致的数值精度问题。这种情况比较少见,但确实存在。
5.3 评测速度太慢
现象:一次完整评测要跑好几个小时,迭代效率低。
优化思路:
- 用vLLM、TensorRT-LLM这类推理加速框架,吞吐能提升好几倍。
- 评测集做分层采样,先用小规模评测集快速筛选,有希望的配置再跑全量。
- 并行化,多卡多进程同时跑不同的评测子集。
- 缓存模型输出,同一模型同一评测集只跑一次,后续分析直接用缓存。
我通常会把评测分成“快速评测”和“完整评测”两档。快速评测用500题左右,10分钟内出结果,用于日常迭代;完整评测用全量评测集,用于阶段性验收。
5.4 常见问题速查表
| 问题 | 可能原因 | 排查动作 |
|---|---|---|
| 分数异常高 | 数据污染 | 做n-gram重叠检测 |
| 分数异常低 | prompt模板错误 | 检查模板与评测集是否匹配 |
| 复现不一致 | 随机性未固定 | 设置种子,用贪心解码 |
| 复现不一致 | 环境差异 | 记录并比对依赖版本 |
| 评测太慢 | 推理未优化 | 换vLLM,或分层采样 |
| 分数波动大 | 评测集太小 | 扩充评测集,或做bootstrap |
| 模型间差异不显著 | 未做统计检验 | 计算置信区间 |
5.5 几个容易踩的坑
坑一:用训练时的loss当评测指标。loss低不代表生成质量好,两者相关性有限。评测一定要用独立的评测集和任务指标。
坑二:评测集和验证集混用。调参时用的验证集,不能再当评测集用,否则就是变相过拟合。
坑三:忽略评测集的时效性。有些评测集的题目已经过时了,比如涉及的知识截止到几年前,模型答对不代表能力强,可能只是记住了。
坑四:只看总分不看分项。总分相同,分项可能差异很大。一个模型数学好语言差,另一个反过来,总分可能一样,但适用场景完全不同。分项分析才能看出模型的真实能力分布。
坑五:评测完不存原始输出。后面想复查、想做错误分析,发现原始输出没存,只能重跑。养成存原始输出的习惯,成本很低,收益很高。
6. 把评测当成训练的一部分
评测不是训练结束后的一个独立环节,它应该贯穿整个训练过程。我的做法是,在训练脚本里集成评测逻辑,每隔一定步数自动跑一次快速评测,把分数记录到训练日志里。这样能实时看到模型能力的变化趋势,及时发现异常。
比如训练过程中loss正常下降,但评测分数突然掉了一截,那可能是过拟合了,或者数据配比出了问题。这种信号如果等到训练结束才发现,就浪费了大量算力。
评测结果还可以反过来指导数据配比。如果发现模型在某个能力维度上一直上不去,可以针对性地补充相关数据。这个闭环建立起来之后,训练效率会有明显提升。
另外,评测集本身也需要迭代。随着模型能力提升,原来的评测集可能变得太简单,区分度不够。这时候需要构造更难的评测题,或者引入新的评测维度。评测集和模型是共同进化的。
我在实际项目里的体会是,评测体系的建设没有终点。每次遇到新的问题,就把对应的检测手段加进去;每次发现新的能力维度,就补充对应的评测题。积累下来,这套体系会越来越完善,成为团队最宝贵的资产之一。