train_rl 完全一致,然后有意地破坏它,每次只引入一个故障:错位一格的掩码、被当前值覆盖的 old_logp、熵坍缩(entropy collapse)、长度偏差、符号错误、奖励投机(reward hacking)、过高的学习率、梯度累积 bug、温度不匹配,以及一个其实就是在线策略本身的参考模型。每个故障都给出它在监控面板上的特征,并与一次无故障训练并列对照。第三,把这些特征转化为代码库可以在第 1 步之前断言的不变量,以及一个从症状到测试的排查流程。这里每次训练都是一个 158k 参数模型在 20 个训练任务上的机制演示(mechanism demonstration);这些特征可用于排查其他系统,具体数值阈值则需要重新测量。
18.1 记录什么,以及为什么
一个指标值得放上监控面板,当且仅当某种故障会在奖励变化之前移动它,或者在奖励不变时移动它。按这条规则,有用的数字可以分成四组。- 结果。 训练奖励、训练批次的隐藏测试通过率,以及留出任务上的通过率。训练奖励是优化器看到的东西,因此它正是故障可以在无人察觉的情况下败坏的数字;在独立验证器下的留出通过率是对它的检验(第 11 章,第 17 章)。
- 行为。 智能体做了什么:每回合的读取、测试和编辑次数,格式错误的调用,提交,使用已知漏洞利用(
hardcode_tests)的回合比例,以及每回合的策略 token 数,并按通过与失败的回合分开统计。它们说明奖励是如何获得的,而第 9 章已经表明,这正是技能与捷径的区别。 - 优化器状态。 动作 token 上的策略熵、更新前后策略之间的近似 KL(
approx_kl)、裁剪比例、比率 的极值、裁剪前的梯度范数、使用参考策略时到参考策略的 KL,以及所有奖励都相等、因而不携带梯度的组所占比例(zero_adv_frac)。如果有 critic,还要加上其价值的解释方差(7.4 节);本章的训练都不使用 critic。 - 一致性。 在正确实现中应当是固定值的量,例如采样器记录的对数概率与训练器重新计算的对数概率之间的差距。无故障训练中它们处于浮点精度噪声水平;任何可见的数值都是 bug。这些是记录成本最低、诊断性最强的指标。
log_metrics 根据某一步采样的回合,构建监控面板一行中属于 rollout 的那一半。它复用第 9 章的 behavior_stats,并加上那一章不需要的指标:格式错误的调用、漏洞利用比例、提交,以及通过与失败回合的长度。属于更新的那一半来自下面的训练器,通过 update 合并进来。
18.2 可注入故障的带诊断记录的训练器
为了展示故障是什么样子,我们需要一个每种故障都对应一个开关的训练器。下面的训练器就是第 9 章的rl_update 和 train_rl,只是在真实 bug 所在的位置被拆开,并带有一个打开某个故障的 bug 参数。BUGS 列出了代码故障;目录中的其他失败(过高的学习率、奖励投机、长度偏差)属于配置选择,不需要开关。
prepare_batch 产生损失所使用的三个张量:动作掩码、old_logp 和优势。这里有三个故障。错位一格的掩码由与 ids 而不是 ids[1:] 对齐的 token 归属构建,这会把每段策略 span 向右移动一个位置:每次调用都失去它的第一个 token <call>,并多出最后一个 token 之后的那个 token,即环境写入的 <obs> 分隔符。温度故障从一个以温度 0.7 运行的采样器获取 old_logp,就像一个直接信任推理引擎对数概率的系统所做的那样。符号错误把优势取反。make_ref 构建 KL 参考模型,在 ref_is_policy 下它直接返回在线模型本身,这在真实代码库中只是一个字符级的 bug(ref = policy 而不是深拷贝)。
minibatch_backward 计算一个小批次的损失并反向传播,但不执行优化器步。这里还有两个故障。在 accum_local 下,小批次被拆成按长度排序的四个微批次,就像按 token 预算调度时那样拆分,并且每个微批次的 token_mean 损失除以四:这正是 14.4 节的归一化 bug。在 entropy_sign 下,熵奖励被加到损失上而不是减去,于是它变成了熵惩罚。到参考模型的 KL 在小批次内部计算,使用参考模型在同样行上的前向传播结果,所以当参考模型就是在线策略时,KL 恰好为零。
debug_update 是由这两部分组装而成的 rl_update,并包含old_logp 被当前值覆盖的故障:在每个小批次之前,它都用当前权重重新计算“旧”对数概率,当不求梯度的前向传播被挪进小批次循环内部时,发生的就是这种情况。它还记录 18.1 节的一致性指标 logp_gap:在损失所训练的 token 上,采样器记录的对数概率与训练器自己重新计算的对数概率之间的平均绝对差。
train_logged 就是 train_rl,加上 log_metrics 行以及温度故障所需的采样器配置。它每隔 eval_every 步在留出任务上评测一次,规程与 train_rl 相同:每个任务 4 个回合,隐藏测试通过率,在训练环境中进行。
train_rl,一份用 train_logged,然后比较每个参数以及两份历史记录都记录的每一项统计量。
run 按名称保存每份历史记录,show 打印每次训练的若干指标:前四步的均值、一个箭头、后四步的均值,以及最终的留出通过率。
logp_gap 始终在 到 之间:采样器和训练器在相同 token 上运行同一个 fp32 模型,二者一致到舍入误差的程度。更新幅度很小。approx_kl 在第 0 步是 0.003,之后约为 ,最大比率不超过 1.27,至少一半的步中裁剪比例为 0,因为两个小批次中的第一个恰好是 on-policy 的,第二个也几乎没有移动。此外,从第一步起,每步就有 0 到 63% 的组(中位数 25%)不携带梯度,因为基座智能体在某些任务上八次尝试全部失败。
下表把 18.1 节浓缩成一张参考卡。无故障一列是上面测得的;失败一列指向接下来的目录,编号与本章开头一致。
18.3 损失输入中的故障
前三个故障破坏的是损失的输入:它在哪些 token 上训练,以及它把哪些概率当作 。如果 18.1 节的分组是对的,它们的特征应该出现在一致性指标中,早于任何曲线有机会变化。logp_gap 平均为每个被训练 token 3.75 nat,而正常值是 。采样器只为它采样的 token 记录对数概率。在错位掩码现在覆盖的 <obs> 分隔符上,它记录的是占位值 0,而训练器的模型给这个分隔符的对数概率远低于零,因为从来没有任何示范训练策略去预测它。同样是这些 token 驱动了优化器指标。 对某个 token 的 logit 的梯度是 ,对于模型认为几乎不可能的 token 接近 1,因此这少数几个 token 主导了更新:梯度范数在开始时为 0.48,结束时为 1.5,而正常值约为 0.2。它们的比率也偏离得很远(最大比率 3.0,裁剪比例 0.044)。这个故障让留出通过率损失了 23 个百分点,0.47 对 0.70。
错误处理 old_logp 的训练则是另一种情形。它的结果与无故障训练无法区分(训练奖励 0.72 对 0.66,留出 0.68 对 0.70),唯一的特征是两个优化器指标在整次训练中恰好停在它们的平凡值上:裁剪比例为 0,最大比率为 1。当 old_logp 用当前权重重新计算时,,裁剪永远不会起作用,PPO 悄悄退化为一串普通的策略梯度步。在这次短程实验中,它没有造成明显差异,因为每个批次只用于两个小批次步,第二步几乎不动。若有更多轮次(epoch)或小批次,正是裁剪阻止了策略在一个批次上移动过远(8.5 节测得裁剪比例和 KL 在四个轮次中增长),而这个 bug 去掉了裁剪,却在奖励上不留任何痕迹。一个在整次训练中恰好为 0 或恰好为 1 的指标是一个警告,而不是一种安慰。
温度故障是三者中破坏性最强的。采样器以温度 0.7 运行,而以温度 1 运行的训练器把采样器的对数概率当作 。训练奖励从 0.23 降到 0.16,留出通过率最终为 0.15,低于盲猜一次的 0.20。logp_gap 在开始时为 0.075 nat,比正常运行的数值噪声高六个数量级;第 0 步的最大比率已经是 2.65,而正确的流水线在第一次更新之前应当恰好有 。行为指标显示了接下来发生了什么。
zero_adv_frac 为 1.0),梯度范数为 0:策略停止了探索,训练停止改进。相比之下,掩码故障提高了熵(0.58 到 0.80)和格式错误的调用(0.04 到 0.12),这是训练一个策略从不输出的 token、却不再训练开启每次调用的 <call> token 的副作用。
为什么温度不匹配会专门扼杀探索?降低温度会把概率从罕见 token 移向常见 token,因此对罕见 token,比率 一开始就大于 1,而对常见 token 小于 1。PPO 的裁剪会去掉比率超过 的正优势 token 的梯度。我们在基座智能体以温度 0.7 采样的一个批次上、在任何更新之前测量这一点。probe 按给定(可能有故障的)流水线的采样方式采样 64 个回合;18.6 节会复用它。
18.4 梯度中的故障
接下来的三个故障保持损失的输入不变,而破坏梯度:它的符号、大小和归一化。符号错误和学习率的训练在第 8 步停止,就像工程师在失败已经明确时会停掉它们一样。approx_kl、熵和格式错误的调用都在正常范围内。只有结果表明训练出了问题,这就是为什么 18.6 节要直接检验更新的方向。
学习率 是默认值的十倍,它首先表现在优化器和格式指标上。格式错误的调用从每回合 0.76 次升到 2.89 次,熵从 0.42 nat 升到 1.08 nat,后期每个回合都用满了全部 6 轮,奖励从 0.04 降到 0。输出的最后两行展示了每步 KL 中事件发生的顺序。
学习率 下的第一次更新使策略移动了每 token 0.31 nat 的近似 KL,约为正常训练第一步 0.0032 的 100 倍,第二次更新为 0.064。此后每步 KL 回落到接近正常水平。训练并没有恢复:它的奖励已经崩溃,各组奖励都相同,几乎没有剩余的梯度可用。这就是不稳定的一般形态:一两步出现很大的 approx_kl 或 grad_norm,随后指标恢复平静,策略却已难以继续学习。只显示平滑曲线的监控面板可能隐藏原因、只显示后果,所以要在记录均值的同时记录每步最大值。
累积故障完全没有特征。它的训练奖励(0.67)和留出通过率(0.65)在 40 回合评测的噪声范围内(通过率 0.7 时二项标准误约为 0.07)与无故障训练一致,每个优化器指标也是如此;格式错误的调用最后略高(0.16 对 0.07)。然而梯度是错的:18.6 节的不变量检查测得它相对单遍梯度的相对误差为 0.54(图 18.2),因为短微批次中的 token 获得的权重是长微批次中 token 的数倍(14.4 节)。在这个 gym 中,回合很短且长度相近,这个误差就像对 token 做了一次带噪声的重新加权,训练得以存活。对于长尾的智能体回合,一个按长度排序的微批次可能只装下一条很长的轨迹,它的 token 只获得预期权重的一小部分。这是否会在监控面板上显现取决于具体训练;但它是否存在与此无关,只有对梯度的检验才能判断。
18.5 目标中的故障
最后四种失败来自目标本身,而不是更新中的缺陷:一个正则项、一种损失聚合、一个奖励和一个参考模型。我们预期它们的特征出现在行为统计中。zero_adv_frac 能把这种情况与成功区分开。收敛的策略和坍缩的策略都有低熵和大量同奖励组,但这里 84% 的组在奖励为 0.18 时奖励完全相同,所以几乎都是全失败组,而收敛策略的同奖励组是全通过组。要把全通过和全失败的比例分开记录,并把“熵的下降快于奖励的上升”当作早期预警。
token_mean 下,失败回合变短了,从 16 个策略 token 降到 13 个。在 seq_mean_token_mean 下,失败回合从 21 个 token 增长到 34 个,回合平均使用 6.8 轮而不是 4.0 轮,每回合读取 2.7 次,而一次就够了。两次训练中通过的回合都保持在约 13 个 token,训练奖励(0.51 对 0.49)和留出通过率(都是 0.55)都无法区分二者。这正是 6.6 节的机制:在按序列归一化下,每个失败回合承担固定的总惩罚,因此回合越长,其中每个 token 受到的惩罚越小,失败的最便宜方式就是慢慢地失败。特征是按结果拆分的长度;整体平均长度会把这一效应与失败比例的变化混在一起。对编程智能体而言,这种失败早在体现到奖励上之前,就已经在消耗 rollout 时间和上下文了。
hardcode_tests 的比例从 0.13 升到 0.97,读取降到每回合 0.04 次,留出通过率最终为 0。漏洞利用比例是最直接的指标,因为它点出了具体行为,但它之所以存在,只是因为有人早已知道这个漏洞。隐藏测试通过率需要一个独立的验证器,它也能捕捉到没有人预料到的漏洞利用。
18.6 每个强化学习代码库都应断言的不变量
18.3–18.5 节中每一个存在于代码中的故障,都违反了一个在正确训练器中于第 0 步、任何训练之前就成立的恒等式。检查这些恒等式只需在一个批次上做几次前向和反向传播,而一次失败的检查对故障的定位远比一条曲线精确。本书的清单如下:- 掩码 = 策略 token。 损失掩码恰好覆盖那些目标 token 由策略采样得到的位置。
- 采样器 = 训练器对数概率。 在这些位置上,训练器重新计算的对数概率与采样器的记录在数值噪声范围内相等:这里两者都是 fp32,阈值为 ;对 bf16 引擎,阈值应根据实测的不一致分布来设定(第 13 章,13.6 节)。
- 任何一步之前比率 = 1。 在一步的第一个小批次中, 恰好为 1。
- 组内优势均值 = 0,针对组基线(7.5 节),这也检查了组的排布是否符合优势函数的预期。
- 损失对填充不变。 向批次追加填充列不会改变任何东西。
- 累积梯度 = 单遍梯度。 梯度累积复现了对整个批次做一次反向传播所得的梯度。
- 更新中
old_logp被冻结。 在第一个小批次步之后,后续小批次看到 ,所以整个更新上的approx_kl为正。 - 更新遵循优势的符号。 一次更新之后, 的回合的 上升, 的回合下降:变化量与优势正相关。
- 到参考模型的 KL 在初始化时为 0、一步之后为正(使用参考模型时)。前半句在这个训练器中由构造保证;后半句能抓住在线参考模型。
pad_batch 和 flat_grad 是两个小辅助函数:一个添加右侧填充,另一个在整个批次上运行训练器的反向传播,并把梯度作为一个向量返回。step_checks 在模型的一份副本上执行一次真实更新,检验不变量 7 到 9。
sanity_checks 完全按照训练器的方式构建批次,运行六项静态检查和三项单步检查,返回每项检查的测量值和判定结果。轨迹带有各 token 的归属(5.6 节),正是这一点让掩码检查独立于被检验的掩码。没有故障时,九项检查全部通过,数值显示了余量。掩码与归属完全一致。在 64 个回合上,采样器与训练器之间的最大差距为 :同步采样以不同于训练器的批次形状运行这些回合,因此 fp32 舍入略有不同,这就是阈值不为零的原因。填充让损失变化 。一步之后,回合对数概率的变化与优势的相关系数为 0.51;在两个小批次上做一次小的、带裁剪的更新,本来就不应期望相关系数接近 1,重要的是它的符号。现在打开每一个代码故障,再运行同样的检查,每个故障一行,每项检查一列,编号与上面的清单一致。
18.7 排查:从症状到测试
监控面板告诉你有问题。排查是一套流程,把一个症状转化为能在其各种原因之间做出判断的最便宜的测试。本书的流程有四步,按成本排序。- 运行不变量检查。
sanity_checks只需在一个批次上做几次前向和反向传播,就能识别或排除本章的每一个代码故障。在每次训练开始时运行它,并在每次改动训练器、模板、采样器或分批代码之后再次运行。 - 查看一致性指标。 高于噪声的
logp_gap、卡在平凡值上的比率或裁剪比例、恰好为 0 的 KL,这些都是 bug 而不是动态现象,不需要对照训练。 - 与无故障参照比较。 优化器指标和行为指标没有固定的正确值。它们的含义来自同一设置下的正常范围(18.2 节的表格)以及种子离散(第 17 章)。相对于参照,每次只改变一样东西。
- 用独立信号检验结果。 在策略看不到的验证器下评测留出任务,是防范奖励测错东西的唯一手段。
zero_adv_frac 趋向 1,在熵坍缩、温度不匹配、符号错误和高学习率下都出现过,所以做判断的是测试,而不是症状。顺序也很重要:一致性故障可以模仿其下游的动态失败。掩码故障提高了梯度范数、裁剪比例、熵和格式错误的调用,这些合在一起看起来就像学习率过高;而降低学习率这个自然的反应对病因毫无作用。在调任何参数之前,先修好这些便宜的不变量。
生产实践。 项目固定提交00094bd下的 verl(2026-10-13 在检查主机上阅读源码)记录了其中大部分组别:分数、奖励、优势和回报的统计量;response_length/mean、max、min和clip_ratio(在长度上限处被截断的回复所占比例);actor/entropy;actor/pg_clipfrac和actor/pg_clipfrac_lower(双重裁剪);actor/ppo_kl,即对数比率的掩码均值( 估计);梯度范数;以及使用 critic 时的critic/vf_explained_var。当 rollout 的对数概率被记录在批次中时,它还会计算training/rollout_probs_diff_mean、_max和_std,即在回复掩码上 rollout 引擎与训练器之间 token 概率的绝对差,以及它们的 Pearson 相关系数:这就是本章的一致性指标,只是作用在概率而不是对数概率上。任务相关的行为统计,例如每回合工具调用次数或漏洞利用比例,需要用户自行添加。
18.8 实践中的设计问题
问题:奖励上升却存在实现错误的训练。 现象: 训练奖励在上升,也许比以前的训练稍慢一点,于是没人再往下看。 原因: 策略梯度对许多实现故障是鲁棒的,因为正确 token 上的梯度仍然存在。一个添加了错误项、丢掉了部分正确项或移除了某种安全机制的故障,会减缓或扭曲学习,但不会让它停止;18.3 节的掩码故障就是典型案例。 可选方案: (a) 让每次训练在第 0 步都必须通过sanity_checks;(b) 记录一致性指标(logp_gap,以及本应恰好处于平凡值的比率和 KL),对任何高于噪声的值发出警报;(c) 将奖励曲线与一次固定的无故障参照训练比较。 建议做法: (a) 和 (b),它们便宜且有决定性;仅靠 (c) 无法区分较慢的 bug 与较慢的随机种子。两条奖励曲线,每四步取一次:
问题:只在留出评测中可见的静默失败。 现象: 训练侧的每个指标看起来都很好,甚至比平时更好:奖励上升、熵下降、KL 步长很小、梯度范数正常。 原因: 训练信号与目标已经分离。奖励投机是最干净的例子(第 11 章);记住的捷径(第 9 章)和衡量代理指标的奖励是其他例子。没有任何训练侧指标能看到它,因为它们衡量的都是策略与其所得奖励之间的关系。 可选方案: (a) 按固定频率在独立验证器下做留出评测;(b) 为迄今发现的每个漏洞利用建立行为统计;(c) 定期由人或模型审阅采样的轨迹。 建议做法: 始终采用 (a),对已知漏洞利用加上 (b),当 (a) 和 (b) 与轨迹所显示的情况不一致时采用 (c)。18.5 节的投机训练:
approx_kl 为 ,这是一次干净收敛的训练的样貌。它的留出隐藏测试通过率是 0.025,开始时为 0.175,到第 23 步降到 0。同样的逻辑也适用于检查点选择:按训练奖励选出的检查点会是投机最严重的那个。应在留出的验证集上选择检查点(17.8 节),并把测试集留给最终报告。
问题:无法复现的尖峰。 现象: 一次训练在grad_norm、approx_kl或奖励上出现尖峰,而用同样配置重跑却没有出现,因此无法检查原因。 原因: 要么重跑没有抽到相同的样本,要么数值上的不确定性改变了它们。 可选方案: (a) 让每一步都可重放:记录随机种子和采样器状态,并在每一步(或每个指标越过阈值的步)保存批次(token id、采样器对数概率、奖励)以及模型检查点,然后确定性地重放更新并检查该批次;(b) 在判定尖峰异常之前,先估计该指标的种子离散(第 17 章);(c) 使用批次不变的算子,以吞吐量为代价消除数值来源(第 20 章中 Thinking Machines 的报告)。 建议做法: (a) 和 (b):可重放的尖峰是一份带证据的 bug 报告,而落在种子离散范围内的尖峰只是噪声。下面的代码块测量三种重跑对前六步的影响有多大:完全相同的重跑、从乘以 噪声的权重开始的重跑(代替非确定性归约),以及换一个随机种子的重跑。
问题:在两个代码版本之间二分定位回归。 现象: 当前代码训练得比上周的差,二者之间隔着二十个提交。 原因: 其中任何一个。强化学习的回归很少出在 diff 看上去危险的地方;重构一个辅助函数或改变一个默认值,就可能翻转一个符号或错位一个掩码。 可选方案: (a) 阅读 diff;(b) 二分查找:测试中点提交,保留包含转变的那一半,重复进行,对 个提交需要 次测试;(c) 用成本最低、足以判定故障的测试做二分:有不变量失败时就用不变量,否则用一次随机种子匹配的短训练,这样每次探测都要一次训练,而且只有当回归超过种子离散时才有效(第 17 章)。 建议做法: (c)。下面的代码块模拟了一段八个提交的历史,其中提交 3 引入了符号错误;搜索在每个提交上只能看到不变量是否通过。
git bisect run 能在真实历史上自动完成同样的搜索。
小结
- 有用的监控面板包含四组指标:结果、行为、优化器状态和一致性。一致性指标(采样器与训练器的对数概率差距、必须恰好为 1 或 0 的比率和 KL)成本最低、最具决定性;在 TinyAgent 上,正常的差距低于 。
- 加入诊断记录的训练器副本逐位复现了
train_rl,并在其上复现了十种失败。在掩码、陈旧比率、累积和投机故障下,训练奖励都在上升;掩码故障在奖励上升的同时让留出通过率损失了 23 个百分点,奖励投机产生了最好看的奖励曲线和 0 的留出通过率。 - 每种失败都在某一组指标中有其特征:掩码、陈旧比率、温度和在线参考模型故障体现在一致性指标中;学习率体现在优化器和格式指标中;熵坍缩、长度偏差和投机体现在行为指标中。累积 bug 没有任何特征。
- 九项第 0 步不变量(保存为
sanity_checks)捕捉到了每一个代码故障,而且各自通过能点名该故障的检查。它们无法捕捉目标或配置层面的失败,那些需要监控面板、无故障参照和留出评测。 - 排查先运行不变量,再看一致性指标,然后与无故障参照和种子离散比较,并始终用独立验证器检验结果。让每一步可重放,有不变量时用不变量二分定位回归。这里所有训练都是小规模的机制演示:这些特征可用于排查其他系统,具体数值阈值则需要重新测量。
练习
- 改变一个假设:运行 18.3 节留下的对照实验。用
RLConfig(temperature=0.7)且不引入故障进行训练,使采样器与训练器都在 0.7 上一致。策略会学会读取吗?然后分别用SAMPLER_TEMP = 0.9和0.5重跑该故障。随着温度差距变化,罕见正优势 token 被裁剪的比例和最终留出通过率如何变化? - 覆盖
old_logp的故障,在一个 epoch、两个小批次的实验中没有造成明显影响。用epochs=4, minibatches=4重跑它和无故障训练,并按小批次记录approx_kl。缺失的裁剪从什么时候开始产生影响? - 编写一个能在一步之内捕捉熵惩罚故障的不变量。一个候选方案:把所有优势设为 0,除非有意使用正则项,否则正确的更新应保持策略不变。实现它,并列出它会误报的配置。
- 注入一个九项检查都抓不到的故障,例如在批次被打乱、脱离组排布之后再计算优势。哪项检查最接近抓住它?什么新的不变量能抓住它?
logp_gap的阈值 假设两侧都是 fp32。利用 13.6 节的 bf16 测量结果,提出一个阈值和一个统计量(均值、p99 或最大值),使其能在 bf16 引擎与训练器的组合中把数值不一致与错位一格的掩码区分开。- 让尖峰可重放:扩展
train_logged,使其在grad_norm超过其滑动中位数三倍时保存批次和模型状态,并编写replay(step),精确复现该步记录的统计量。 - 设计评审:你的 SWE 智能体的训练奖励已经稳定上升了 300 步,熵平滑下降,而留出通过率在最近 100 步中持平。一位同事提议把学习率加倍。写出你会首先执行的排查,按顺序列出每一步读取的指标或测试,以及它支持的决策。