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); - 事件批量 flush 成一行行 JSON(
_flush_events_internal,record.py:346-358);线程安全靠_event_lock,进程退出atexit兜底 flush(record.py:88); - 跑完
record_final_report在末尾追加{"final_report": {...}}(record.py:367-369)。
所以一份日志 = 头部 spec + 中间一堆事件 + 末尾报告,逐行 JSON,可流式解析。还有 hidden_data_fields(record.py:334-337)能在写盘时屏蔽敏感字段(如用模型评客户数据时)。另有 HttpRecorder(发到 URL,带失败率阈值兜底回本地)和 DummyRecorder(dry-run 只打印)两个变体(record.py:374、274)。
4. 巧妙之处
- 两层考生抽象,演化清晰。 CompletionFn 简单够用;Solver 为"评测系统而非单纯模型"而生,用 TaskState/SolverResult 把脚手架从卷子里剥离,并通过适配器向后兼容。
- deepcopy + per-sample copy 双保险,让有状态 solver 在并行评测里互不干扰。
- 事件流式日志而非内存大对象。 指标事后从事件反推(见 02 章),既省内存、又让"中途中断也能出部分报告"成为可能,还天然适配流式落盘 / 远程上报。
- 重试封装在考生层。 限流/超时重试在
OpenAI*CompletionFn内部(api_utils.create_retrying),评测逻辑不必关心网络抖动。
5. 边界与局限
- Solver 仍是 Beta,接口可能变,且
SolverCompletionFn适配器明确不支持有状态 solver、不支持超出role/content的复杂 prompt(solver_completion_fn.py顶部注释)。 - deepcopy 可能很重:对持有大对象的 solver,
copy()开销不小,框架留了"自行 overridecopy"的口子(solver.py:122-125)。 - 日志可能很大:每步 sampling 都记完整 prompt+回答,大规模跑会产生很大的 JSONL;
hidden_data_fields只能屏蔽字段,不能压缩规模。
6. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 考生协议 | evals/api.py | CompletionFn、CompletionResult、DummyCompletionFn |
| OpenAI 考生 + 重试 | evals/completion_fns/openai.py | OpenAIChatCompletionFn、OpenAICompletionFn、openai_chat_completion_create_retrying |
| Solver 抽象 | evals/solvers/solver.py | Solver、SolverResult、NestedSolver、create_solver |
| 任务状态 | evals/task_state.py | TaskState、Message |
| Solver→CompletionFn 适配 | evals/completion_fns/solver_completion_fn.py | SolverCompletionFn |
| 记录器 | evals/record.py | RecorderBase、LocalRecorder、HttpRecorder、DummyRecorder、as_default_recorder、record_sampling、record_match |
| SolverEval | evals/eval.py | SolverEval |