LLM 评测体系
第 9 章 OpenAI evals 源码:模板系统与 Solver 抽象
OpenAI evals 把评测拆成 Eval / CompletionFn / Recorder 三层。四个基础 grader 加起来不到 300 行,真正值得读的是它们如何被模板系统组织起来。
本章要点
- OpenAI evals 仓库的整体架构:Eval / CompletionFn / Recorder 三层抽象
Match/Includes/JsonMatch/FuzzyMatch四大基础 grader 的源码逐行解读record_and_check_match函数:判分结果如何被结构化存储- 与 promptfoo / ragas 的设计哲学对比——为什么 OpenAI 这个框架更"学术"
- 自己 fork 这套框架做内部评测的工程改造要点
9.1 仓库一瞥
本章源码引用统一基于 openai/evals 仓库的 commit 4bfc1f5(2025-11-03,本专栏的基准版本)。读者可通过下面命令获取本章引用的完整源码:
git clone https://github.com/openai/evals.git
cd evals
git checkout 4bfc1f5
仓库的核心目录结构(去掉非关键文件):
evals/
├── api.py # CompletionFn / CompletionResult / record_and_check_match
├── base.py # 各种 *Spec dataclass 定义
├── data.py # JSONL / CSV 加载工具
├── eval.py # Eval 基类
├── elsuite/
│ ├── basic/ # 5 个开箱即用 grader
│ │ ├── match.py # Exact match
│ │ ├── includes.py # Substring match
│ │ ├── fuzzy_match.py # 模糊匹配
│ │ ├── json_match.py # JSON 结构匹配
│ │ └── json_validator.py # 只校验"是不是合法 JSON"
│ ├── ballots/ # 多轮说服评测(模型劝说模拟选民就投票提案表态)
│ ├── ...
└── completion_fns/
└── openai.py # 官方 OpenAI API 适配器
整个仓库的代码量适中——核心抽象层(api.py 105 行 + base.py 89 行 + eval.py 255 行)合计不到 450 行,剩下都是各种 Eval 子类和 elsuite/ 下的具体评测套件。这是值得借鉴的工程美感:抽象层薄、扩展层厚。
9.2 三层核心抽象:Eval / CompletionFn / Recorder
OpenAI evals 的全部架构可以浓缩成三个抽象类:
flowchart LR
subgraph 数据
DS[Samples JSONL]
end
subgraph 模型层
CF[CompletionFn]
end
subgraph 评测层
EV[Eval 基类<br/>子类化]
REC[Recorder]
end
DS --> EV
EV -->|sample by sample| CF
CF -->|response| EV
EV -->|结果| REC
REC --> Out[Metrics + Events]
style EV fill:#dbeafe
style CF fill:#dcfce7
style REC fill:#fef3c7
9.2.1 CompletionFn:对模型调用的最小抽象
evals/api.py:22-40 定义 CompletionFn Protocol:
@runtime_checkable
class CompletionFn(Protocol):
def __call__(
self,
prompt: Union[str, OpenAICreateChatPrompt],
**kwargs,
) -> CompletionResult:
...
这是一个协议(Protocol),不是具体类。任何"输入 prompt → 输出 CompletionResult"的可调用都符合这个协议。带来的工程灵活性:
- OpenAI API →
OpenAIChatCompletionFn(evals/completion_fns/openai.py) - Anthropic / Gemini / Llama → 各自的 wrapper 实现
CompletionFn - 离线模型 / vLLM → 同样实现这个协议
- 测试 / mock →
DummyCompletionFn(evals/api.py:48-52)
这个 Protocol 的存在让框架核心完全模型无关——Match 类不知道也不关心后面接的是 GPT-4 还是 Claude 还是本地 Llama。这种"依赖倒置"是工业框架的标准设计。
9.2.2 Eval 基类:评测逻辑的骨架
evals/eval.py 中的 Eval 基类规定每个具体评测必须实现两个方法:
eval_sample(sample, ...):对单条样例执行评测run(recorder):聚合评测整个数据集
这种"分而治之"的设计有两个好处:
- 并行化天然支持:
eval_sample按单条样例调用,eval_all_samples默认用线程池并发执行(见 §9.8.4) - 逐条可追溯:每条样例在自己的
sample_id下记录事件,结果能落到具体哪一题。注意默认实现不做错误隔离:eval_sample抛出的异常会经imap_unordered冒泡,中断整次运行(evals/eval.py:139-147),需要容错得在自己的eval_sample里捕获
9.2.3 Recorder:判分事件的结构化存储
每次判分调用 record_match(match, expected, picked, sampled, options)(在 evals/record.py 里实现),把判分事件写入结构化日志。这种"判分事件流"模型让后续分析能用 SQL 一样的方式按 category / difficulty / model_version 切片。
evals/api.py:55-105 的 record_and_check_match 函数是这一切的入口:
def record_and_check_match(
prompt: Any,
sampled: str,
expected: Union[str, list[str], tuple[str]],
separator: Callable[[str], bool] = None,
options: Optional[list[str]] = None,
):
# ...
picked = None
for option in options:
if not sampled.startswith(option):
continue
if (separator is not None and len(sampled) > len(option)
and not separator(sampled[len(option)])):
continue
picked = option
break
# ...
record_match(match, expected=expected, picked=picked, sampled=sampled, options=options)
return picked
值得注意的细节:
- 多 expected:同一道题可以有多个"对的"答案("Paris" / "巴黎" 都算对),
expected接受 list - separator 参数:注意它的作用是收紧而不是放宽。不传 separator 时光凭
startswith判定,"Parisian ..."也会被判成选了"Paris";传了 separator 之后还要求紧跟 option 的那个字符满足separator(...)(通常是空白 / 标点),才算真的选了这个选项 - picked 与 match 分开:先看 sampled 选了哪个 option,再看选的对不对——这种解耦让"模型选错了什么"和"对不对"两个分析都可做
9.3 四大基础 grader 逐行解读
9.3.1 Match:精确匹配的极简实现
evals/elsuite/basic/match.py:9-65 的完整实现只有 65 行(含空行注释)。核心 eval_sample 方法(行 30-56):
def eval_sample(self, sample: Any, *_):
assert isinstance(sample, dict), "sample must be a dict"
assert "input" in sample, "sample must have an 'input' key"
assert "ideal" in sample, "sample must have an 'ideal' key"
# ...
prompt = sample["input"]
if self.num_few_shot > 0:
# 拼接 few-shot 样例
prompt = sample["input"][:-1]
for s in self.few_shot[: self.num_few_shot]:
prompt += s["sample"]
prompt += sample["input"][-1:]
result = self.completion_fn(
prompt=prompt,
temperature=0.0,
)
sampled = result.get_completions()[0]
return evals.record_and_check_match(
prompt=prompt,
sampled=sampled,
expected=sample["ideal"],
)
工程要点:
temperature=0.0写死(行 48):评测调用强制非随机,避免 §4.7 讨论的"模型采样噪声"assert守卫每个字段(行 31-36):不规范的样例直接抛错,避免静默错误- few-shot 拼接逻辑(行 39-44):少量示例可以加在 prompt 前,模拟 ICL(in-context learning)评测
run 方法(行 58-65)极简:
# evals/elsuite/basic/match.py:58
def run(self, recorder):
samples = self.get_samples()
self.eval_all_samples(recorder, samples)
events = recorder.get_events("match")
return {
"accuracy": evals.metrics.get_accuracy(events),
"boostrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
}
注意 boostrap_std(带 typo 的字段名)——这是用 bootstrap 估计 accuracy 的标准差(详见第 4 章 §4.7.3)。Match / Includes 在 accuracy(简单 mean)之外同时输出 bootstrap 标准差——这是"统计推断意识"刻在框架里的体现。
9.3.2 Includes:宽松的子串匹配
evals/elsuite/basic/includes.py 整文件 57 行。它和 Match 的差异只在判定方式——Match 用 startswith,Includes 用子串包含(行 42-44):
includes_answer = any(
[utils.get_answer(sampled, ref, self.ignore_case) is not None
for ref in ideal]
)
适合"答案藏在长回答里"的场景。例如题目要求"列出三个法国大城市",模型回答"法国有很多大城市,比如巴黎、马赛、里昂..."——Match 会判错(不 startswith"巴黎"),但 Includes 会判对(包含"巴黎"等关键词)。
9.3.3 JsonMatch:结构化输出的标准做法
evals/elsuite/basic/json_match.py 实现 JSON 解析后的字段对比。它把模型输出先 json.loads(),再与 expected JSON 做递归字段对比。这正是第 5 章 §5.5 的 Schema Validation 思路——但 OpenAI 这个实现还停留在"字段对比"层面,不如 promptfoo 的 is-valid-openai-tools-call 那么完整地用 JSON Schema。
9.3.4 FuzzyMatch:不是编辑距离,是归一化后的双向子串包含
名字容易误导:evals/elsuite/basic/fuzzy_match.py 并没有用 Levenshtein 编辑距离。它调的是 evals/elsuite/utils.py:46 的 fuzzy_match,实现只有三行——先 normalize()(小写化 + 去标点 + 去冠词 a/an/the + 压空白),再做双向子串包含判定:
def fuzzy_match(s1: str, s2: str) -> bool:
s1 = normalize(s1)
s2 = normalize(s2)
if s1 == "" or s2 == "":
return s1 == s2
return s1 in s2 or s2 in s1
也就是说它相当于"归一化后的双向 Includes",能吃掉大小写 / 标点 / 冠词差异,对错字其实无能为力。FuzzyMatch.run 除 accuracy 外还输出 f1_score(utils.f1_score,按 token 算 QA 式 F1),这比单纯的对错更能反映"答得多接近"。工程隐患是双向包含过于宽松——模型只要把标准答案抄进一段长回答就算对,所以工业评测里 fuzzy_match 用得相对少,远不如 LLM-as-Judge。
9.4 注册系统:如何挂上几百个评测
OpenAI evals 仓库的真正威力在于它的 registry 系统——把评测的"代码"和"数据集"分开管理:
代码层:evals/elsuite/basic/match.py(一个通用 Match 类)
数据层:evals/registry/data/<task>/samples.jsonl(具体题集)
注册层:evals/registry/evals/<task>.yaml(绑定代码 + 数据)
例如一个绑定 yaml 看起来像:
match-french-cities:
id: match-french-cities.s1.simple-v0
description: Match French city capitals
metrics: [accuracy]
match-french-cities.s1.simple-v0:
class: evals.elsuite.basic.match:Match
args:
samples_jsonl: french_cities/samples.jsonl
这种解耦让仓库能轻松挂载几百个评测——每个新评测只是一份 yaml + 一份 jsonl,不需要写 Python。4bfc1f5 版本下 evals/registry/evals/ 共 463 个 spec YAML,声明了 700 余个可直接调用的 eval 名。
graph LR Y[YAML registry] --> R[Registry 解析] R --> Spec[EvalSpec dataclass] Spec -->|cls 字段| Cls[Match / Includes / JsonMatch / ...] Spec -->|args 字段| Init[初始化参数] Cls --> E[Eval 实例] Init --> E E --> Run[执行评测] style Spec fill:#fef3c7 style E fill:#dcfce7
9.5 与 promptfoo / ragas 的设计哲学对比
读完 OpenAI evals 源码再回看 promptfoo 和 ragas,能感觉到三家完全不同的设计哲学:
| 维度 | OpenAI evals | promptfoo | ragas |
|---|---|---|---|
| 主用户 | 模型研发 / 学术 | 应用工程师 | RAG 工程师 |
| 配置形态 | YAML + jsonl + Python class | 单一 YAML | Python pipeline |
| 抽象层级 | Eval / CompletionFn / Recorder 三层 | Test / Assertion 两层 | Metric × Dataset 两层 |
| 默认指标 | accuracy + bootstrap_std | 多 assertion 直接报告 | Faithfulness / Recall 等专用 |
| 二开难度 | 中(要写 Python class) | 低(写 YAML) | 中(要懂 Python pipeline) |
OpenAI evals 适合做模型层评测——它的设计明显更"学术风",每个 evaluation 都是一个完整的实验单元,输出 bootstrap CI,方便发论文。
promptfoo 适合做应用层快速迭代——开发者能在 5 分钟内写完一份 YAML、跑出对比报告。
ragas 适合做 RAG 系统专项——它的 metric 体系(Faithfulness / Context Recall 等)是其他两家没有的。
工业团队的常见组合是:用 promptfoo 做日常评测,用 ragas 做 RAG 专项,用 OpenAI evals 做模型对比——三家工具是补充关系而非替代。
9.6 Fork 这套框架做内部评测的改造要点
如果你的团队想 fork OpenAI evals 做内部使用,关键的改造方向:
- 替换 CompletionFn:实现内部 LLM gateway 的 wrapper,统一鉴权、限流、retry
- 扩展 Recorder:把判分事件写到内部数据仓库(BigQuery / ClickHouse),而不只是本地文件
- 加 LLM-as-Judge 类:OpenAI evals 默认偏 rule-based,要补上
ModelGradedEval子类,复用第 6 章的 prompt 模板 - 改 registry 后端:从本地 yaml 文件改到内部配置中心(Apollo / Consul)
- 集成 trace 系统:把每次
eval_sample的 prompt + response 推到 langsmith / langfuse - 改 metric 输出:把 accuracy + bootstrap_std 扩展为多指标(Faithfulness / Recall / Latency / Cost)
这 6 个改造点的工程量视团队已有基础设施而定(本专栏未做实测统计)。改造完成后你拥有的就是一个"贴合自己业务的内部评测平台"——比直接用 SaaS 评测平台更可控、更省钱(特别是大规模场景)。
9.7 设计哲学与仓库现状
9.7.1 设计哲学的折射:为什么这套抽象能跑这么久
OpenAI evals 仓库首次公开提交是 2023-03-13,此后核心抽象几乎没动过——git log 显示 2023 年有 600 多次提交,2024 年只剩 48 次,2025 全年仅 1 次(即本专栏的 pin 4bfc1f5);截至 2026-09-25,上游此后只有 2026 年 4 月两次固定 CI / pre-commit 依赖版本的维护提交,仓库未归档。一个 LLM 时代的工具能让核心抽象 3 年不大改,本身就值得拆解。
仔细看它的核心抽象只有 3 个名词:
- Eval:评测什么
- CompletionFn:用什么模型评
- Recorder:把结果存哪
这种"3 个名词 cover 所有工程关注点"的设计,是"接口越少越不打扰使用者"的范本。把同样的需求做到 30 个抽象会非常普遍——比如 "EvalConfig"、"EvalRunner"、"EvalScheduler"、"EvalReporter"、"GraderConfig"、"GraderRunner" 等等——但 OpenAI 的工程师选择把这一切都压进 3 个抽象,靠子类化扩展。这种"抽象克制"在快速演化的领域是稀缺的。
对照后续出现的几个"更现代"的评测框架,可以看到这种克制的代价 / 收益:
- DSPy 的评测层:抽象更复杂(Modules / Metrics / Optimizers / Programs),灵活度高但学习成本指数级增长
- Inspect(英国 AI Security Institute,原 AI Safety Institute):抽象数量介于 OpenAI 和 DSPy 之间,但绑定更深(更难独立使用 grader)
OpenAI 的 3 抽象不是"完美",而是"够用 + 可演化"。当 LLM 评测领域剧烈变化时(2023 年 3 月仓库开源时 ragas 的 Faithfulness 等 RAG 指标尚未提出,Arena Hard 到 2024 年才出现),这套朴素抽象能不被颠覆——这就是它的工程胜利。
9.7.2 OpenAI evals 仓库的"半官方"地位与维护现状
OpenAI evals 仓库挂在 OpenAI 官方 GitHub 组织下,但它的工程现状与一般"官方仓库"有微妙差异——值得团队选型时心里有数。
它有官方背书的依据:
- 仓库位于
github.com/openai组织下,README 称其 registry 用于"test different dimensions of OpenAI models" - GPT-4 Technical Report(arXiv:2303.08774)第 4 节宣布开源 OpenAI Evals,称其为"our framework for creating and running benchmarks for evaluating models like GPT-4",致谢名单里也列有 OpenAI Evals 的负责人
- OpenAI 后续模型发布是否仍用这套框架,官方没有公开说明,本专栏不作推断
但它不是"全力维护"的状态(核对日期 2026-09-25):
- 主线代码在 2024 年以后更新频率明显放缓(见 §9.7.1 的提交统计),2025-11-03 之后只有 CI 依赖固定之类的维护提交
- registry 里的部分数据集已经过时
- OpenAI 平台上的托管 Evals(dashboard + Evals API)是另一个产品,与开源仓库不同步;仓库 README 顶部已改为引导用户去 dashboard 配置 evals。该托管平台已于 2026-06-03 宣布弃用,见 §9.7.3
工程团队的实际做法:
- 作为学习材料:OpenAI evals 仓库的代码至今仍是学习"评测框架架构设计"的最优秀范本之一
- 作为基础 fork:fork 后改造,不依赖上游会不会接 PR
- 不作为唯一工具:实际生产评测组合 promptfoo + ragas 比单用 OpenAI evals 更适合 2026 年后的需求
这种"官方组织下、维护放缓"的现象在 LLM 工具生态里并不少见。理解项目背后的人力投入、组织结构、商业化路线,能帮团队避免"押注一个会被弃维护的工具"。
9.7.3 OpenAI evals 与 OpenAI Evals API 的关系澄清
读者在搜 OpenAI evals 时往往会同时搜到两个东西,混淆是常态。
时效说明(核对日期 2026-09-25)
据 OpenAI 官方弃用页(developers.openai.com/api/docs/deprecations),OpenAI 于 2026-06-03 宣布弃用托管的 Evals 平台:2026-10-31 起现有 evals 只读,2026-11-30 Evals dashboard 与 Evals API 计划关停,官方给出的迁移路径是迁到 Promptfoo(第 12 章)。关停的是托管平台;本章剖析的开源仓库 openai/evals(pin 4bfc1f5)是另一回事,截至核对日仍可 clone、未归档,本地自托管运行不受此影响。下表中 Evals API 一列只描述它关停前的形态,新项目不应再以它为落点。
| 维度 | openai/evals 仓库 | platform.openai.com Evals API(已宣布 2026-11-30 关停) |
|---|---|---|
| 性质 | 开源 Python 框架(github) | OpenAI 平台的托管服务(dashboard) |
| 历史 | 2023-03 开源,本章源码分析对象 | 2024-10 DevDay 以 dashboard 形态首发,client.evals.runs.create() 这套 API 到 2025-04 才推出(openai-python 1.72.0);2026-06-03 宣布弃用 |
| 接入 | pip install evals + 命令行 |
OpenAI SDK 的 client.evals.runs.create() |
| 数据存储 | 本地 jsonl + 自建持久化 | OpenAI 后端托管 |
| 是否需要 API key | 看 CompletionFn 配置 | 必须 OpenAI API key |
| 评测目标 | 任意 LLM | 主要是 OpenAI 自家模型 |
| 计费 | 只付模型调用 token | 模型调用 token(官方价格页未单列 Evals 费用) |
| 私有性 | 完全本地 | 数据进入 OpenAI(合规需评估) |
| 何时用 | 学习方法 / 自托管 / 私有数据 | 快速上线 / 团队共享 / 与 fine-tune 联动 |
flowchart TB
subgraph "openai/evals(开源仓库)"
OS1[本地 jsonl 数据] --> OS2[Eval class]
OS2 --> OS3[CompletionFn]
OS3 --> OS4[本地结果 jsonl]
end
subgraph "Evals API(平台服务)"
A1[OpenAI dashboard 上传] --> A2[client.evals.runs.create]
A2 --> A3[OpenAI 内部 grader]
A3 --> A4[dashboard 报表]
end
OS4 -. "手动同步可选" .-> A2
style OS2 fill:#e3f2fd
style A2 fill:#fff3e0
工程实务的两大边界:
- 数据合规线:受监管行业(医疗 / 金融 / 政府)默认用 openai/evals 自托管——评测题不能离境
- 能力天花板:Evals API 自带 model graders、与 fine-tune 强耦合,是 OpenAI 自家模型团队的"全家桶"——但锁定 OpenAI,且托管平台已宣布关停(见上方时效说明)
学完本章 openai/evals 源码后,再看 Evals API 文档会发现两者概念上能对应:data_source_config 描述数据形态(≈ samples jsonl),testing_criteria 里的 grader 负责判分(≈ Eval.eval_sample 里的判分逻辑),run 的 data_source 指定被测模型(≈ CompletionFn)。理解了仓库的三层抽象,再看其他评测产品(UK AISI Inspect / Braintrust / Phoenix Evals)也更容易找到对应物。
工业实务:选型时直接列两条问题——
- 我能让评测数据离境吗?不能 → openai/evals 自托管
- 我会不会想几个月后换模型?会 → openai/evals(不锁定)
在托管 Evals 平台宣布关停之后(2026-06-03 公告),需要托管式体验的团队按官方建议迁到 Promptfoo 或其他平台,不再把 Evals API 作为新项目选项。
9.7.4 模型方与开发者方的工具链:双轨制
OpenAI evals 仓库带出一个有意思的现象——LLM 工具生态正在分化为两条独立轨道:
- 模型方工具链:OpenAI evals / lm-eval-harness——用于训练 / 模型对比 / 学术发布
- 应用方工具链:promptfoo / ragas / langsmith——用于 prompt 工程 / RAG 调参 / 业务评测
两条轨道的工程关注点根本不同:
| 维度 | 模型方工具 | 应用方工具 |
|---|---|---|
| 主要单位 | 模型版本 | prompt / chain |
| 数据集形态 | benchmark(固定) | 业务集(演化) |
| 优化目标 | 模型能力 | 应用质量 |
| 评测频率 | 模型 release 时 | 每次 PR / commit |
| 主要受众 | 研究员 | 工程师 / PM |
工业团队最常踩的坑是用错轨——比如用 OpenAI evals 跑应用层评测(太重)、或用 promptfoo 报告学术 benchmark(不合规)。
正确做法:两轨工具并存,按需选用。模型选型时跑 lm-eval / OpenAI evals,看 MMLU 等学术 benchmark;应用日常评测用 promptfoo / ragas。这种分轨认知能让团队的工程时间花在刀刃上。
9.7.5 OpenAI evals 的"长期未来"——它会消失吗?
读到这里读者会问"维护放缓的 OpenAI evals 还值得学吗?"——下面给出 3 年视角的判断(以下均为本专栏的预测,不代表 OpenAI 官方路线):
flowchart TB P[OpenAI evals 现状<br/>2026-Q2] --> S1[1 年后] S1 --> S2[2 年后] S2 --> S3[3 年后] S1 --> P1["继续是教学经典<br/>但生产用户减少"] S1 --> P2[OpenAI 内部 fork<br/>持续维护] S2 --> P3[公开版本归档<br/>类似 Caffe → PyTorch] S2 --> P4[Inspect / Braintrust<br/>占主导] S3 --> P5[archived<br/>但抽象遗产长存] style P fill:#e3f2fd style P3 fill:#fff3e0 style P5 fill:#e8f5e9
3 个时点的判断(预测):
| 时点 | 状态 | 学习价值 |
|---|---|---|
| 2026-Q2(now) | 维护放缓但仍可用 | ★★★★★ 必学 - 仍是教学经典 |
| 2027 | 大概率"不再活跃但可读" | ★★★★ 抽象层面学习 |
| 2028+ | 可能 archived | ★★★ 仅作历史参考 |
核心判断:OpenAI evals 作为"具体工具"会衰退;但作为"评测方法学的基础抽象"会长期存活——所有继任者(Inspect / Braintrust / DeepEval)都在它的 design space 里演化。
工程实务的 4 条 succession 原则:
- 不要赌单一工具:永远准备 fallback
- 学方法学比学工具更长寿:抽象 > 具体 API
- 跟着活跃的来:lm-eval / ragas / promptfoo / Inspect 是 2026 主力
- 每年评估一次工具栈:可能 2027 又有新工具上位
读 OpenAI evals 不是为了学一个可能被淘汰的工具,而是为了理解评测框架的 design space。
9.8 源码地图与运行机制
9.8.1 OpenAI evals 的"3 类基类"源码地图
OpenAI evals 的内置 task(4bfc1f5 版本下 evals/registry/evals/ 有 463 个 spec YAML,声明了 700 余个可直接调用的 eval 名)几乎都建立在 3 类基类之上。读者在自己实现新 task 前,先要"按图索骥"——选对基类能省掉大量重复工作。下面是 evals/elsuite/ 与 evals/api.py 的核心抽象地图:
| 基类 | 源码位置 | 适合场景 | 核心方法 | 已有派生 |
|---|---|---|---|---|
Eval |
evals/eval.py:46 |
标准点评测(一题一判) | eval_sample(sample, rng) |
basic/match, basic/includes |
ModelBasedClassify(构造参数含 metaeval) |
evals/elsuite/modelgraded/classify.py:14 |
用 LLM 当分类器判分 | eval_sample(test_sample, rng) + run(recorder) |
chinese_idioms、abstract2title 等 61 个 modelgraded spec |
SolverEval |
evals/eval.py:168 |
多 turn / 工具使用类 | eval_sample(solver, sample, rng) |
self_prompting、bluff、twenty_questions |
下面这段是读者示例,不是 openai/evals 的源码原文——它照着 Eval 基类的接口写:构造函数收 completion_fns、eval_sample(sample, rng) 的形参、evals.record.record_match(...) 的关键字参数都与源码对得上。
其中 _normalized_match 是这个示例自带的辅助方法,仓库里没有同名成员;真要做模糊匹配,去看 basic/fuzzy_match 那一支的写法。
# 派生 Eval 的最小骨架(参考 evals/elsuite/basic/match.py:30)
import evals
import evals.metrics
from evals.api import CompletionFn
from typing import Any
class CnRefundPolicyEval(evals.Eval):
"""中文退款政策评测——Eval 派生范例"""
def __init__(self, completion_fns: list[CompletionFn], samples_jsonl: str,
*args, **kwargs):
super().__init__(completion_fns, *args, **kwargs)
assert len(completion_fns) == 1, "只支持单 completion_fn"
self.samples_jsonl = samples_jsonl
def eval_sample(self, sample: Any, rng):
prompt = sample["input"]
expected = sample["ideal"]
result = self.completion_fn(prompt=prompt).get_completions()[0]
match = self._normalized_match(result, expected)
evals.record.record_match(
correct=match,
expected=expected,
picked=result,
sampled=result,
)
def _normalized_match(self, sampled: str, expected: str) -> bool:
return expected.strip() in sampled.strip()
def run(self, recorder):
samples = self.get_samples()
self.eval_all_samples(recorder, samples)
events = recorder.get_events("match")
return {"accuracy": evals.metrics.get_accuracy(events)}
flowchart TB
subgraph "OpenAI evals 抽象层级"
A[Eval 基类<br/>evals/eval.py:46] --> A1[basic/match]
A --> A2[basic/includes]
A --> A3[basic/fuzzy_match]
M[ModelBasedClassify<br/>elsuite/modelgraded/classify.py:14] --> M1[chinese_idioms]
M --> M2[abstract2title]
M --> M3[choice graded 模板]
S[SolverEval<br/>evals/eval.py:168] --> S1[self_prompting]
S --> S2[bluff]
S --> S3[twenty_questions]
end
subgraph "你的自定义 Eval"
NEW[CnRefundPolicyEval] -.派生.-> A
end
style A fill:#e3f2fd
style M fill:#fff3e0
style S fill:#e8f5e9
工程实务 4 条派生选择规则:
- 单题答案 + 一次 LLM 调用 → 派生
Eval(最简单,2 行eval_sample解决) - 需要 LLM 自己分类 / 抽取再打分 → 派生
ModelBasedClassify(自带 modelgraded 模板) - 多 turn 对话 / 工具调用 / Agent → 派生
SolverEval(有 Solver 抽象) - 需要多个 completion_fn 配合(如 challenger vs defender) → 派生
Eval,自己写多 fn 协调逻辑
读源码顺序的建议:
- 第一周:
evals/api.py+evals/eval.py:46+evals/elsuite/basic/match.py——理解最简 Eval 形态 - 第二周:
evals/elsuite/modelgraded/classify.py——理解 ModelBasedClassify 怎么把 LLM 当分类器 - 第三周:
evals/solvers/solver.py+evals/solvers/providers/openai/openai_solver.py——理解 multi-turn
学完这 3 周,你能在 1 小时内自己派生新 task。这正是把 openai/evals 当作"启蒙读本"来读的具体落地——理解 3 个基类 = 掌握 OpenAI evals。
9.8.2 OpenAI evals 的"registry / specs"机制——任务怎么被发现和加载
读 OpenAI evals 源码的最后一道关卡:仓库里数百个 task,是怎么被 oaieval gpt-4o-mini accounting_audit 这样的命令找到的?答案在 evals/registry.py 与 evals/registry/evals/*.yaml。这套机制的本质是任务自描述 + 工厂模式——任何外部团队都能把自己的 task plug-in 进来,无需改主仓库。
下面这段是照着 registry 思路重写的示意实现,不是仓库原文——真身是上面那个文件里的 Registry 类,这里只保留"扫 yaml 目录、按 eval 名建索引、查不到给近似建议"这条主干。
块里的 _load / _suggest / list_evals 都是示意版自取的方法名,openai/evals 仓库里没有同名成员,别照着去源码里找。
# 简化版 registry.py 核心逻辑(实际实现见 evals/registry.py:103 起的 class Registry)
import yaml
from pathlib import Path
class Registry:
"""任务注册表:从 yaml 文件加载所有 task spec"""
def __init__(self, registry_paths: list[Path]):
self.registry_paths = registry_paths
self._eval_specs = {}
self._load()
def _load(self):
for base in self.registry_paths:
for f in base.glob("**/*.yaml"):
spec = yaml.safe_load(f.read_text())
for name, cfg in spec.items():
cfg["__path__"] = str(f)
self._eval_specs[name] = cfg
def get_eval(self, name: str) -> dict:
if name not in self._eval_specs:
raise KeyError(f"Unknown eval: {name}. Did you mean {self._suggest(name)}?")
return self._eval_specs[name]
def _suggest(self, name: str) -> list[str]:
import difflib
return difflib.get_close_matches(name, self._eval_specs.keys(), n=3)
def list_evals(self, prefix: str = None) -> list[str]:
if prefix:
return [n for n in self._eval_specs if n.startswith(prefix)]
return list(self._eval_specs.keys())
任何 task spec yaml 都是"别名条目 + 实现条目"两段式,下面是仓库里 evals/registry/evals/accounting_audit.yaml 的原文:
accounting_audit:
id: accounting_audit.dev.v0
metrics: [accuracy]
accounting_audit.dev.v0:
class: evals.elsuite.basic.match:Match
args:
samples_jsonl: accounting_audit/samples.jsonl
第一段只有 id + metrics,是给人打的短别名;第二段才带 class + args,是真正的实现绑定。Registry.get_eval("accounting_audit") 会沿 id 一路 dereference 到 accounting_audit.dev.v0,oaieval 再经 Registry.get_class(调用点在 evals/cli/oaieval.py:217,内部调 evals/utils/misc.py 的 make_object)按 spec 的 cls 与 args 构造出 Match 实例。
顺带一提:MMLU 在这个仓库里没有叫 mmlu 的总入口,evals/registry/evals/mmlu.yaml 是按学科拆开的 57 个条目(mmlu-abstract-algebra、mmlu-anatomy……),而且用的是 evals.elsuite.multiple_choice:MultipleChoice + hf://hendrycks_test 数据源,而不是 Match + 本地 jsonl。
flowchart TB
CLI[oaieval gpt-4o-mini accounting_audit] --> R[Registry.get_eval]
R --> Y[yaml spec]
Y --> RES{"resolve id"}
RES -->|"accounting_audit → accounting_audit.dev.v0"| SPEC[spec dict]
SPEC --> CLS["make_object('evals.elsuite.basic.match:Match')"]
CLS --> INST[Match 实例化 + 注入 args]
INST --> RUN[eval.run]
RUN --> OUT[结果 jsonl]
style R fill:#e3f2fd
style CLS fill:#fff3e0
style OUT fill:#e8f5e9
工程实务的 4 条插件化经验:
- 新 task 只需 2 个文件:
my_task.yaml+my_task.py(派生自Eval) - 不要修改主仓库 registry:通过
--registry_path参数指定外部 yaml 目录 - id 要带版本号:
my_task.dev.v0比纯my_task好——便于 task 演化时区分 - alias 命名:常用 task 给短别名(
accounting_audit→accounting_audit.dev.v0)减少打字
具体例子:公司内部要新增"客服 RAG 评测",只需在自家 repo 创建:
internal_evals/
├── registry/
│ └── customer_service.yaml # 任务定义
├── customer_service/
│ ├── __init__.py
│ ├── eval.py # CustomerServiceEval 派生类
│ └── samples.jsonl # 评测数据
跑命令:oaieval gpt-4o-mini customer_service --registry_path internal_evals/registry
整套机制让 OpenAI evals 成为"评测的 Linux distribution"——核心仓库提供基础类与公开 task;各团队的私有 task 自由 plug-in,互不干扰。这种"插件式注册表"思路在后来的评测框架里也很常见。
理解了 registry 机制,读者已经掌握 OpenAI evals 的主干源码意图:3 个基类(§9.8.1)+ record 事件流(§9.8.3)+ registry 插件机制。
9.8.3 细节:record_match 的事件流模型
evals/api.py:104 里一行容易被忽略的代码:
record_match(match, expected=expected, picked=picked, sampled=sampled, options=options)
这一行把每条样例的判分结果作为事件写入 recorder。但更深的设计是:record_match 本身只是众多 record_* 函数之一(evals/record.py:593-635 一口气定义了 record_match / record_embedding / record_sampling / record_function_call / record_cond_logp / record_pick_option / record_raw / record_metrics / record_error / record_extra / record_event 共 11 个)。框架层提供"事件追加 + 攒批刷盘"的基础设施(RecorderBase.record_event 在锁内追加事件,攒够条数且间隔足够才调用 _flush_events_internal),每个 Eval 子类只决定"我要记什么事件"。
这种"事件流"模型的工程价值有三:
- 结构化分析能力:所有事件都进同一份 jsonl,可以用 pandas / SQL / DuckDB 任意切片分析
- 失败重放:完整事件流意味着任意一条 trace 都能被重建(replay)
- 跨工具兼容:第三方工具(如 langsmith)可以解析事件流而不需要改 OpenAI evals 的代码
很多评测框架直接把"分数"算完就丢了,缺失这条事件流——结果你拿到一份"75% 通过率"的报告,但永远不知道是哪 25% 失败、为什么失败、怎么修。OpenAI evals 这一处看似不起眼的 record 设计,避免了这个常见陷阱。
9.8.4 OpenAI evals 的"流式 / 批 / async 三模式调度"——大规模评测吞吐优化
先纠正一个常见误解:Eval.eval_all_samples(evals/eval.py:112)默认就是多线程——threads = int(os.environ.get("EVALS_THREADS", "10")),走 ThreadPool(threads).imap_unordered;只有显式设 EVALS_SEQUENTIAL=1 才退回 map 串行。所以调优的第一步不是"从串行改并行",而是把 EVALS_THREADS 调到你的速率上限。但这个线程池仍有天花板:它是同步阻塞 + 单进程,也不会用供应商的批处理接口。在工业场景:CI 触发的评测要 5 分钟内出结果,夜间回归评测要在 8 小时跑完几十万题,紧急 hotfix 验证要 1 分钟内拿到 smoke 信号——下面按规模和时间预算把调度分成三档,线程池之外再配 async 并发和供应商批处理接口。
graph LR
A[评测请求] --> B{规模 + 时间预算}
B --> C[小规模 < 50 题<br/>需要实时 streaming]
B --> D[中规模 100-1000 题<br/>CI 5 min 出结果]
B --> E[大规模 10k+ 题<br/>夜间 batch]
C --> F[streaming 模式<br/>每题完成立即返回]
D --> G[async concurrent 模式<br/>50 并发]
E --> H[batch API 模式<br/>50% 折扣 + 24h SLA]
F & G & H --> I[结果统一回流]
I --> J[evals 标准格式]
3 种模式 × 适用场景 × 性能 × 成本(耗时为示意值,取决于模型延迟与速率上限;batch 的 50% 折扣与 24 小时完成窗口见 OpenAI Batch API 官方文档):
| 模式 | 适用规模 | 1k 题耗时 | 成本相对 | 何时选 |
|---|---|---|---|---|
| streaming | < 100 | 实时(每题秒级) | 1.0x | dev / debug / smoke |
| async concurrent | 100-5000 | 30-60 秒 | 1.0x | CI 主流 |
| batch API | > 5000 | 12-24 小时 | 0.5x | 夜间回归 / 大数据集 |
9.8.5 OpenAI evals 的"task 失败诊断器"——让 oaieval 报错从天书变可读
oaieval 报错是著名的"难懂"——堆栈深 / 嵌套 yaml 路径 / OpenAI API 错误混杂、CompletionFn 异常被层层包装。新人 debug 一个 task 失败往往要花不少时间。下面把常见 5 类错误按特征关键词归类,各给 3 步修法。
graph LR
A[oaieval task 失败] --> B[捕获错误]
B --> C{错误分类}
C --> D[1. registry 错误<br/>找不到 task]
C --> E[2. yaml 解析错误<br/>schema 不对]
C --> F[3. CompletionFn 错<br/>API key / 网络]
C --> G[4. 数据加载错<br/>jsonl 格式]
C --> H[5. metric 计算错<br/>除 0 / 类型错]
D & E & F & G & H --> I[3 行可执行修法]
5 类常见错误 × 错误特征 × 3 行修法:
| 错误类型 | 特征关键词 | 3 行修法 |
|---|---|---|
| registry 错误 | "Eval <name> not found. Available: ..."(evals/cli/oaieval.py:132 的 assert)/ 日志里的 "eval ' |
1)照 assert 打印的 Available 列表或 Closest matches 找正确名字 2)检查 yaml 在 registry path 下 3)修 typo |
| yaml 解析 | "yaml.scanner.ScannerError" / "ValidationError" | 1)yamllint <yaml> 2)对照官方 task 模板 3)补缺字段 |
| CompletionFn | "openai.error" / "RateLimitError" / "AuthenticationError" | 1)检查 OPENAI_API_KEY env 2)检查 base_url 3)retry 或换 fn |
| 数据加载 | "JSONDecodeError" / "KeyError" / 行号 | 1)head -1 <jsonl> | jq . 2)对每条做 schema check 3)修缺字段 |
| metric 计算 | "ZeroDivisionError" / "TypeError" | 1)空 sample 集 → 检查 filter 2)字段 None → 加默认值 3)类型对齐 |
9.9 写一个自定义 task
9.9.1 Fork 出来的代表作:自定义 Eval 类的具体形态
OpenAI evals 仓库被 fork 后最常见的扩展是写自定义 Eval 子类。看一个具体例子——一个简化版的 LLM-as-Judge 评测:
# my_evals/llm_judge.py
import evals
import evals.metrics
from evals.api import CompletionFn
class LLMJudgeEval(evals.Eval):
def __init__(
self,
completion_fns: list[CompletionFn],
samples_jsonl: str,
judge_model: str = "claude-sonnet-5", # 示例值,按官方模型页(2026-09-25)选现行模型
rubric: str = "判断 answer 是否准确回答了 question, 0-10 分",
threshold: float = 7.0,
*args,
**kwargs,
):
super().__init__(completion_fns, *args, **kwargs)
self.samples_jsonl = samples_jsonl
self.judge_model = judge_model
self.rubric = rubric
self.threshold = threshold
def eval_sample(self, sample, *_):
prompt = sample["input"]
response = self.completion_fn(prompt=prompt, temperature=0.0)
answer = response.get_completions()[0]
# judge 调用
judge_prompt = f"""
Question: {prompt}
Answer: {answer}
Rubric: {self.rubric}
Output your reasoning then a final score (0-10) on a new line.
"""
# call_judge / parse_score 需自行实现(调用 judge 模型、从输出里解析分数),仓库里没有这两个函数
judge_response = call_judge(self.judge_model, judge_prompt)
score = parse_score(judge_response)
match = score >= self.threshold
evals.record.record_match(match,
expected=f">= {self.threshold}",
picked=str(score),
sampled=answer)
return match
def run(self, recorder):
samples = self.get_samples()
self.eval_all_samples(recorder, samples)
events = recorder.get_events("match")
return {
"accuracy": evals.metrics.get_accuracy(events),
"boostrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
"judge_model": self.judge_model,
}
不到 50 行的子类,把第 6 章的 LLM-as-Judge 方法学完整接入 OpenAI evals 框架。注册到 yaml:
my-judge-eval.s1.simple-v0:
class: my_evals.llm_judge:LLMJudgeEval
args:
samples_jsonl: customer_support/samples.jsonl
judge_model: claude-sonnet-5
rubric: "判断 answer 是否符合客服规范"
threshold: 7.5
这种"小而美"的扩展模式正是 OpenAI evals 仓库设计哲学的体现——不需要改框架代码、不需要 PR 上游、自己仓库里 50 行就能用。这种"低摩擦扩展性"也是它能在 LLM 评测领域活到 2026 年的工程基石。
9.9.2 完整的 OpenAI evals 自定义 task 示例
整合本章方法学,给一份"自家业务 task 注册"的完整代码 + yaml 示例:
Step 1:写自定义 Eval 类
# my_evals/refund_policy.py
import evals
import evals.metrics
from evals.api import CompletionFn
class RefundPolicyMatch(evals.Eval):
"""评测 chatbot 在退货政策问题上的事实一致性"""
def __init__(
self,
completion_fns: list[CompletionFn],
samples_jsonl: str,
policy_keywords: list[str],
forbidden_keywords: list[str],
*args,
**kwargs,
):
super().__init__(completion_fns, *args, **kwargs)
self.samples_jsonl = samples_jsonl
self.policy_keywords = policy_keywords
self.forbidden_keywords = forbidden_keywords
def eval_sample(self, sample, *_):
prompt = sample["input"]
result = self.completion_fn(prompt=prompt, temperature=0.0)
sampled = result.get_completions()[0]
# 必须包含至少一个政策关键词
has_policy = any(kw in sampled for kw in self.policy_keywords)
# 不能包含违规关键词
no_forbidden = not any(kw in sampled for kw in self.forbidden_keywords)
match = has_policy and no_forbidden
evals.record.record_match(
match,
expected=f"contains: {self.policy_keywords}, not: {self.forbidden_keywords}",
picked=sampled,
sampled=sampled,
)
return match
def run(self, recorder):
samples = self.get_samples()
self.eval_all_samples(recorder, samples)
events = recorder.get_events("match")
return {
"accuracy": evals.metrics.get_accuracy(events),
"boostrap_std": evals.metrics.get_bootstrap_accuracy_std(events),
}
Step 2:注册 yaml
# evals/registry/evals/refund_policy.yaml
refund-policy:
id: refund-policy.s1.v1
description: Customer service refund policy match
metrics: [accuracy]
refund-policy.s1.v1:
class: my_evals.refund_policy:RefundPolicyMatch
args:
samples_jsonl: refund_policy/samples.jsonl
policy_keywords: ["7 天", "14 天", "无理由退货"]
forbidden_keywords: ["先买后报", "终身保修", "免费送货"]
Step 3:跑评测
$ oaieval gpt-4o-mini refund-policy
不到 50 行代码 + 10 行 yaml,完成了一个"客服退货政策"的专项评测。这就是 OpenAI evals 框架"低摩擦扩展"的具体形态。
工业实务:每个业务专项评测都按这种"class + yaml"模式写。半年下来团队的 my_evals/ 目录会积累几十个业务专项 task——这就是评测体系的"工程资产"。
9.9.3 接入内部 LLM Gateway 的完整 CompletionFn
下面是把自家内部 Gateway 适配成 OpenAI evals CompletionFn 的完整代码:
# completion_fns/internal_gateway.py
import os
import requests
import time
from typing import Union
from evals.api import CompletionFn, CompletionResult
from evals.prompt.base import OpenAICreateChatPrompt, OpenAICreatePrompt, Prompt
class InternalGatewayCompletionResult(CompletionResult):
"""实现 CompletionResult 协议"""
def __init__(self, completions: list[str]):
self._completions = completions
def get_completions(self) -> list[str]:
return self._completions
class InternalGatewayCompletionFn(CompletionFn):
"""接入公司内部 LLM Gateway 的 CompletionFn"""
def __init__(
self,
endpoint: str,
model: str,
api_key: str = None,
max_retries: int = 3,
timeout: int = 60,
**kwargs,
):
self.endpoint = endpoint
self.model = model
# registry 的 yaml **不做** ${ENV} 展开(evals/registry.py 里没有任何
# expandvars 逻辑),所以密钥要在这里自己读环境变量,不能写进 yaml
self.api_key = api_key or os.environ["INTERNAL_GATEWAY_KEY"]
self.max_retries = max_retries
self.timeout = timeout
def __call__(
self,
prompt: Union[str, OpenAICreateChatPrompt, Prompt],
**kwargs,
) -> CompletionResult:
# 把不同 prompt 形式统一成 messages
if isinstance(prompt, str):
messages = [{"role": "user", "content": prompt}]
elif isinstance(prompt, list):
messages = prompt
else:
messages = prompt.to_formatted_prompt()
# 调内部 Gateway(OpenAI compatible)
for attempt in range(self.max_retries):
try:
response = requests.post(
f"{self.endpoint}/v1/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": messages,
"temperature": kwargs.get("temperature", 0.0),
"max_tokens": kwargs.get("max_tokens", 500),
},
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
completion = data["choices"][0]["message"]["content"]
return InternalGatewayCompletionResult([completion])
except (requests.RequestException, KeyError) as e:
if attempt == self.max_retries - 1:
raise
time.sleep(2 ** attempt)
raise RuntimeError("Should not reach here")
# evals/registry/completion_fns/internal_gateway.yaml(新建文件,上游没有)
internal-gateway-gpt-4o:
class: completion_fns.internal_gateway:InternalGatewayCompletionFn
args:
endpoint: https://llm-gateway.internal
model: gpt-4o
# 注意:不要在这里写 api_key —— registry 加载 yaml 时不会展开 ${ENV},
# 会把 "${INTERNAL_GATEWAY_KEY}" 当字面字符串传进去
约 60 行 Python + 5 行 yaml 完成内部 Gateway 接入。后续 OpenAI evals 所有 task 都能用这个 CompletionFn 跑:
$ oaieval internal-gateway-gpt-4o my-eval-task
工业实务:把上面这个 Gateway 适配器作为自家 evals 仓库的核心组件(示例放在仓库根目录的 completion_fns 包里,模块名 internal_gateway,yaml 里的 class 据此写成 completion_fns.internal_gateway:InternalGatewayCompletionFn;上游 openai/evals 没有这个模块,是你要自己写的)。所有 evals 调用都过这个 Gateway——统一鉴权 / 限流 / 审计 / 多 provider 切换。这是企业级 evals 体系的标准基础设施。
9.9.4 "openai/evals 实战 onboarding"——新人 1 周上手路径
下面给出一份 5 天 onboarding 计划,目标是让新人在 1 周内能独立派生新 task。
flowchart LR
D1[Day 1<br/>环境 + 跑 mmlu] --> D2[Day 2<br/>读 basic/match]
D2 --> D3[Day 3<br/>读 modelgraded]
D3 --> D4[Day 4<br/>派生第 1 个 task]
D4 --> D5[Day 5<br/>接入团队 LLM Gateway]
D5 --> CHECK{自检 5 题}
CHECK -->|全过| READY[独立做 evals]
CHECK -->|2+ 错| RT[回看薄弱章节]
style D5 fill:#e8f5e9
style READY fill:#e8f5e9
day_1:
morning:
- 安装 evals 仓库 + 配 OPENAI_API_KEY
- 跑 oaieval gpt-4o-mini test-match(仓库 docs/run-evals.md 给的最简任务;文档原文用 gpt-3.5-turbo,该型号按 OpenAI 弃用页 2026-10-23 下线)
- 看 /tmp/evallogs/ 输出 jsonl
afternoon:
- 读 evals/api.py + README 链接的 cookbook 入门文章(Getting Started with OpenAI Evals)
- 做练习:把 test-match 某条样例的 ideal 改一个字符,看 fail
outcome: 跑通 + 理解 jsonl 是怎么生成
day_2:
morning:
- 读 evals/elsuite/basic/match.py 全部代码
- 理解 Eval.eval_all_samples → eval_sample 的执行栈(evals/eval.py:112 与 :77)
afternoon:
- 读 evals/registry/evals/test-basic.yaml(`test-match` / `test-includes` / `test-fuzzy-match` 都定义在这个文件里)
- 改其中一个测试题,重跑看 accuracy 变化
outcome: 理解"task → spec → class → eval_sample"路径
day_3:
morning:
- 读 evals/elsuite/modelgraded/classify.py
- 理解 LLM-as-judge 在 evals 框架下怎么实现
afternoon:
- 读对应 yaml + evals/registry/modelgraded/ 下的 modelgraded spec
- 做练习:让 modelgraded 用本团队的"客服评分 prompt"
outcome: 能用现成 modelgraded 框架接入自家 prompt
day_4:
morning:
- 派生 CustomEval 派生类(参考 §9.8.1 的骨架)
- 写一个 50 题的 samples.jsonl
afternoon:
- 写对应 yaml spec
- 跑 oaieval gpt-4o-mini customeval --registry_path ./internal_evals
outcome: 第一份完整自定义 task
day_5:
morning:
- 写 CompletionFn 派生类接入团队 LLM Gateway(§9.9.3 例子)
- 测试:能用自家 model 跑 evals
afternoon:
- 把所有产物提 PR review
- 接入团队 CI 跑该 task(Quality Gate 检查器见 §18.10.1)
outcome: 端到端流程闭环
self_check_5_questions:
- "OpenAI evals 的 3 个核心抽象是什么?"
- "completion_fn 的接口签名?"
- "yaml spec 中 class 字段的作用?"
- "registry_path 的查找顺序?"
- "多个子任务复合一个 group 的方法?"
工程实务的 4 个 onboarding 优化经验:
- Day 1 不读源码,先跑通——先建立"框架能用"的信心,再深入
- Day 4 的"自己派生"是分水岭——若卡 4 小时以上 → 回看 §9.8.1 基类源码
- Day 5 接团队 LLM Gateway 是真实价值起点——不接团队系统的 onboarding 没落地
- 5 题自检 ≥ 4 对:能独立做 evals;< 3 对:回炉 2-3 天重学
9.10 工程纪律:隔离、代码与数据
9.10.1 工程纪律:评测框架与生产代码必须强隔离
读完 OpenAI evals 源码后还有一条正文没展开的工程纪律值得强调——评测代码必须独立成包、独立成进程、独立成 dependency。
具体含义:
- 评测代码不进生产 Python 包(如
requirements.txt不引入evals) - 评测进程独立运行(CI / 本地 dev,不与生产同进程)
- 评测的 LLM 调用走专门 API key(与生产 key 区隔,方便成本归集和限流)
为什么这么严格?因为评测代码会大量调用 grader / judge / 模拟流量——一旦混入生产代码路径,意外触发评测可能导致严重的副作用(重复扣费、生产数据被当成评测样例污染等)。OpenAI evals 的"独立 CLI + 独立 dataset"设计就是这个纪律的体现。
9.10.2 评测框架的"代码与数据分离"是软件工程的老智慧
OpenAI evals 的"代码 + 数据 + 注册"三层分离,与软件工程领域的老智慧高度同构:
- MVC 模式:代码(M/C)和数据(V)分离
- 配置即代码(Configuration as Code):把参数写在 yaml 而非 hardcoded
- 数据驱动测试(Data-Driven Testing):测试逻辑和测试样例分离
这种设计的工程红利:
- 复用最大化:一个
Match类能挂上几百个不同 task 的 yaml - 修改风险低:改 yaml 不改代码,不会引入逻辑 bug
- 审查门槛低:PR review 改的是 yaml 而非 Python,PM / 数据工程师都能 review
- 历史可追:yaml 进 git,每条评测题的版本变更都可追溯
工业团队 fork OpenAI evals 时,最关键的纪律就是保持这种分离。常见的反例:team 为了"方便"把数据集嵌入 Python 代码 → 半年后想改一条样例必须改代码 → review 流程激增 → 最后大家避而远之。
这条纪律不只适用于评测——所有"代码 + 内容"的系统(CMS、模板引擎、规则引擎)都应该遵循。OpenAI evals 给我们的不只是评测框架知识,而是一份"如何设计可演化系统"的工程范本。
9.10.3 关于"评测代码与生产代码同源"的工程争议
工程团队 fork OpenAI evals 时常遇到一个争议——评测代码是否应该与生产代码共享同一份 LLM 调用层?
支持同源的论点:
- 避免"评测时跑 v1,生产时跑 v2"的 silent divergence
- 同一份 retry / fallback / monitoring 逻辑
- 工程维护成本低
反对同源的论点:
- 评测调用与生产调用的 API key / quota 应分离(避免评测撑爆生产配额)
- 评测可能调试用 mock / fake provider,与生产不兼容
- 评测的并发模式(高并发跑数据集)与生产(每用户 1-2 调用)差异大
工业团队的妥协:
- 抽象层共享(CompletionFn 协议、retry / log 等基础组件)
- Provider 实例分离(评测有自己的 API key、自己的 rate limit、自己的 mock 选项)
这就是 OpenAI evals 的 CompletionFn Protocol 设计的精妙之处——接口同源、实现分离。生产的 OpenAIChatCompletionFn 和评测的 OpenAIChatCompletionFn 是两个不同的 instance,但都符合同一接口,可以无缝替换。
这种"接口共享、实现独立"的工程哲学,远比"完全同源"或"完全分离"更有韧性。fork 改造时建议保留这种边界清晰度。
9.10.4 补充观察:OpenAI evals 的"测试金字塔"映射
软件工程的"测试金字塔"(70% 单测 + 20% 集成测 + 10% E2E)能直接映射到 OpenAI evals 体系:
graph TD E2E[E2E 评测<br/>10%<br/>完整应用流] --> Int[集成评测<br/>20%<br/>子模块组合] Int --> Unit[单元评测<br/>70%<br/>单个 grader / metric] style Unit fill:#dcfce7 style Int fill:#fef3c7 style E2E fill:#fee2e2
具体含义:
- 70% 单元评测:单个 metric 在小样本上的快速跑(< 1 分钟)—— 类似 unit test
- 20% 集成评测:多个 metric 组合 / 子模块的端到端跑 —— 类似 integration test
- 10% E2E 评测:完整业务场景的全流程 —— 类似 E2E test
OpenAI evals 的 Eval 抽象支持所有这三类——只是同一个抽象,按 sample 规模和判分复杂度自适应。
工程意义:评测体系按"金字塔"分配资源,而非"啥都堆 E2E"。多写单元评测让快速迭代成为可能;少量 E2E 验证整体——这种比例分配让评测体系既快又可信。
9.11 与其他框架的对照
9.11.1 OpenAI evals 与 UK AISI Inspect 的对照
英国 AI Safety Institute(后更名 AI Security Institute,AISI)在 2024 年 5 月开源了 Inspect 框架——注意它是英国政府机构的项目,不是 Anthropic 的产品。它定位类似 OpenAI evals 但更偏 safety / alignment 评测。两者对比:
| 维度 | OpenAI evals | UK AISI Inspect |
|---|---|---|
| 推出时间 | 2023 年初 | 2024 年中 |
| 主要场景 | 通用 LLM 评测 | safety / alignment 专项 |
| 设计哲学 | 简单 + 可扩展 | 复杂 + 完整 |
| 核心抽象 | Eval / CompletionFn / Recorder(3 个) | Task / Solver / Scorer / Sandbox(4+ 个) |
| 抽象数量 | 少 | 多 |
| 学习曲线 | 低 | 中 |
| 适合 | 学术 + 应用层 | 安全研究 / 合规审计 |
工程团队的实操:日常评测用 OpenAI evals 风格;做安全 / alignment 专项研究时学习 Inspect 的设计思路。两者不必二选一——可以借鉴 Inspect 的 Sandbox 概念加进自家 fork。
第 16 章讨论的 Agent 安全评测可以用 Inspect 的工具更高效——它专为这类场景优化。
9.11.2 OpenAI evals 与 UK AISI Inspect 的源码级对照
OpenAI evals 与 UK AISI Inspect 是 LLM 评测框架的"两大设计哲学"——读懂两者源码差异能让读者掌握"评测框架设计的整个 design space"。下面给出关键源码对照:
| 维度 | OpenAI evals | UK AISI Inspect |
|---|---|---|
| 核心抽象 | Eval class 派生 |
@task decorator 函数式 |
| 任务定义 | yaml + Python class | 纯 Python 文件 |
| 评分逻辑 | eval_sample() 方法 |
Scorer 类 + solver |
| 数据格式 | jsonl + class 解析 | dataclass + native Python |
| LLM 调用 | CompletionFn 抽象 |
Model + generate() |
| 多步评测 | 派生 ModelBasedClassify / SolverEval |
solver chain(map / chain / tool_use) |
| 安全评测 | 第三方扩展 | 一等公民 |
| logging | jsonl 文件 | structured + Web UI |
| 并发 | asyncio + ThreadPool | asyncio native |
# OpenAI evals 风格(最简自定义 task;另需一份 yaml 注册,见 §9.9.2)
import evals
from evals.api import CompletionFn
class CnIntentEval(evals.Eval):
def __init__(self, completion_fns, samples_jsonl, *args, **kwargs):
super().__init__(completion_fns, *args, **kwargs)
self.samples_jsonl = samples_jsonl
def eval_sample(self, sample, rng):
prompt = f"分类意图:{sample['input']}"
result = self.completion_fn(prompt=prompt).get_completions()[0]
evals.record.record_match(
correct=(result.strip() == sample['ideal']),
expected=sample['ideal'],
picked=result,
)
def run(self, recorder):
samples = self.get_samples()
self.eval_all_samples(recorder, samples)
events = recorder.get_events("match")
return {"accuracy": evals.metrics.get_accuracy(events)}
# Inspect 风格(同等功能,task 定义即注册)
from inspect_ai import task, Task
from inspect_ai.dataset import json_dataset, Sample
from inspect_ai.solver import generate, system_message
from inspect_ai.scorer import answer, includes
@task
def cn_intent():
return Task(
dataset=json_dataset(
"samples.jsonl",
sample_fields=lambda r: Sample(
input=f"分类意图:{r['input']}",
target=r['ideal'],
),
),
solver=[
system_message("你是中文意图分类员"),
generate(),
],
scorer=includes(), # 内置 scorer
)
flowchart LR
subgraph "OpenAI evals 心智"
OE[Eval class] --> ES[eval_sample 方法]
ES --> CF[CompletionFn 调用]
CF --> RC[record_match]
RC --> JL[jsonl 文件]
end
subgraph "Inspect 心智"
AT["@task 函数"] --> TASK[Task 对象]
TASK --> DS[Dataset → Sample]
TASK --> SOL[Solver chain]
TASK --> SC[Scorer 内置或自定义]
SOL --> GEN[generate]
SC --> WEB[Web UI 实时]
end
OE -. "2023 范本" .-> INFLU[影响整个领域]
AT -. "2024 革新" .-> INFLU
style OE fill:#e3f2fd
style AT fill:#fff3e0
工程实务的 4 类设计哲学差异:
- OpenAI = OOP:派生 class、override 方法、注重继承层次
- Inspect = FP:函数 + decorator + chain,少有继承
- OpenAI 重 jsonl artifact:方便外部分析工具消费
- Inspect 重 web UI:评测体验优先,结果直接可视
什么时候选哪个:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 想学评测方法学 | OpenAI | 源码经典 / 教材级 |
| 工业生产 / 上手快 | Inspect | 不需要额外的 yaml 注册 |
| 多框架兼容 / 大企业 | OpenAI(其抽象更"通用") | 更易适配旧代码 |
| 安全评测优先 | Inspect | 一等公民支持 |
| 跑公开 leaderboard | 两者皆可 | OpenAI 更老但 Inspect 兼容 |
9.11.3 OpenAI evals 的"现代替代方案"对照
OpenAI evals 已经 3 年了。2024-2026 年涌现了一批"现代替代方案",给读者一份对照:
| 项目 | 设计取向 | 主要场景 | 与 OpenAI evals 关系 |
|---|---|---|---|
| Inspect (UK AISI) | safety / alignment 评测 | 高合规 / AI Safety 研究 | 借鉴 + 扩展 |
| DSPy Evaluate | 与 DSPy 程序绑定 | DSPy 用户 | 互补 |
| Ax(Meta) | 自适应实验 / 贝叶斯优化 | 参数调优与 A/B 测试 | 不同维度 |
| Helicone | 集成在 LLM proxy | 应用层 | 层级不同 |
| Phoenix Evals | OTel 原生 | 已有 OTel 栈 | 互补 |
| Braintrust | 商业 SaaS | 企业级 | 商业版替代 |
工业团队的判断:
- 学习评测方法学:仍读 OpenAI evals 源码(最经典)
- 生产应用层评测:promptfoo / ragas / Braintrust(更现代)
- safety 专项:Inspect(专门设计)
- 大规模团队:商业版(Braintrust 等)
OpenAI evals 不是"最佳"工具,但是"最有教学价值"的范本。每位 LLM 评测工程师都该读它的源码——但生产用其他工具。这种"学一套 / 用另一套"是工程师常态。
9.11.4 OpenAI evals 的 5 个易踩坑细节
读完源码后,工业团队 fork / 学习时容易踩的 5 个坑:
temperature=0.0写死的隐含假设:Match类强制 temperature=0,假设你的 grader 期待 deterministic 输出。如果你的业务模型要求 temperature > 0(如创意写作),这套评测会得到误导性结论record_and_check_match不传separator会过于宽松:只靠startswith判定时,"Parisian cuisine ..."也会被算成选了"Paris";传separator才会要求选项后面紧跟空白 / 标点。文档没强调,新手常踩- registry 的 yaml 路径强依赖:
Eval._prefix_registry_path(evals/eval.py:161)先看samples_jsonl是不是一个已存在的文件,不是就拼成<registry_path>/data/<samples_jsonl>——也就是相对 registry 目录下的data/子目录,不是项目根。这种"相对路径不一致"是新人 fork 后跑不通的高频原因 - few_shot 拼接逻辑微妙:
evals/elsuite/basic/match.py:41-44的做法用文字概括是:先取样例 input 去掉最后一条消息,接上前 num_few_shot 条 few-shot 样例,再补回最后一条消息(这是对源码的概括,不是原文代码)——它假设sample["input"]是 chat prompt 列表,不是字符串。好在evals/elsuite/basic/match.py:40有一行assert is_chat_prompt(sample["input"])守着,纯字符串 prompt 会直接 AssertionError 而不是静默算错 - bootstrap_std 的 typo 别随手改:
Match/Includes的run返回的字段名是boostrap_std(少了 't'),上游至今没改。fork 时若改名,会破坏依赖这个字段名的下游脚本和历史报表
这些细节写在源码里但不在文档里——读源码的隐藏价值之一就是发现这种"不可改的 typo"和"未文档化的契约"。fork 改造时要么保留这些怪异,要么显式 break 并升级 major version。
9.12 在 release CI 与团队中的位置
9.12.1 OpenAI evals 与"模型 release CI"的工程图谱
GPT-4 Technical Report 把 OpenAI Evals 描述为评测 GPT-4 这类模型、并可跟踪部署中模型表现的框架;它在 OpenAI 内部 release 流程里的具体位置官方没有公开。下面这张图是本专栏按通用做法画的"模型 release CI"示意,用来说明这类框架在更大工程图谱里的位置:
flowchart TB
Train[模型训练] --> Eval1[OpenAI evals 跑学术 benchmark]
Eval1 --> Card[Model Card]
Eval1 --> RT[红队评测]
RT --> Decision{是否 ready?}
Card --> Decision
Decision -->|是| Beta[Beta 内测]
Decision -->|否| Train
Beta --> Online[Online eval 收 trace]
Online --> Card
style Eval1 fill:#dbeafe
style Card fill:#fef3c7
这种"训练 → 评测 → model card → 内测 → 在线评测 → 反馈"的闭环,是头部 LLM 团队的标准工程节奏。OpenAI evals 这类框架在其中承担"标准化离线评测"的角色——保证每个新模型版本都在一致的 benchmark 上跑过。
工业团队 fork 这套架构时建议保留闭环结构:
- 训练后自动跑 OpenAI evals → 生成 internal model card
- 内测前必须有评测报告
- 在线 trace 反哺评测集
这是从"训一个模型上线"到"工业化模型迭代"的工程升级。
9.12.2 OpenAI evals 与"模型 release CI"在 OpenAI 内部的角色还原
仓库公开后我们已知它"长什么样",但它在 OpenAI 内部承担什么角色,官方只有零星说明(README 称 registry 用于测试 OpenAI 模型的不同维度;GPT-4 Technical Report 称其可用于跟踪部署中模型的表现)。下面是本专栏据此做的推测性还原,不是 OpenAI 公开的内部流程,读者可以借它理解源码里的一些设计取舍。
推测的角色分工:
| 角色 | 在 OpenAI 内部职能 | 仓库中的体现 |
|---|---|---|
| Pre-release benchmark | 每次新模型 RC 前跑全套 task | evals/registry/evals/ 下的全部 task spec |
| Regression detection | 与上一版模型分数对比 | oaieval --record_path + 历史 jsonl 对比 |
| Capability matrix | 给产品团队的能力 map | task 按 category 分组 |
| Custom red-team | 内外团队提交对抗 task | open PR mechanism |
| Public-facing benchmark | 对外宣称"通过这些 eval" | OpenAI Model Card 引用 |
flowchart TB
subgraph "OpenAI 内部 release pipeline"
M[新模型 RC] --> R1[全 task evaluation]
R1 --> CMP{vs 上一版?}
CMP -->|涨| RC[发版 candidate]
CMP -->|有 task 跌| BLK[阻塞 / 调查]
BLK --> RT[修 → 重测]
RT --> R1
end
subgraph "公开外部"
PR[community PR<br/>新 task] --> RV[OpenAI 团队 review]
RV --> AC[accepted → 合入 registry]
AC --> CARD[Model Card 引用]
end
RC --> CARD
R1 --> PR
style RC fill:#e8f5e9
style BLK fill:#ffebee
style CARD fill:#fff3e0
按这个推测,可以这样理解源码里 5 个看似奇怪的设计(同样是解读,不是官方说明):
- 为什么 task spec 用 yaml 而非 Python 类? —— 因为社区 PR 提交新 task 时 yaml 阻力最小
- 为什么 record 系统记录 jsonl 而非 SQL? —— 因为内部需要对接多种存储后端,jsonl 是最低公分母
- 为什么没有 web UI? —— 仓库定位是"产生数据",看结果交给外部工具(README 提到可配合 Weights & Biases 使用)
- 为什么 elsuite/ 子目录命名晦涩? —— 仓库文档只称它存放各类 eval 实现与模板,没有解释命名来历,本专栏不作猜测
- 为什么
Eval类的eval_sample接口有rng参数? —— 为了让评测可重现(fix random seed)
工程实务上读 OpenAI evals 的两种"层次":
- 作为评测工具用 —— 读 README 与 cookbook 即可,1 周入门
- 作为评测方法学起源理解 —— 读 §9.8.1 + §9.8.2 + 本节,3 周深入
第二种层次的回报:下次评估任何评测框架(UK AISI Inspect / Braintrust / EleutherAI lm-eval / promptfoo / ragas)时,能看清它继承了 OpenAI evals 哪些抽象、修正了哪些短板。
对照几个后来的框架:
- UK AISI Inspect 把
Eval派生模式简化为taskdecorator —— 解决了"必须写一整个 class"的笨重 - Braintrust 把 jsonl 升级为 web 查询 —— 解决了"无 UI"
- EleutherAI lm-eval(2020 年起步,早于 openai/evals,是并行发展而非继任)把 generate_until / loglikelihood 显式化 —— 对照看正好暴露 openai/evals"output_type 隐含约定"的短板
- ragas 用 Metric × Sample 模式替代 Eval × CompletionFn —— 解决了"task 与 grader 边界模糊"
每个改进都对应 OpenAI evals 里的一个工程"债务"——理解债务才理解为什么继任者那样设计。
9.12.3 OpenAI evals 的"内部 fork 治理"——多团队共用一份框架的工程实践
多团队公司 fork OpenAI evals 时,更可持续的模式不是"一个团队 fork 一份",而是"全公司共用一份内部 fork"。下面是一份"内部 fork 治理"清单,列出 8 个治理决策及各档选项。
graph LR
A[公司决定 fork openai/evals] --> B{治理模式选择}
B -->|无治理| C[各团队各 fork]
B -->|轻治理| D[共享 fork + 各自 branch]
B -->|重治理| E[内部平台化 + 单 branch]
C --> F[3 个月后<br/>5 份不兼容 fork<br/>无法跨团队对比]
D --> G[1 年后<br/>主分支 conflict 累积<br/>升级 upstream 需 1 周]
E --> H[长期可持续<br/>新团队接入 1 天<br/>upstream 同步自动化]
H --> I[配套机制]
I --> J[platform team 维护 main]
I --> K[业务团队走 PR 模式]
I --> L[CI 卡 upstream 兼容]
8 个内部 fork 治理决策:
| # | 决策 | 选项 A(无治理) | 选项 B(轻治理) | 选项 C(重治理) | 推荐 |
|---|---|---|---|---|---|
| G1 | 仓库归属 | 各团队私 fork | 共享 fork + 各 branch | 中央 platform 仓库 | C(>30 工程师) |
| G2 | upstream 同步 | 不同步 | 季度手动 merge | 月度自动 PR | C |
| G3 | 自定义 task 位置 | 自由 | evals/registry/<team>/ |
internal_evals/<team>/ 子目录 |
B/C |
| G4 | CompletionFn 共享 | 各写各的 | 公共 lib | 中央 gateway 唯一入口 | C |
| G5 | CI 兼容性 | 无 | smoke test | 完整回归 | B/C |
| G6 | 评测结果存储 | 本地文件 | S3 共享桶 | 评测数据库 + dashboard | C |
| G7 | 权限模型 | 全员 admin | 团队 owner + 跨团队 review | RBAC + audit log | C(合规场景) |
| G8 | 文档治理 | README | 内部 wiki | 仓库 docs/ + ADR | B/C |
9.13 社区与未来
9.13.1 OpenAI evals 的"贡献者视角"——给开源社区贡献评测的工程实践
读完源码后读者会想给 OpenAI evals 贡献新 task 或 fix。先说现状:上游自 2025 年起几乎没有合入新内容(见 §9.7.1 的提交统计),动手前先确认维护者是否仍在 review 外部 PR。下面的贡献流程对其他开源评测框架同样适用:
flowchart LR IDEA[贡献想法] --> ISS[GitHub Issue 讨论] ISS -->|maintainer 同意| WORK[fork + 写代码] ISS -->|拒绝| EXIT[别浪费时间] WORK --> TEST[单测 + CI 通过] TEST --> PR[提 PR + 详细描述] PR --> REV[等 review] REV -->|approve| MERGE[合并 → 你的 commit 入开源] REV -->|改| ITER[改后再 push] ITER --> REV style EXIT fill:#fff3e0 style MERGE fill:#e8f5e9
工程实务的 4 类贡献回报:
| 贡献类型 | 个人回报 | 公司回报 |
|---|---|---|
| 新 task | GitHub 履历 | 自家 task 内置标准 |
| bug fix | "OSS contributor" 标签 | 你修的 bug 不再痛 |
| 文档 | 入门最快 | 全公司新人受益 |
| 大 feature | 长期影响 | 影响 industry direction |
3 类贡献者新人坑:
| 坑 | 现象 | 修法 |
|---|---|---|
| 跳过 issue 直接 PR | 撞规划 → 拒绝 | 必先 issue |
| PR 描述空 | reviewer 不知 motivation | 必含 why + how |
| 不加单测 | 即使代码对也卡 review | CI 测试覆盖 |
9.13.2 给读者的"延伸阅读"清单
关于 OpenAI evals 的延伸阅读:
- OpenAI evals 仓库本身:
openai/evalsGitHub repo(最权威) - OpenAI Cookbook 中的 evals 部分:从用户视角的使用指南
- GPT-4 Technical Report(arXiv:2303.08774)第 4 节:宣布开源 OpenAI Evals 的原始出处
- "Trustworthy LLMs: a Survey and Guideline for Evaluating Large Language Models' Alignment"(arXiv:2308.05374):早期 LLM 评测综述
- Inspect 框架文档(https://inspect.aisi.org.uk/,UK AISI):与 OpenAI evals 互补的 alignment 评测视角
9.14 跨专栏关联
- 本专栏第 5 章 §5.6 约 90 行的规则判分器,与 OpenAI evals
elsuite/basic/这一系列思路相同 - 本专栏第 11 章 ragas 源码:会展示 ragas 如何用完全不同的抽象(Metric × Sample)实现同样的目标
- 本专栏第 12 章 promptfoo 源码:会对比"YAML-first"哲学与 OpenAI 的"class-first"哲学
- 《RAG 工程与检索系统设计》专栏第 21 章讨论的"半自托管 LLM gateway",对应本章 §9.6 改造点 1
- 《MCP 协议设计与实现》专栏第 17 章 Sampling 协议,可以作为 CompletionFn 的标准化协议层
9.15 本章小结
- OpenAI evals 仓库展示了工业级评测框架的最简核心:Eval / CompletionFn / Recorder 三层抽象,
api.py+base.py+eval.py合计不到 450 行 Match/Includes/JsonMatch/FuzzyMatch是评测体系最常见的四类规则判分,源码加起来不到 300 行但覆盖了大部分基础场景Match/Includes默认输出boostrap_std(字段名原样带 typo),体现了"统计推断意识"刻在框架里- registry 系统(YAML + jsonl + Python class)的解耦让仓库轻松挂载 463 个 spec YAML / 700 余个 eval 名
- OpenAI evals 偏学术,promptfoo 偏应用,ragas 偏 RAG——三家工具是补充关系
- Fork 改造是"自建内部评测平台"的一个低门槛起点,工程量视团队已有基础设施而定
下一章我们看 EleutherAI 的 lm-evaluation-harness——学术评测的事实标准、与 OpenAI evals 完全不同的工程取向。