LLM 评测体系

第 9 章 OpenAI evals 源码:模板系统与 Solver 抽象

作者 杨艺韬 · 14,617 字 · 发布于 · 更新于

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):聚合评测整个数据集

这种"分而治之"的设计有两个好处:

  1. 并行化天然支持:eval_sample 按单条样例调用,eval_all_samples 默认用线程池并发执行(见 §9.8.4)
  2. 逐条可追溯:每条样例在自己的 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 做内部使用,关键的改造方向:

  1. 替换 CompletionFn:实现内部 LLM gateway 的 wrapper,统一鉴权、限流、retry
  2. 扩展 Recorder:把判分事件写到内部数据仓库(BigQuery / ClickHouse),而不只是本地文件
  3. 加 LLM-as-Judge 类:OpenAI evals 默认偏 rule-based,要补上 ModelGradedEval 子类,复用第 6 章的 prompt 模板
  4. 改 registry 后端:从本地 yaml 文件改到内部配置中心(Apollo / Consul)
  5. 集成 trace 系统:把每次 eval_sample 的 prompt + response 推到 langsmith / langfuse
  6. 改 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)也更容易找到对应物。

工业实务:选型时直接列两条问题——

  1. 我能让评测数据离境吗?不能 → openai/evals 自托管
  2. 我会不会想几个月后换模型?会 → 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 原则:

  1. 不要赌单一工具:永远准备 fallback
  2. 学方法学比学工具更长寿:抽象 > 具体 API
  3. 跟着活跃的来:lm-eval / ragas / promptfoo / Inspect 是 2026 主力
  4. 每年评估一次工具栈:可能 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 协调逻辑

读源码顺序的建议:

  1. 第一周:evals/api.py + evals/eval.py:46 + evals/elsuite/basic/match.py——理解最简 Eval 形态
  2. 第二周:evals/elsuite/modelgraded/classify.py——理解 ModelBasedClassify 怎么把 LLM 当分类器
  3. 第三周: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 条插件化经验:

  1. 新 task 只需 2 个文件:my_task.yaml + my_task.py(派生自 Eval)
  2. 不要修改主仓库 registry:通过 --registry_path 参数指定外部 yaml 目录
  3. id 要带版本号:my_task.dev.v0 比纯 my_task 好——便于 task 演化时区分
  4. 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 子类只决定"我要记什么事件"。

这种"事件流"模型的工程价值有三:

  1. 结构化分析能力:所有事件都进同一份 jsonl,可以用 pandas / SQL / DuckDB 任意切片分析
  2. 失败重放:完整事件流意味着任意一条 trace 都能被重建(replay)
  3. 跨工具兼容:第三方工具(如 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 '' not found. Closest matches: ..." 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 优化经验:

  1. Day 1 不读源码,先跑通——先建立"框架能用"的信心,再深入
  2. Day 4 的"自己派生"是分水岭——若卡 4 小时以上 → 回看 §9.8.1 基类源码
  3. Day 5 接团队 LLM Gateway 是真实价值起点——不接团队系统的 onboarding 没落地
  4. 5 题自检 ≥ 4 对:能独立做 evals;< 3 对:回炉 2-3 天重学

9.10 工程纪律:隔离、代码与数据

9.10.1 工程纪律:评测框架与生产代码必须强隔离

读完 OpenAI evals 源码后还有一条正文没展开的工程纪律值得强调——评测代码必须独立成包、独立成进程、独立成 dependency。

具体含义:

  1. 评测代码不进生产 Python 包(如 requirements.txt 不引入 evals)
  2. 评测进程独立运行(CI / 本地 dev,不与生产同进程)
  3. 评测的 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):测试逻辑和测试样例分离

这种设计的工程红利:

  1. 复用最大化:一个 Match 类能挂上几百个不同 task 的 yaml
  2. 修改风险低:改 yaml 不改代码,不会引入逻辑 bug
  3. 审查门槛低:PR review 改的是 yaml 而非 Python,PM / 数据工程师都能 review
  4. 历史可追: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 类设计哲学差异:

  1. OpenAI = OOP:派生 class、override 方法、注重继承层次
  2. Inspect = FP:函数 + decorator + chain,少有继承
  3. OpenAI 重 jsonl artifact:方便外部分析工具消费
  4. 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 个坑:

  1. temperature=0.0 写死的隐含假设:Match 类强制 temperature=0,假设你的 grader 期待 deterministic 输出。如果你的业务模型要求 temperature > 0(如创意写作),这套评测会得到误导性结论
  2. record_and_check_match 不传 separator 会过于宽松:只靠 startswith 判定时,"Parisian cuisine ..." 也会被算成选了 "Paris";传 separator 才会要求选项后面紧跟空白 / 标点。文档没强调,新手常踩
  3. registry 的 yaml 路径强依赖:Eval._prefix_registry_path(evals/eval.py:161)先看 samples_jsonl 是不是一个已存在的文件,不是就拼成 <registry_path>/data/<samples_jsonl>——也就是相对 registry 目录下的 data/ 子目录,不是项目根。这种"相对路径不一致"是新人 fork 后跑不通的高频原因
  4. 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 而不是静默算错
  5. 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 个看似奇怪的设计(同样是解读,不是官方说明):

  1. 为什么 task spec 用 yaml 而非 Python 类? —— 因为社区 PR 提交新 task 时 yaml 阻力最小
  2. 为什么 record 系统记录 jsonl 而非 SQL? —— 因为内部需要对接多种存储后端,jsonl 是最低公分母
  3. 为什么没有 web UI? —— 仓库定位是"产生数据",看结果交给外部工具(README 提到可配合 Weights & Biases 使用)
  4. 为什么 elsuite/ 子目录命名晦涩? —— 仓库文档只称它存放各类 eval 实现与模板,没有解释命名来历,本专栏不作猜测
  5. 为什么 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 派生模式简化为 task decorator —— 解决了"必须写一整个 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/evals GitHub 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 完全不同的工程取向。