跳到主要内容

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:147sorted)。

每个样本还有自己的确定性 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,日志也能正确归属。

3. 最简单的判分:Match 模板

3.1 它要解决的小问题

很多题答案唯一且短(选择题、数值题)。这类"模型输出开头匹配上标准答案就算对"的逻辑,Match 一个类搞定(evals/elsuite/basic/match.py:9)。

3.2 eval_sample 全貌

样本必须是 {"input": ..., "ideal": ...}(match.py:31-36)。eval_sample 做三件事:

# 示意,非源码:Match.eval_sample 的骨架
prompt = sample["input"]
result = self.completion_fn(prompt=prompt, temperature=0.0) # 调考生,温度0求确定
sampled = result.get_completions()[0] # 取第一条回答
return evals.record_and_check_match( # 判 + 记
prompt=prompt, sampled=sampled, expected=sample["ideal"],
)

真实实现见 match.py:30-56。判分规则在 record_and_check_match(evals/api.py:55-105):遍历候选选项,只要 sampled.startswith(option) 就算命中(可选 separator 防止 "42" 误匹配 "4")。它顺手把 match 布尔通过 record_match 记进 recorder(api.py:104)。

Match 还内建 few-shot 支持:num_few_shot > 0 时,把若干示例样本插进 chat prompt 的倒数第二个位置(match.py:39-44)。

3.3 兄弟模板:判分规则的一行差异

几个基础模板的差别只在那一行判分规则(docs/eval-templates.md:9-16 也列了)。设模型输出 a、标准答案集合 B:

模板判对规则文件
Matchany(a.startswith(b) for b in B)basic/match.py
Includesany(b in a for b in B)basic/includes.py
FuzzyMatchany(a in b or b in a for b in B)basic/fuzzy_match.py
JsonMatcha 作为 JSON 与某个 b 结构相等basic/json_match.py

Includes 的核心就一句(includes.py:42-44):用 utils.get_answer 在输出里找子串,ignore_case 可控大小写。这说明"基础模板"的设计哲学:主循环统一、只把"算不算对"做成可替换的一行

4. 汇总:run 怎么出分

Match.run(match.py:58-65)是所有基础模板的范式:

# 示意,非源码:跑完所有样本后,从 recorder 收 match 事件算指标
self.eval_all_samples(recorder, self.get_samples())
events = recorder.get_events("match") # 取出所有判分事件
return {
"accuracy": evals.metrics.get_accuracy(events),
"boostrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
}
  • accuracy = 答对数 / 总数(metrics.py:12-18)。
  • bootstrap_std = 用自助法(bootstrap)估准确率的标准差(metrics.py:21-23):反复随机抽一半样本算均值、取这些均值的标准差,给出"这个准确率有多稳"的误差棒。这是个朴素但实用的不确定性度量。

注意 run 不自己持有结果列表,而是事后从 recorder 把 match 事件捞出来——这把"判分"和"汇总"解耦了,也意味着 recorder 是单一事实源。

5. 巧妙之处

  • 模板法分离"骨架"与"判分"。 主循环、并行、随机、记录全在基类;子类只写一道题怎么判。新增一种基础评测 = 改一行匹配规则。
  • 双层确定性种子。 全局洗牌种子(123)定样本顺序、每样本种子定样本内随机——并行也能逐位复现。
  • ContextVar 当"隐式参数"。 as_default_recorder 让深层代码无需层层传 sample_id 就能正确归属日志,且线程安全。
  • 指标从事件流反推。 run 不维护可变累加器,而是查询 recorder 里的 match 事件——天然适合"中途中断也能出部分报告"(SolverEval 甚至专门处理了 KeyboardInterrupt 做"温柔中断",eval.py:240-253)。

6. 边界与局限

  • 基础模板只认 input/ideal,且把回答当纯字符串比。答案有意义变体(同义、不同格式)时会误判为错——这正是要上"模型裁判"(见 03 章)的动机。
  • 线程池不是隔离沙箱。 eval_sample 之间共享进程内存;若子类持有可变共享状态会有竞态(这也是 SolverEval 给每个样本 copy() 一个新 solver 的原因,eval.py:225)。
  • bootstrap_std 用样本的一半(len//2)估计,样本极少时这个误差棒本身也不稳。

7. 代码地图

主题文件路径符号名
评测基类 + 主循环evals/eval.pyEvalSolverEvaleval_all_samples_index_samplesset_max_samples
精确/包含匹配evals/elsuite/basic/match.pyincludes.pyMatchIncludes
判分 + 记录evals/api.pyrecord_and_check_match
数据加载evals/data.pyget_jsonlopen_by_file_pattern
指标evals/metrics.pyget_accuracyget_bootstrap_accuracy_std