数据截至 (上游 commit 8eac7a7de521)
04 · 被测系统抽象与记录器
本章讲两件事:"考生"的两层抽象(老的
CompletionFn和新的Solver,为什么要分),以及"成绩单"Recorder怎么把整轮运行的每一步落成可分析的 JSONL。
1. 第一层:CompletionFn —— 最小考生接口
最朴素的考生就是个函数:给 prompt,返回文本。CompletionFn 就是这个协议(evals/api.py:22-40):
# 示意,非源码:CompletionFn 的本质
class CompletionFn(Protocol):
def __call__(self, prompt, **kwargs) -> CompletionResult: ...
# 结果对象只需能交出文本列表
class CompletionResult(ABC):
def get_completions(self) -> list[str]: ...
它是 runtime_checkable 的 Protocol(api.py:22),所以任何"长得像"的对象都算 CompletionFn——这让接非 OpenAI 模型很容易。OpenAI 的实现是 OpenAIChatCompletionFn / OpenAICompletionFn(evals/completion_fns/openai.py),它们:
- 把裸 prompt 包成
Prompt对象再格式化成 API 入参(openai.py:104-116); - 调 API 时走带退避重试的包装(
openai_chat_completion_create_retrying,openai.py:41-52,对限流/超时/连接错误重试); - 调完顺手
record_sampling(openai.py:175-181)把"prompt + 回答 + 模型 + token 用量"记进 recorder。
这一层还自带几个变体:cot.py(链式思考)、langchain_llm.py/langchain_math.py(接 LangChain)、retrieval.py(检索增强)——都是 CompletionFn,所以能直接被 oaieval 当考生用。
2. 第二层:Solver —— 把"策略"从"卷子"里剥出来
2.1 为什么 CompletionFn 不够
Solver README(evals/solvers/README.md)点出了痛处:"GPT-4 在这个 eval 上多好?"是个没说全的问题——同一个模型,配不同脚手架(prompt、工具、记忆)表现天差地别。如果 eval 直接收 prompt,eval 作者就会把适配某类模型的 prompt 写死进卷子,评测就偏向了那类模型。
所以新抽象的目标是:让 eval 与具体模型/策略无关,把模型相关的脚手架全挪进 Solver。
2.2 接口:TaskState ⇄ SolverResult
每一回合,eval 给 solver 一个 TaskState,solver 还回一个 SolverResult:
Eval ──TaskState──▶ Solver
(任务描述+对话历史+状态)
Eval ◀─SolverResult─ Solver
(输出文本 + 元数据)
TaskState(evals/task_state.py:23-46)刻意只放"solver 答题所需的全部信息":
task_description:固定的任务说明(含期望输出格式),所有样本一致;messages:到目前为止的对话(Message(role, content)),输入样本通常是第一条;current_state:显式的状态变量(如"还剩几回合""当前比分"),省得 solver 去 parse 对话。
SolverResult(solver.py:18-38)就是 output 文本 + 任意元数据。
2.3 Solver 基类的两个设计点
# 示意,非源码:Solver.__call__ 的两层包装
def __call__(self, task_state, **kwargs) -> SolverResult:
res = self._solve(deepcopy(task_state), **kwargs) # ① 防 solver 改坏原 task_state
for pp in self.postprocessors: # ② 链式后处理输出
res = pp(res)
record_event("postprocessor", {...}) # 后处理也记事件
return res
真实实现见 solver.py:76-97。两个点:
- deepcopy 隔离:
_solve拿到的是 task_state 的副本,solver 无法污染 eval 的原始状态(solver.py:82)。 - postprocessors 链:输出可经一串后处理器(如提取
\boxed{}、去多余文字),每步都记一条postprocessor事件,便于复盘。
2.4 嵌套与可复制
Solver 可以包别的 solver(NestedSolver,solver.py:137-209),比如一个"先 CoT 再抽取答案"的组合 solver 内部持有子 solver。配套 SolverEval 给每个样本 copy() 一份新 solver(eval.py:225),这样 solver 可以有状态(如记忆)而不互相串味——NestedSolver.copy 还会逐个复制子 solver 以保留各自的特殊拷贝逻辑(solver.py:191-197)。
Solver 也实现了 CompletionFn(solver.py:41:class Solver(ABC, CompletionFn)),且有 SolverCompletionFn 适配器(evals/completion_fns/solver_completion_fn.py)把 solver 包成 completion_fn——这是为了和老的 Eval 类兼容。
注意:Solver 框架在仓库里仍标注为 Beta(
solvers/README.md:3),并建议新的数据集类评测仍用经典Eval+CompletionFn。
3. 记录器:把整轮运行当事件流
3.1 事件模型
Evals 不在内存里攒一个大结果结构,而是把运行中的每一步当成一条 Event(record.py:44-52:含 run_id/event_id/sample_id/type/data)流式记下来。RecorderBase(record.py:54)文档列了标准事件类型:
| 事件类型 | 记录什么 |
|---|---|
sampling | 给模型的 prompt 和模型采样出的回答 |
match | 一次判分(correct + expected/picked) |
metrics | 一组指标(模型裁判用) |
cond_logp / pick_option / embedding | 条件对数概率 / 选项 / 嵌入 |
error / raw / extra | 错误 / 原始样本 / 任意附加 |
每种事件有个模块级便捷函数(record_match、record_sampling...,record.py:593-633),内部找到"当前默认 recorder"再写。
3.2 当前样本是怎么"隐式"知道的
前面 02 章 提过:as_default_recorder(sample_id)(record.py:90-96)用 ContextVar 把"当前 recorder"和"当前 sample_id"绑进上下文。于是深处的 record_match() 不必显式传 sample_id,也线程安全(每个线程有独立的 ContextVar 值)。
3.3 落盘:LocalRecorder 写 JSONL
默认用 LocalRecorder(record.py:316,oaieval 默认 --local-run)。它的写法:
- 文件第一行先写
{"spec": ...}(运行规格,record.py:343-344);