数据截至 (上游 commit 8eac7a7de521)
02 · 评测主循环与基础模板
本章讲"卷子"内部:
Eval基类定义的评测骨架(怎么并行跑、怎么保证可复现),以及最简单的几种判分规则(精确/包含匹配)。读完你能自己写一个 Eval 子类。
1. Eval 的契约:只要实现两个方法
Eval 是抽象基类(evals/eval.py:46),它只强制子类实现两个方法:
| 方法 | 职责 | 类比 |
|---|---|---|
eval_sample(sample, rng) | 判一道题:调考生拿回答、和标准答案比、记一笔 | 判一张答题卡 |
run(recorder) | 跑全程:加载数据 → 并行跑所有样本 → 汇总指标 | 组织整场考试 + 出成绩单 |
基类把"并行跑所有样本"这件累活儿实现好了(eval_all_samples),子类的 run 通常就三步:get_samples() → eval_all_samples() → 从 recorder 收集事件算指标。
2. 主循环:eval_all_samples 怎么并行又可复现
2.1 确定性洗牌
并行评测最怕"每次跑结果不一样"。Evals 用两层确定性保证可复现:
# 示意,非源码:样本顺序由固定种子决定,与运行无关
indices = list(range(len(samples)))
random.Random(SHUFFLE_SEED).shuffle(indices) # SHUFFLE_SEED = 123,写死
work_items = [(samples[i], i) for i in indices]
真实实现见 _index_samples(eval.py:30-38),SHUFFLE_SEED = 123 是模块常量。洗牌带每个样本的原始下标一起走,所以即使乱序并行跑,最后还能按下标排回原顺序(eval.py:147 的 sorted)。
每个样本还有自己的确定性 RNG:用 "{base}.{split}.{idx}:{seed}" 编码成种子(eval.py:135-137),这样"第 N 题"用的随机数永远一样——对需要随机抽 few-shot 之类的逻辑很关键。
2.2 线程池并行
# 示意,非源码:每题在线程池里跑,as_default_recorder 把日志归到对应样本
def eval_sample(args):
sample, idx = args
sample_id = f"{base_name}.{split}.{idx}"
with recorder.as_default_recorder(sample_id): # 线程内绑定"当前样本"
rng = random.Random(per_sample_seed)
return idx, self.eval_sample(sample, rng)
with ThreadPool(threads) as pool:
iter = pool.imap_unordered(eval_sample, work_items) # 乱序完成
真实实现见 eval.py:112-147。要点:
- 线程数可调:环境变量
EVALS_THREADS,默认 10(eval.py:124)。LLM 评测是 IO 密集(等 API),线程池就够,不用进程池。 - 可切顺序模式:
EVALS_SEQUENTIAL=1时改用map串行跑(eval.py:140-142),调试时方便。 as_default_recorder是线程局部的:它用ContextVar把"当前 sample_id"绑到上下文(record.py:90-96),于是eval_sample里随手调record_match()不用传 sample_id,日志也能正确归属。