跳到主要内容

01 · CLI 与注册表:名字怎么变成可运行对象

本章讲 Evals 的"装配车间":你在命令行写的两个字符串名字,是怎么变成真实的考生对象和卷子对象的。读完你会明白为什么大多数 eval "只写 YAML、不写代码"。

1. 这一章要解决的问题

你敲 oaieval gpt-3.5-turbo test-match,框架手里只有两个字符串。它得回答两件事:

  • gpt-3.5-turbo 是个什么考生?(一个 OpenAI 模型?还是注册表里某个带脚手架的系统?)
  • test-match 是哪张卷子?(用哪个 Eval 类、配哪个数据集、什么参数?)

把字符串解析成对象的这套机制,就是 Registry

2. CLI 入口:run() 的编排

oaievalmain 只是解析参数后调 run(evals/cli/oaieval.py:297-307)。真正的编排在 run(oaieval.py:118-239),它的步骤很直白:

1. registry.get_eval(args.eval) → 拿到 EvalSpec(卷子规格)
2. registry.make_completion_fn(url) → 造出考生实例(可有多个,逗号分隔)
3. registry.get_class(eval_spec) → 拿到 Eval 子类(还没实例化)
4. eval = eval_class(completion_fns=..., name=..., ...) → 实例化卷子
5. result = eval.run(recorder) → 跑!
6. recorder.record_final_report(result) → 落盘

几个值得注意的设计:

  • 考生可以是多个。 args.completion_fn.split(",")(oaieval.py:168)——一条命令能传多个考生,逗号分隔。这是"模型 vs 模型"或"模型 + 裁判模型"类评测的入口。
  • 额外参数走 --extra_eval_params 形如 key1=val1,key2=val2,由 parse_extra_eval_params(oaieval.py:136-155)解析,并智能转成 int/float,合并进 eval_spec.args
  • 运行有唯一 id。 RunSpec__post_init__ 里用时间戳 + 随机后缀生成 run_id(evals/base.py:85-89),用作日志文件名和事件归属。

3. 注册表:YAML 即配置

3.1 它要解决的小问题

要做到"零代码加 eval",就得有个地方把"名字 → (用哪个类、什么参数)"的映射声明出来。Evals 用一堆 YAML 文件干这事,Registry 负责加载和查询。

3.2 注册表长什么样

注册表按资源类型分目录:evals/completion_fns/solvers/eval_sets/modelgraded/Registry@cached_property 懒加载每一类(registry.py:312-330)。一个最简单的 eval YAML 是这样(真实例子,evals/registry/evals/test_japanese_english_numerals.yaml):

test_japanese_english_numerals: # 基础名(别名)
id: test_japanese_english_numerals.dev.v0
description: ...
metrics: [accuracy]
test_japanese_english_numerals.dev.v0: # 真正的规格
class: evals.elsuite.basic.match:Match # 用哪个 Eval 类
args:
samples_jsonl: test_japanese_english_numerals/samples.jsonl # 数据集

注意两点:一是命名约定 {base_eval}.{split}(如 .dev.v0),Eval.__init__ 会断言名字至少有这两段(eval.py:65-67);二是 class: 字段在加载时被改写成内部的 cls 键(见下)。

3.3 加载:把目录读成一个大字典

_load_registry(registry.py:287-310)遍历每个 registry path 下某资源类型目录里的所有 *.yaml,把每个顶层 key 当作名字,值当作规格 dict,并就地注入三个元字段:

# 示意,非源码:展示加载时给每条规格补的元信息
spec["key"] = name # 规格自己的名字
spec["group"] = os.path.basename(path).split(".")[0] # 来自哪个 yaml 文件
spec["registry_path"] = registry_path # 来自哪个根目录
if "class" in spec: # YAML 里写 class,内部统一叫 cls
spec["cls"] = spec.pop("class")

真实实现见 registry.py:300-308。它还断言不允许重名(registry.py:297)和保留字校验(_validate_reserved_keywords,registry.py:279-285:key/group/cls 不能在 YAML 里手写)。默认搜两个根:仓库内置的 registry/ 和用户家目录的 ~/.evals(registry.py:30-33),--registry_path 还能再追加。

3.4 解引用:别名链一路跟到底

注册表的精髓是别名(alias):一个名字可以指向另一个名字,可以链式。_dereference(registry.py:156-191)是核心——给定名字,一路跟随别名直到拿到真正的规格 dict,再用对应的 dataclass(如 EvalSpec)实例化:

# 示意,非源码:别名解引用的核心循环
while True:
alias = get_alias(d[name]) # 若 d[name] 是字符串、或含 "id" 键,取出它指向的名字
if alias is None:
break # 不再是别名,name 就是最终规格
name = alias
spec = d[name]
return EvalSpec(**spec) # 用规格构造强类型对象

所以 test-match → 别名 → test-match.s1.simple → 真正带 cls/args 的规格。找不到时它不抛错而是给"最接近的匹配"提示(difflib.get_close_matches,registry.py:163),对拼错名字很友好。

4. 考生的构造:make_completion_fn 的三岔路

make_completion_fn(registry.py:120-151)决定"这个名字是什么考生",按优先级走三岔路:

名字
├─ == "dummy" → DummyCompletionFn(测试用,返回固定文本)
├─ is_chat_model(name) → OpenAIChatCompletionFn(走 chat API)
├─ 在 api_model_ids 里 → OpenAICompletionFn(走旧 completion API)
└─ 否则 → 去注册表查 completion_fn / solver 规格,动态构造
  • 是不是 chat 模型,靠名字前缀判断(is_chat_model,registry.py:83-96:gpt-3.5-turbo-/gpt-4- 前缀算 chat)。
  • 是不是已知 API 模型,靠真的去 OpenAI 拉模型列表(api_model_ids,registry.py:110-118,拉不到就降级为空)。
  • 都不是,就当成注册表里的 completion_fn 或 solver key,取其规格、动态实例化。

动态实例化用 make_object(evals/utils/misc.py):

# 示意,非源码:把 "evals.elsuite.basic.match:Match" 这种字符串变成可调用对象
modname, _, qualname = object_ref.partition(":") # 模块路径 : 符号名
obj = importlib.import_module(modname)
for attr in qualname.split("."):
obj = getattr(obj, attr)
return functools.partial(obj, *args, **kwargs) # 预绑定 args,返回可调用

真实实现见 evals/utils/misc.py:20(make_object)。这就是"YAML 里的 class: a.b.c:ClassName 怎么变成真类"的全部魔法:字符串 → import → getattr → partial。get_class(registry.py:153-154)用同样的手段把 EvalSpec.cls 变成 Eval 类。

5. 巧妙之处

  • 声明式装配 = 字符串路径 + 动态导入。 整个框架不硬编码任何 eval/考生的类引用,全靠 "module:Symbol" 字符串在运行时解析(make_object)。新增一个 eval 只要丢一个 YAML——这是"零代码加 eval"体验的根。
  • 别名链让命名稳定。 数据集换版本(.v0.v1)时,外部仍用稳定的基础名,别名指向新版本即可,调用方无感。
  • 强类型规格。 YAML 读进来是裸 dict,但立刻喂给 pydantic dataclass(EvalSpec 等,base.py)做校验,字段写错会在加载期就报错而非跑一半才崩。

6. 边界与局限

  • 重名直接崩。 两个 YAML 出现同名 key 会在加载时 assert 失败(registry.py:297)——多人协作大注册表时要小心命名空间。
  • api_model_ids 依赖网络。 判断"是不是已知非 chat 模型"要调 OpenAI API;离线或用自定义端点时会降级为空列表(registry.py:114-118),此时只有名字前缀匹配上 chat 的才认得,其余都得走注册表 key。
  • module:Symbol 字符串无静态检查。 写错类路径要到运行期 import 才暴露。

7. 代码地图

主题文件路径符号名
CLI 编排evals/cli/oaieval.pyrunmainparse_extra_eval_params
eval set 批量跑evals/cli/oaievalset.pyProgressmain
注册表加载/查询evals/registry.pyRegistry_load_registry_dereferencemake_completion_fnget_class
动态构造evals/utils/misc.pymake_object
规格类型evals/base.pyEvalSpecCompletionFnSpecRunSpec