跳转到主要内容
前十八章已经实现了环境与隐藏测试、token-in/token-out rollout、advantage、策略更新、正则化、SFT、评测和训练器检查。本章把这些 #@save 组件接成一次完整训练:先固定配置和数据划分,检查训练器并选择起点,再训练、选检查点、做消融,最后报告带区间的测试结果。重点是用证据说明智能体学到了什么,以及哪些结论还不能下。 前面保存的组件现在可以直接组装起来。下面每个代码块都只调用已保存的 arl 函数,而整个训练器展开写出、让每个阶段都清晰可见,也只需要十二行。后面有两节会离开 TinyAgent。19.7 节报告同一配方在 Qwen3-0.6B 这个真实预训练模型上、在一块 H200 上的实测运行。19.8 节把本书的每个函数对应到在大规模系统中实现它的公开基础设施,并说明哪些东西会改变。TinyAgent 的每个数字都是一个参数量 158k、有 20 个训练任务和 10 个留出(held-out)任务的模型上的机制演示(mechanism demonstration),而不是基准证据。本章不再保存新函数,重点是检查已有组件能否接成完整、可信的训练流程。
Output:
划分在训练任何东西之前就已固定。验证任务决定本章的每一个决策(保留哪个检查点、相信哪个消融结果)。测试任务对每个要报告的模型只评估一次(17.8 节)。每个划分有五个任务,每个 bug 类型的留出变体两边各分到一个。

19.1 配方及其预检

这次运行分为六个阶段,本章按顺序展开。 固定配置。 结果的可复现程度取决于产生它的配置,所以第一个代码块打印出每个字段。目标函数是本书在 9.1 节中的 GRPO 默认设置,其中每个选择都基于前面某一章的测量: 环境字段与目标函数同样重要。在函数被重命名、每个回合只允许一次编辑的条件下,任何高于 0.2 的通过率都需要在回合中收集信息(9.4 节)。奖励来自隐藏测试。
Output:
最后一行记录了两个不在 RLConfig 中却仍会改变数字的事实:库版本和 CPU 线程数。19.9 节表明,仅改变线程数就会改变运行结果。 预检。 在花费算力之前,先在第一个批次上运行 18.6 节中的九个不变量。它们只需几次前向和反向传播,合起来能抓出第 18 章目录中的每一种代码故障。检查时把 KL 系数设为 0.1,这只是为了让参考模型的不变量有东西可测。
Output:
九项检查全部通过。除了结论,也要读数值。掩码恰好覆盖策略 token(0 处不匹配)。这个批次上采样器与训练器对数概率之间的最大差距为 1.3×10−61.3 \times 10^{-6},这是浮点噪声。第一步之前比率严格为 1,组优势的平均值在 10−810^{-8} 以内为零。经过一次真实更新后,具有正优势的回合的对数概率上升了:log⁡π\log\pi 的变化与优势的相关系数为 0.55。一个团队会把这个代码块设为一道关卡,使预检失败的运行永远不会启动。

19.2 选择初始策略

强化学习只是对初始策略已经会做的事情重新加权(9.4 节),所以基座的选择是第一个能决定运行成败的决策。我们比较两个候选。第一个是 make_rl_base,它是本书中预训练模型的类比:它知道调用格式,偶尔会先读代码再做出正确的编辑。第二个在此基础上加了一个简短的 SFT 冷启动(cold start),使用少量专家数据,就像第 15 章那样。它为每个训练任务使用一条经过重命名、在训练评测框架中录制的 oracle 演示,并训练十个 SFT 步。
Output:
冷启动被刻意设计得很小,只有 260 个监督 token,使它能改变策略而不至于直接解决任务。在第 15 章中,60 步的 oracle SFT 不需要任何强化学习就达到了 0.89 的留出通过率,那样本章就没有东西可研究了。 在强化学习之前比较基座,应该测量什么?仅有 pass@1 是不够的。GRPO 从奖励各不相同的组中学习,所以有用的统计量是:成功是否会出现在策略的样本中(训练任务上的 pass@8);大小为 8 的组中携带梯度的比例;以及维持探索的熵(第 10 章)。describe 用第 17 章保存的评估函数报告这三者。
Output:
十个 SFT 步改变了每一项统计。冷启动通过了 0.37 的训练回合,而不是 0.14,pass@8 达到 0.96,并且 95% 的组携带梯度,RL 基座则为 65%。它几乎每个回合都会读代码,而且经常不止一次(每回合 1.61 次读取,部分学会了模仿 oracle 的单次读取)。代价也看得见:它的动作熵从 0.57 降到 0.38 nats,朝第 15 章中完整 SFT 产生的近乎确定性的策略走了三分之一。 这些数字预测冷启动会学得更快,但并不表明它最终会更好;而且这里它的 oracle 数据完美且免费,真实的演示数据从来不是这样。因此主运行从本书的参考起点 make_rl_base 出发,而 19.5 节把冷启动作为一个单字段消融,用三个随机种子来检验。配方就应该这样改变:一次一个决策,每个决策都有自己的证据。

19.3 训练与监控

第 18 章的 train_logged 已经包含诊断记录。本章展开它的循环,逐步展示采样、组批、计算优势、裁剪更新和记录指标,并加入保存与评估检查点的钩子;这是选择检查点所需、而原函数尚未提供的功能。
重写的循环是一个新的实现,因此第 18 章的规则同样适用:在信任它之前,先对照参考实现检查它。
Output:
参数逐位一致。第 18 章已经对照 train_rl 检查过 train_logged,所以这个循环就是本书的训练器。检查点钩子用它自己的随机数生成器在验证任务上评估,不触动训练的随机数流。19.5 节确认了这一点。 主运行训练 60 步,每步 64 个回合。每 10 步它保留一份策略的冻结副本,并在五个验证任务上评估该副本,每个任务 16 个回合。
Output:
图 19.1:综合项目运行的监控面板。上排:训练批次通过率,以及各验证检查点和它们的任务自助法 95% 区间;每回合的读取、测试和编辑次数;动作 token 上的策略熵。下排:奖励全部相等的组所占比例;每次更新前后策略之间的近似 KL(对数刻度);被训练 token 上采样器–训练器对数概率的平均差距,以及预检所用的 1e-4 阈值。 图 19.1 是这次运行的监控面板,第 18 章的每组指标各占一个面板。每个面板看起来都是正常的。
  • 结果。 批次奖励从 0.17 升到 0.88,验证通过率随之从 0.13 升到 0.88。
  • 行为。 读取最先上升。到第 10 步,策略每回合读取 1.6 次,超出了需要,到第 30 步稳定在一次。测试上升得更晚,并稳定在每回合约 0.85 次,尽管在只允许一次编辑的情况下,测试结果什么也改变不了。结果优势也会给无用的步骤记功(第 7 章)。编辑始终是一次。
  • 优化器状态。 熵从 0.57 平稳地降到 0.07 nats,没有突然坍缩。随着策略稳定下来,每步 KL 从 3×10−33 \times 10^{-3} 缩小到约 10−510^{-5}。第 0 步之后打印的每一行中裁剪比例都是 0,打印出的梯度范数保持在 0.15 到 0.29 之间,远低于 1 的裁剪阈值。
  • 一致性。 采样器–训练器差距保持在 10−710^{-7} 以下,比预检阈值低三个数量级。这在整个运行期间(而不只是在第 0 步)排除了第 18 章中的掩码、温度和陈旧比率故障。
对下一个决策最重要的面板是零优势比例。在策略学习期间它降到 0.1–0.2,然后从第 30 步起升到 0.6–0.8。此时大多数组都是全部通过,因此每一步的梯度只来自少数几个组。19.9 节用这一点来决定何时停止。

19.4 评估与检查点选择

现在我们有七个检查点。我们只用验证任务来选择一个:通过率最高者,平局时选更早的检查点。每个检查点都附上其任务级自助法区间(17.3 节)。
Output:
验证通过率在每个检查点都上升,所以规则选中最后一个,即第 60 步。第 20 步到第 50 步的区间很宽,约为 ±0.3\pm 0.3,因为五个验证任务中有一两个落后于其他任务。到第 60 步时,这些任务已经赶上,区间收窄到 [0.78, 0.98]。由于验证曲线单调,这次选择没有需要权衡的接近情形。无论如何,下面的测试数字都不受这一选择影响,因为测试任务没有参与选择。 测试评估对每个要报告的模型只运行一次:初始策略和选中的检查点。它对每个测试任务使用 32 个回合,并用 17.2 节的无偏估计量计算 pass@kk。
Output:
测试通过率 0.625 远低于同一检查点 0.875 的验证通过率,逐任务计数说明了原因。三个测试任务几乎被解决(32 次中分别通过 29、31 和 31 次)。另外两个没有。compare/under_cap 32 次中通过 4 次,zero/speed 通过 5 次,与未训练的基座(4 次和 6 次)大致相同。在这两个任务上,强化学习没有带来任何可测量的效果。这也是区间 [0.29, 0.96] 如此之宽的原因:它是在五个任务上做的自助法,其中两个与另外三个结论截然相反。pass@kk 各列显示了 17.7 节中的模式。强化学习在 k=1k = 1 时优势很大(0.63 对 0.16),并随 kk 增大而缩小(k=8k = 8 时为 0.90 对 0.78),因为基座的随机猜测最终会碰中正确的操作。 区间这么宽,提升是真的吗?比较应当按任务配对:两个模型面对相同的任务,而任务效应是最大的方差来源(17.4 节)。
Output:
配对差值为 +0.47,95% 区间为 [+0.13, +0.81],不包含零。区间仍然很宽,它也理应如此。这是一个关于任务总体的陈述,却只用五个任务来估计,而五个中有两个没有提升。这次运行的诚实报告是:留出测试 pass@1 为 0.63 [0.29, 0.96],初始策略为 0.16;配对提升 +0.47 [+0.13, +0.81];一个训练随机种子、五个测试任务,其中两个没有提升。19.6 节探究其原因。

19.5 多随机种子消融实验

配方有很多旋钮。哪些值得花算力?我们检验三个。每一个之所以被选中,要么是因为前面某章留下了悬而未决的问题,要么是因为选错的代价很高:
  1. 到参考策略的 KL(β=0.05\beta = 0.05,k3k_3 放在损失中)。第 10 章的单个随机种子倾向于它,而已发表的编程智能体配方都去掉了它(第 20 章)。
  2. SFT 冷启动。 19.2 节预测它会学得更快。
  3. 用公开测试奖励代替隐藏测试。 这不是候选方案,而是一个对照,让第 11 章的奖励投机(reward hacking)结果保持在视野中;任何削弱验证器(verifier)的配方改动都应对照它检查。
每个实验组相对默认设置只改变一处,运行 30 步(第 17 章发现此时仍有提升空间),使用随机种子 0、1 和 2。每次运行在全部十个留出任务上评估,每个任务 16 个回合。评估所用的生成器只依赖于随机种子,因此相同随机种子的各组在相同的评估随机数流上进行比较。这十二次运行总共约需 80 秒。
Output:
随机种子为 0 的默认组就是主运行的前 30 步,其参数与主运行第 30 步的检查点完全一致,因此检查点评估没有扰动训练。每个实验组都按任务与默认组比较。对每个组,我们把每个任务的通过率在三个随机种子上取平均,然后对十个逐任务差值做自助法。这个区间捕捉的是任务之间的变化。随机种子之间的离散程度打印在旁边,19.9 节会用到它。
Output:
图 19.2:消融实验,每组三个随机种子,30 步。左:各组的留出 pass@1。大圆点是随机种子上的均值,并附上对随机种子平均后逐任务比率做任务自助法得到的 95% 区间;小圆点是各个随机种子。右:在十个留出任务上相对默认组的配对差值,附 95% 区间。红色:公开测试奖励对照。 图 19.2 把这张表画成了森林图。对照每个组的区间来解读:
  • KL k3k_3 0.05。 均值为 0.706 对 0.719,配对差值为 −0.01-0.01 [−0.12-0.12, +0.13+0.13]。检测不到任何效应。熵几乎没变(0.17 对 0.18),第 10 章单个随机种子下的优势没能在三个随机种子下保持。默认设置仍为无 KL,这也省下了参考模型的显存和前向传播。
  • SFT 冷启动。 均值为 0.798,在每个随机种子下都是四个组中最好的。配对差值为 +0.08 [−0.03-0.03, +0.21+0.21],区间包含零,因此在三个随机种子和十个任务下,这只是值得继续验证的线索,还不能定论。它后期的训练奖励高得多(0.87 对 0.71),正如 19.2 节所预测,熵也更低(0.12 对 0.18)。是否采用它是一个不确定条件下的决策,19.9 节计算了要定论需要多少代价。
  • 公开测试奖励。 它的训练奖励为 0.99,是所有组中最好的曲线。在最后一步,98% 的回合使用了 hardcode_tests,而它在隐藏测试上的留出通过率为 0.004。−0.72-0.72 的效应不需要任何统计。这个组放在表中,是为了让任何读结果的人都不会忘记:训练奖励只衡量验证器所奖励的东西。
证据。 本书测得:每组三个随机种子,30 步,每个随机种子 160 个留出回合,一个参数量 158k 的模型。配对区间重采样的是任务而不是随机种子,因此在一定程度上低估了总不确定性。SFT 结果依赖于免费且完美的演示数据,真实团队没有这种条件。这些数字都不能预测同样的改动在大模型上的效果。

19.6 智能体学到了什么

通过率说明智能体成功的频率。行为统计说明它如何成功,逐任务分解说明它在哪里失败。我们从选中的检查点为每个留出任务采样 32 个回合。
Output:
智能体学会了一套流程:读取,应用一个操作,测试,提交。它基本上每个回合都会读取(每回合 1.02 次),并在 93% 的回合中执行编辑。它的通过率等于首次编辑的准确率(0.74):只要选对操作就会成功,所以每次失败都是选错或者没有编辑。最常见的五个序列就是这套流程分别配上五种操作,而它们的计数并不均匀。swap_minmax 出现在 60 个回合中,而 fix_comparison 只有 27 个,尽管每种操作都恰好在十个任务中的两个上正确(各 64 个回合)。逐任务表格显示了这些回合去了哪里。
Output:
有三种缺陷类型被解决了:minmax、range 和 init 在两个留出变体上的通过率都在 0.91 到 1.00 之间。弱点在于智能体相互混淆的两种缺陷类型。compare 和 zero 的通过率在 0.06 到 0.72 之间,而且错误是交叉的。在 under_cap 上,智能体在 32 个回合中有 23 个应用了 zero 类型的修复 add_zero_guard。在 speed 上,它在 19 个回合中应用了 compare 类型的修复 fix_comparison,另有 10 个回合没有编辑就提交了。把读取到的观测与 oracle 的关键词规则对比,并各看一个失败案例。
Output:
在 oracle 的规则下,两份源代码都毫无歧义:比较运算符意味着 fix_comparison,// 意味着 add_zero_guard。智能体学到的是另一条规则。在第一个失败案例中,它读到 return load < cap,应用了除零保护,然后运行测试,测试报告了失败(fail ( 2 , 2 ) -> False want True)。随后它照样提交,因为一次编辑的预算让它别无他法。在第二个案例中,它读到 miles * 2 // hours,应用 fix_comparison,得到 no change,然后提交。它的观测中包含正确的证据,而在这些留出变体上,它依据的是证据中错误的那部分。这两种缺陷类型的源代码也是最短的,只有一行、几个 token,而且它们的留出变体使用了训练中从未见过的操作数名称和常数。我们无法从权重中读出策略的特征。我们能说的是,对这两种缺陷类型,它学到的特征不能迁移到每一个变体上,而训练奖励无法揭示这一点,因为同样的特征在训练变体上是有效的。19.4 节的测试结果是透过两个任务看到的同一事实:under_cap 和 speed 正是这两种缺陷类型在测试集一侧的成员。 这是整个运行最有用的产出,因为它指明了下一个实验:在对目标函数做任何改动之前,先为 compare 和 zero 增加更多代码变体,使用不同的操作数名称,或者像 17.6 节那样做留出类型评估。练习 4 直接探测这些特征。

19.7 在真实模型上运行同一循环

TinyAgent 的设计目的,是以 CPU 级的成本让每个机制都可见。换成真实预训练模型后,这些组件能否继续工作?下面用两个可选实验作初步检验(见它们的 README)。两者都训练 Qwen3-0.6B,使用来自 agentrl 的相同任务、隐藏测试奖励、组优势和裁剪损失。第一个实验 tutorial/labs/qwen_grpo_bugfix.py 保留 gym 的动作菜单。第二个实验 tutorial/labs/qwen_grpo_tools.py 用通用编程工具取代菜单,就像生产中的智能体那样。我们先看第一个。只有真实模型所需的部分是新的:
  • 带工具的真实对话模板(chat template)。 4.7 节的四个工具模式由 Qwen3 自己的模板在非思考模式下渲染。模型写出 <tool_call>{"name": "edit", "arguments": {"op": ...}}</tool_call>,一个解析器把 JSON 映射为 gym 的 <call> edit op </call>。其他任何内容,包括普通文字,都是一次格式错误的调用,会耗掉一轮。
  • token-in/token-out(5.4 节)。 采样得到的 id 按采样原样保留。每个工具结果以模板中工具响应块的确切 token id 追加。启动时,脚本断言这些 id 与模板对同一对话的渲染结果相等。
  • 用 Hugging Face generate 做批量生成,温度为 1,禁用 top-k 和 top-p,使采样分布就是训练器求导的那个分布(3.3 节)。不使用 vLLM;生成和训练都在一块 GPU 上以 fp32 运行。
  • 只在损失需要的位置计算 logits。 语言模型头只在策略位置上运行。完整的 [B, T, 151936] logits 每 1,000 token 的一行约需 0.6 GB(14.3 节)。更新在微批次上累积,整个小批次使用同一个归一化因子(14.4 节)。
rollout 的核心很短。下面的节选来自该脚本,本书的检查器不会执行它:
这次运行使用 30 个 GRPO 步,每步 8 个训练任务 × 8 个回合,AdamW 学习率为 10−610^{-6},每轮最多 64 个新 token,并在训练前后各做一次留出评估,每个任务 8 个回合。下面的代码块读取保存的结果文件并打印。这些是 2026-10-14 一次运行的实测数字。
Output:
预训练模型起始的留出通过率为 0.20 [0.06, 0.39],相当于盲目单次猜测的水平。它的主要问题是格式,而不是能力:每回合 1.69 次格式错误的调用,只有 61% 的回合最终提交。它评估中的第一个回合说明了原因。在一次格式正确的编辑之后,它用文字作答(“The function issue was fixed by adjusting the range …”),然后反复向用户询问函数名,gym 将这些记为格式错误的调用。30 步之后,通过率为 0.80 [0.50, 1.00],在十个留出任务上的配对提升为 +0.60 [+0.33, +0.88]。不再有格式错误的调用,每个回合都提交,每个回合都先读取。
Output:
格式最先被学会,而且几乎是立刻学会的。格式错误的调用从每回合 1.73 次降到第 5 步的 0.16 次,到第 10 步降为零;随着文字回答消失,每回合策略 token 数从 113 降到约 56。奖励随之上升。然后策略停止了探索。其动作 token 的熵从 0.55 nats 降到第 10 步的 0.03,到第 15 步降为 0.000。从第 15 步起,大多数步的零优势比例为 1.00:对某个任务的每组 8 次尝试都产生了完全相同的回合,所以梯度恰好为零,批次奖励只随恰好抽到的任务而变动。最终评估显示,哪些行为随着探索停止而固定了下来。训练后的逐任务计数全是 8 中 8 或 8 中 0,在这次评测中,策略表现得近乎确定。它每次都解决十个留出任务中的八个,每次都在两个 range 任务上失败,因为它对这两个任务应用了 fix_init(结果文件中保存的示例显示了这一点)。range 类型从未被稳定地解决,而一旦熵降到零,range 任务上的组内很难再采样到成功,因此这次训练未能纠正这些错误。 采样器–训练器差距保持在每 token 4×10−84 \times 10^{-8} 到 3×10−63 \times 10^{-6} nats 之间。两边都以 fp32 运行,所以这是带 KV cache、左填充的生成路径与一次完整前向传播之间的差距,比 13.6 节用同一模型测得的 bf16 差距低约四个数量级。这次运行在一块共享的 H200 上耗时 9.5 分钟,GPU 显存峰值为 32.8 GB。
证据。 本书于 2026-10-14 测得,仅一次:一个随机种子,30 步,在 10 个任务上做留出评估,每个任务 8 个回合。0.20 → 0.80 的提升对这次运行是真实的(配对区间不包含零),但其中大部分是模型学会了 gym 的工具调用格式和“读取—编辑—测试—提交”流程,这是预训练模型所缺少的,而不是其修复代码能力的普遍提升。在 10−610^{-6} 的学习率下 15 步内发生的熵坍缩是最具可迁移性的观察:在真实模型上,就像第 10 章中的 TinyAgent 一样,熵是一种资源,一次小规模的强化学习运行就可能把它完全耗尽,而到那时仍未解决的问题就会一直无法解决。我们没有调学习率,也没有运行其他随机种子;练习 5 会去做。

用通用工具取代编辑菜单

上面的运行有一个不真实之处:模型从一份菜单中选择修复(edit swap_minmax),由 gym 中预先编写的代码完成修改。生产中的编程智能体拿到的是通用工具,修改要由它们自己写出。第二个实验 tutorial/labs/qwen_grpo_tools.py 以 Claude Code 的工具集为参照:工具名和参数遵循 Claude Code 的工具参考文档(2026-10-14 查阅),工具描述是我们自己写的简短版本。任务、奖励、优势、损失、更新以及 token-in/token-out 路径都与第一个实验相同。接口的变化如下:
  • 每个回合一个仓库。 每个任务按本书默认的强化学习设置(9.4 节)用 arl.randomize_names 换成一个无关的 gym 函数名,然后变成两个内存中的文件:有缺陷的源文件 <fn>.py,以及以 pytest 函数形式保存公开测试的 test_<fn>.py。隐藏测试从不进入仓库。
  • 四个工具。 Read(file_path, offset, limit) 返回带行号的文件内容。Edit(file_path, old_string, new_string, replace_all) 做精确字符串替换;如果 old_string 不存在,或者在未设置 replace_all 时出现多次,或者本回合还没有读过该文件,它就会失败。要求先 Read 是我们的选择;参考文档只对部分模型这样要求。Write(file_path, content) 覆盖或创建文件。Bash(command, timeout) 是一个白名单模拟,支持 pytest、python <file>.py、ls、cat 和 grep;其他任何命令都返回一条错误观测。Grep 和 Glob 没有包含在内,因为参考文档说它们在 Linux 上默认不存在,Claude Code 在那里通过 Bash 搜索。
  • 回合像真实智能体那样结束。 不含工具调用的助手轮次就是最终回答。否则回合在 6 轮预算用完时结束。如果最终的 <fn>.py 通过全部隐藏测试,奖励为 1。格式错误的调用和失败的调用都返回一条错误观测,并耗掉这一轮。
  • 模型写的代码在子进程中运行。 每次运行测试、每次 python <file>.py,都会把仓库复制到一个新的临时目录,然后以空环境和 10 秒超时启动 python -I -S。在导入任何仓库代码之前,子进程先用 resource 设定限制:5 个 CPU 秒、1 GiB 地址空间、每个写出的文件 1 MiB,并且不能创建子进程。隐藏测试的评分以同样方式运行。训练器进程从不对模型输出调用 exec。这是在受控主机上带资源限制的进程隔离,不是安全沙箱:子进程仍然可以读取该用户的文件并建立网络连接。生产中模型写的代码应当放在没有网络访问的容器或虚拟机中运行(4.7 节)。
Edit 工具是模型必须学会满足的部分。下面的节选来自该脚本,本书的检查器不会执行它:
这次运行使用 18 个 GRPO 步,每步 8 个训练任务 × 8 个回合,学习率 10−610^{-6},每轮最多 256 个新 token,训练前后在每个留出任务上各评估 8 个回合。由于 GPU 是共享的,生成按每批 16 个回合分块进行。以下是 2026-10-14 一次运行中测得的数字:
Output:
Output:
预训练模型的起点是 0.10 [0.02, 0.21],是它在编辑菜单下起点的一半。格式不再是主要问题:每个回合只有 0.09 次格式错误的调用,99% 的回合以最终回答结束。它的问题是通过通用工具正确地行动。每个回合有 0.85 次失败的调用,主要是 old_string 不在文件中的 Edit(0.30)、在任何 Read 之前的 Edit(0.20),以及两个字符串完全相同的 Edit(0.25)。在第一次评估和第一个训练步保存的 20 个示例回合中,每个找不到的 old_string 都是编造的文本,例如 'saturate_result' 或 'print(5)',而不是空白字符上的细微偏差。模型每个回合还调用 0.46 次 Write,在保存的示例中,这些调用覆盖了源文件或某个测试文件,或者创建了新文件。它很少运行测试(每个回合 0.07 次 Bash 调用),而且无论编辑做了什么,通常都以一句成功声明结束,例如“The bug was fixed, and the tests now pass”。 18 步之后,留出通过率为 0.49 [0.24, 0.75],配对提升为 +0.39 [+0.15, +0.65]。模型学会了满足工具的要求:没有格式错误的调用,没有找不到的 old_string,没有先于 Read 的 Edit,每个回合调用 Edit 1.00 次。学到的流程是:Read 源文件,用一个 Edit 把 Read 输出中的一整行原样复制为 old_string(保留缩进,去掉行号前缀),然后作答。Write 从第 0 步的每回合 0.52 次降到第 17 步的 0.02 次。minmax 和 compare 两种缺陷类型 8 次全部解决。在 compare/qualifies 上,模型把 score > cutoff + 1 改成了 score > cutoff。这个修复不是 gym 的 fix_comparison(>=),但在整数上与之等价,并通过了隐藏测试,而编辑菜单无法表达它。 有两件事没有学到。第一,模型不再测试。Bash 调用降到零,而当编辑是错的时候,最终回答仍然说“The public tests are now passing correctly.”。奖励不依赖于是否运行测试,一次测试要耗掉一轮和若干 token,而奖励对这个习惯没有直接的回报,所以没有任何力量让它保留下来。第二,剩下的失败是语义上的,而不是机械上的。在 range 任务上,它修改循环体(out += i * limit、t += 5),而不是 range 的终点。在 zero 任务上,它把 // 换成 /,或者做一个两个字符串完全相同的 Edit,这是唯一剩下的失败类型(每回合 0.21 次)。在 init 任务上,它把初始值设成一个错误的常数。与此同时,策略的熵从第 4 步的 0.185 nats 降到第 17 步的 0.121(第 0 步没有任何组的优势非零,因此没有更新,也没有熵)。它在 18 步内没有像菜单策略那样在第 15 步坍缩,但两次运行的长度不同,动作 token 的含义也不同,所以这不是同条件的比较。这次运行耗时 20.0 分钟,GPU 显存峰值为 14.2 GB。
证据。 本书于 2026-10-14 测得,仅一次:一个随机种子,18 步,在 10 个任务上做留出评估,每个任务 8 个回合。对这次运行而言提升是真实的(配对区间不包含零)。两个实验的差别不只在接口(步数、每轮 token 数也不同),所以这张表是把两个机制演示并排放在一起,而不是受控比较。起点估计带有噪声:更早的一次尝试使用 32 个回合的生成分块和 8 轮预算,在共享 GPU 上于第 2 步因显存不足而停止,它在训练前对同样的留出任务测得 0.05。最具可迁移性的观察是测试行为的消失。一个不关心智能体如何得到答案的结果奖励不会保留验证行为;一个不经检查就声称成功的策略是预期中的结果,而不是异常。

19.8 扩展规模

本书的系统在一个进程、一个 CPU 上几分钟就能跑完。生产中的智能体强化学习在数百块 GPU 上把同样的阶段运行数天;DeepSWE 报告在 64 块 H100 GPU 上用 4.5K 个 R2E-Gym 问题训练 Qwen3-32B 六天(DeepSWE,2025-07-02,作者报告)。图 19.3 把每个阶段对应到实现它的公开基础设施,下表为本书的每个函数列出其生产系统中的对应组件,以及扩大规模后哪些部分需要调整。生产一栏只引用记录在 sources.md 和第 20 章中的来源;除非给出来源,第三栏陈述的是一般性的工程观察。我们读过这些系统的文档和部分源文件,但没有运行它们。 图 19.3:综合项目的各个阶段、实现每个阶段的 arl 函数,以及在大规模系统中实现同一阶段的公开系统。实线箭头在一步之内传递数据;虚线箭头是到达采样器的权重更新。 本书的两处简化掩盖了最大的变化。第一,本书的环境只执行编写好的可信代码。它的智能体只是在固定的编辑操作中做选择。真实的智能体会编写代码,就像 19.7 节的工具实验那样,而安全地执行这些代码本身就是一个系统问题。该实验中带资源限制的子进程只是朝这个方向迈出的最小一步,而不是沙箱(第 4 章)。第二,本书的采样器和训练器是同一个进程中的同一个模块,所以它们的对数概率一致到 10−810^{-8}。在生产中它们是不同的引擎,往往精度也不同,13.6 节对 bf16 的 Qwen3-0.6B 测得平均差距为 0.03 nats。右侧一栏中的每个组件之所以存在,都是因为这两个事实中的某一个不再成立。

19.9 实践中的设计问题

问题:在算力预算下选择消融什么。 现象: 一个团队在十几个旋钮上每种配置只跑一个随机种子,按留出得分排序,而排序无法复现。 原因: 单次运行的得分把改动的效应与随机种子噪声和评估噪声混在一起,而合理配置之间的差异往往比这两种噪声都小。 可选方案: (a) 更少的实验组、更多的随机种子,依据先验证据和决策出错的代价来选择;(b) 配对设计,让各组共享任务、评估随机数流和随机种子,从而把任务方差从比较中去除;(c) 先廉价地筛选许多组(短运行、一个随机种子),再用许多随机种子确认少数幸存者。 建议做法: 始终采用 (b),并根据运行前所做的功效计算选择 (a) 或 (c)。19.5 节的消融实验给出了这一计算的输入。
Output:
一个组的留出均值在不同随机种子间的波动约为 0.04。每组三个随机种子能以 80% 的功效检测出 0.10 的差异,但 0.05 的差异需要每组 12 个随机种子,0.02 则需要 70 个。SFT 组的 +0.08 落在三个随机种子无法定论的区域,这就是 19.5 节中“提示性”一词的定量版本。任务一栏说明了另一重样本量限制:两个几乎相当的组之间,逐任务差值的标准差为 0.21,因此仅靠任务级配对来检测 0.05 的效应需要约 140 个任务。无论随机种子有多少,十个留出任务都限定了这个 gym 所能分辨的精度。后训练团队在面对只有几百个任务的基准时,面临的是同样的算术。
问题:决定何时停止训练。 现象: 训练奖励仍在缓慢上升,留出检查点持平或有噪声,而运行仍在消耗算力。 原因: 随着策略使训练分布饱和,越来越多的组变为全部通过,因此每一步的梯度来自更少的任务,剩下的学习大多是锐化(熵持续下降)。验证衡量的是这种锐化是否仍能迁移。 可选方案: (a) 当验证在设定数量的检查点内没有改善(对照其区间判断)时停止;(b) 当零优势比例持续偏高时停止或改变任务分布,因为训练信号已经耗尽(14.5 节);(c) 事先选定的固定预算,可复现但盲目。 建议做法: (a) 和 (b) 结合使用,并在运行前写下决策规则。主运行的两个信号:
Output:
从第 30 步起,60–80% 的组不携带梯度,而验证通过率从 0.73 缓慢升到 0.88,各区间直到第 60 步之前都相互重叠(19.4 节)。在规则 (a)“连续两个检查点没有超出前一个区间的改善”下,这次运行会在第 40 步停止,并失去最后的提升。在规则 (b) 下,高零优势比例说明后续步骤只从少数几个组中学习,而 19.6 节指出了是哪些组:仍未解决的 compare 和 zero 变体。在第 30 步时,更好的算力用法是一个更频繁采样这些任务的课程(arl.train_curriculum),或者为两种薄弱的缺陷类型增加新变体,而不是在已饱和的分布上继续训练更多步。
问题:跨主机的可复现性。 现象: 相同的代码、配置和随机种子在两台机器上,或在同一台机器的不同负载或线程设置下,产生不同的学习曲线。 原因: 浮点加法不满足结合律。CPU 和 GPU 的 kernel 会根据线程数、批次形状和硬件来划分归约,因此梯度在最后几位上不同。参数逐渐分离,一旦某个采样 token 落到概率边界的另一侧,轨迹就会分叉,两次运行就成了不同的随机抽样(Defeating Nondeterminism in LLM Inference 对推理 kernel 提出了同样的观点)。第 10 章和第 12 章在线程数改变导致运行结果改变时遇到过这一点。 可选方案: (a) 固定并记录所有进入数值计算的因素(线程数、库版本、硬件、批次形状);(b) 在可用时使用确定性或批次不变的 kernel,代价是速度;(c) 把运行视为随机抽样,在随机种子之间比较分布,永远不要跨主机比较单条曲线。 建议做法: 调试和重放时用 (a),每个结论都用 (c);只有当逐位重放很重要时(例如采样器–训练器这一对)才用 (b)。下面用 1 个线程而不是 4 个线程重跑主运行的前 30 步,复现这一症状:
Output:
在前 22 步中,两次运行采样了完全相同的批次,因为微小的参数差异很少能让一个采样 token 越过概率边界。在第 22 步,有一个 token 越过了,从那以后两次运行就是不同的抽样。到第 30 步,部分参数相差 0.017,留出通过率分别为 0.731 和 0.694,差值在上面测得的 0.04 的随机种子波动范围之内。两个数字都没有错。如果方法优劣的判断会随这点差异翻转,现有实验就不足以支持该判断。

接下来做什么

你现在已经构建、运行并测量了智能体强化学习系统的每个阶段。还剩两个方向:阅读在规模上运行这些阶段的工作,以及在公开记录还很薄弱的地方做研究。 阅读路线。 按以下顺序阅读这些一手来源,每一篇都对照为它做铺垫的那一章:
  1. 读 DeepSeekMath 了解 GRPO,然后把 DAPO 和 Dr. GRPO 当作相对它的差异来读(第 9 章)。
  2. DeepSWE 报告和 SkyRL-Agent,这是最详细的公开多轮编程配方。把它们的每个选择对应到 RLConfig 的一个字段(第 20 章)。
  3. R2E-Gym 和 SWE-Gym,了解任务和验证器是如何构建的(第 4 章和第 11 章)。
  4. HybridFlow(verl)和 AReaL,了解围绕损失函数的系统,以及第 20 章中关于训推不一致(training–inference mismatch)的笔记(第 13 章)。
  5. 然后在固定提交下阅读 verl 的 core_algos.py,并把本书的函数放在旁边对照。其中的每个函数现在都应该有一个你写过的对应物。
研究项目。 下面每一个都可以从本书的 gym 开始,并扩展到 Qwen 实验:
  1. 在易混淆的缺陷类型之间实现迁移。 19.6 节中训练好的智能体在留出变体上把 compare 与 zero 混淆了。找出它使用了哪些输入特征(对读取观测中的 token 做消融,观察所选择的操作),并检验更多的代码变体或一个辅助损失能否修复迁移。
  2. 真实模型中的熵坍缩。 Qwen3-0.6B 在 15 步内变成确定性的,并冻结了一个错误的映射(19.7 节)。在那次运行上比较更低的学习率、clip-higher、熵目标和 KL 锚点,以留出通过率和仍未解决的缺陷类型数量来衡量。
  3. 节省 rollout 的课程。 在第 30 步之后零优势组占 60–80% 的情况下,测量 train_curriculum、动态采样和自适应组大小在每条 rollout 上带来的留出通过率。
  4. 长时程下的轮级回报归因。 提高 max_turns,加入步成本,并在足够长、使差异显现的回合上比较结果优势、基于 critic 的优势和轮级优势(第 7 章和第 12 章)。
  5. 验证器的鲁棒性。 在 hardcode_tests 之外添加更多漏洞,测量在公开奖励下每个漏洞被发现的速度,以及哪种检测信号最先抓到它(第 11 章)。
  6. 在真实模型上做异步训练。 用一步 off-policy 采样和 tis_weights 校正扩展该实验,并测量 bf16 下的采样器–训练器差距和稳定性(第 13 章)。

小结

  • 每个阶段都已保存之后,完整的流水线很短:一个固定的 RLConfig,在第一个批次上运行 sanity_checks,一个由 sample_step、collate、debug_update 和 log_metrics 构建的十二行训练器(与 train_logged 逐位一致),在验证任务上选择检查点,以及用 sample_counts、bootstrap_ci 和 paired_bootstrap_diff 做的测试报告。
  • 综合项目运行(从 make_rl_base 出发的默认 GRPO,60 步,一个随机种子)通过了全部九个预检不变量,使采样器–训练器差距保持在 10−710^{-7} 以下,并把留出测试 pass@1 从 0.16 提高到 0.63 [0.29, 0.96],在五个测试任务上的配对提升为 +0.47 [+0.13, +0.81]。验证选中了最后一个检查点。
  • 三个随机种子的消融实验发现:小 KL 项没有效应(−0.01-0.01);10 步 SFT 冷启动带来提示性但未确立的提升(+0.08 [−0.03-0.03, +0.21+0.21]);公开测试奖励下出现了预期中的崩溃(训练奖励 0.99,留出 0.004)。要分辨 0.05 的效应,需要每组约 12 个随机种子,仅靠任务级配对则需要约 140 个任务。
  • 行为统计揭示了学到的东西:一套“读取、编辑、测试、提交”的流程,它解决了三种缺陷类型,却在留出变体上把 compare 与 zero 混淆。指明下一个实验的是这个失败,而不是主打数字。
  • 在 Qwen3-0.6B 上,使用相同的奖励、优势和损失,30 个 GRPO 步在一块 H200 上用 9.5 分钟把留出 pass@1 从 0.20 提高到 0.80,主要是通过学会格式和流程;同时在第 15 步时把策略的熵降到零,使一种缺陷类型的错误映射固定下来。
  • 用 Claude Code 风格的工具(Read、精确字符串 Edit、Write 和白名单 Bash)取代编辑菜单后,18 步把 Qwen3-0.6B 从 0.10 提高到 0.49。强化学习消除了编造的 old_string 和其他工具失败,但策略也不再运行测试,并且无论结果如何都声称成功,因为结果奖励不依赖于验证。
  • 本书的每个函数都有生产系统中的对应组件(rollout 用 vLLM 和 SGLang;训练用 verl、OpenRLHF 和 slime;容器中的 R2E-Gym 和 SWE-Gym 环境;带重要性校正的异步流水线)。规模改变的是回合长度、环境的信任模型以及采样器与训练器之间的一致性,而不是数学。

练习

  1. 改变一个假设:从 sft_base 而不是 base 重跑 19.3 节的主运行。验证会选择不同的检查点吗?报告测试通过率及其区间,以及相对本章运行的配对差值,并说明这是否会改变 19.5 节中的决策。
  2. 19.4 节的验证曲线在每个检查点都上升。把运行延长到 120 步,每 10 步一个检查点。事先利用 19.9 节的两个信号写出一条停止规则,应用它,并报告它节省了多少步、在测试通过率上付出了多少代价。
  3. 利用 19.9 节的功效计算,设计一个能够定论 SFT 冷启动是否能把留出 pass@1 提高至少 0.05 的最小实验。通过在各组之间配对随机种子,你能节省多少所需预算?如果预算允许,运行它。
  4. 探测 19.6 节中的混淆。对 zero/speed,编辑读取到的观测(去掉 * 2、重命名操作数、换成某个训练变体的名称),并记录每次改动后训练好的策略选择了哪个操作。是哪些 token 驱动了它的选择?
  5. 在 Qwen 实验中,熵降到 0 之后,range 类型在 16 个留出回合中始终是 0 次成功。预测把学习率降到 5×10−75 \times 10^{-7},或加入 clip_high=0.28,会对熵坍缩发生的步数和 range 类型产生什么影响。然后设计一次能在 40 GPU 分钟内检验该预测的运行。
  6. 为图 19.1 的监控面板添加一个基于 info 的漏洞利用计数器,使它能在不知道名称的情况下抓到一种新的漏洞利用操作(不是 hardcode_tests)。它依赖于被投机利用的回合的什么性质?什么时候会产生误报?
  7. 设计评审:你们团队的 SWE 智能体在一次强化学习运行后,在一个 500 任务的留出集上 pass@1 从 38% 提高到 44%,这是用一个随机种子、一次评估测得的。你的经理想要发布并宣布提升了 6 个百分点。利用本章内容,列出你在同意之前会要求的证据,以及每一项所需的计算和大致代价。
  8. 在工具实验中改变一个假设:训练后的策略不再调用 Bash。预测下列每一项对测试频率和留出 pass@1 的影响:(a) 只有在运行测试之后才允许第二次 Edit;(b) 对通过公开测试给予部分奖励;(c) 从一个在少量“先测试再作答”的示范上微调过的策略开始(第 15 章)。其中哪一项(如果有的话)能阻止策略声称它并未检查过的成功?设计一次在 40 GPU 分钟内检验其中一项的运行。