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() 的编排
oaieval 的 main 只是解析参数后调 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.py | run、main、parse_extra_eval_params |
| eval set 批量跑 | evals/cli/oaievalset.py | Progress、main |
| 注册表加载/查询 | evals/registry.py | Registry、_load_registry、_dereference、make_completion_fn、get_class |
| 动态构造 | evals/utils/misc.py | make_object |
| 规格类型 | evals/base.py | EvalSpec、CompletionFnSpec、RunSpec |