跳到主要内容

第 1 章:两个接口 —— Sampler 与 Eval

这章讲全库的「承重墙」:SamplerEval 两个基类。看懂它们的契约,再看一次评测从命令行到分数的端到端走读,整库就通了。

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对接关键差异文件
ChatCompletionSamplerOpenAI Chat Completions普通聊天模型,system 角色,带 temperature/max_tokenssampler/chat_completion_sampler.py:16
OChatCompletionSamplerOpenAI Chat Completions(o 系)推理模型,传 reasoning_effort,没有 temperaturesampler/o_chat_completion_sampler.py:10
ResponsesSamplerOpenAI Responses API新接口,system 消息改叫 developer 角色,推理模型传 reasoning={"effort": ...}sampler/responses_sampler.py:11
ClaudeCompletionSamplerAnthropic Messages APIsystem 走独立 system= 参数,只接受 user/assistant 消息sampler/claude_sampler.py:26

这些差异正是「适配器模式」要吸收的脏活。例如 o 系推理模型不接受 temperature,所以 OChatCompletionSamplercreate 调用里根本没传它(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 对照真实代码:fnmmlu_eval.py:97-126,结尾两行 map_with_progress + aggregate_resultsmmlu_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 选择题从命令行走到分数:

  1. 建实例。 --model gpt-4.1 在注册表查到一个 ChatCompletionSampler(model="gpt-4.1-2025-04-14", ...)(simple_evals.py:214-218);--eval mmluget_evals 里建一个 MMLUEval(simple_evals.py:356-357)。MMLUEval.__init__ 从一个公开 blob URL 下载 CSV 题库(mmlu_eval.py:84-94)。
  2. 核心调用。 result = eval_obj(sampler)(simple_evals.py:476)。
  3. 组 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-24common.py:153-154)。这一句模板就是「零样本 + 思维链」取向的具体体现。
  4. 问模型。 sampler(prompt_messages)(mmlu_eval.py:103)。sampler 自动在前面插上 system 消息「You are a helpful assistant.」(sampler/chat_completion_sampler.py:958-62)。
  5. 抽答案。normalize_response 去掉 markdown/LaTeX 噪声(common.py:355-374),再用一组多语言正则在文本里找「Answer: X」并抽出那个字母(mmlu_eval.py:106-113)。
  6. 判分。 score = 1.0 if extracted_answer == row["Answer"] else 0.0(mmlu_eval.py:114),并按学科把分记到 stem/humanities 等类别(subject2category,mmlu_eval.py:23-81)。
  7. 聚合 + 出报告。 单题结果带着 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.pySamplerBaseSamplerResponseMessageList
Eval 基类与结果结构types.pyEvalEvalResultSingleEvalResult
OpenAI 聊天模型适配器sampler/chat_completion_sampler.pyChatCompletionSampler
OpenAI 推理模型(o 系)适配器sampler/o_chat_completion_sampler.pyOChatCompletionSampler
OpenAI Responses 适配器sampler/responses_sampler.pyResponsesSampler
Claude 适配器sampler/claude_sampler.pyClaudeCompletionSampler
并发执行common.pymap_with_progress
结果聚合common.pyaggregate_results_compute_stat
编排:模型注册表 + eval 工厂simple_evals.pymainget_evals
最简单的 eval 范例mmlu_eval.pyMMLUEval