跳转至

登记:一个 eval 就是数据加一段 YAML

baseline 每加一个题库就新写一个脚本:读文件、拼 prompt、调 API、判对错、算正确率,五件事里有四件跟上一个脚本一模一样。Evals 把判法和汇总写成模板类,题库变成模板的一个参数,名字、类、参数登记在 YAML 里。本篇只读了源码,没有实跑,每节只贴源码模版。

判法写成带参数的类:题库只是一个参数

问题:一个常识问答题库,一个算术题库,判法都是「回答的开头是不是标准答案」。baseline 两份脚本只差一个文件名;第三份题库来了,再抄一遍。

功能:评测模板(项目叫 eval template)。Eval 基类只要求子类实现两个方法:eval_sample 判一条,run 读数据、跑全部样本、汇总。

模版 · evals/eval.py:46-54
class Eval(abc.ABC):
    """
    Evaluation classes generally should override two methods:
    `eval_sample`: Takes in a test sample and a random number generator and
        records the metrics of interest.
    `run`: Takes in a recorder and runs the evaluation. Generally, most `run`
        methods will follow this same pattern: loading the data, calling
        `eval_all_samples`, and aggregating the recorded results.
    """

最常用的模板是 Match。题库路径 samples_jsonl 是构造参数;判一条就是调一次被测接口、比一次开头;汇总是正确率加 bootstrap 标准差:

模版 · evals/elsuite/basic/match.py:30-65
    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"
        assert isinstance(sample["ideal"], str) or isinstance(
            sample["ideal"], list
        ), "sample['ideal'] must be a string or list of strings"

        prompt = sample["input"]
        if self.num_few_shot > 0:
            assert is_chat_prompt(sample["input"]), "few shot requires chat prompt"
            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"],
        )

    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),
        }

题库里的一行长这样:input 是 chat 格式的 messages,few-shot 示例用 name 标成 example_user / example_assistant;标准答案(项目叫 ideal)可以给几个说法:

模版 · evals/registry/data/test_fuzzy_match/samples.jsonl:2
{"input": [{"role": "system", "content": "Answer the following questions as concisely as possible."}, {"role": "system", "content": "What's the capital of France?", "name": "example_user"}, {"role": "system", "content": "Paris", "name": "example_assistant"}, {"role": "system", "content": "What's 2+2?", "name": "example_user"}, {"role": "system", "content": "4", "name": "example_assistant"}, {"role": "user", "content": "What season did derek die in grey's?"}], "ideal": ["Season 11", "11"]}

841 个注册 eval 里,290 个用 Match,174 个用 Includes,38 个用 FuzzyMatch,加起来 502 个没写一行判分代码。

YAML 里一个名字对应一个类和一组参数

问题:模板有了,还得有个地方写明「test-fuzzy-match 用 FuzzyMatch、读这份数据」;不然每个题库还得配一个入口脚本。

功能:YAML 注册。每个条目写 class(模块:类名)和 args,args 原样传给构造函数:

模版 · evals/registry/evals/test-basic.yaml:9-16
test-fuzzy-match:
  id: test-fuzzy-match.s1.simple-v0
  description: Example eval that uses fuzzy matching to score completions.
  metrics: [f1_score]
test-fuzzy-match.s1.simple-v0:
  class: evals.elsuite.basic.fuzzy_match:FuzzyMatch
  args:
    samples_jsonl: test_fuzzy_match/samples.jsonl

加载时,每个条目记下自己的名字、所在文件名(当分组用)和所在的注册目录;class 改名叫 cls。key、group、cls 三个是保留字,用户不许自己写:

模版 · evals/registry.py:287-310
    def _load_registry(self, registry_paths: Sequence[Path], resource_type: str) -> RawRegistry:
        """Load registry from a list of regstry paths and a specific resource type

        Each path includes yaml files which are a dictionary of name -> spec.
        """

        registry: RawRegistry = {}

        for registry_path in registry_paths:
            for name, path, spec in self._load_resources(registry_path, resource_type):
                assert name not in registry, f"duplicate entry: {name} from {path}"
                self._validate_reserved_keywords(spec, name, path)

                spec["key"] = name
                spec["group"] = str(os.path.basename(path).split(".")[0])
                spec["registry_path"] = registry_path

                if "class" in spec:
                    spec["cls"] = spec["class"]
                    del spec["class"]

                registry[name] = spec

        return registry

跑 oaieval gpt-3.5-turbo test-fuzzy-match 时,命令行查到条目,按 cls 导入类,把被测接口、种子、全名、注册目录和 args 一起传进构造函数:

模版 · evals/cli/oaieval.py:217-226
    eval_class = registry.get_class(eval_spec)
    eval: Eval = eval_class(
        completion_fns=completion_fn_instances,
        seed=args.seed,
        name=eval_name,
        eval_registry_path=eval_spec.registry_path,
        registry=registry,
        **extra_eval_params,
    )
    result = eval.run(recorder)

数据路径先当本地文件找,找不到就拼到这个条目所在注册目录的 data/ 下面:

模版 · evals/eval.py:158-165
    def _get_samples_path(self) -> Path:
        return self._prefix_registry_path(self.samples_jsonl)

    def _prefix_registry_path(self, data_path: str) -> Path:
        if os.path.isfile(data_path):
            return Path(data_path)

        return self.eval_registry_path / "data" / data_path

数据路径跟着「条目从哪个注册目录读出来」走,所以私有注册目录的数据放进它自己的 data/ 就行。读文件走 blobfile,云存储的路径式 URL 也能直接填,但公开仓库不收。

三段命名加别名:改了题就升版本

问题:题库里改了几道题的答案,还用原名字跑。新分数和半年前的旧分数放在一起比,其实已经不是同一套题。

功能:全名分三段,<名>.<split>.<版本>。短名是一个只写 id 的条目,指向当前版本,description 和 metrics 也写在短名上,metrics 的第一项是主指标。项目文档1的规矩:

In general, running the same eval name against the same model should always give similar results so that others can reproduce it. Therefore, when you change your eval, you should bump the version.

基类构造时检查名字至少有两段:

模版 · evals/eval.py:65-67
        splits = name.split(".")
        if len(splits) < 2:
            raise ValueError(f"Eval name must at least have <base_eval>.<split>. Got name {name}")

查名字时,别名一路跟下去,直到碰上一个不再指向别处的条目:

模版 · evals/registry.py:168-191
        def get_alias() -> Optional[str]:
            if isinstance(d[name], str):
                return d[name]
            if isinstance(d[name], dict) and "id" in d[name]:
                return d[name]["id"]
            return None

        logger.debug(f"Looking for {name}")
        while True:
            alias = get_alias()

            if alias is None:
                break
            name = alias

        spec = d[name]
        if kwargs:
            spec = copy.deepcopy(spec)
            spec.update(kwargs)

        try:
            return type(**spec)
        except TypeError as e:
            raise TypeError(f"Error while processing {object} '{name}': {e}")

升版本就是新加一个全名条目,再把短名的 id 改过去;旧版本的条目留着,照样能按全名跑。名字拼错时,注册表用 difflib 给出五个最接近的名字。

注册目录叠加:私有题库不进公开仓库

问题:拿公司内部数据做 eval,既不能提交进公开仓库,也不想 fork 一份改。

功能:注册表按一串目录依次读:包内的 registry/、家目录的 ~/.evals,再加上命令行的 --registry_path(可以写多次)。每个目录下按资源类型分子目录:evals/、eval_sets/、completion_fns/、solvers/、modelgraded/。

模版 · evals/registry.py:30-33
DEFAULT_PATHS = [
    Path(__file__).parents[0].resolve() / "registry",
    Path.home() / ".evals",
]
模版 · evals/registry.py:104-108
    def __init__(self, registry_paths: Sequence[Union[str, Path]] = DEFAULT_PATHS):
        self._registry_paths = [Path(p) if isinstance(p, str) else p for p in registry_paths]

    def add_registry_paths(self, paths: Sequence[Union[str, Path]]) -> None:
        self._registry_paths.extend([Path(p) if isinstance(p, str) else p for p in paths])

跨目录重名直接报错,就是上一节 _load_registry 里那行 assert name not in registry。私有目录不能悄悄盖掉内置条目,只能起新名字。被测接口、答题方、打分规格也走同一套注册,私有目录里可以放全套。项目文档2举的例子是把自写的被测接口放在仓库外:~/my_project/completion_fns/my_completion_fn.yaml 里写 class: my_project.my_completion_fn:MyCompletionFn 指向自己的模块,跑的时候用 --registry_path 把这个目录叠上去:oaieval my_completion_fn test-match --registry_path ~/my_project。

评测集与命令行覆盖

问题:发一个新模型,要跑几十个 eval;又想临时把某个 eval 的参数改小试一下,不想动 YAML。

功能:评测集(项目叫 eval set)是一串名字。展开时每个名字都当成模式:. 转义,* 换成 .*,跟所有注册名逐个匹配。

模版 · evals/registry/eval_sets/test-basic.yaml:1-6
test-basic:
  evals:
    - test-match
    - test-fuzzy-match
    - test-includes
    - test-includes-ignore-case
模版 · evals/registry.py:216-227
    def get_evals(self, patterns: Sequence[str]) -> Iterator[Optional[EvalSpec]]:
        # valid patterns: hello, hello.dev*, hello.dev.*-v1
        def get_regexp(pattern: str) -> re.Pattern[str]:
            pattern = pattern.replace(".", "\\.")
            pattern = pattern.replace("*", ".*")
            return re.compile(f"^{pattern}$")

        regexps = list(map(get_regexp, patterns))
        for name in self._evals:
            # if any regexps match, return the name
            if any(map(lambda regexp: regexp.match(name), regexps)):
                yield self.get_eval(name)

匹配到的名字都会解到全名,评测集运行器按全名拼命令,再去重;同一个 eval 被短名和通配各命中一次,也只跑一遍。

临时改参数用 --extra_eval_params key=value,...:解析成字典,能转数字的转成 int 或 float,盖在 args 上,再传给构造函数:

模版 · evals/cli/oaieval.py:136-162
    def parse_extra_eval_params(
        param_str: Optional[str],
    ) -> Mapping[str, Union[str, int, float]]:
        """Parse a string of the form "key1=value1,key2=value2" into a dict."""
        if not param_str:
            return {}

        def to_number(x: str) -> Union[int, float, str]:
            try:
                return int(x)
            except (ValueError, TypeError):
                pass
            try:
                return float(x)
            except (ValueError, TypeError):
                pass
            return x

        str_dict = dict(kv.split("=") for kv in param_str.split(","))
        return {k: to_number(v) for k, v in str_dict.items()}

    extra_eval_params = parse_extra_eval_params(args.extra_eval_params)

    if eval_spec.args is None:
        eval_spec.args = extra_eval_params
    else:
        eval_spec.args.update(extra_eval_params)

谁管什么

  • 程序:读 YAML,记下每个条目的来源目录和分组;别名一路跟到底;跨目录重名就报错;按来源目录解析数据路径;评测集通配展开再去重;命令行参数转成数字后覆盖 args
  • 模型:不参与这一层
  • 用户:写 JSONL 和 YAML,挑模板,定 split 和版本号。改了题要自己升版本——程序不比对数据,同一个名字换了一套题,它查不出来

附录:源码与文档

evals/registry.py 全文
"""
Functions to handle registration of evals. To add a new eval to the registry,
add an entry in one of the YAML files in the `../registry` dir.
By convention, every eval name should start with {base_eval}.{split}.
"""
import copy
import difflib
import functools
import logging
import os
import re
from functools import cached_property
from pathlib import Path
from typing import Any, Generator, Iterator, Optional, Sequence, Tuple, Type, TypeVar, Union

import openai
import yaml
from openai import OpenAI

from evals import OpenAIChatCompletionFn, OpenAICompletionFn
from evals.api import CompletionFn, DummyCompletionFn
from evals.base import BaseEvalSpec, CompletionFnSpec, EvalSetSpec, EvalSpec
from evals.elsuite.modelgraded.base import ModelGradedSpec
from evals.utils.misc import make_object

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

logger = logging.getLogger(__name__)

DEFAULT_PATHS = [
    Path(__file__).parents[0].resolve() / "registry",
    Path.home() / ".evals",
]
SPEC_RESERVED_KEYWORDS = ["key", "group", "cls"]


def n_ctx_from_model_name(model_name: str) -> Optional[int]:
    """Returns n_ctx for a given API model name. Model list last updated 2023-10-24."""
    # note that for most models, the max tokens is n_ctx + 1
    PREFIX_AND_N_CTX: list[tuple[str, int]] = [
        ("gpt-3.5-turbo-16k-", 16384),
        ("gpt-3.5-turbo-", 4096),
        ("gpt-4-32k-", 32768),
        ("gpt-4-", 8192),
    ]
    MODEL_NAME_TO_N_CTX: dict[str, int] = {
        "ada": 2048,
        "text-ada-001": 2048,
        "babbage": 2048,
        "text-babbage-001": 2048,
        "curie": 2048,
        "text-curie-001": 2048,
        "davinci": 2048,
        "text-davinci-001": 2048,
        "code-davinci-002": 8000,
        "text-davinci-002": 4096,
        "text-davinci-003": 4096,
        "gpt-3.5-turbo": 4096,
        "gpt-3.5-turbo-16k": 16384,
        "gpt-3.5-turbo-instruct": 4096,
        "gpt-3.5-turbo-instruct-0914": 4096,
        "gpt-4": 8192,
        "gpt-4-32k": 32768,
        "gpt-4-base": 8192,
        "gpt-4-1106-preview": 128_000,
        "gpt-4-turbo-preview": 128_000,
        "gpt-4-0125-preview": 128_000,
    }

    # first, look for an exact match
    if model_name in MODEL_NAME_TO_N_CTX:
        return MODEL_NAME_TO_N_CTX[model_name]

    # otherwise, look for a prefix match
    for model_prefix, n_ctx in PREFIX_AND_N_CTX:
        if model_name.startswith(model_prefix):
            return n_ctx

    # not found
    return None


def is_chat_model(model_name: str) -> bool:
    if model_name in {"gpt-4-base"} or model_name.startswith("gpt-3.5-turbo-instruct"):
        return False

    CHAT_MODEL_NAMES = {"gpt-3.5-turbo", "gpt-3.5-turbo-16k", "gpt-4", "gpt-4-32k"}

    if model_name in CHAT_MODEL_NAMES:
        return True

    for model_prefix in {"gpt-3.5-turbo-", "gpt-4-"}:
        if model_name.startswith(model_prefix):
            return True

    return False


T = TypeVar("T")
RawRegistry = dict[str, Any]


class Registry:
    def __init__(self, registry_paths: Sequence[Union[str, Path]] = DEFAULT_PATHS):
        self._registry_paths = [Path(p) if isinstance(p, str) else p for p in registry_paths]

    def add_registry_paths(self, paths: Sequence[Union[str, Path]]) -> None:
        self._registry_paths.extend([Path(p) if isinstance(p, str) else p for p in paths])

    @cached_property
    def api_model_ids(self) -> list[str]:
        try:
            return [m.id for m in client.models.list().data]
        except openai.OpenAIError as err:
            # Errors can happen when running eval with completion function that uses custom
            # API endpoints and authentication mechanisms.
            logger.warning(f"Could not fetch API model IDs from OpenAI API: {err}")
            return []

    def make_completion_fn(
        self,
        name: str,
        **kwargs: Any,
    ) -> CompletionFn:
        """
        Create a CompletionFn. The name can be one of the following formats:
        1. openai-model-id (e.g. "gpt-3.5-turbo")
        2. completion-fn-id (from the registry)
        """
        if name == "dummy":
            return DummyCompletionFn()

        n_ctx = n_ctx_from_model_name(name)

        if is_chat_model(name):
            return OpenAIChatCompletionFn(model=name, n_ctx=n_ctx, **kwargs)
        elif name in self.api_model_ids:
            return OpenAICompletionFn(model=name, n_ctx=n_ctx, **kwargs)

        # No match, so try to find a completion-fn-id in the registry
        spec = self.get_completion_fn(name) or self.get_solver(name)
        if spec is None:
            raise ValueError(f"Could not find CompletionFn/Solver in the registry with ID {name}")
        if spec.args is None:
            spec.args = {}
        spec.args.update(kwargs)

        spec.args["registry"] = self
        instance = make_object(spec.cls)(**spec.args or {})
        assert isinstance(instance, CompletionFn), f"{name} must be a CompletionFn"
        return instance

    def get_class(self, spec: EvalSpec) -> Any:
        return make_object(spec.cls, **(spec.args if spec.args else {}))

    def _dereference(
        self, name: str, d: RawRegistry, object: str, type: Type[T], **kwargs: dict
    ) -> Optional[T]:
        if name not in d:
            logger.warning(
                (
                    f"{object} '{name}' not found. "
                    f"Closest matches: {difflib.get_close_matches(name, d.keys(), n=5)}"
                )
            )
            return None

        def get_alias() -> Optional[str]:
            if isinstance(d[name], str):
                return d[name]
            if isinstance(d[name], dict) and "id" in d[name]:
                return d[name]["id"]
            return None

        logger.debug(f"Looking for {name}")
        while True:
            alias = get_alias()

            if alias is None:
                break
            name = alias

        spec = d[name]
        if kwargs:
            spec = copy.deepcopy(spec)
            spec.update(kwargs)

        try:
            return type(**spec)
        except TypeError as e:
            raise TypeError(f"Error while processing {object} '{name}': {e}")

    def get_modelgraded_spec(self, name: str, **kwargs: dict) -> Optional[ModelGradedSpec]:
        assert name in self._modelgraded_specs, (
            f"Modelgraded spec {name} not found. "
            f"Closest matches: {difflib.get_close_matches(name, self._modelgraded_specs.keys(), n=5)}"
        )
        return self._dereference(
            name, self._modelgraded_specs, "modelgraded spec", ModelGradedSpec, **kwargs
        )

    def get_completion_fn(self, name: str) -> Optional[CompletionFnSpec]:
        return self._dereference(
            name, self._completion_fns | self._solvers, "completion_fn", CompletionFnSpec
        )

    def get_solver(self, name: str) -> Optional[CompletionFnSpec]:
        return self._dereference(name, self._solvers, "solver", CompletionFnSpec)

    def get_eval(self, name: str) -> Optional[EvalSpec]:
        return self._dereference(name, self._evals, "eval", EvalSpec)

    def get_eval_set(self, name: str) -> Optional[EvalSetSpec]:
        return self._dereference(name, self._eval_sets, "eval set", EvalSetSpec)

    def get_evals(self, patterns: Sequence[str]) -> Iterator[Optional[EvalSpec]]:
        # valid patterns: hello, hello.dev*, hello.dev.*-v1
        def get_regexp(pattern: str) -> re.Pattern[str]:
            pattern = pattern.replace(".", "\\.")
            pattern = pattern.replace("*", ".*")
            return re.compile(f"^{pattern}$")

        regexps = list(map(get_regexp, patterns))
        for name in self._evals:
            # if any regexps match, return the name
            if any(map(lambda regexp: regexp.match(name), regexps)):
                yield self.get_eval(name)

    def get_base_evals(self) -> list[Optional[BaseEvalSpec]]:
        base_evals: list[Optional[BaseEvalSpec]] = []
        for name, spec in self._evals.items():
            if name.count(".") == 0:
                base_evals.append(self.get_base_eval(name))
        return base_evals

    def get_base_eval(self, name: str) -> Optional[BaseEvalSpec]:
        if name not in self._evals:
            return None

        spec_or_alias = self._evals[name]
        if isinstance(spec_or_alias, dict):
            spec = spec_or_alias
            try:
                return BaseEvalSpec(**spec)
            except TypeError as e:
                raise TypeError(f"Error while processing base eval {name}: {e}")

        alias = spec_or_alias
        return BaseEvalSpec(id=alias)

    def _load_file(self, path: Path) -> Generator[Tuple[str, Path, dict], None, None]:
        with open(path, "r", encoding="utf-8") as f:
            d = yaml.safe_load(f)

        if d is None or not isinstance(d, dict):
            # no entries in the file
            return

        for name, spec in d.items():
            yield name, path, spec

    def _load_directory(self, path: Path) -> Generator[Tuple[str, Path, dict], None, None]:
        files = Path(path).glob("*.yaml")
        for file in files:
            yield from self._load_file(file)

    def _load_resources(
        self, registry_path: Path, resource_type: str
    ) -> Generator[Tuple[str, Path, dict], None, None]:
        path = registry_path / resource_type
        logging.info(f"Loading registry from {path}")

        if os.path.exists(path):
            if os.path.isdir(path):
                yield from self._load_directory(path)
            else:
                yield from self._load_file(path)

    @staticmethod
    def _validate_reserved_keywords(spec: dict, name: str, path: Path) -> None:
        for reserved_keyword in SPEC_RESERVED_KEYWORDS:
            if reserved_keyword in spec:
                raise ValueError(
                    f"{reserved_keyword} is a reserved keyword, but was used in {name} from {path}"
                )

    def _load_registry(self, registry_paths: Sequence[Path], resource_type: str) -> RawRegistry:
        """Load registry from a list of regstry paths and a specific resource type

        Each path includes yaml files which are a dictionary of name -> spec.
        """

        registry: RawRegistry = {}

        for registry_path in registry_paths:
            for name, path, spec in self._load_resources(registry_path, resource_type):
                assert name not in registry, f"duplicate entry: {name} from {path}"
                self._validate_reserved_keywords(spec, name, path)

                spec["key"] = name
                spec["group"] = str(os.path.basename(path).split(".")[0])
                spec["registry_path"] = registry_path

                if "class" in spec:
                    spec["cls"] = spec["class"]
                    del spec["class"]

                registry[name] = spec

        return registry

    @functools.cached_property
    def _completion_fns(self) -> RawRegistry:
        return self._load_registry(self._registry_paths, "completion_fns")

    @functools.cached_property
    def _solvers(self) -> RawRegistry:
        return self._load_registry(self._registry_paths, "solvers")

    @functools.cached_property
    def _eval_sets(self) -> RawRegistry:
        return self._load_registry(self._registry_paths, "eval_sets")

    @functools.cached_property
    def _evals(self) -> RawRegistry:
        return self._load_registry(self._registry_paths, "evals")

    @functools.cached_property
    def _modelgraded_specs(self) -> RawRegistry:
        return self._load_registry(self._registry_paths, "modelgraded")


registry = Registry()
evals/registry/evals/test-basic.yaml 全文
test-match:
  id: test-match.s1.simple-v0
  description: Example eval that checks sampled text matches the expected output.
  disclaimer: This is an example disclaimer.
  metrics: [accuracy]
test-match.s1.simple-v0:
  class: evals.elsuite.test.match:TestMatch

test-fuzzy-match:
  id: test-fuzzy-match.s1.simple-v0
  description: Example eval that uses fuzzy matching to score completions.
  metrics: [f1_score]
test-fuzzy-match.s1.simple-v0:
  class: evals.elsuite.basic.fuzzy_match:FuzzyMatch
  args:
    samples_jsonl: test_fuzzy_match/samples.jsonl

test-includes:
  id: test-includes.s1.simple-v0
  description: Example eval that uses fuzzy matching to score completions.
  metrics: [accuracy]
test-includes.s1.simple-v0:
  class: evals.elsuite.basic.includes:Includes
  args:
    samples_jsonl: test_fuzzy_match/samples.jsonl

test-includes-ignore-case:
  id: test-includes-ignore-case.s1.simple-v0
  description: Example eval that uses fuzzy matching to score completions.
  metrics: [accuracy]
test-includes-ignore-case.s1.simple-v0:
  class: evals.elsuite.basic.includes:Includes
  args:
    samples_jsonl: test_fuzzy_match/samples.jsonl
    ignore_case: true

  1. openai/evals 的 docs/build-eval.md,项目写的建 eval 流程,2026-10-20 读。 ↩

  2. openai/evals 的 docs/completion-fns.md「Developing Completion Functions outside of Evals」一节,2026-10-20 读。 ↩