针对 Anthropic 机器学习配置题的完整解答:组合与覆盖、类型化校验、解析后的配置、用于可复现性的运行记录、一个可用的 Python 加载器,以及面试官会追问的权衡取舍。
作者:The Forward Deployed 编辑团队已审阅
本文属于 Anthropic 系统设计题库。该题目具有该轮面试的代表性。分析与解答为本站原创。
问题描述
这是研究方向的选题。它将设计讨论与一个小型实现结合在一起。该系统接收配置文件和命令行覆盖参数,生成一份完全解析并经过校验的配置,交给训练器,并记录后续重新启动该运行所需的一切信息。
澄清问题
- 运行数量有多少,规模多大? 每周数百次运行,从单块 GPU 到数千块 GPU 不等。
- 谁在写配置? 研究人员,而且常常很赶时间。错误很常见,且在大规模场景下代价高昂。
- “可复现”必须意味着什么? 相同的设置、代码、数据和环境。可以追问是否要求逐位一致的结果。在 GPU 上通常无法保证这一点。
- 运行在哪里启动? 一个接收容器镜像和命令的集群调度器。
- 是否已有现成格式? 假设训练仓库中已有 YAML 文件。
- 这一轮需要写代码吗? 假设需要:写出加载器。
配置为什么难
配置看起来像是一个文本文件问题。但有三点使它成为一个系统问题。
错误代价高昂。学习率键名中的一个拼写错误会静默回退到默认值,运行会烧掉一千块 GPU 一整天,直到有人发现损失曲线不对劲。
配置会在旧运行之下发生变化。下个月有人修改了共享默认值,上个月的配置文件含义就变了。“重新运行该配置文件”不再能复现那次运行。
研究人员需要速度。如果一个系统严格到每个实验都要改 schema,它就会被复制粘贴的脚本绕过。
因此核心矛盾是灵活性 versus 安全性。设计必须让常见的修改快速完成,同时让危险的错误不可能发生。
flowchart LR
F[Config pieces<br/>model, optim, data, cluster]:::svc --> M[Merge in fixed order]:::svc
O[CLI overrides]:::user --> M
M --> V{Validate:<br/>types, unknown keys,<br/>cross-field rules}:::svc
V -->|fail| E[Error before launch]:::bad
V -->|ok| R[Resolved config]:::store --> RR[(Run record)]:::store
R --> T[Trainer]:::svc
classDef user fill:#e6efec,stroke:#315e55,color:#171717;
classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717;
classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717;关键思路。 在启动前进行校验,并保存解析后的结果,而不是配方。配方会变;解析后的配置不会。
核心概念
组合
一个运行配置由按类别分组的可复用部件组装而成:例如模型预设
model/7b
、优化器预设、数据配比、集群布局。运行文件指明使用哪些部件,并添加自己的取值。
合并顺序
各部件按固定顺序合并,后出现的值优先:默认值,然后按列出的顺序依次合并各部件,然后是运行文件,最后是命令行覆盖参数。字典按键逐键合并。列表则整体替换,因为逐项合并列表会产生谁也无法预测的意外结果。
覆盖参数
点分路径可以从命令行设置任意值:
optim.lr=3e-4
。值按 YAML 解析,因此
true
会被解析为布尔值,
2048
会被解析为整数。一个陷阱:YAML 1.1 解析器(包括 PyYAML)会把
3e-4
读作字符串,因为它没有小数点。Schema 必须进行类型强制转换和检查,否则该字符串会传到训练器。未知路径会报错,除非用户通过类似
+new.key=1
.
解析后的配置
经过合并和插值后,解析后的配置中每个值都是显式的,且不再有任何引用。它就是训练器接收到的内容,也是被保存下来的内容。它的哈希值就是该次运行的配置指纹。
可复现性
复现一次运行需要五样东西:解析后的配置、确切的代码提交、容器镜像摘要、数据快照,以及所有随机种子。缺少任何一项,重跑的结果都可能不同。
核心思想。 由片段组合而成,按固定顺序合并,通过点分路径覆盖,严格校验,并将解析后的结果与代码、镜像、数据和种子一起冻结。
需求
在继续阅读之前。 列出需求。哪个属性最重要?什么约束决定了设计?
1.1 功能需求
- 由可复用的配置片段组合出一次运行。
- 从命令行覆盖任意值。
- 在启动之前校验类型、拒绝未知键,并强制执行跨字段规则。
- 生成一份解析后的配置和一个指纹。
- 记录重新启动该运行所需的一切。
- 从记录重新启动任何历史运行。
- 展示两次运行的配置之间的差异。
1.2 非功能需求
- 快速反馈。 校验在笔记本电脑上几秒内即可完成,且在任何作业到达集群之前。
- 可读。 配置是纯数据,在代码评审中能干净地进行 diff。
- 稳定。 对共享默认值的修改不能改变历史运行的记录。
1.3 约束与属性
可复现性是属性。 无法重跑的实验无法被信任、比较或调试。研究人员速度是约束。 每个安全特性对研究人员来说必须几乎零成本,否则他们会绕过它。
粗略估算
这不是一个规模问题,但有两个数字可以证明设计的合理性。
静默错误的代价。 举例来说:在 1,024 块 GPU 上运行一天就是 24,576 GPU 小时。一个回退到默认值的拼写错误会浪费全部这些资源。在启动前拒绝未知键只需几毫秒。这个比例就是严格校验的理由。
记录的大小。 一份解析后的配置只有几 KB。每周数百次运行,一年产生的记录也只有几 MB。永久存储所有解析后的配置几乎不花任何成本。
API 设计
3.1 命令行
train run experiment=exp/scaling_7b optim.lr=3e-4 data.mix=web_v3 train validate experiment=exp/scaling_7b # resolve + validate, no launch train show experiment=exp/scaling_7b # print the resolved config train diff <run_id_a> <run_id_b> # key-by-key difference train relaunch <run_id> [--allow-drift] # rerun from the record
3.2 库
build(files: list[path], overrides: list[str]) -> Run # 合并、校验 fingerprint(run: Run) -> str # 解析后配置的哈希 record(run: Run) -> RunRecord # 添加 commit、镜像、数据、种子 load_record(run_id) -> RunRecord
数据模型
4.1 磁盘上的配置片段
configs/ defaults.yaml model/1b.yaml model/7b.yaml model/70b.yaml optim/adamw.yaml optim/adafactor.yaml data/web_v3.yaml data/code_mix.yaml cluster/a_1024.yaml exp/scaling_7b.yaml # 列出各片段 + 本次运行特有的值
4.2 实验文件
compose: [defaults, model/7b, optim/adamw, data/web_v3, cluster/a_1024] total_steps: 200000 global_batch: 2048 seed: 17 optim: lr: 3.0e-4 warmup_steps: 2000
4.3 运行记录
run_record
run_id, created_at, created_by,
resolved_config (full JSON), config_fingerprint,
git_commit, git_dirty: false,
image_digest,
data_snapshot_id, data_manifest_hash,
seeds: {python, numpy, torch, data_order},
hardware: {gpu_type, gpu_count, parallel_layout},
library_versions: {...}重新运行时读取的是这条记录,而绝不会重新读取配置文件。
高层设计
5.1 一个装满常量的 Python 文件
LR = 3e-4 BATCH = 2048 # 编辑并运行
这种方式很快,但每条要求都不满足:没有组合、没有校验、没有记录。下一个人改了文件,旧的运行就找不回来了。
5.2 修复 1:YAML 片段与合并
把配置移入 YAML 片段,并按固定顺序合并。支持从命令行传入点号路径的覆盖项。组合由此可行,配置在代码评审中也能清晰对比。
但拼写错误仍会静默通过:
learning_rte: 1e-3
合并正常,训练器读取的是默认值
lr
.
5.3 修复 2:类型化 schema 与严格校验
把配置定义为类型化的类。从合并后的字典构建它们时,遇到未知键或错误类型就会失败。跨字段规则写在类上,并在构建时执行。
flowchart LR D[Merged dict]:::svc --> S[Typed schema]:::new S -->|unknown key| X1[Error: learning_rte is not a field]:::bad S -->|wrong type| X2[Error: lr must be float]:::bad S -->|rule broken| X3[Error: batch must divide by workers]:::bad S -->|ok| R[Run object]:::store classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717; classDef bad fill:#fbe9e4,stroke:#c4492d,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;
5.4 修复 3:解析、指纹与记录
将校验后的对象序列化为其解析后的形式,计算哈希,并在第一个训练步之前写入包含 commit、镜像摘要、数据快照和种子的运行记录。启动器拒绝在脏的 git 工作区中启动,除非用户显式传入一个标志,该标志同样会被记录下来。
5.5 组合后的设计
sequenceDiagram autonumber actor R as 研究员 participant CLI as train CLI participant L as 加载器 participant RS as 运行存储 participant S as 集群调度器 R->>CLI: train run experiment=... optim.lr=3e-4 CLI->>L: build(files, overrides) L->>L: 按顺序合并,应用覆盖项 L->>L: 校验类型、键、规则 L-->>CLI: Run(或错误信息,几秒内返回) CLI->>RS: 写入运行记录(解析后的配置、提交、镜像、数据、种子) CLI->>S: 使用 --run-id 启动镜像 S->>S: 训练器通过 run id 加载记录
训练器通过运行 ID 加载记录,而不是配置文件。记录下来的内容就是实际运行的内容。
核心思想。 合并是为了便利,校验是为了安全,并让保存的记录成为训练器唯一读取的东西。
深入探讨
6.1 加载器,代码实现
继续阅读之前。 编写一个加载器,能够合并 YAML 文件、应用点号覆盖项、拒绝未知键,并检查全局 batch 能否在各个 worker 之间均匀分配。
一个使用 dataclasses 的精简版本。只要安装了 PyYAML,代码即可直接运行。
from dataclasses import dataclass, field, asdict, fields
import hashlib, json
import yaml
@dataclass(frozen=True)
class Optim:
name: str = 'adamw'
lr: float = 3e-4
warmup_steps: int = 2000
weight_decay: float = 0.1
@dataclass(frozen=True)
class Run:
model: str
total_steps: int
global_batch: int
dp_workers: int
seed: int = 0
optim: Optim = field(default_factory=Optim)
def __post_init__(self):
if self.global_batch % self.dp_workers:
raise ValueError('global_batch must divide evenly by dp_workers')
if self.optim.warmup_steps >= self.total_steps:
raise ValueError('warmup_steps must be less than total_steps')
if not 0 < self.optim.lr < 1:
raise ValueError('optim.lr is out of range')
def deep_merge(base: dict, over: dict) -> dict:
out = dict(base)
for key, value in over.items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = deep_merge(out[key], value)
else:
out[key] = value # scalars and lists replace
return out
def apply_override(cfg: dict, dotted: str, raw: str) -> dict:
*parents, leaf = dotted.split('.')
node = cfg
for part in parents:
if part not in node or not isinstance(node[part], dict):
raise KeyError(f'unknown config path: {dotted}')
node = node[part]
if leaf not in node:
raise KeyError(f'unknown config key: {dotted}')
node[leaf] = yaml.safe_load(raw) # 'true' -> bool, '2048' -> int
return cfg
def strict(cls, data: dict):
kinds = {f.name: f.type for f in fields(cls)}
unknown = set(data) - set(kinds)
if unknown:
raise KeyError(f'unknown keys for {cls.__name__}: {sorted(unknown)}')
out = {}
for key, value in data.items():
kind = kinds[key]
if kind in (int, float, str) and not isinstance(value, kind):
if kind is int and isinstance(value, float) and not value.is_integer():
raise TypeError(f'{cls.__name__}.{key} must be an int, got {value!r}')
try:
value = kind(value) # '3e-4' -> 3e-4, 2048.0 -> 2048
except (TypeError, ValueError):
raise TypeError(f'{cls.__name__}.{key} must be {kind.__name__}, got {value!r}')
out[key] = value
return cls(**out)
def load_yaml(path: str) -> dict:
with open(path) as fh:
return yaml.safe_load(fh) or {}
def build(files: list[str], overrides: list[str], root: str = 'configs') -> Run:
cfg: dict = {'optim': asdict(Optim())}
for path in files:
data = load_yaml(path)
for piece in data.pop('compose', []): # pieces first, in order
cfg = deep_merge(cfg, load_yaml(f'{root}/{piece}.yaml'))
cfg = deep_merge(cfg, data) # then the file itself
for item in overrides:
key, raw = item.split('=', 1)
cfg = apply_override(cfg, key, raw)
optim = strict(Optim, cfg.pop('optim'))
return strict(Run, {**cfg, 'optim': optim})
def fingerprint(run: Run) -> str:
blob = json.dumps(asdict(run), sort_keys=True).encode()
return hashlib.sha256(blob).hexdigest()[:16]向面试官讲解四个设计选择。覆盖项必须指向已存在的键,这也能捕获命令行上的拼写错误。
strict
在每一层都拒绝未知键。它还会进行类型转换和类型检查,因为 dataclasses 不会这样做,而且 PyYAML 会把
3e-4
解析为字符串。这个 bug 很常见,值得主动提出来。指纹是对解析后对象的排序 JSON 转储进行哈希得到的,因此两个设置完全相同的运行会共享同一个指纹,无论它们是如何组合出来的。
在生产环境中,校验库能提供更好的错误信息和嵌套类型转换,组合库则增加了配置组、插值和多运行扫描。说出你知道的库,并说明你会从每个库中保留哪些功能。
答案的差距在哪里:加载器
弱:合并后听天由命
合并字典后直接传给训练器。拼写错误和错误类型会到达集群。
良好:有类型且严格
拒绝未知键和错误类型,检查跨字段规则,并在启动前几秒内失败。
强:严格、有解释、可测试
还会拒绝指向未知路径的覆盖项,记录列表替换语义,为解析后的配置生成指纹,并为每条规则编写单元测试。
6.2 “可复现”能意味着什么、不能意味着什么
继续阅读之前。 一位研究员在同一硬件上从运行记录重新启动一次运行。损失曲线略有不同。系统出问题了吗?
不一定。某些 GPU kernel 是非确定性的:并行归约会以不同的顺序对浮点数求和,而浮点加法不满足结合律。GPU 数量不同会改变归约的布局。库升级会改变 kernel。
精确地陈述承诺:相同的设置、代码、镜像、数据和种子,结果在正常的运行间噪声范围内一致。提供用于调试的确定性模式,强制使用确定性内核和固定的归约顺序,并说明其代价是速度。记录硬件布局和库版本,以便差异可以追溯到其原因。
6.3 重新启动与漂移
从上个月重新启动一次运行时,会使用记录的镜像和提交。如果代码此后发生了旧配置无法理解的变化,记录的镜像仍然运行旧代码,因此运行可以复现。如果研究人员想在旧配置上使用新代码,重新启动工具会将旧配置与新 schema 进行解析并显示差异:带默认值的新键、被移除的键、已更改的默认值。研究人员通过
--allow-drift
确认,新记录会注明这一点。
6.4 会变化的共享默认值
在继续阅读之前。 团队将默认 warmup 从 2,000 步改为 4,000 步。上周启动的运行会怎样?
什么都不会变,因为它们的记录保存的是已解析的值。会变化的是旧实验文件的含义:今天启动
exp/scaling_7b
会得到 4,000 步的 warmup。要让这一点可见。
train diff
与同一实验的上一次运行对比会显示这一变化,代码评审中的默认值变更会列出其影响的实验。
6.5 扫描
超参数扫描从一个配置加上对覆盖值的网格搜索或随机搜索生成许多运行。每个生成的运行都是一个拥有自己记录的普通运行。在每个记录中存储扫描 ID,以便对结果进行分组,并将扫描定义本身作为版本化文件。
6.6 插值与派生值
在继续阅读之前。 一位研究人员希望 warmup_steps 是 total_steps 的 1%,并且学习率调度在 total_steps 处结束。如何在不手动复制数字的情况下表达这一点?
两种选择,风险不同。
配置中的插值。 允许诸如
${total_steps}之类的引用和简单表达式。加载器在合并之后、验证之前解析它们,并检测循环。这很方便,但它把逻辑隐藏在配置中,难以测试。
Schema 中的派生字段。 保持配置为纯值,在代码中计算派生值:schema 类上的一个属性,例如
warmup_steps
在未设置时默认为
total_steps // 100
。该规则存在于一个经过测试的地方,且已解析的配置会记录计算出的值。
对于适用于所有地方的规则,优先使用派生字段,并允许对一次性的接线使用简单引用。无论哪种方式,已解析的配置必须只包含具体的数字,这样运行记录才能显示实际运行的内容。
一个在启动时很有用的检查:打印最常出错的值的简短摘要,例如有效批大小、每步 token 数、总 token 数和峰值学习率。研究人员通过阅读这五个数字能发现许多错误。
6.7 针对集群进行验证
阅读前须知。 一个配置通过了所有类型检查,但任务在启动 20 分钟后仍然失败,因为模型放不进 GPU 显存。校验本可以提前发现吗?
通常可以,而且只需廉价的估算。Schema 校验检查类型和简单规则;第二阶段则将配置与所选硬件进行核对:
- 显存。 根据模型大小、精度、并行布局和微批次大小,估算每个 GPU 上的参数、梯度、优化器状态和激活值。如果估算值超出 GPU 显存并留有余量后仍超限,则在启动前直接失败。
- 布局。 张量并行、流水线并行和数据并行的度数相乘必须等于 GPU 数量,且层数必须能被流水线阶段数整除。
- 数据。 数据快照必须存在,且包含足够支撑计划步数的 token。
- 配额。 请求的 GPU 数量必须符合团队配额,否则任务只能在队列中等待。
实践参考:一个 70 亿参数的模型,采用混合精度训练和 Adam 类优化器,每个参数大约需要 16 字节用于权重、梯度和优化器状态:在计入激活值之前就需要 112 GB。在 80 GB 的 GPU 上,即使不计激活值,也必须分片到至少两块 GPU,因此一个要求纯数据并行且不分片的配置会立即未通过检查。
这些检查只需几秒钟即可完成,却避免了研究中最昂贵的失败模式:一个任务在队列中等待数小时,然后在启动时崩溃。
区分答案的关键:校验深度
弱仅校验类型
只校验字段类型,到此为止。
中校验类型和跨字段规则
增加诸如批次可整除性和 warmup 边界之类的规则。
强对照现实进行检查
还会估算每个 GPU 的显存,根据 GPU 数量检查并行布局,确认数据快照和配额,并在启动前打印关键的推导数值。
变体
7.1 用 Python 编写配置
一些团队将配置写成 Python 函数。它们很强大:支持循环、条件语句和导入。但它们也难以 diff、难以静态校验,并且可能在加载时执行任意代码。一个常见的折中方案:Python 构建器必须返回纯数据,由同一 schema 校验,并以解析后的形式记录下来。
7.2 密钥与环境变量
永远不要把密钥放进配置;它们最终会出现在运行记录和日志中。应通过名称引用它们,例如
wandb_key: ${secret:wandb},并在启动时解析为从不记录的环境变量。
7.3 服务部署的配置
同一套系统也可以配置推理部署。区别在于发布方式:服务配置的变更要经过金丝雀发布和回滚,就像代码一样。
7.4 当运行量扩大十倍时
每周数千次运行,运行存储就成了团队的研究记忆。按实验、指纹和关键设置对运行记录建立索引,让研究人员可以查询哪些运行使用了某个数据配比或学习率。两个指纹相同的运行意味着重复劳动。保留策略也很重要:保留每条记录,但让被放弃运行的检查点过期。
可迁移的模式
配置系统就是一个有严格前端和冻结输出的编译器:多个可读的输入,一个经过验证的产物,以及一条记录,固定该产物所依赖的一切。同样的模式适用于构建系统、基础设施即代码和功能开关平台。尽早验证,冻结输出,并让冻结的输出成为唯一可执行的东西。
回顾:30 秒答案
- 按固定顺序从片段组合。 后者胜出;字典合并;列表替换。
- 点号覆盖仅限已有键。 拼写错误会失败。
- 带跨字段规则的类型化模式。 在任何 GPU 启动之前,几秒内报错。
- 记录解析后的配置及 commit、镜像、数据和种子。 训练器只读取该记录。
- 承诺等价性,而非逐位一致。 提供确定性模式并说明其代价。
测验
+为什么拒绝未知键而不是忽略它们?
因为被忽略的拼写错误会静默回退到默认值。一次运行可能在错误设置上烧掉数千 GPU 小时才有人察觉。拒绝它只需启动时几毫秒。
+为什么训练器应读取运行记录而不是配置文件?
文件在启动后可能因编辑或新默认值而改变。记录保存的是运行实际使用的解析值,因此训练器、重新启动以及任何审计该运行的人看到的都是同样的内容。
+复现一次运行需要哪五样东西?
解析后的配置、确切的代码 commit、容器镜像摘要、数据快照,以及所有随机种子。
+即使五样都记录了,重新启动为什么仍可能有细微差异?
一些 GPU 核函数是非确定性的:并行归约以不同顺序对浮点数求和,而浮点加法不满足结合律。不同的 GPU 数量和库版本会进一步改变结果。
+为什么列表是替换而不是合并?
逐项合并列表没有唯一合理的含义。较短的列表应该截断,还是条目应该追加?替换是可预测的,而可预测性正是合并顺序的意义所在。
+像 warmup 步数这样的派生值应该在哪里计算?
最好在模式中,作为代码里经过测试的规则,并把计算出的值写入解析后的配置。这样运行记录就能显示实际运行的数值。
+为什么在验证阶段估算 GPU 显存?
不适配的任务会在启动时崩溃,而且往往是在队列中等待数小时之后。通过模型大小、精度和布局进行几秒钟的估算,就能在启动前发现这类问题。
资料来源与延伸阅读
- Hydra 介绍了配置组、组合以及命令行覆盖。
- OmegaConf 涵盖了结构化配置、合并和插值。
- PyTorch 可复现性说明 解释了非确定性操作和确定性模式。
- Deployment 介绍了版本管理和回滚,即同一纪律在生产环境中的应用。