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

“The best abstractions become invisible after you’ve used them for a while.” —— Hyrum Wright

本章要点

  • OpenAI evals 仓库的整体架构:Eval / CompletionFn / Recorder 三层抽象
  • Match / Includes / JsonMatch / FuzzyMatch 四大基础 grader 的源码逐行解读
  • record_and_check_match 函数:判分结果如何被结构化存储
  • 与 promptfoo / ragas 的设计哲学对比——为什么 OpenAI 这个框架更”学术”
  • 自己 fork 这套框架做内部评测的工程改造要点

9.1 仓库一瞥

本章源码引用基于 openai/evals 仓库的当前主线版本(截至 2026 年 4 月仍在维护)。读者可通过下面命令获取本章引用的完整源码:

git clone https://github.com/openai/evals.git
cd evals

仓库的核心目录结构(去掉非关键文件):

evals/
├── api.py            # CompletionFn / CompletionResult / record_and_check_match
├── base.py           # 各种 *Spec dataclass 定义
├── data.py           # JSONL / CSV 加载工具
├── eval.py           # Eval 基类
├── elsuite/
│   ├── basic/        # 4 个开箱即用 grader
│   │   ├── match.py        # Exact match
│   │   ├── includes.py     # Substring match
│   │   ├── fuzzy_match.py  # 模糊匹配
│   │   └── json_match.py   # JSON 结构匹配
│   ├── ballots/      # 复杂多轮投票评测
│   ├── ...
└── completion_fns/
    └── openai.py     # 官方 OpenAI API 适配器

整个仓库的代码量适中——核心抽象层(api.py + base.py + eval.py)合计不到 300 行,剩下都是各种 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 → OpenAIChatCompletionFncompletion_fns/openai.py
  • Anthropic / Gemini / Llama → 各自的 wrapper 实现 CompletionFn
  • 离线模型 / vLLM → 同样实现这个协议
  • 测试 / mock → DummyCompletionFnapi.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 是无状态的,可以多线程 / 多进程同时跑
  2. 错误隔离:单条样例失败不影响整体——评测 1000 条挂掉 5 条仍能拿到 99.5% 的有效结果

9.2.3 Recorder:判分事件的结构化存储

每次判分调用 record_match(match, expected, picked, sampled, options)(在 evals/record.py 里实现),把判分事件写入结构化日志。这种”判分事件流”模型让后续分析能用 SQL 一样的方式按 category / difficulty / model_version 切片。

api.py:55-105record_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 参数:处理”答案后面有空格 / 标点”的情况——sampled = “Paris.” 也算 startswith “Paris”
  • 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)极简:

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)。OpenAI evals 默认输出 bootstrap CI 而不是简单 mean——这是”统计推断意识”刻在框架里的体现。

9.3.2 Includes:宽松的子串匹配

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:结构化输出的标准做法

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:编辑距离

elsuite/basic/fuzzy_match.py 用 Levenshtein 距离(编辑距离)做模糊匹配。适合”答案大部分对但有错字 / 顺序变化”的情况。但 fuzzy match 的工程隐患是阈值难定——0.8 还是 0.9 的相似度算”对”?这是为什么 fuzzy_match 在工业评测里用得相对少,远不如 LLM-as-Judge。

9.4 注册系统:如何挂上 200+ 个评测

OpenAI evals 仓库的真正威力在于它的 registry 系统——把评测的”代码”和”数据集”分开管理:

代码层:evals/elsuite/basic/match.py(一个通用 Match 类)
数据层:registry/evals/match-french-cities.yaml(具体题集)
注册层: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。OpenAI 内部据公开 model card 提到的有 200+ 个内置 evals。

graph LR
  Y[YAML registry] --> R[EvalRegistry 解析]
  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 evalspromptfooragas
主用户模型研发 / 学术应用工程师RAG 工程师
配置形态YAML + jsonl + Python class单一 YAMLPython 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 个改造点合计 1-2 人月工程量。改造完成后你拥有的就是一个”贴合自己业务的内部评测平台”——比直接用 SaaS 评测平台更可控、更省钱(特别是大规模场景)。

9.6.5 设计哲学的折射:为什么这套抽象能跑这么久

OpenAI evals 仓库 2023 年初公开,到 2026 年初仍在维护。一个 LLM 时代的工具能保持核心抽象 3 年不大改,本身就值得拆解。

仔细看它的核心抽象只有 3 个名词:

  • Eval:评测什么
  • CompletionFn:用什么模型评
  • Recorder:把结果存哪

这种”3 个名词 cover 所有工程关注点”的设计,是 Linus Torvalds 所说”让你看不见的接口才是好接口”的范本。把同样的需求做到 30 个抽象会非常普遍——比如 “EvalConfig”、“EvalRunner”、“EvalScheduler”、“EvalReporter”、“GraderConfig”、“GraderRunner” 等等——但 OpenAI 的工程师选择把这一切都压进 3 个抽象,靠子类化扩展。这种”抽象克制”在快速演化的领域是稀缺的。

对照后续出现的几个”更现代”的评测框架,可以看到这种克制的代价 / 收益:

  • DSPy 的评测层:抽象更复杂(Modules / Metrics / Optimizers / Programs),灵活度高但学习成本指数级增长
  • Inspect (UK AI Safety Institute):抽象数量介于 OpenAI 和 DSPy 之间,但绑定更深(更难独立使用 grader)

OpenAI 的 3 抽象不是”完美”,而是”够用 + 可演化”。当 LLM 评测领域剧烈变化时(2023 年还没有 Faithfulness 这个概念、2024 年才出现 Arena Hard),这套朴素抽象能不被颠覆——这就是它的工程胜利。

9.6.6 一个细节:record_match 的事件流模型

api.py:104 里一行容易被忽略的代码:

record_match(match, expected=expected, picked=picked, sampled=sampled, options=options)

这一行把每条样例的判分结果作为事件写入 recorder。但更深的设计是:record_match 本身只是众多 record_* 函数之一(在 evals/record.py 里还有 record_samplerecord_metric 等)。框架层提供”事件追加 + 异步刷盘”的基础设施,每个 Eval 子类只决定”我要记什么事件”。

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

  1. 结构化分析能力:所有事件都进同一份 jsonl,可以用 pandas / SQL / DuckDB 任意切片分析
  2. 失败重放:完整事件流意味着任意一条 trace 都能被重建(replay)
  3. 跨工具兼容:第三方工具(如 langsmith)可以解析事件流而不需要改 OpenAI evals 的代码

很多评测框架直接把”分数”算完就丢了,缺失这条事件流——结果你拿到一份”75% 通过率”的报告,但永远不知道是哪 25% 失败、为什么失败、怎么修。OpenAI evals 这一处看似不起眼的 record 设计,避免了这个常见陷阱。

9.6.7 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-3-5-sonnet-20241022",
        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.
        """
        judge_response = call_judge(self.judge_model, judge_prompt)
        score = parse_score(judge_response)

        match = score >= self.threshold
        evals.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-3-5-sonnet-20241022
    rubric: "判断 answer 是否符合客服规范"
    threshold: 7.5

这种”小而美”的扩展模式正是 OpenAI evals 仓库设计哲学的体现——不需要改框架代码、不需要 PR 上游、自己仓库里 50 行就能用。这种”低摩擦扩展性”也是它能在 LLM 评测领域活到 2026 年的工程基石。

9.6.8 OpenAI evals 仓库的”半官方”地位与维护现状

OpenAI evals 仓库是 OpenAI 官方在 GitHub 上维护,但它的工程现状与一般”官方仓库”有微妙差异——值得团队选型时心里有数。

它是”半官方”的依据:

  • ⭐ 由 OpenAI 内部工程师在 working hours 维护
  • 📦 出现在多份 OpenAI 官方文档(如 GPT-4 Technical Report 引用)
  • 🔧 GPT-4 / GPT-4o / o1 等模型发布前的内部 benchmark 都在这套框架上跑

但它不是”全官方”,因为:

  • 主线代码在 2024 年以后更新频率明显放缓——OpenAI 内部正在迁移到一套新的、更适合 reasoning 模型的评测框架(基于 Inspect / 内部工具,未完全公开)
  • registry 里的部分数据集已经过时
  • 一些 issue / PR 数月没人响应
  • 商业版的 OpenAI Evals API 是 SaaS 形态、与开源仓库不直接同步

工程团队的实际做法:

  • 作为学习材料:OpenAI evals 仓库的代码至今仍是学习”评测框架架构设计”的最优秀范本之一,本章读完你已经吸收完它的核心智慧
  • 作为基础 fork:fork 后改造,不依赖上游会不会接 PR
  • 不作为唯一工具:实际生产评测组合 promptfoo + ragas 比单用 OpenAI evals 更适合 2026 年后的需求

这种”半官方”现象在 LLM 工具生态里很常见——LangChain 早期的 langchain-experimental、Anthropic 的 claude-cookbook 都是类似情况。理解项目背后的人力投入、组织结构、商业化路线,能帮团队避免”押注一个会被弃维护的工具”。

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

读完 OpenAI evals 源码后还有一条不在书里的工程纪律值得强调——评测代码必须独立成包、独立成进程、独立成 dependency

具体含义:

  1. 评测代码不进生产 Python 包(如 requirements.txt 不引入 evals
  2. 评测进程独立运行(CI / 本地 dev,不与生产同进程)
  3. 评测的 LLM 调用走专门 API key(与生产 key 区隔,方便成本归集和限流)

为什么这么严格?因为评测代码会大量调用 grader / judge / 模拟流量——一旦混入生产代码路径,意外触发评测可能导致严重的副作用(重复扣费、生产数据被当成评测样例污染等)。OpenAI evals 的”独立 CLI + 独立 dataset”设计就是这个纪律的体现。

9.6.10 评测框架的”代码与数据分离”是软件工程的老智慧

OpenAI evals 的”代码 + 数据 + 注册”三层分离,与软件工程领域的老智慧高度同构:

  • MVC 模式:代码(M/C)和数据(V)分离
  • 配置即代码(Configuration as Code):把参数写在 yaml 而非 hardcoded
  • 数据驱动测试(Data-Driven Testing):测试逻辑和测试样例分离

这种设计的工程红利:

  1. 复用最大化:一个 Match 类能挂上 200+ 个不同 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.6.11 模型方与开发者方的工具链:双轨制

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.6.12 OpenAI evals 之后:评测框架的下一步是什么

OpenAI evals 的 3 抽象设计在 2026 年仍然合理,但下一代评测框架已经在演化几个新方向:

  1. Reasoning trace 评测:o1 / DeepSeek-R1 等推理模型输出长 chain-of-thought,传统”看最终答案”评测不够。需要评测中间推理步骤是否合理(已有 Inspect 等框架)
  2. Agent / Tool eval:第 14 章详述
  3. 多模态评测:图文混合 / 视频理解 / 音频对话等多模态评测刚起步
  4. 个性化评测:每个用户对”好回答”的标准不同,评测如何捕捉个体差异
  5. Constitutional Eval:用 constitution 而非 rubric 做更高维度的对齐评测

读完本书的方法学,理解 OpenAI evals 这种第一代框架的核心抽象后,再看这些新方向时不会觉得陌生——它们都是在第一代抽象上的延伸而非颠覆。

9.6.13 OpenAI evals 与”模型 release CI”的工程图谱

OpenAI evals 仓库是 OpenAI 内部”模型 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.6.14 OpenAI evals 的 5 个易踩坑细节

读完源码后,工业团队 fork / 学习时容易踩的 5 个坑:

  1. temperature=0.0 写死的隐含假设Match 类强制 temperature=0,假设你的 grader 期待 deterministic 输出。如果你的业务模型要求 temperature > 0(如创意写作),这套评测会得到误导性结论
  2. record_match 需要 separator 时容易遗漏:sampled = “Paris.” 应该匹配 “Paris”,但如果不传 separator 参数会判错。文档没强调,新手常踩
  3. registry 的 yaml 路径强依赖:yaml 里的 samples_jsonl 路径相对于 registry 目录,不是项目根。这种”相对路径不一致”是新人 fork 后跑不通的高频原因
  4. few_shot 拼接逻辑微妙prompt = sample["input"][:-1] + few_shot + sample["input"][-1:]——它假设 sample["input"] 是 chat prompt 列表,不是字符串。对纯字符串 prompt 这段会出错
  5. bootstrap_std 的 typo 不能改run 方法返回的字段名是 boostrap_std(少了 ‘t’),是仓库历史 typo。改了会破坏所有依赖此 API 的下游工具,所以保留至今

这些细节写在源码里但不在文档里——读源码的隐藏价值之一就是发现这种”不可改的 typo”和”未文档化的契约”。fork 改造时要么保留这些怪异、要么显式 break 并升级 major version。

9.6.15 OpenAI evals 给中国团队的特殊适配建议

国内团队 fork 这套框架时还有几个特殊适配点:

  • API 中转:默认 OpenAIChatCompletionFn 走官方 endpoint,国内访问不稳定。需要把 base_url 替换成中转或自家 LLM Gateway
  • HuggingFace 数据集:很多 task 的 dataset 在 HF 上,国内访问慢。建议自托管镜像(如 ModelScope)或建内部 dataset 仓库
  • 中文 task:仓库默认 task 中文较少,需要补 CMMLU / C-Eval / SuperCLUE / AGIEval 等中文 benchmark 的 yaml 适配
  • 付费支付:OpenAI API 不支持国内信用卡,需要走第三方代付或自家 LLM Gateway 统一鉴权

这些适配大多是工程问题不是设计问题——把 base_url、dataset 镜像、支付通道搞定,OpenAI evals 的整个抽象框架完全可用。国内团队 fork 后的版本通常叫 “internal-evals”,是大厂 LLM 团队的标配资产。

9.6.16 关于”评测代码与生产代码同源”的工程争议

工程团队 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.6.17 OpenAI evals 给国内大模型团队的特殊价值

国内大模型公司(百度文心、阿里通义、字节豆包、月之暗面 Kimi、智谱 GLM、DeepSeek 等)大多采用类似 OpenAI evals 的内部评测体系。其工程价值集中在三点:

  1. 学术 benchmark 报数对标:国内模型对外发布性能数据时与 GPT-4 / Claude 对标,需要在同一框架下跑出可比数字。OpenAI evals + lm-eval 是事实标准
  2. 内部模型迭代基准:每个 model checkpoint 上跑相同评测集,数字直接可比对,决定 release 哪一版
  3. 快速验证 fine-tune 效果:对开源模型做 fine-tune 后,跑一遍 evals 看是否真的提升

国内团队的特殊适配(参见 §9.6.15):

  • LLM Gateway 集成(统一鉴权 / 限流 / 多 provider 切换)
  • HuggingFace 数据集镜像(ModelScope)
  • 中文 task 自建(CMMLU / C-Eval / SuperCLUE / AGIEval 的 yaml 适配)
  • 内部审计 trail(评测 run 关联到具体工程师 / commit)

完整内化 OpenAI evals 框架 + 4 项适配,约需 1-2 人月工程量。这是国内头部 LLM 团队的标配资产——也是评测工程师的入门必读。

9.6.18 OpenAI evals 与 Anthropic Inspect 的对照

Anthropic 在 2024 年推出 Inspect 框架(与 UK AI Safety Institute 合作),定位类似 OpenAI evals 但更偏 safety / alignment 评测。两者对比:

维度OpenAI evalsAnthropic 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.6.19 OpenAI evals 仓库的”长期投资视角”

回顾 OpenAI evals 仓库 2023 → 2026 的 3 年演化,给工程师一个长期视角的启示:

好的开源工具的特征

  • 核心抽象稳定(Eval / CompletionFn / Recorder 3 年不变)
  • 扩展点丰富(自定义 Eval 子类 + yaml 注册 + provider 接入)
  • 文档与源码同步
  • 社区活跃

容易消亡的工具特征

  • 频繁 break change
  • 抽象过度复杂
  • 依赖大公司单一团队维护
  • 文档与代码不同步

OpenAI evals 在前者上做得不错(虽然维护放缓),后者的反例在 LLM 工具生态里也不少见——许多 2023 年明星项目到 2026 年已经无人维护。

工程团队的教训:选评测工具时不只看当下功能,更看长期可持续性。一个”功能完整但 6 个月后没人维护”的工具远不如”功能朴素但持续迭代 5 年”的工具。

判断信号:

  • GitHub commit frequency
  • issue 响应速度
  • 维护者多样性(不是单人项目)
  • release 节奏(稳定 vs 不稳定)

读完源码后再看仓库的 commit history 和 issue list,能给工具选型一个长期视角的判断。

9.6.20 一个不显眼的 OpenAI evals 价值:作为 LLM 工程师的”启蒙读本”

最后讨论一个非工程的价值——OpenAI evals 是 LLM 工程师入行的最好”读本”之一

它的源码量级合适(核心 < 500 行),抽象设计经典(3 抽象示范),文档相对清晰,覆盖了:

  • LLM 接口设计(CompletionFn Protocol)
  • 数据驱动测试(registry yaml)
  • Pluggable grader(Eval 子类化)
  • 统计推断(bootstrap_std)
  • 可复现性(temperature=0 / seed 控制)

每一个都是 LLM 工程的核心概念。读懂这套源码 = 理解 LLM 评测工程的 80% 核心知识。

工程师入行的推荐路径:

  1. 读 OpenAI evals 源码(1-2 周)
  2. fork 它跑通自己的 50 题评测(1 周)
  3. 加 1 个自定义 Eval 子类(如 LLMJudgeEval,参见 §9.6.7)
  4. 接入 LangSmith / Langfuse 看 trace
  5. 上 promptfoo 体验 YAML-first 工程范式

走完 5 步约 1-2 个月,就具备了 LLM 评测工程师的核心能力。本书是配合这条学习路径的”地图”——理论方法学 + 源码深度 + 工程实战的组合。

9.6.21 一个补充观察: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.6.22 OpenAI evals 写作的”工程语言学”启示

最后一个非常元的观察——读 OpenAI evals 源码能看到几条”工程语言学”的最佳实践:

  1. 类名即文档CompletionFnRecorderEval——一看就知道职责,不需要长 docstring
  2. 方法名即用法get_samples(), record_match(), run(recorder)——一看就知道怎么调
  3. 参数名即语义samples_jsonl, max_tokens, num_few_shot——参数自带类型 + 用途
  4. 错误信息即引导assert "input" in sample, "sample must have an 'input' key"——出错时直接告诉怎么修

这种”代码自解释”的工程写法,让仓库的学习曲线极低——读者不需要先看一遍长文档再开始写代码。

工业团队的代码风格借鉴:评测代码作为”工具型代码”,特别需要这种自解释。变量名 / 函数名 / 错误信息上多花 5-10% 时间,能让团队 10 倍效率使用代码。这是 OpenAI evals 给读者的”软价值”——除了评测方法学之外的工程素养启示。

9.6.23 OpenAI evals 的”代码考古”价值

读完源码后还有一个隐性价值——通过 commit history 看评测领域的演化

仓库的 git log 能看到这些里程碑:

  • 2023-03:仓库公开,初版 3 抽象设计
  • 2023-06:开始接入更多模型(不只 OpenAI)
  • 2023-09:引入 Solver 概念(介于 Eval 和 CompletionFn 之间)
  • 2024-01:开始添加 reasoning task(GSM8K 等)
  • 2024-06:引入 Inspect 风格的子集
  • 2024-12:维护频率明显放缓

这种”代码考古”让读者看到评测领域不是一蹴而就的——OpenAI 团队也在边做边学。每个 commit 都是当时工程师对”什么是好的评测框架”的理解的物化。

工业团队的实操:fork 自家评测框架时,建议保留完整的 commit history(不要 squash 重写)。3-5 年后回头看自己 fork 的演化,能学到很多关于”评测体系如何在团队里成长”的经验。这种”自家代码考古”是工程团队成熟度的标志。

9.6.24 一份给读者的”延伸阅读”清单

读完本章后,关于 OpenAI evals 的延伸阅读:

  • OpenAI evals 仓库本身openai/evals GitHub repo(最权威)
  • OpenAI Cookbook 中的 evals 部分:从用户视角的使用指南
  • GPT-4 Technical Report Section 3:模型方对评测的工程描述
  • “Evaluation by Prompting” 论文(arXiv:2308.05374):早期 LLM 评测综述
  • Inspect 框架文档(aisi.gov.uk):与 OpenAI evals 互补的 alignment 评测视角

每条延伸阅读都对应不同维度的深入。读完本章 + 这 5 个资源 = 对”通用 LLM 评测框架”领域的系统性掌握。

工业团队的实务:把这份延伸阅读列表贴到团队 wiki 上,作为”LLM 评测工程师的入职阅读清单”。新人按这个 reading list 走 2 周,就能上手做评测体系建设。

9.6.25 关于 OpenAI evals 的最后一个思考

读完整章 OpenAI evals 源码与方法学后,最后一个跨章节的思考——为什么这套源码值得花一章细读

工程师能从中带走的有 4 层价值:

第 1 层:评测框架的具体设计

知道 Eval / CompletionFn / Recorder 这 3 抽象,知道 yaml 注册系统怎么工作。这是最显的知识。

第 2 层:评测领域的工程范式

理解”代码与数据分离”、“接口与实现分离”等设计原则在评测领域的落地。这种范式级理解能跨工具迁移——读懂 OpenAI evals 后,看 promptfoo / ragas 的源码会更快。

第 3 层:开源工程的”代码语言学”

学会”类名即文档 / 方法名即用法 / 错误信息即引导”的写作风格。这种素养超出评测领域,在所有工程项目中都受用。

第 4 层:LLM 评测领域的历史脉络

通过仓库 commit history 看评测方法学如何随时间演化——从 2023 年简单 Eval 到 2026 年 reasoning trace、Agent 等新维度。这种历史视角让工程师理解”为什么是现在这样”。

读源码的价值不只是看代码本身,是从代码里提取这 4 层认知。本章试图把 4 层都讲清楚——所以它不是 OpenAI evals 的”使用指南”,是”通过 OpenAI evals 学评测工程”的认知地图。这也是为什么我们花一整章而非几页篇幅介绍它。

9.6.26 OpenAI evals 的”哲学遗产”

最后讨论 OpenAI evals 给整个 LLM 评测领域的”哲学遗产”——它影响了 promptfoo / ragas / langsmith 等所有后续工具。

遗产 1:评测是”配置驱动”而非”代码驱动”

OpenAI evals 用 yaml + jsonl 把”评测”变成数据。这种思路被所有后续评测工具继承——promptfoo 是 yaml-first、ragas 是 dataset-first、langsmith 是 dataset+experiment。

遗产 2:评测必须可复现

temperature=0 写死、bootstrap_std 内置、prompt 模板进 yaml——OpenAI evals 把”可复现”做成默认。这种”可复现性优先”的姿态被业界普遍采纳。

遗产 3:评测系统应该是”开放生态”

OpenAI evals 的 registry + 自定义 Eval 子类的设计,让贡献门槛极低。这种”开放生态”的设计哲学被 promptfoo / ragas 等工具继承——它们都鼓励社区贡献。

理解这 3 条遗产,让你看任何后续评测工具时都能识别”它继承了 OpenAI evals 的什么、改进了什么”。这种”溯源式理解”是工具学习的最高形态。

9.6.27 OpenAI evals 的”工程作品”价值

最后一个跨章节的认知——OpenAI evals 不只是工具,是一份”工程作品”

工程作品的特征:

  • 解决一个真实问题
  • 有清晰的设计哲学
  • 经过时间检验仍然 useful
  • 开放给社区改进

OpenAI evals 满足这 4 项。它是 LLM 评测领域的”经典工程作品”——值得每一位从业者花时间深入研究。

读 OpenAI evals 源码的价值不只是”学评测”——更是”学如何写一个被时间检验的工程作品”。这种工程素养超出评测领域,对所有软件工程都有借鉴。

读完本章希望读者带走的最后一个认知:好的工程作品给读者带走的不只是技术知识,更是工程审美。审美是工程师的最高素养——它让你在面对任何技术选择时都能识别”什么是好的”、“为什么这是好的”。

9.6.28 OpenAI evals 给”评测项目”的启示

最后讨论 OpenAI evals 给所有”评测项目”的启示——评测项目应该像产品而非工具

具体含义:

  • 有清晰的目标用户(研究者 / 工程师 / 模型团队)
  • 有明确的设计哲学(简洁 / 可复现 / 开放)
  • 有持续的演化(不是一次发布完事)
  • 有社区文化(鼓励贡献 / 响应 issue)

工程团队搭自家评测体系时,借鉴这种”产品思维”:

  • 把评测体系当作产品,而非内部工具
  • 确定服务的”用户”(团队内的算法 / 应用 / PM)
  • 设计明确的”产品哲学”(如”快速反馈优于全面”)
  • 持续迭代,发布 release notes
  • 鼓励团队 contributors

这种”评测体系即产品”的思维让评测体系更易长期演化、更易跨团队复用。读完本章希望读者带走的最深认知:评测体系是有生命的工程作品,不是死的工具。

9.6.29 OpenAI evals 的”教学价值”再深化

最后再深化一次 OpenAI evals 的教学价值——它不只教评测,更教”如何读源码”。

读源码的能力在工程师职业发展中极为重要。读 OpenAI evals 提供的训练:

  • 从入口找主流程:评测如何 run?追 cli/oaieval.pyEval.run()
  • 从抽象找扩展点:怎么加新 task?看 Eval 子类
  • 从测试看预期行为:每个 grader 的边界条件
  • 从 commit history 看演化:理解为什么这样设计
  • 从 issue / PR 看争议:知道社区在讨论什么

这些读源码技能不限于评测领域——任何成熟的开源项目都能用同样方法学习。

工业实务:把”读 OpenAI evals 源码”作为新人 onboarding 的一项任务(1-2 周)。完成的工程师不只学了评测,更掌握了”读源码”这个职业级技能。这种”学一个技能学到多个能力”的训练 ROI 极高。

读完本章希望读者带走的元认知:好工具不只教技术,还教读代码 / 学习方法。OpenAI evals 是这种”教学型工具”的范本——值得每位工程师认真深入。

9.6.30 OpenAI evals 的”读完里程碑”

读完整章 OpenAI evals 内容后,给读者一份”读完里程碑”清单:

  • 能解释 Eval / CompletionFn / Recorder 三抽象的设计动机
  • 能写自定义 Eval 子类(含 yaml 注册)
  • 能解释为什么 temperature=0.0 写死
  • 能解释 yaml + jsonl 注册系统的工程价值
  • 能从 git history 看 OpenAI evals 演化轨迹
  • 能 fork 改造 + 加自家 LLM Gateway

6 项里程碑全过 = 你已经具备 OpenAI evals 工业级使用 / 改造能力。

读完本章希望读者带走的最朴素行动:今天就 git clone OpenAI evals + 跑一次 quickstart。读源码不能只是”看”,必须”亲手跑”——这是从”读懂”到”会用”的关键一步。

9.6.31 OpenAI evals 的”读完最后一句”

读完整章 OpenAI evals 源码与方法学后,给读者一句话总结:

OpenAI evals 是”评测工程作品”的范本——它教你的不只是怎么做评测,更是怎么做一个能活 5 年以上的工程作品。

这句话覆盖:

  • 抽象稳定(3 抽象 3 年不变)
  • 设计哲学清晰(简洁 / 可复现 / 开放)
  • 社区可贡献
  • 文档可读
  • 历史可追溯

读完本章希望读者带走的最高视角:学评测工具的同时学工程作品哲学。这种”双层学习”让单一章节的价值翻倍——你不只学了 OpenAI evals、更学了如何评判好工程作品。

9.6.32 一份完整的 OpenAI evals 自定义 task 示例

整合本章方法学,给一份”自家业务 task 注册”的完整代码 + yaml 示例:

Step 1:写自定义 Eval 类

# my_evals/refund_policy.py
import evals
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——这就是评测体系的”工程资产”。

读完本章希望读者带走的最具体行动:今天就 fork OpenAI evals + 写一个自家业务的 RefundPolicyMatch 类。30 分钟能上手,是从”读懂”到”会写”的关键转换。

9.6.33 一份接入内部 LLM Gateway 的完整 CompletionFn

整合本章方法学,给一份”自家内部 Gateway 的 OpenAI evals CompletionFn 适配”完整代码:

# completion_fns/internal_gateway.py
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
        self.api_key = api_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: ${INTERNAL_GATEWAY_KEY}

约 60 行 Python + 5 行 yaml 完成内部 Gateway 接入。后续 OpenAI evals 所有 task 都能用这个 CompletionFn 跑:

$ oaieval internal-gateway-gpt-4o my-eval-task

工业实务:把 completion_fns/internal_gateway.py 作为团队 evals 仓库的核心组件。所有 evals 调用都过这个 Gateway——统一鉴权 / 限流 / 审计 / 多 provider 切换。这是企业级 evals 体系的标准基础设施。

9.6.34 OpenAI evals 的”现代替代方案”对照

OpenAI evals 已经 3 年了。2024-2026 年涌现了一批”现代替代方案”,给读者一份对照:

项目设计取向主要场景与 OpenAI evals 关系
Inspect (UK AISI)safety / alignment 评测高合规 / AI Safety 研究借鉴 + 扩展
DSPy Evaluate与 DSPy 程序绑定DSPy 用户互补
Ax (Microsoft)实验管理 + 评测A/B 测试不同维度
Helicone集成在 LLM proxy应用层层级不同
Phoenix EvalsOTel 原生已有 OTel 栈互补
Braintrust商业 SaaS企业级商业版替代

工业团队的判断:

  • 学习评测方法学:仍读 OpenAI evals 源码(最经典)
  • 生产应用层评测:promptfoo / ragas / Braintrust(更现代)
  • safety 专项:Inspect(专门设计)
  • 大规模团队:商业版(Braintrust 等)

OpenAI evals 不是”最佳”工具,但是”最有教学价值”的范本。每位 LLM 评测工程师都该读它的源码——但生产用其他工具。这种”学一套 / 用另一套”是工程师常态。

读完本章希望读者带走的最高视角:好工具不一定流行,流行工具不一定好用。理解 OpenAI evals 的设计哲学比”会用 OpenAI evals”更重要——前者让你能评判任何评测工具的好坏。

9.6.35 OpenAI evals 与 OpenAI Evals API 的关系澄清

读者在搜 OpenAI evals 时往往会同时搜到两个东西,混淆是常态:

维度openai/evals 仓库platform.openai.com Evals API
性质开源 Python 框架(github)OpenAI 平台的托管服务(dashboard)
历史2023-03 开源,本章源码分析对象2024-09 推出,基于内部框架
接入pip install evals + 命令行OpenAI SDK 的 client.evals.runs.create()
数据存储本地 jsonl + 自建持久化OpenAI 后端托管
是否需要 API key看 CompletionFn 配置必须 OpenAI API key
评测目标任意 LLM主要是 OpenAI 自家模型
计费只付模型调用 tokentoken + Evals API 调用费
私有性完全本地数据进入 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 文档会立刻发现:API 的核心抽象(testing_criteria ≈ Eval class、grader ≈ CompletionFn)几乎是仓库代码的”托管化包装”。这是 §9.6.20 “OpenAI evals 是 LLM 工程师启蒙读本”的最直接证明——一旦理解了仓库,所有衍生产品(Anthropic Inspect / Braintrust / Phoenix Evals)都能在 30 分钟内上手。

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

  1. 我能让评测数据离境吗?不能 → openai/evals 自托管
  2. 我会不会想几个月后换模型?会 → openai/evals(不锁定)

两个”是”满足任一就用 openai/evals;都”否”才考虑 Evals API 的便利性。

9.6.36 OpenAI evals 的”3 类抽象基类”源码地图

OpenAI evals 的全部 145 个内置 task 都建立在 3 类抽象基类之上。读者在自己实现新 task 前,先要”按图索骥”——选对基类能省 80% 重复工作。下面是 evals/elsuite/evals/api.py 的核心抽象地图:

基类源码位置适合场景核心方法已有派生
Evalevals/eval.py:62标准点评测(一题一判)eval_sample(sample, rng)basic/match, basic/includes
MetaEvalevals/elsuite/modelgraded/classify.py:38多步分类(先 LLM 抽取再判)eval_sample + classify_subsetbbq, multistep_arithmetic
SolverEvalevals/solvers/eval.py:24多 turn / 工具使用类eval_sample(sample, solver)self_prompt, ballot_chain
# 派生 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:62] --> A1[basic/match]
    A --> A2[basic/includes]
    A --> A3[basic/fuzzy_match]

    M[MetaEval<br/>elsuite/modelgraded] --> M1[bbq]
    M --> M2[multistep_arithmetic]
    M --> M3[choice graded]

    S[SolverEval<br/>solvers/eval.py] --> S1[self_prompt]
    S --> S2[ballot_chain]
    S --> S3[tool_calling]
  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 自己分类 / 抽取再打分 → 派生 MetaEval(自带 modelgraded 模板)
  • 多 turn 对话 / 工具调用 / Agent → 派生 SolverEval(有 Solver 抽象)
  • 需要多个 completion_fn 配合(如 challenger vs defender) → 派生 Eval,自己写多 fn 协调逻辑

读源码顺序的建议:

  1. 第一周api.py + eval.py:62 + basic/match.py——理解最简 Eval 形态
  2. 第二周elsuite/modelgraded/classify.py——理解 MetaEval 怎么把 LLM 当分类器
  3. 第三周solvers/eval.py + solvers/openai_solver.py——理解 multi-turn

学完这 3 周,你能在 1 小时内自己派生新 task。这是 §9.6.20 “启蒙读本”路径的具体落地——理解 3 个基类 = 掌握 OpenAI evals

9.6.37 OpenAI evals 的”registry / specs”机制——任务怎么被发现和加载

读 OpenAI evals 源码的最后一道关卡:仓库里有 145 个 task,是怎么被 oaieval gpt-4 mmlu 这样的命令找到的?答案在 evals/registry.pyevals/registry/evals/*.yaml。这套机制的本质是任务自描述 + 工厂模式——任何外部团队都能把自己的 task plug-in 进来,无需改主仓库。

# 简化版 registry.py 核心逻辑(实际见 evals/registry.py:48-130)
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/mmlu.yaml):

mmlu:
  id: mmlu.dev.v0
  metrics: [accuracy]
  description: "Multi-task Language Understanding test"

mmlu.dev.v0:
  class: evals.elsuite.basic.match:Match
  args:
    samples_jsonl: mmlu/samples.jsonl

# 别名(短指令)
mmlu.test:
  id: mmlu.test.v0
  metrics: [accuracy]

mmlu.test.v0:
  class: evals.elsuite.basic.match:Match
  args:
    samples_jsonl: mmlu/test_samples.jsonl
flowchart TB
  CLI[oaieval gpt-4 mmlu] --> R[Registry.get_eval]
  R --> Y[yaml spec]
  Y --> RES{"resolve id"}
  RES -->|"mmlu → mmlu.dev.v0"| SPEC[spec dict]
  SPEC --> CLS["import_class('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 给短别名(mmlummlu.dev.v0)减少打字

具体例子:公司内部要新增”客服 RAG 评测”,只需在自家 repo 创建:

internal_evals/
├── registry/
│   └── customer_service.yaml      # 任务定义
├── customer_service/
│   ├── __init__.py
│   ├── eval.py                    # CustomerServiceEval 派生类
│   └── samples.jsonl              # 评测数据

跑命令:oaieval gpt-4 customer_service --registry_path internal_evals/registry

整套机制让 OpenAI evals 成为”评测的 Linux distribution”——核心仓库提供基础类与公开 task;各团队的私有 task 自由 plug-in,互不干扰。这是 2023 年主流评测框架(包括 ragas / promptfoo / Inspect)共同采纳的”插件式注册表”模式起点。

理解了 registry 机制,读者已经掌握 OpenAI evals 的全部源码意图:3 个基类(§9.6.36)+ filter pipeline(lm-eval 同款)+ registry 插件机制 = 完整框架。这套抽象在过去 3 年已成为 LLM 评测领域的”事实约定”。

9.6.38 OpenAI evals 与”模型 release CI”在 OpenAI 内部的角色还原

仓库公开后我们已知它”长什么样”,但很少人讨论”它在 OpenAI 内部承担什么角色”。下面把这个语境还原清楚——读者理解后会更明白源码中很多设计取舍。

公开线索(来自 evals 仓库 README、OpenAI Cookbook、Model Spec 文档):

角色在 OpenAI 内部职能仓库中的体现
Pre-release benchmark每次新模型 RC 前跑全套 taskevals/registry/evals/ 下 145 个 task
Regression detection与上一版模型分数对比oaieval --record + 历史 jsonl 对比
Capability matrix给产品团队的能力 maptask 按 category 分组
Custom red-team内外团队提交对抗 taskopen 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? —— 因为 OpenAI 内部用 W&B / 自家系统看结果,仓库只负责”产生数据”
  4. 为什么 elsuite/ 子目录命名晦涩? —— “el” = “evals language”,OpenAI 内部代号,开源时未重命名
  5. 为什么 Eval 类的 eval_sample 接口有 rng 参数? —— 为了让评测可重现(fix random seed)

工程实务上读 OpenAI evals 的两种”层次”:

  • 作为评测工具用 —— 读 README 与 cookbook 即可,1 周入门
  • 作为评测方法学起源理解 —— 读 §9.6.36 + §9.6.37 + 本节,3 周深入

第二种层次的回报:当读者下次评估任何评测框架(Anthropic Inspect / Braintrust / EleutherAI lm-eval / promptfoo / ragas)时,会立即看清”它继承了 OpenAI evals 哪些抽象、修正了哪些短板”——这是评测领域工程师的”X 光视野”。

具体例子:

  • Anthropic Inspect 把 Eval 派生模式简化为 task decorator —— 解决了”必须写一整个 class”的笨重
  • Braintrust 把 jsonl 升级为 web 查询 —— 解决了”无 UI”
  • EleutherAI lm-eval 把 generate_until / loglikelihood 显式化 —— 解决了”output_type 隐含约定”
  • ragas 用 Metric × Sample 模式替代 Eval × CompletionFn —— 解决了”task 与 grader 边界模糊”

每个改进都对应 OpenAI evals 里的一个工程”债务”——理解债务才理解为什么继任者那样设计。这是源码学习的最高境界:从”能用”到”懂为什么”。

9.6.39 一份”openai/evals 实战 onboarding”——新人 1 周上手路径

读到这里读者已经掌握了 OpenAI evals 的源码全貌,但实战上手仍有”知与行”的鸿沟。下面给出一份具体的 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-3.5-turbo basic-includes(最简任务)
    - 看 /tmp/evallogs/ 输出 jsonl
  afternoon:
    - 读 evals/api.py + cookbook 的 Hello World
    - 做练习:把 Hello World 的 expected 改一个字符,看 fail
  outcome: 跑通 + 理解 jsonl 是怎么生成

day_2:
  morning:
    - 读 evals/elsuite/basic/match.py 全部代码
    - 理解 Eval._eval_sample 的执行栈
  afternoon:
    - 读 evals/registry/evals/test-match.yaml
    - 改其中一个测试题,重跑看 accuracy 变化
  outcome: 理解"task → spec → class → eval_sample"路径

day_3:
  morning:
    - 读 evals/elsuite/modelgraded/classify.py
    - 理解 LLM-as-judge 在 evals 框架下怎么实现
  afternoon:
    - 读对应 yaml + ChoiceBasedClassify spec
    - 做练习:让 modelgraded 用本团队的"客服评分 prompt"
  outcome: 能用现成 modelgraded 框架接入自家 prompt

day_4:
  morning:
    - 派生 CustomEval 派生类(参考 §9.6.36 的骨架)
    - 写一个 50 题的 samples.jsonl
  afternoon:
    - 写对应 yaml spec
    - 跑 oaieval --registry_path ./internal_evals customeval
  outcome: 第一份完整自定义 task

day_5:
  morning:
    - 写 CompletionFn 派生类接入团队 LLM Gateway(§9.6.33 例子)
    - 测试:能用自家 model 跑 evals
  afternoon:
    - 把所有产物提 PR review
    - 接入 §18.8.30 团队 CI 跑该 task
  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.6.36 基类源码
  3. Day 5 接团队 LLM Gateway 是真实价值起点——不接团队系统的 onboarding 没落地
  4. 5 题自检 ≥ 4 对:能独立做 evals;< 3 对:回炉 2-3 天重学

具体例子:某团队按此 onboarding,3 名新人平均 6 天独立派生新 task。半年后 3 人共贡献 14 个内部 task —— 比”听别人讲一遍 OpenAI evals 然后凭感觉写”的传统培训路径高 3 倍效率。

研究背景:

  • “70-20-10” learning model(McCall et al. 1988):70% 实操 + 20% 同侪学习 + 10% 课堂——本节路径正符合这个比例
  • Software craftsman apprenticeship 模式给 evals 学习的工程化范本

把这份 5 天计划作为团队 evals onboarding 的标准——新人入职即按此跑。这是把 §9 章源码理论转换为团队战斗力的具体路径。

9.6.40 一份 OpenAI evals 与 Anthropic Inspect 的源码级对照

OpenAI evals 与 Anthropic Inspect 是 LLM 评测框架的”两大设计哲学”——读懂两者源码差异能让读者掌握”评测框架设计的整个 design space”。下面给出关键源码对照:

维度OpenAI evalsAnthropic Inspect
核心抽象Eval class 派生@task decorator 函数式
任务定义yaml + Python class纯 Python 文件
评分逻辑eval_sample() 方法Scorer 类 + solver
数据格式jsonl + class 解析dataclass + native Python
LLM 调用CompletionFn 抽象Model + generate()
多步评测派生 MetaEval / SolverEvalsolver chain(map / chain / tool_use)
安全评测第三方扩展一等公民
loggingjsonl 文件structured + Web UI
并发asyncio + ThreadPoolasyncio native
# OpenAI evals 风格(约 60 行最简自定义 task)
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)}


# Anthropic Inspect 风格(约 30 行同等功能)
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 "Anthropic 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 -. "2022 范本" .-> 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源码经典 / 教材级
工业生产 / 上手快Inspect30 行 vs 60 行
多框架兼容 / 大企业OpenAI(其抽象更”通用”)更易适配旧代码
安全评测优先Inspect一等公民支持
跑公开 leaderboard两者皆可OpenAI 更老但 Inspect 兼容

具体例子:某团队从 OpenAI evals 迁移到 Inspect 4 周后:

  • 代码量从 800 行减到 350 行(class 派生 → decorator)
  • 新 task onboarding 从 5 天 (§9.6.39) 降到 2 天
  • 评测产出有 web UI 替代纯 jsonl,PM / 主管能直接消费
  • 但保留 OpenAI evals 跑公开 mmlu / gsm8k(不重写公开 task)

研究背景:

  • Anthropic 2024-Q1 公开 Inspect framework,并配 paper 说明设计哲学
  • OpenAI evals 自 2024 后维护放缓,社区逐步迁移到 Inspect / Braintrust
  • “decorator vs class” 设计争论是 Python 社区永恒话题(FastAPI vs Flask 也类似)

读者把”两套框架”当作 LLM 评测的”两条思维路径”——理解两者后,遇到任何新评测框架(Braintrust / DeepEval / Phoenix Evals)都能在 30 分钟内识别它属于哪一派、能消化得多快。

9.6.41 OpenAI evals 阅读后的”知识连接”——从仓库到全书的知识图谱

读完本章后读者已经掌握 OpenAI evals 仓库的全部,但单点知识容易”读完忘”。下面给出一份”知识连接图”,把 §9 的 source-level 知识与全书其他章节连接起来:

mindmap
  root((OpenAI evals))
    抽象层
      "Eval class(§9.6.36)"
      "CompletionFn(§9.6.33)"
      "Registry(§9.6.37)"
      "MetaEval / SolverEval"
    与其他评测库
      "lm-eval(§10)"
      "ragas(§11)"
      "promptfoo(§12)"
      "Anthropic Inspect(§9.6.40)"
    评测方法学连接
      "规则判分(§5)"
      "LLM-judge(§6)"
      "元评测(§8)"
      "filter pipeline(§10.7.37)"
    工程实务
      "registry 插件(§9.6.37)"
      "Gateway 接入(§9.6.33)"
      "Onboarding 5 天(§9.6.39)"
      "OpenAI internal CI 角色(§9.6.38)"
    生产线
      "CI gate(§18)"
      "trace 平台(§17)"
      "Quality 监控(§4)"

5 类典型连接路径——读者按这些路径走可深化理解:

关注点起点终点中间桥梁
”如何跑公开 benchmark”§9 OpenAI evals§10 lm-eval-harness§10.7.37 filter pipeline
”如何跑 RAG 评测”§9 Eval 派生§11 ragas Metric§11.7.43 testset_generator
”如何接 CI”§9.6.34 LLM Gateway§18 Quality Gate§12.8.39 promptfoo CI
”如何评 judge”§6 LLM-as-Judge§8 元评测§8.6.36 季度仪式
”如何在线评测”§9 离线 base§17 trace 平台§17.10.36 自动 mining
from dataclasses import dataclass
from typing import Iterable

@dataclass
class KnowledgeNode:
    chapter: str
    section: str
    keyword: str
    upstream: list[str]    # 前置章节
    downstream: list[str]  # 衍生应用

class EvalsBookKnowledgeGraph:
    """全书知识图谱——给读者"复习时按图索骥"用"""

    NODES = [
        KnowledgeNode("§9", "OpenAI evals 抽象", "Eval class",
                       upstream=["§5 规则判分"],
                       downstream=["§10 lm-eval", "§11 ragas",
                                   "§12 promptfoo"]),
        KnowledgeNode("§9.6.36", "3 类基类", "Eval/MetaEval/SolverEval",
                       upstream=["§9.6.37 registry"],
                       downstream=["§14 Agent 评测的派生"]),
        KnowledgeNode("§9.6.37", "Registry 机制", "插件式注册表",
                       upstream=[], downstream=["§14.8.34 sandbox"]),
        KnowledgeNode("§9.6.40", "OpenAI vs Inspect", "OOP vs FP",
                       upstream=["§9.6.36"],
                       downstream=["§12 promptfoo yaml-first"]),
    ]

    def find_path(self, from_concept: str,
                   to_concept: str) -> list[KnowledgeNode]:
        # 简化:BFS 从 from 找到 to
        # 实际实现 略
        return []

    def show_neighbors(self, node_id: str) -> dict:
        node = next((n for n in self.NODES if n.section == node_id), None)
        if not node:
            return {}
        return {
            "node": node.section,
            "concept": node.keyword,
            "前置必读": node.upstream,
            "衍生应用": node.downstream,
        }

工程实务的 4 条复习方法:

  1. 每章读完画 mind map:5 分钟整理本章核心 + 与其他章关联
  2. 遇到问题 → 找 graph 上 3 个最近邻 chapter:避免线性回看
  3. 季度回炉:选 3 个跨章节话题深读(如 “judge → 元评测 → drift watchdog”)
  4. 教别人最快学:让团队新人讲 5 节,自己重新理解

具体使用例子:用户问”我们的 judge κ 突然降”——按 graph:

  • 起点:§6 LLM-as-Judge(最直接)
  • 邻居:§8 元评测、§6.7.2 drift watchdog
  • 深入路径:§6.7.5 judge hacking、§6.7.7 leakage
  • 关联工具:§17 在线 trace 找异常

10 分钟内定位 4 个章节的相关方法。这是评测工程师”快速诊断”能力的工程化。

研究背景:

  • Bloom’s Taxonomy 教育学的 6 层认知目标——本书各章覆盖 remember → create
  • “spaced repetition”(间隔重复)是软件工程长期记忆的认知科学基础
  • Obsidian / Roam Research 的”双向链接”是这种知识图谱思路的工具

读者把这份 mindmap 打印贴墙——半年后回看本书时不必从头读,按图索骥找需要的章节即可。这是把”500 页书”变成”可索引知识库”的最后一步工程化。

9.6.42 OpenAI evals 的”长期未来”——它会消失吗?

读到这里读者会问”维护放缓的 OpenAI evals 还值得学吗?“——下面给出 3 年视角的判断:

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 里演化。

from dataclasses import dataclass

@dataclass
class FrameworkLifecycleAdvice:
    framework: str
    current_status: str
    learn_value: str
    use_in_production: str
    when_to_skip: str

class FrameworkSuccessionAdvisor:
    """评测框架的 succession advice"""

    FRAMEWORKS = [
        FrameworkLifecycleAdvice(
            "OpenAI evals", "维护放缓",
            "高(方法学经典)",
            "新项目谨慎;老项目继续",
            "若团队完全 OpenAI-free 则跳过",
        ),
        FrameworkLifecycleAdvice(
            "Anthropic Inspect", "活跃增长",
            "高(最现代化)",
            "首选",
            "若需要 yaml-first 则用 promptfoo",
        ),
        FrameworkLifecycleAdvice(
            "lm-evaluation-harness", "稳定 + 活跃",
            "高(公开 benchmark 标准)",
            "跑 benchmark 必备",
            "应用层评测不必",
        ),
        FrameworkLifecycleAdvice(
            "ragas", "活跃增长",
            "高(RAG 必学)",
            "RAG 评测首选",
            "纯文本评测不必",
        ),
        FrameworkLifecycleAdvice(
            "promptfoo", "活跃增长",
            "中(应用层友好)",
            "PM / QA 友好场景",
            "深度方法学不够",
        ),
    ]

工程实务的 4 条 succession 原则:

  1. 不要赌单一工具:永远准备 fallback
  2. 学方法学比学工具更长寿:抽象 > 具体 API
  3. 跟着活跃的来:lm-eval / ragas / promptfoo / Inspect 是 2026 主力
  4. 每年评估一次工具栈:可能 2027 又有新工具上位

具体例子:某团队 4 年工具栈演化:

年份主工具次工具备注
2023OpenAI evals---唯一选项
2024OpenAI + ragas+ lm-eval多框架开始
2025ragas + promptfooOpenAI 维护OpenAI 退居 backup
2026ragas + InspectpromptfooOpenAI 仅作教学引用

洞察:4 年内主工具切换 2 次。如果完全押 OpenAI evals 在 2024,2026 就需要大迁移。

研究背景:

  • “Innovator’s Dilemma” (Clayton Christensen 1997) 解释为何老牌工具会被替代
  • ML 工具历史:Caffe → TensorFlow → PyTorch 三代演化
  • “Lindy Effect”:抽象比具体实现长寿——本节核心

读者读 OpenAI evals 不是为了”学一个会被淘汰的工具”——是为了理解评测框架的 design space。这种理解 10 年都不会过时,远超工具本身的寿命。

9.6.43 OpenAI evals 的”贡献者视角”——给开源社区贡献评测的工程实践

读完源码后读者会想给 OpenAI evals 贡献新 task 或 fix——下面给出工程化贡献流程:

from dataclasses import dataclass
from typing import Iterable

@dataclass
class OpenSourceContribution:
    contribution_type: str   # "new_task" / "bug_fix" / "doc" / "feature"
    estimated_loc: int       # 代码行数
    estimated_review_days: int
    success_probability: float
    impact: str

class OpenAIEvalsContributorGuide:
    """开源贡献的工程化建议"""

    CONTRIBUTION_TYPES = {
        "new_task": OpenSourceContribution(
            "new_task", 200, 14, 0.7,
            "中:增加一个评测维度",
        ),
        "bug_fix": OpenSourceContribution(
            "bug_fix", 50, 7, 0.85,
            "高:修复影响所有用户",
        ),
        "doc": OpenSourceContribution(
            "doc", 30, 3, 0.95,
            "低:但门槛低适合新人",
        ),
        "feature": OpenSourceContribution(
            "feature", 500, 30, 0.4,
            "极高:但需 maintainer 同意",
        ),
    }

    BEST_PRACTICES = [
        "1. 先开 issue 讨论方案 → 等 maintainer 回应",
        "2. fork + branch + 写代码 + 加单测",
        "3. PR 描述含 motivation + design + benchmark",
        "4. CI 全绿 + 文档同步",
        "5. 耐心等 review(开源 PR 平均 2-4 周)",
    ]

    def estimate_contribution(self, contribution_type: str) -> dict:
        c = self.CONTRIBUTION_TYPES[contribution_type]
        return {
            "type": c.contribution_type,
            "estimated_loc": c.estimated_loc,
            "estimated_review_days": c.estimated_review_days,
            "success_probability": c.success_probability,
            "impact": c.impact,
            "best_practices": self.BEST_PRACTICES,
        }
flowchart LR
  IDEA[贡献想法] --> ISS[GitHub Issue 讨论]
  ISS -->|maintainer 同意| WORK[fork + 写代码]
  ISS -->|拒绝| EXIT[别浪费时间]

  WORK --> TEST[单测 + CI 通过]
  TEST --> PR[提 PR + 详细描述]
  PR --> REV[等 review 2-4 周]
  REV -->|approve| MERGE[合并 → 你的 commit 入开源]
  REV -->|改| ITER[改后再 push]

  ITER --> REV

  style EXIT fill:#fff3e0
  style MERGE fill:#e8f5e9

工程实务的 4 类贡献回报:

贡献类型个人回报公司回报
新 taskGitHub 履历自家 task 内置标准
bug fix”OSS contributor” 标签你修的 bug 不再痛
文档入门最快全公司新人受益
大 feature长期影响影响 industry direction

具体例子:某团队工程师贡献过的 OpenAI evals PR:

PR 类型LOCreview 时长状态
中文 task 添加18018 天merged
jsonl loader bug125 天merged
README 中文翻译2003 天merged
大 refactor60060 天rejected

洞察:小贡献成功率 95%、大贡献 30%。建议从小贡献开始建立信誉。

3 类贡献者新人坑:

现象修法
跳过 issue 直接 PR撞规划 → 拒绝必先 issue
PR 描述空reviewer 不知 motivation必含 why + how
不加单测即使代码对也卡 reviewCI 测试覆盖

研究背景:

  • The Pragmatic Programmer 和 Cathedral and Bazaar 是开源贡献的方法学
  • “OSS Contribution Best Practices” by GitHub 是官方指南
  • 中国《开源软件治理体系建设指南》2024 给开源贡献的合规框架

读者考虑给 OpenAI evals 贡献时按本节流程——3 个月内能从”新人”变成”信任贡献者”。这是评测工程师”反哺开源生态”的工程化路径。

9.6.44 OpenAI evals 的”内部 fork 治理”——多团队共用一份框架的工程实践

OpenAI evals 在工业界的真实使用模式不是”一个团队 fork 一份”,而是”全公司共用一份内部 fork”。这个 9.6.44 给读者一份”内部 fork 治理”清单——8 个治理决策 + 配套实现,让 50+ 工程师共用同一份评测框架不打架。

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 工程师)
G2upstream 同步不同步季度手动 merge月度自动 PRC
G3自定义 task 位置自由evals/registry/<team>/internal_evals/<team>/ 子目录B/C
G4CompletionFn 共享各写各的公共 lib中央 gateway 唯一入口C
G5CI 兼容性smoke test完整回归B/C
G6评测结果存储本地文件S3 共享桶评测数据库 + dashboardC
G7权限模型全员 admin团队 owner + 跨团队 reviewRBAC + audit logC(合规场景)
G8文档治理README内部 wiki仓库 docs/ + ADRB/C

配套实现:内部 fork 治理审计器

from dataclasses import dataclass
from typing import Literal

GovernanceLevel = Literal["none", "light", "heavy"]

@dataclass
class InternalForkAudit:
    repo_count: int                      # 公司内部 fork 数
    last_upstream_sync_days: int         # 距 upstream 最后同步天数
    custom_tasks_in_central_dir: bool    # 自定义 task 是否在统一目录
    completion_fn_centralized: bool      # CompletionFn 是否中央化
    ci_runs_full_regression: bool        # CI 是否跑完整回归
    results_in_shared_storage: bool      # 结果是否存共享存储
    rbac_enabled: bool                   # 是否启用 RBAC
    has_adr_docs: bool                   # 是否有架构决策记录

    def governance_level(self) -> GovernanceLevel:
        score = sum([
            self.repo_count == 1,
            self.last_upstream_sync_days <= 30,
            self.custom_tasks_in_central_dir,
            self.completion_fn_centralized,
            self.ci_runs_full_regression,
            self.results_in_shared_storage,
            self.rbac_enabled,
            self.has_adr_docs,
        ])
        if score >= 7: return "heavy"
        if score >= 4: return "light"
        return "none"

    def risk_assessment(self) -> list[str]:
        risks = []
        if self.repo_count > 3:
            risks.append(f"内部存在 {self.repo_count} 份 fork——跨团队无法对比")
        if self.last_upstream_sync_days > 90:
            risks.append(f"距 upstream {self.last_upstream_sync_days} 天未同步——技术债累积")
        if not self.completion_fn_centralized:
            risks.append("CompletionFn 未中央化——LLM gateway 治理失控")
        if not self.ci_runs_full_regression:
            risks.append("CI 不跑完整回归——升级 upstream 高风险")
        if not self.rbac_enabled and self.repo_count > 1:
            risks.append("无 RBAC——审计与合规风险")
        return risks

    def suggest_next_action(self) -> str:
        level = self.governance_level()
        if level == "none":
            return "立即立项 platform team 收编各 fork,目标 6 个月达 light"
        if level == "light":
            return "完善 CI 兼容性 + 启用 RBAC + 建 ADR 仓库,目标 12 个月达 heavy"
        return "维持治理水平 + 季度演练 upstream 同步 + 邀请新团队接入"

举例:某 80 人 ML 团队第一次审计 → 5 份 fork、180 天未同步、无 RBAC,等级 = none,5 个 risk 全亮红。立项后 6 个月收敛到 light(1 份共享 fork + 季度同步 + 团队 owner 模型),评测结果跨团队首次可对比。

配套行业研究背景

  • “Inner source”概念 来自 PayPal / SAP / Comcast 联合发起的 InnerSource Commons 2015
  • 大公司开源 fork 治理 来自 Google “monorepo paper” Communications of the ACM 2016
  • ADR (Architecture Decision Record) 来自 Michael Nygard 2011
  • 中国《大型企业开源治理实践指南》2023 给国内场景的合规框架

读者把 InternalForkAudit 接入半年度技术债 review——5 分钟扫描公司评测框架治理水平,量化”无治理→重治理”的演化路径。这是评测工程”组织规模化”的最后一道治理工具。

9.6.45 OpenAI evals 的”业务侧自定义 task 模板生成器”——把 30 分钟新建 task 压到 30 秒

OpenAI evals 的 task 创建流程对工程师友好但对 PM / 业务专家是高墙:要写 jsonl + 写 yaml + 选 EvalClass + 注册 registry,全流程 30 分钟+。这个 9.6.45 给读者一份”task 模板生成器”——输入 4 个业务字段(任务类型 / 数据样本 / 期望判分 / 严格度),自动生成 OpenAI evals 兼容的 task 模板,让业务团队 30 秒新建 task 成为可能。

graph LR
    A[业务专家输入 4 个字段] --> B[模板生成器]
    B --> C[1. 任务类型]
    B --> D[2. 数据样本]
    B --> E[3. 期望判分]
    B --> F[4. 严格度]
    C & D & E & F --> G[模板生成器引擎]
    G --> H[task.jsonl 生成]
    G --> I[task.yaml 生成]
    G --> J[EvalClass 选择]
    G --> K[registry 自动注册]
    H & I & J & K --> L[新 task 30 秒上线]
    L --> M[业务团队直接 PR]

4 类业务任务 × 模板自动选择

业务任务数据样本提示词期望判分自动选择的 EvalClass默认 sample 数
客服 FAQ 准确性(问, 标准答)子串包含basic.Includes50
结构化输出(JSON)(输入, JSON output)schema 校验basic.JsonMatch30
数学 / 计算(题, 数字答案)精确匹配basic.Match100
自由文本质量(输入, 标准答)LLM judgemodelgraded30

配套实现:业务侧 task 模板生成器

import json
import yaml
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

TaskKind = Literal["faq_accuracy", "structured_output", "math_reasoning", "free_text_quality"]
Strictness = Literal["lenient", "moderate", "strict"]

EVAL_CLASS_MAP: dict[TaskKind, str] = {
    "faq_accuracy": "evals.elsuite.basic.includes:Includes",
    "structured_output": "evals.elsuite.basic.json_match:JsonMatch",
    "math_reasoning": "evals.elsuite.basic.match:Match",
    "free_text_quality": "evals.elsuite.modelgraded.classify:ModelBasedClassify",
}

@dataclass
class TaskTemplateGenerator:
    output_dir: str = "./internal_evals/registry"

    def generate_jsonl(self, samples: list[dict], task_name: str) -> Path:
        """每行一个 sample, 兼容 OpenAI evals 格式"""
        path = Path(self.output_dir) / "data" / f"{task_name}.jsonl"
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w") as f:
            for s in samples:
                # 转换为 OpenAI evals chat-completion 格式
                line = {
                    "input": [{"role": "user", "content": s["question"]}],
                    "ideal": s["expected_answer"] if isinstance(s["expected_answer"], str)
                            else json.dumps(s["expected_answer"]),
                }
                f.write(json.dumps(line, ensure_ascii=False) + "\n")
        return path

    def generate_yaml(self, task_name: str, kind: TaskKind, strictness: Strictness,
                     samples_path: Path) -> Path:
        eval_class = EVAL_CLASS_MAP[kind]
        threshold = {"lenient": 0.6, "moderate": 0.75, "strict": 0.9}[strictness]
        spec = {
            task_name: {"id": f"{task_name}.dev.v0", "metrics": ["accuracy"]},
            f"{task_name}.dev.v0": {
                "class": eval_class,
                "args": {"samples_jsonl": str(samples_path)},
            },
        }
        if kind == "free_text_quality":
            spec[f"{task_name}.dev.v0"]["args"]["modelgraded_spec"] = "fact"
        path = Path(self.output_dir) / "evals" / f"{task_name}.yaml"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(yaml.dump(spec, allow_unicode=True))
        return path

    def generate(self, task_name: str, kind: TaskKind, samples: list[dict],
                 strictness: Strictness = "moderate") -> dict:
        # 输入校验
        if len(samples) < 10:
            return {"status": "error", "reason": "至少 10 个 sample 才能上线"}
        if not all("question" in s and "expected_answer" in s for s in samples):
            return {"status": "error", "reason": "每个 sample 必须有 question 和 expected_answer"}

        jsonl_path = self.generate_jsonl(samples, task_name)
        yaml_path = self.generate_yaml(task_name, kind, strictness, jsonl_path)

        return {
            "status": "ok",
            "task_id": f"{task_name}.dev.v0",
            "files_created": [str(jsonl_path), str(yaml_path)],
            "next_step": (
                f"oaieval gpt-4-turbo {task_name} --registry_path {self.output_dir}"
            ),
        }

举例:HR 团队 PM 想新建一个 task 评测”员工 FAQ chatbot”:

  • gen.generate(task_name=“hr_faq_v1”, kind=“faq_accuracy”, samples=[{“question”: “病假怎么请?”, “expected_answer”: “提前 1 天通过 HR 系统”}, …], strictness=“moderate”)
  • 30 秒后产出 hr_faq_v1.jsonl + hr_faq_v1.yaml
  • PM 直接 PR 进 internal_evals/registry/,CI 自动 picks up,oaieval 跑通
  • 全公司 task 数从 12 → 78(6 个月),其中 70% 由非工程师贡献

配套行业研究背景

  • “Low-code eval” 来自 Argilla / Label Studio 的 task generator 模式
  • “Citizen developer” 来自 Microsoft Power Platform 战略
  • “Self-service ML platform” 来自 Uber Michelangelo 2017
  • 中国《人工智能产品低代码开发指南》对模板化有规范

读者把 TaskTemplateGenerator 接入内部 evals 仓库 + 给 PM / QA 团队培训 30 分钟——业务专家从此能独立上 task,把 OpenAI evals 框架”工程师专属工具”升级为”全公司评测能力的承载平台”。这是 §9.6.44 内部 fork 治理之上的”民主化”补丁。

9.6.46 OpenAI evals 的”流式 / 批 / async 三模式调度”——大规模评测吞吐优化

OpenAI evals 默认串行模式跑 1000 题约 30-60 分钟(取决于 LLM 延迟)。在工业场景:CI 触发的评测必须 5 分钟内出结果、夜间回归评测要在 8 小时跑完 50 万题、紧急 hotfix 验证要 1 分钟内拿到 smoke 信号。这个 9.6.46 给读者一份”三模式调度”工程方案——把 oaieval 单进程串行升级为 streaming / batch / 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 种模式 × 适用场景 × 性能 × 成本

模式适用规模1k 题耗时成本相对何时选
streaming< 100实时(每题秒级)1.0xdev / debug / smoke
async concurrent100-500030-60 秒1.0xCI 主流
batch API> 500012-24 小时0.5x夜间回归 / 大数据集

配套实现:三模式调度器

import asyncio
import time
from dataclasses import dataclass, field
from typing import Literal, Callable, AsyncGenerator

ScheduleMode = Literal["streaming", "async_concurrent", "batch_api"]

@dataclass
class EvalTask:
    task_id: str
    input_messages: list[dict]
    expected_output: str | None = None

@dataclass
class EvalResult:
    task_id: str
    output: str
    elapsed_ms: int
    cost_usd: float
    mode: ScheduleMode

@dataclass
class TripleModeScheduler:
    streaming_threshold: int = 100
    async_threshold: int = 5000
    async_concurrency: int = 50
    batch_api_discount: float = 0.5

    def select_mode(self, total_tasks: int,
                    time_budget_seconds: float | None = None) -> ScheduleMode:
        if time_budget_seconds and time_budget_seconds < 60:
            return "async_concurrent"
        if total_tasks < self.streaming_threshold:
            return "streaming"
        if total_tasks < self.async_threshold:
            return "async_concurrent"
        return "batch_api"

    async def run_streaming(self, tasks: list[EvalTask],
                            llm_call: Callable[[list[dict]], str]
                            ) -> AsyncGenerator[EvalResult, None]:
        """逐题完成立即 yield"""
        for t in tasks:
            t0 = time.time()
            output = llm_call(t.input_messages)
            yield EvalResult(
                task_id=t.task_id, output=output,
                elapsed_ms=int((time.time() - t0) * 1000),
                cost_usd=0.005, mode="streaming",
            )

    async def run_async_concurrent(self, tasks: list[EvalTask],
                                    llm_call_async: Callable
                                    ) -> list[EvalResult]:
        """50 并发 async"""
        semaphore = asyncio.Semaphore(self.async_concurrency)

        async def one_task(t: EvalTask) -> EvalResult:
            async with semaphore:
                t0 = time.time()
                output = await llm_call_async(t.input_messages)
                return EvalResult(
                    task_id=t.task_id, output=output,
                    elapsed_ms=int((time.time() - t0) * 1000),
                    cost_usd=0.005, mode="async_concurrent",
                )

        return await asyncio.gather(*(one_task(t) for t in tasks))

    def submit_batch_api(self, tasks: list[EvalTask],
                        batch_submit_fn: Callable[[list[dict]], str]) -> dict:
        """提交 batch API(OpenAI Batch API / Anthropic Message Batches)"""
        batch_payload = [{"custom_id": t.task_id, "messages": t.input_messages}
                         for t in tasks]
        batch_id = batch_submit_fn(batch_payload)
        return {
            "batch_id": batch_id,
            "task_count": len(tasks),
            "expected_completion_hours": 24,
            "estimated_cost_usd_per_call": 0.005 * self.batch_api_discount,
            "total_estimated_cost_usd": len(tasks) * 0.005 * self.batch_api_discount,
        }

    async def execute(self, tasks: list[EvalTask],
                     time_budget_seconds: float | None = None,
                     llm_call: Callable | None = None,
                     llm_call_async: Callable | None = None,
                     batch_submit_fn: Callable | None = None) -> dict:
        mode = self.select_mode(len(tasks), time_budget_seconds)
        if mode == "streaming":
            results = []
            async for r in self.run_streaming(tasks, llm_call):
                results.append(r)
            return {"mode": mode, "results": results}
        if mode == "async_concurrent":
            results = await self.run_async_concurrent(tasks, llm_call_async)
            return {"mode": mode, "results": results}
        return {"mode": mode, **self.submit_batch_api(tasks, batch_submit_fn)}

    def estimate_throughput(self, n_tasks: int) -> dict:
        mode = self.select_mode(n_tasks)
        if mode == "streaming":
            return {"mode": mode, "expected_seconds": n_tasks * 1.5}
        if mode == "async_concurrent":
            return {"mode": mode,
                    "expected_seconds": (n_tasks / self.async_concurrency) * 1.5}
        return {"mode": mode, "expected_hours": 12}

举例:某团队夜间回归 50K 题:

  • mode = batch_api,cost = 50000 × 0.005 × 0.5 = 125vsstreaming125(vs streaming 250)
  • 一夜跑完,第二天 morning report
  • 同一团队中午紧急 hotfix 验证 30 题:mode = streaming,3 分钟内全部完成、每题完成立即更新 dashboard 让工程师边看边判断
  • CI 每 PR 触发 800 题:mode = async_concurrent,800/50 × 1.5 = 24 秒完成(vs 串行 20 分钟),节省 50x 工程师等待时间

配套行业研究背景

  • “OpenAI Batch API” 公开文档 2024(50% 折扣 + 24h SLA)
  • “Anthropic Message Batches” 公开文档 2024
  • “ML eval throughput” 来自 Anyscale “Evaluating LLMs at Scale” 2024
  • 中国《大模型评测算力调度规范》对批 / 流模式有规范

读者把 TripleModeScheduler 接入 oaieval CLI——5 分钟根据规模 + 时间预算自动选模式,把”评测一律串行 30 分钟”升级为”小则秒级 / 中则分钟级 / 大则夜间 batch”的三档调度。这是 OpenAI evals 在大规模工业场景的关键吞吐优化。

9.6.47 OpenAI evals 的”task 失败诊断器”——让 oaieval 报错从天书变可读

oaieval 报错是著名的”难懂”——堆栈深 / 嵌套 yaml 路径 / OpenAI API 错误混杂、CompletionFn 异常被层层包装。新人 debug 一个 task 失败常常 2 小时起步。这个 9.6.47 给读者一份”task 失败诊断器”——把常见 5 类错误从堆栈翻译成「3 行可执行修法」,让团队的 oaieval debug 平均时间从 2 小时压到 5 分钟。

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 行可执行修法]
    I --> J[新人 5 分钟修复]

5 类常见错误 × 错误特征 × 3 行修法

错误类型特征关键词3 行修法
registry 错误”EvalNotFoundError” / “task not found”1)oaieval --list | grep <task> 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)类型对齐

配套实现:oaieval 错误诊断器

import re
import traceback
from dataclasses import dataclass, field
from typing import Literal

ErrorCategory = Literal["registry", "yaml", "completion_fn", "data_loading",
                        "metric_computation", "unknown"]

@dataclass
class ErrorDiagnosis:
    category: ErrorCategory
    matched_keywords: list[str]
    summary: str
    fix_steps: list[str]
    confidence: float

@dataclass
class OAIEvalErrorDiagnostician:
    PATTERN_TABLE: dict[ErrorCategory, list[str]] = field(default_factory=lambda: {
        "registry": [
            r"EvalNotFoundError", r"task .* not found", r"unknown task",
            r"registry path", r"could not find eval",
        ],
        "yaml": [
            r"yaml\.scanner\.ScannerError", r"ValidationError",
            r"missing required field", r"yaml\.parser",
        ],
        "completion_fn": [
            r"openai\.error", r"RateLimitError", r"AuthenticationError",
            r"InvalidRequestError", r"401", r"429", r"OPENAI_API_KEY",
        ],
        "data_loading": [
            r"JSONDecodeError", r"KeyError", r"FileNotFoundError",
            r"line \d+ in .*\.jsonl", r"NoneType.*input",
        ],
        "metric_computation": [
            r"ZeroDivisionError", r"TypeError.*float", r"sample size 0",
            r"empty sample", r"no observations",
        ],
    })

    FIX_TABLE: dict[ErrorCategory, list[str]] = field(default_factory=lambda: {
        "registry": [
            "1) `oaieval --list | grep <task_name>` 确认 task 是否注册",
            "2) 检查 yaml 在 registry_path 下且文件名匹配",
            "3) 修 task name typo / 添加 --registry_path",
        ],
        "yaml": [
            "1) `yamllint <yaml_path>` 检查语法",
            "2) 对照 official task yaml 模板补结构",
            "3) 必填字段:id / class / args.samples_jsonl",
        ],
        "completion_fn": [
            "1) 检查 OPENAI_API_KEY 环境变量",
            "2) 检查 base_url / model_id 拼写 / API 配额",
            "3) 加 retry 或切换备用 CompletionFn (如本地 mock)",
        ],
        "data_loading": [
            "1) `head -1 <jsonl> | jq .` 验证 JSON 合法",
            "2) 每行必须含 input + ideal 字段",
            "3) 检查路径相对位置 / 编码 utf-8",
        ],
        "metric_computation": [
            "1) 检查 filter 条件是否过滤掉所有样本",
            "2) ideal / output 必须非 None",
            "3) 转换类型一致 (str vs int vs json)",
        ],
    })

    def diagnose(self, error_text: str) -> ErrorDiagnosis:
        scores: dict[ErrorCategory, list[str]] = {k: [] for k in self.PATTERN_TABLE}
        for cat, patterns in self.PATTERN_TABLE.items():
            for p in patterns:
                if re.search(p, error_text, re.IGNORECASE):
                    scores[cat].append(p)
        # 选匹配数最多的类别
        best_cat = max(scores.items(), key=lambda x: len(x[1]))
        if not best_cat[1]:
            return ErrorDiagnosis(
                "unknown", [], "未匹配到已知错误模式",
                ["手动 read traceback", "在团队 wiki 搜历史相同错误",
                 "如果新错误模式 — 加入此 diagnostician PATTERN_TABLE"],
                0.0,
            )
        cat = best_cat[0]
        return ErrorDiagnosis(
            category=cat,
            matched_keywords=best_cat[1],
            summary=f"匹配 {cat} 类错误(命中 {len(best_cat[1])} 关键词)",
            fix_steps=self.FIX_TABLE[cat],
            confidence=min(1.0, len(best_cat[1]) / 3),
        )

    def diagnose_traceback(self, exc: Exception) -> ErrorDiagnosis:
        return self.diagnose("\n".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))

    def to_pr_comment(self, diagnosis: ErrorDiagnosis) -> str:
        return (
            f"## 评测失败诊断 — {diagnosis.category}\n"
            f"**置信度**:{diagnosis.confidence:.0%}\n"
            f"**匹配关键词**:{', '.join(diagnosis.matched_keywords)}\n\n"
            f"**3 步修法**:\n" + "\n".join(diagnosis.fix_steps)
        )

举例:CI 上 oaieval task 失败,原始 traceback 50 行:

  • diagnose → category = data_loading(匹配 JSONDecodeError + line 12 in samples.jsonl
  • to_pr_comment 自动发到 PR:「3 步修法 1) head -1 jq . 2) 检查 input/ideal 字段 3) utf-8」
  • 工程师 5 分钟修好(实际是第 12 行 jsonl 漏 ideal 字段),不需 2 小时翻 oaieval 源码
  • 团队半年内 oaieval debug 平均时间从 2h 降到 8min(节省 ≈ 92%)

配套行业研究背景

  • “Error message taxonomy” 来自 Sentry / Bugsnag 错误分类设计
  • “Self-healing CI” 来自 Netflix / Spotify CI / CD 实践
  • “Triage automation” 来自 Datadog incident management 2024
  • 中国《人工智能开发工具错误诊断规范》对自动诊断有规范

读者把 OAIEvalErrorDiagnostician 接入 GitHub Actions——任何 oaieval 失败自动诊断 + PR comment,把 oaieval debug 从「源码翻读」升级为「3 步可执行修法」。这是 OpenAI evals 在工程团队 onboarding 上的关键友好度补丁。

9.6.48 OpenAI evals 的”跨版本兼容性矩阵”——upstream 升级了,自定义 task 还能跑吗

OpenAI evals 仓库本身在持续演化,自定义 fork 在 upstream 更新后常常出现「task 突然不能跑了」、「CompletionFn 接口签名变了」、「registry 字段重命名」等兼容性问题。这个 9.6.48 给读者一份「跨版本兼容性矩阵 + 自动检测」工程方案,让 fork 维护者 5 分钟评估升级影响。

graph LR
    A[upstream 新版] --> B{检测兼容性}
    B --> C[1. EvalClass 接口 diff]
    B --> D[2. CompletionFn signature]
    B --> E[3. registry yaml schema]
    B --> F[4. 内置 metric 列表]
    B --> G[5. CLI 参数变化]
    C & D & E & F & G --> H[兼容性矩阵报告]
    H --> I[红区: breaking]
    H --> J[黄区: deprecation]
    H --> K[绿区: 新增]
    I --> L[必须修 fork]
    J --> M[迁移期 90 天]
    K --> N[可选采用]

5 类升级影响 × 检测信号 × 修复 SLA

影响类型检测信号修复 SLA风险
breaking 接口变abstract method 签名变立即task 跑挂
字段 renameyaml 字段名 / Python attr 改30 天部分 task 异常
新增 abstract method父类多了未实现方法60 天新 task 报错
deprecationwarning 提示但仍可用90 天只是 warn
新功能(向后兼容)新增可选参数自由无影响

配套实现:跨版本兼容性矩阵生成器

import inspect
import re
from dataclasses import dataclass, field
from typing import Literal

CompatibilityImpact = Literal["breaking", "rename", "new_abstract",
                              "deprecation", "additive", "no_change"]

@dataclass
class APIChange:
    api_path: str           # 例 evals.api.CompletionFn.create
    old_signature: str | None
    new_signature: str | None
    impact: CompatibilityImpact
    detected_in_version: str
    fix_sla_days: int

@dataclass
class CompatibilityMatrix:
    fork_version: str
    upstream_version: str
    changes: list[APIChange] = field(default_factory=list)

    SLA_DAYS = {"breaking": 0, "rename": 30, "new_abstract": 60,
                "deprecation": 90, "additive": 999, "no_change": 999}

    def add_change(self, api_path: str, old: str | None, new: str | None,
                   impact: CompatibilityImpact):
        self.changes.append(APIChange(
            api_path=api_path, old_signature=old, new_signature=new,
            impact=impact, detected_in_version=self.upstream_version,
            fix_sla_days=self.SLA_DAYS[impact],
        ))

    def report(self) -> dict:
        from collections import Counter
        c = Counter(ch.impact for ch in self.changes)
        breaking = [ch.api_path for ch in self.changes if ch.impact == "breaking"]
        return {
            "fork_version": self.fork_version,
            "upstream_version": self.upstream_version,
            "total_changes": len(self.changes),
            "by_impact": dict(c),
            "breaking_apis": breaking,
            "must_fix_immediately": len(breaking) > 0,
            "estimated_migration_effort_hours": (
                len(breaking) * 8 + c.get("rename", 0) * 2 + c.get("new_abstract", 0) * 4
            ),
        }

    def upgrade_recommendation(self) -> str:
        rep = self.report()
        if rep["must_fix_immediately"]:
            return f"暂缓升级 — 先修 {len(rep['breaking_apis'])} 个 breaking APIs"
        if rep["by_impact"].get("rename", 0) > 5:
            return "可升但需 30 天迁移期 — 名字 rename 较多"
        return "安全升级 — 仅 deprecation / 新增"

@dataclass
class CompatibilityScanner:
    """简化检测:扫描 fork 与 upstream Python class 签名差异"""

    def detect_signature_changes(self, old_class: type, new_class: type) -> list[dict]:
        old_methods = {n: m for n, m in inspect.getmembers(old_class, inspect.isfunction)}
        new_methods = {n: m for n, m in inspect.getmembers(new_class, inspect.isfunction)}
        changes = []
        for name in old_methods:
            if name not in new_methods:
                changes.append({"api": name, "type": "removed",
                                "impact": "breaking"})
            else:
                old_sig = str(inspect.signature(old_methods[name]))
                new_sig = str(inspect.signature(new_methods[name]))
                if old_sig != new_sig:
                    changes.append({"api": name, "type": "signature_changed",
                                    "old": old_sig, "new": new_sig,
                                    "impact": "breaking"})
        for name in new_methods:
            if name not in old_methods:
                changes.append({"api": name, "type": "added",
                                "impact": "additive"})
        return changes

    def scan_yaml_schema_diff(self, old_yaml_keys: set[str],
                              new_yaml_keys: set[str]) -> list[dict]:
        removed = old_yaml_keys - new_yaml_keys
        added = new_yaml_keys - old_yaml_keys
        return (
            [{"key": k, "type": "removed_field", "impact": "breaking"} for k in removed]
            + [{"key": k, "type": "added_field", "impact": "additive"} for k in added]
        )

举例:fork v0.3 升级到 upstream v0.5:

  • detect 出 7 个变化:2 breaking(CompletionFn.create 签名加新参数 + Eval.eval_sample 改返回类型)/ 3 rename / 1 new_abstract / 1 additive
  • report → must_fix_immediately = True,estimated_migration_effort = 23h
  • recommendation:暂缓升级,先修 2 个 breaking API
  • 团队 1 周内修完后再升 → 平稳过渡,所有 internal task 0 异常

配套行业研究背景

  • “API versioning compatibility” 来自 SemVer 2.0 标准
  • “Python deprecation warnings” 来自 PEP 387
  • “Migration assistant” 来自 Django / Rails 升级工具设计
  • 中国《人工智能开发框架兼容性管理规范》对升级矩阵有规范

读者把 CompatibilityScanner 接入 fork 维护流程——upstream 任何升级先扫一遍兼容性矩阵 + 出 SLA 报告,把”盲目升级 fork 跑挂”升级为”提前 30 天精准 plan”。这是 OpenAI evals fork 长期维护的关键工程化武器。

9.7 跨书关联

  • 本书第 5 章 §5.6 的 200 行规则判分器,正是 OpenAI evals elsuite/basic/ 这一系列的简化版
  • 本书第 11 章 ragas 源码:会展示 ragas 如何用完全不同的抽象(Metric × Sample)实现同样的目标
  • 本书第 12 章 promptfoo 源码:会对比”YAML-first”哲学与 OpenAI 的”class-first”哲学
  • **《Claude Code 工程化》**第 8 章讨论的”统一 LLM gateway”,对应本章 §9.6 改造点 1
  • **《MCP 协议工程》**第 19 章 Sampling 协议,可以作为 CompletionFn 的标准化协议层

9.8 本章小结

  • OpenAI evals 仓库展示了工业级评测框架的最简核心:Eval / CompletionFn / Recorder 三层抽象,不到 300 行代码
  • Match / Includes / JsonMatch / FuzzyMatch 是评测体系最常见的四类规则判分,源码加起来不到 300 行但覆盖了大部分基础场景
  • 默认输出 bootstrap_std 体现了”统计推断意识”刻在框架里
  • registry 系统(YAML + jsonl + Python class)的解耦让仓库轻松挂载 200+ 个评测
  • OpenAI evals 偏学术、promptfoo 偏应用、ragas 偏 RAG——三家工具是补充关系
  • Fork 改造的工程量约 1-2 人月,是”自建内部评测平台”性价比最高的起点

下一章我们看 EleutherAI 的 lm-evaluation-harness——学术评测的事实标准、与 OpenAI evals 完全不同的工程取向。

评论 0