第 1 章:两个接口 —— Sampler 与 Eval
这章讲全库的「承重墙」:
Sampler和Eval两个基类。看懂它们的契约,再看一次评测从命令行到分数的端到端走读,整库就通了。
1.1 全库只有两个抽 象
types.py 一共才 67 行,定义了整个库的全部公共契约。最关键的是两个基类,各只有一个方法:
# 示意,非源码 —— 提炼自 types.py 的两个基类签名
class SamplerBase:
# 问一个模型:给它一串消息,拿回它的回答
def __call__(self, message_list) -> SamplerResponse: ...
class Eval:
# 评一个基准:给它一个 sampler(考生),跑完返回总分
def __call__(self, sampler) -> EvalResult: ...
真实定义见 types.py:18-28(SamplerBase)和 types.py:59-65(Eval)。重点看:两个基类都用 __call__——所以一个 sampler 实例可以直接当函数 sampler(msgs) 调,一个 eval 实例可以直接 eval(sampler) 调。后面所有代码都靠这个写得很短。
为什么这么设计就够了? 因为评测这件事本质就是一个二元组:(怎么问模型, 怎么判分)。把这两件事各抽一个接口,基准之间就只在「判分逻辑」上不同,模型之间就只在「调哪个 API」上不同,两边正交、互不污染。
1.2 Sampler 的契约:消息进,SamplerResponse 出
Sampler 吃一个 MessageList(就是 [{"role": ..., "content": ...}, ...],types.py:4-5),吐一个 SamplerResponse,后者只有三个字段(types.py:9-16):
| 字段 | 含义 |
|---|---|
response_text | 模型回答的纯文本——判分只认它 |
actual_queried_message_list | 实际发出去的消息(含库自动加的 system 消息)——用于报告里如实还原 |
response_metadata | 附加信息,主要是 token 用量 |
注意 第二个字段的用心:库会在你给的消息前面自动插一条 system 消息,所以「你以为问的」和「实际问的」不一样。actual_queried_message_list 让报告还原的是后者(真问出去的那份),不撒谎。
四个 Sampler 实现,差异只在「调哪个 API、消息怎么包」:
| Sampler | 对接 | 关键差异 | 文件 |
|---|---|---|---|
ChatCompletionSampler | OpenAI Chat Completions | 普通聊天模型,system 角色,带 temperature/max_tokens | sampler/chat_completion_sampler.py:16 |
OChatCompletionSampler | OpenAI Chat Completions(o 系) | 推理模型,传 reasoning_effort,没有 temperature | sampler/o_chat_completion_sampler.py:10 |
ResponsesSampler | OpenAI Responses API | 新接口,system 消息改叫 developer 角色,推理模型传 reasoning={"effort": ...} | sampler/responses_sampler.py:11 |
ClaudeCompletionSampler | Anthropic Messages API | system 走独立 system= 参数,只接受 user/assistant 消息 | sampler/claude_sampler.py:26 |
这些差异正是「适配器模式」要吸收的脏活。例如 o 系推理模型不接受 temperature,所以 OChatCompletionSampler 的 create 调用里根本没传它(sampler/o_chat_completion_sampler.py:53-57);而 Responses API 把系统提示的角色名从 system 换成了 developer(sampler/responses_sampler.py:56-59)。
一个共同的硬骨头:限流重试。 真实 API 会限流,所以每个 sampler 的 __call__ 都是一个 while True 循环 + 指数退避:
# 示意,非源码 —— 三个 OpenAI sampler 共有的重试骨架
trial = 0
while True:
try:
resp = client.create(...) # 调真实 API
return SamplerResponse(...)
except BadRequestError: # 请求本身坏了,别重试
return SamplerResponse(response_text="...", ...)
except Exception: # 多半是限流
time.sleep(2 ** trial) # 1s, 2s, 4s, 8s...
trial += 1
真实实现见 sampler/chat_completion_sampler.py:63-95。重点看两点: ① BadRequestError(请求本身非法,比如内容被拒)不退避、直接返回一个占位回答,免得卡死;② 其它异常一律当限流处理,睡 2**trial 秒再试。
注意这个骨架只属于三个 OpenAI sampler。 ClaudeCompletionSampler(sampler/claude_sampler.py:66-103)只 except anthropic.RateLimitError 退避(claude_sampler.py:95),既没有 BadRequestError 占位分支,也没有 catch-all——非限流异常会直接抛出(源码末尾注释 # unknown error shall throw exception,claude_sampler.py:103)。
1.3 Eval 的契约:sampler 进,EvalResult 出
Eval.__call__ 拿一个 sampler,返回 EvalResult(types.py:31-41):顶层 score、其它 metrics、每题的 htmls/convos、以及 metadata。
几乎每个 eval 的 __call__ 都长成同一个模子——这是全库最值得记住的「主循环」:
# 示意,非源码 —— 所有 eval 的 __call__ 通用骨架
def __call__(self, sampler):
def fn(row): # 处理一道题
messages = [pack(format(row))] # ① 把题目组成 prompt
resp = sampler(messages) # ② 问模型(唯 一碰模型的地方)
answer = extract(resp.response_text) # ③ 从回答里抽出答案
score = judge(answer, row) # ④ 判分
return SingleEvalResult(score=score, html=..., convo=...)
results = common.map_with_progress(fn, self.examples) # 并发跑所有题
return common.aggregate_results(results) # 聚合成总分
拿最简单的 MMLU 对照真实代码:fn 见 mmlu_eval.py:97-126,结尾两行 map_with_progress + aggregate_results 见 mmlu_eval.py:128-129。四步(组 prompt / 问模型 / 抽答案 / 判分)在源码里一一对应。
并发与聚合两件公共件:
map_with_progress(common.py:219-234):用multiprocessing.pool.ThreadPool并发跑(默认线程数 = CPU 数),套个tqdm进度条。设了环境变量debug时退化成单线程顺序跑,方便调试(common.py:230-231)。aggregate_results(common.py:183-216):把每题metrics里同名的值收集成列表,按_compute_stat(common.py:164-180)算mean/std等,顶层score单列出来。
1.4 端到端走一次(以 MMLU 为例)
把前面的零件串起来,跟着一道 MMLU 选择题从命令行走到分数:
- 建实例。
--model gpt-4.1在注册表查到一个ChatCompletionSampler(model="gpt-4.1-2025-04-14", ...)(simple_evals.py:214-218);--eval mmlu在get_evals里建一个MMLUEval(simple_evals.py:356-357)。MMLUEval.__init__从一个公开 blob URL 下载 CSV 题库(mmlu_eval.py:84-94)。 - 核心调用。
result = eval_obj(sampler)(simple_evals.py:476)。 - 组 prompt。
format_multichoice_question把题目套进一个固定模板:「Answer the following multiple choice question. The last line of your response should be of the following format: 'Answer: $LETTER'… Think step by step」(common.py:15-24、common.py:153-154)。这一句模板就是「零样本 + 思维链」取向的具体体现。 - 问模型。
sampler(prompt_messages)(mmlu_eval.py:103)。sampler 自动在前面插上 system 消息「You are a helpful assistant.」(sampler/chat_completion_sampler.py:9、58-62)。 - 抽答案。 先
normalize_response去掉 markdown/LaTeX 噪声(common.py:355-374),再用一组多语言正则在文本里找「Answer: X」并抽出那个字母(mmlu_eval.py:106-113)。 - 判分。
score = 1.0 if extracted_answer == row["Answer"] else 0.0(mmlu_eval.py:114),并按学科把分记到 stem/humanities 等类别(subject2category,mmlu_eval.py:23-81)。 - 聚合 + 出报告。 单题结果带着 HTML 片段(用
HTML_JINJA渲染,common.py:139-150)回到aggregate_results,最终make_report拼成一份独立 HTML 落到/tmp/(simple_evals.py:481-484)。
至此你已经看懂了最简单的一类 eval。下一章专攻全库真正难的部分:第 5 步「抽答案」和第 6 步「判分」其实有四种截然不同的玩法。
1.5 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| Sampler 基类与响应结构 | types.py | SamplerBase、SamplerResponse、MessageList |
| Eval 基类与结果结构 | types.py | Eval、EvalResult、SingleEvalResult |
| OpenAI 聊天模型适配器 | sampler/chat_completion_sampler.py | ChatCompletionSampler |
| OpenAI 推理模型(o 系)适配器 | sampler/o_chat_completion_sampler.py | OChatCompletionSampler |
| OpenAI Responses 适配器 | sampler/responses_sampler.py | ResponsesSampler |
| Claude 适配器 | sampler/claude_sampler.py | ClaudeCompletionSampler |
| 并发执行 | common.py | map_with_progress |
| 结果聚合 | common.py | aggregate_results、_compute_stat |
| 编排:模型注册表 + eval 工厂 | simple_evals.py | main、get_evals |
| 最简单的 eval 范例 | mmlu_eval.py | MMLUEval |