train_rl,并把每个已发表的变体(Dr. GRPO、DAPO、RLOO、GSPO)表达为这一个循环的若干配置字段,而不是各自独立的程序。第 10 章到第 19 章都用它训练。随后我们在 BugFix gym 上运行它,遇到一个任何目标函数都修不好的问题:智能体(agent)学会了不读代码就通过训练任务。用行为统计诊断这个问题、并通过修改环境堵住捷径,是 9.4 节的核心,它也决定了全书其余部分所用的任务设置。本章最后对各变体做小规模的受控消融实验(ablation),并如实标注它们的性质:这是在一个 158k 参数模型上的机制演示(mechanism demonstration),不是基准评测证据。
9.1 DeepSeekMath 中的 GRPO
DeepSeekMath(Shao et al., 2024)针对一个问题 ,用从 采样的 个输出 定义 GRPO: 其中结果优势 由输出 的每个 token 共享,逐 token 的 KL 估计为 ,即 估计量(estimator)。对照前几章来读,它由四个选择组成:- 组采样。 同一步内对每个任务做 次尝试(DeepSeekMath 报告 )。
- 不依赖 critic 的优势。 以组均值为基线、组标准差为尺度:即 7.5 节的
group_advantages。 - PPO 的逐 token 裁剪比率(8.2 节),聚合方式是先在每个序列内对 token 取平均,再对序列取平均:即 6.6 节的
seq_mean_token_mean。 - 在损失中加入对冻结参考策略(reference policy)的 KL 惩罚,系数为 (报告值为 0.04),而不是像 RLHF 风格的 PPO 那样作为逐 token 的奖励。
RLConfig 汇集了 train_rl 读取的全部设置。默认值是本书在 9.4 节最终得到的那个 gym 版本上的 GRPO 配置。属于某个变体的字段在 9.5 节介绍。
token_mean 取代了 seq_mean_token_mean,原因是 6.6 节讨论的长度问题,9.7 节会再回到这一点。kl_coef 为 0,与 DAPO 以及第 20 章中公开的代码智能体配方一致;第 10 章专门研究 KL 项。另外,每个批次切成 2 个小批次,这样裁剪会被触发,但更新仍然接近 on-policy(8.6 节)。
9.2 训练循环
train_rl 由五个部分组装而成,每部分都单独保存,以便后面的章节替换其中之一:环境工厂与组采样器、行为统计、优势、裁剪更新,以及循环本身。
按组采样
make_env 按配置的描述构建一个环境。它默认构建的环境 EditBudgetEnv 在 9.4 节定义;当 max_edits=None 时,它原样返回第 4 章的 BugFixEnv。sample_groups 把任务排成连续的、每组 个的布局,这正是 group_advantages 所期望的布局,并用第 5 章的 rollout_batch 让所有回合(episode)同步推进。设置了 cfg.rename 时,每个回合的任务都会经过 randomize_names(9.4 节),它使用一个由 torch 生成器播种的 Python 随机流,因此整个步骤可以由一个随机种子完全复现。env_factory 参数是接入自定义环境和奖励的钩子(第 11 章会用到)。
智能体做了什么
仅凭奖励无法区分读代码的智能体和猜得准的智能体,而 9.4 节恰恰依赖于把两者区分开。behavior_stats 按回合报告:隐藏测试通过率、奖励、read、test 调用次数和实际生效的 edit 调用次数、第一次生效的编辑是否为该任务的正确操作、轮数,以及策略 token 数。每个任务的正确操作取自第 4 章的任务类型表,因此 9.4 节中改过名的任务仍按其原始身份计分。
优势与 KL 项
compute_advantages 把一个整理好的批次转换为每个回合一个优势。奖励可以先经过 DAPO 的超长惩罚塑形(9.5 节);三种优势模式都是第 7 章的估计量。kl_penalty 是用 采样的 token 对 做的逐 token 估计,把对数比率 记为 :,,。第 10 章推导它们的偏差、方差,以及把它们用作损失时产生的梯度。
更新
policy_loss 按比率的层级分派:逐 token 比率交给第 8 章的 ppo_policy_loss,序列级比率交给 gspo_policy_loss(9.5 节)。rl_update 就是去掉 critic 的第 8 章 PPO 阶段。它通过对批次做一次无梯度前向传播来冻结 ,在给定参考模型时也冻结 (按 8.6 节的建议,训练器在采样温度下重新计算 old_logp),然后运行 cfg.epochs 遍、每遍 cfg.minibatches 个裁剪更新步。KL 项在策略 token 上取平均。熵(entropy)由更新本来就有的 logits 计算,所以记录它不需要额外代价。
循环
sample_step 抽取 prompts_per_step 个互不相同的训练任务,并为它们采样组。启用动态采样时,它只保留奖励不全相同的组,并继续采样更多任务,直到批次填满或达到轮数上限;9.5 节解释这一选择。它同时返回保留的回合和所有采样过的回合,因为一个训练步的成本取决于采样了多少。
train_rl 运行 steps 次“采样、计算优势、更新、记录”的迭代。它原地训练 model,并返回一个历史记录,每步一个字典:所有采样回合的行为统计;更新的统计量(clip_frac、approx_kl、比率范围、entropy、grad_norm,使用参考模型时还有 kl_ref);采样的回合数;奖励全部相同的采样组所占比例(zero_adv_frac);以及每隔 eval_every 步在 eval_tasks 上、每个任务 4 个回合的通过率。当 kl_coef > 0 且没有传入参考模型时,参考模型是初始模型的一份冻结副本。
evaluate 在与训练相同的环境设置下,对每个任务采样 n_samples 个回合,返回行为统计。默认是温度 1 下采样的 pass@1;第 17 章会认真讨论评估。
9.3 在原始 gym 上运行 GRPO
我们先完全照搬 第 6 章的设置:基座智能体(base agent),第 4 章的 gym,保留函数名、不限编辑次数。唯一的改变是更新方式,换成 GRPO,每组 8 个、每步 8 个任务。rename=False 和 max_edits=None 选择原始 gym。
9.4 环境设计中的捷径特征
捷径特征(shortcut feature)是这样一种输入:它在训练分布上能预测奖励,却不携带任务本应要求的信息。优化器之所以会找到捷径,是因为捷径比预期的技能更便宜。行为统计指向了 BugFix gym 的第一个捷径:issue 文本。issue : 之后第一个词映射到某个操作的策略,根本不需要从环境获取任何信息。在留出任务上,函数名都是它从未训练过的词,这个映射便失去了作用。真实基准也会以同样的方式泄漏(leakage)。SWE-Bench+(Aleithan et al., 2024)报告,在它检查的 SWE-bench 成功补丁中,有 32.67% 的解法“直接写在 issue 报告中”(“directly provided in the issue report”);SWE-Bench Illusion(Liang et al., 2025)报告,模型仅凭 issue 文本就能以最高 76% 的准确率识别出有 bug 的文件路径,而在基准之外的仓库上最高降到 53%,作者将此解读为记忆(第 20 章)。两种情况下,issue 都携带了真实工程师必须通过读代码才能获得的信息。
修复应当放在环境里:让捷径不再携带信息。randomize_names 把任务 issue 和源码中的函数改名,新名字从 gym 全部 30 个函数名中随机抽取,并排除源码中已出现的词。所有名字都在第 5 章的词表(vocabulary)内,因此不需要修改分词器(tokenizer)。对每个回合独立地应用改名之后,函数名就成了噪声:每个名字与每个任务类型同样频繁地出现,策略再也无法凭它预测操作。
name,所以日志和 FIX_OP 仍能识别它。我们把 clamp 任务改名两次,并检查每个任务的每个改名后的 issue 和源码在编码时都没有未知 token。
第二个捷径
改名把训练与留出通过率之间的差距从 0.58 缩小到 0.12(0.59 对 0.48),但并没有带来读代码的行为:每个回合 0.01 次读取。第一次编辑现在只在 22% 和 18% 的回合中正确,相当于均匀猜测的水平,而策略转而在每个回合中生效约 2.5 次编辑。它切换到了第 6 章发现的另一种策略:堆叠编辑。错误的操作通常不改变源码,或者只做无害的改动,因此按固定顺序应用几个操作,就能在不看代码的情况下修复好几个任务类型。这能走多远?blind_pass_rate 对每个任务应用一个固定的操作序列,然后运行隐藏测试。
EditBudgetEnv 拒绝超过 max_edits 的编辑:这次调用消耗一轮,返回 no change,源码保持不变。
read 的观测,或者编辑之前的 test 输出。我们再一次从基座智能体重新训练,这次使用本书的默认配置:函数改名、一次编辑。
更根本的探索问题
两个捷径都关闭之后,策略也没有学会读代码。它的通过率停在盲目上限,训练任务 0.19、留出任务 0.15,读取次数从第一步的每回合 0.30 次降到 0.02 次。随着策略把唯一的那次猜测从一个操作换到另一个,批次奖励在 0.14 到 0.34 之间游移。环境现在要求读代码,而策略梯度仍然找不到它。原因就在基座智能体自己的样本里。RL 是对策略已有的行为重新加权:一个动作序列只有在获得比同组其他尝试更高的奖励时才会提高概率。我们测量基座智能体在第一次编辑前读代码的频率,以及之后那次编辑正确的频率。random 和 blind 两种玩法,读取之后从未跟随过有依据的选择。因此读与不读得到的奖励相同,读取的优势期望为零,噪声和改进更快的其他行为会把它淘汰掉。这是一个探索(exploration)失败,关闭捷径无法修复它:环境可以让一项技能变得必要,但 RL 只能放大策略偶尔已经表现出来的技能。
预训练语言模型的处境比这个基座智能体好:它见过代码如何被阅读和使用,有时会出于正确的理由做出正确的事。本书中与之对应的,是一个示范里包含少量根据观测选择正确操作的行为的基座智能体。make_rl_base 在每四组 random 和四组 blind 示范之外,加入一组来自第 4 章 oracle 的示范,这样每九条示范中有一条先读代码,再应用正确操作。
test 调用并无必要,因为它的结果无法再被利用,策略保留它只是因为没有任何东西惩罚它。这再次印证了第 7 章的观点:结果优势也会为无用的步骤记功。
这个对照提供的信息出乎我们设计它时的预期。RL 基座智能体在原始 gym 上也学会了读代码,并达到相同的留出通过率 0.838,只是堆叠稍多一些(每个留出回合 1.21 次编辑,对比 0.99)。一旦初始策略会探索,读代码在两种 gym 中都是最优策略,捷径不再胜出。
图 9.1 汇总了这五次运行。两项改变承担的作用不同。初始策略决定了技能是否被学会:从第 6 章的基座智能体出发,任何环境都没有产生读代码的行为;从 RL 基座智能体出发,两种环境都产生了。环境决定的是通过率意味着什么。在原始 gym 中,0.6 的通过率可能来自读代码、来自函数名,也可能来自盲目堆叠,只有行为统计才能区分它们。在匿名化的一次编辑 gym 中,任何高于 0.2 的部分都必须来自回合中收集的信息,因此如果后续某章的改动损害了探索(过强的 KL 惩罚、熵坍缩、可被投机的奖励),它会表现为向 0.2 的下跌,而不是悄无声息地切换到某个捷径。
证据。 本书测得,每种设置一个随机种子,80 步、每步 64 个回合。定性模式(先记忆,再堆叠,然后停滞,最后学会读代码)与我们准备本章时用其他学习率和随机种子做的探索性运行一致;那些运行未予报告。这些数字是小模型上的机制演示。一般性的结论针对的是机制:策略梯度会放大策略自身样本中任何能预测奖励的特征,因此必须从环境中移除捷径特征,而预期的技能必须在初始策略中出现,哪怕很少。全书其余部分的默认设置。 从这里开始,各章使用
RLConfig() 的默认值训练,即匿名化的 gym(rename=True)、每回合一次编辑(max_edits=1),从 make_rl_base(tok, seed) 出发,并用 evaluate(..., cfg=cfg) 在 heldout_tasks 上评估。在这些设置下,稳定高于 0.2 的通过率,需要利用回合中收集的信息,而不能只靠盲猜,而每份历史记录中的行为统计使这一点可以核查。有意研究某个捷径的章节,比如 第 11 章中的奖励投机(reward hacking),会通过 RLConfig.reward 或 env_factory 改变奖励或环境,其余一切保持不变。
9.5 各种变体改了什么
GRPO 的每个公开变体都只改变了 9.1 节所列选择中的几项。下表把每个变体表述为相对 DeepSeekMath GRPO 的差异,并给出实现它的RLConfig 字段。“来源”一列概括了各论文的规定,记录见 第 20 章;日期为 arXiv 首版日期。
Dr. GRPO 去掉了 6.6 节和 7.5 节测量过其偏差的两种归一化:除以每个输出的长度,这会降低长失败输出中每个 token 受到的惩罚;以及除以组标准差,这会放大策略几乎总能解出或几乎总是失败的任务的权重。
DAPO 做了四项改变。Clip-higher 提高 ,让具有正优势的稀有 token 增长得更快(8.6 节测得上裁剪界主要作用在这类 token 上)。动态采样丢弃奖励全部相同的组(其优势恰为零),并采样更多任务直到批次填满,从而让每个保留的回合都带有梯度。token 级损失即
token_mean。超长奖励塑形加入一个惩罚,在达到最大长度之前的一段缓冲区内从 0 线性增长到 。在 gym 中起作用的长度是轮数预算,因此 overlong_buffer=2 会对用到第五轮或第六轮的回合分别施加 和 的惩罚。DAPO 还描述了超长过滤(overlong filtering),即把被截断的回合从损失中掩掉;DeepSWE 的“紧凑过滤”(compact filtering)把它推广到了超时和步数上限。
RLOO 使用 7.5 节的留一法基线,并把整个生成视为一个动作,因此它就是带有逐任务基线的 REINFORCE。它的优势是 Dr. GRPO 的 倍,这个整体尺度大体会被 Adam 吸收。
GSPO 用每个序列一个比率取代逐 token 比率,即其 token 比率的几何平均 ,并对 做裁剪。这样一个序列的所有 token 共享同一个裁剪决定和同一个重要性权重。作者的动机是序列级奖励与 token 级修正之间的不匹配,并报告它稳定了混合专家模型的训练。由于 是对数比率的平均,它的变动远小于单个 token 的比率,这就是它报告的裁剪范围比 PPO 小三个数量级的原因。
VARIANTS 是实验的起点,而不是任何论文的复现:学习率、组大小、批次大小和模型都与论文不同,只有列出的字段发生了改变。每个预设从 RL 基座智能体出发运行 40 步。
9.6 小规模下的受控消融
预设一次改变好几个字段。受控消融则只改变默认配置的一个字段,其余一切保持固定,包括随机种子:下面每个实验组都从同一个 RL 基座智能体出发、用随机种子 0、1、2 各运行 40 步,并在留出任务上以每个任务 8 个回合进行评估。五个实验组共 15 次运行。seq_mean_token_mean、把 提高到 0.28,结果都落在这个范围之内。这个实验中,clip-higher 的影响本就很有限:在这个配置下裁剪只作用于 0.15% 的 token,所以它放宽的那条边界几乎从未被触及。更值得关注的是两个机制上的变化。seq_mean_token_mean 产生了最长的回合,策略 token 数为 14.9,对比 12.9(9.7 节会继续讨论)。动态采样在全部三个随机种子上都取得了最高的后期奖励和留出通过率(0.81 到 0.85),为此它每步采样 123 个回合,而不是 64 个。
证据。 本书测得:三个随机种子,40 步,一个 158k 参数的模型,20 个训练任务。这些是机制演示。小于随机种子间离散程度的差异不算差异,这些结果也都无法预测同样的改动对大模型、长时程智能体或不同奖励的效果。来源论文中报告的这些选择的收益,来自规模大得多的数学推理任务(第 20 章)。
9.7 实践中的设计问题
问题:固定采样预算下的组大小与提示数。 现象: 在每步回合数相同的情况下,一种配置能学会难任务,另一种却在这些任务上停滞;或者每个批次的大部分都是零优势。 原因: 对通过率为 的任务做 次尝试,这一组方差为零的概率是 。小组会在几乎总能解出或总是失败的任务上把尝试浪费在退化的组上,基线也更嘈杂。大组每步覆盖的不同任务更少,因此每步的梯度涉及的任务更少。 可选方案: (a) 对困难、奖励稀疏的任务用大 ,因为成功很罕见,必须先采样到才能从中学习。(b) 对通过率中等、分布广泛的任务,用许多提示配小 。(c) 根据每个任务的滑动通过率自适应地选择 。 建议做法: 在这个规模下 、8 个提示;改动之前先测量零方差比例和每步不同任务数。下面的代码块把预算固定为 64 个回合,对 RL 基座智能体和训练后的策略测量这两个量。
问题:零优势组与过滤。 现象: 随着训练成功,全部通过的组的比例上升,有效批次缩小,进展变慢;在token_mean下,零优势回合仍计入归一化项,稀释了其他每个 token 的权重。 原因: 零方差组在第 7 章的任何基线下优势都恰好为零,因此它的回合消耗了 rollout,却除了往分母里添 token 之外毫无贡献。 可选方案: (a) 动态采样,保持有效批次满额,代价是额外的 rollout。(b) 过滤零优势组但不重新采样,成本不变,批次缩小。(c) 用课程学习去掉策略总能解出或从来解不出的任务(第 14 章)。 建议做法: 每步记录zero_adv_frac;该比例适中时只过滤、不重新采样,整个任务饱和时再加入课程。只有当 rollout 相对更新很便宜时,动态采样才划算,而这恰好与智能体 RL 的情况相反。默认运行的历史记录显示了这一趋势。
token_mean 仍把它们的 token 计入分母。动态采样会补满批次,而它的成本也随同一趋势上升,从早期每步约 110 个回合增加到后期约 150 个,超过名义值 64 的两倍。
问题:长度增长。 现象: 每个回合的平均策略 token 数或轮数在训练中上升,通过率却没有相应提高,增长往往出现在失败的回合中。 原因: 聚合方式决定了每个 token 的权重(6.6 节)。在seq_mean_token_mean下,一个失败回合的总惩罚是固定的,回合越长,每个 token 受到的惩罚越少,失败回合中多余的调用就很便宜。不对轮数计费的奖励也无法抵消这一点。 可选方案: (a) 与长度成比例的聚合方式(token_mean、constant或seq_mean_token_sum)。(b) 超长塑形或步数成本,它们会改变任务本身,必须为有用的信息收集留出空间(第 12 章)。(c) 掩掉被截断的回合。 建议做法: (a),并分别记录成功回合和失败回合的长度。消融实验的历史记录直接比较了这两种聚合方式。
seq_mean_token_mean 下,回合从前十步起就有约 14 个策略 token,而 token_mean 下是从 11.6 上升到 13.4;轮数也更多(4.36 对 4.07),读取略多(每回合 1.06 对 1.00)。差异很早就出现,是因为基座智能体的失败回合很长,而按序列归一化对其中每个 token 的惩罚更少。在六轮预算、40 步的训练中,这个效应很小;在有数百轮的智能体回合中,它正是 Dr. GRPO 所指出的响应长度偏差的机制。
问题:选择损失聚合方式。 现象: 两次只差一个“归一化细节”的运行学习速度不同,或者长度发生漂移;批次组成的变化会改变有效学习率。 原因:token_mean让每个 token 的权重与批次的总长度耦合;seq_mean_token_mean对各轨迹等权,对长轨迹中的 token 赋予较小权重;constant和seq_mean_token_sum保持每个 token 的权重固定,需要针对其尺度调学习率。 可选方案:aggregate_loss的四种模式。 建议做法: 本书使用token_mean,因为它在批次内对长度中立、被广泛使用(DAPO),而且其依赖批次的尺度很容易通过记录的长度来监控;当训练中长度变化很大时使用constant(Dr. GRPO),因为它连批次耦合也消除了。多轮智能体应避免seq_mean_token_mean,因为它们的回合长度相差一个数量级,并且与成功相关;上一个代码块测量了它在这个 gym 上的效果。
小结
- GRPO 是 PPO 的裁剪代理目标,用组基线代替 critic;在 DeepSeekMath 的形式中,它还在序列内对 token 取均值,并在损失中加入 KL 惩罚。它已发表的变体只是少数几个字段上的差异;
RLConfig和train_rl把它们实现为同一个循环的选项。 - 在原始 gym 上,GRPO 重现了第 6 章的记忆现象:训练任务 0.98,留出任务 0.40,读取次数为零,第一次编辑根据函数名选择。
- 捷径特征存在于环境之中。逐回合给函数改名去掉了函数名这一捷径,也消除了训练与留出之间的大部分差距,策略随即转向堆叠编辑,而一个盲目序列就能把它推到 0.60。把每个回合限制为一次编辑去掉了这个捷径,却暴露出一个探索失败:基座智能体几乎从不在读代码之后做出有依据的编辑,因此没有可放大的东西。
- 一个九条示范中有一条有依据示范的基座智能体,在通过率相同的情况下,于 80 步内学会了“读代码、编辑、测试、提交”,留出任务达到 0.84,在匿名化 gym 上如此,作为对照在原始 gym 上也是如此。初始策略决定技能是否被学会;环境决定通过率能否证明这项技能。从这里开始,匿名化的一次编辑 gym 加上
make_rl_base是本书的默认设置。 - 在小规模下,对标准差归一化、聚合方式和 clip-higher 的单字段消融都落在随机种子噪声之内,而在裁剪比例只有 0.15% 的情况下,clip-higher 根本没有作用对象。可以看到两个机制:
seq_mean_token_mean使回合变长;动态采样每次更新学到的更多,但采样的回合数是 1.9 倍,并且随着零优势组上升到批次的 80%,这一成本还在增长。使用论文裁剪范围的 GSPO 在 12 token 的回合上裁掉了 31% 的序列。这些是机制演示,不是基准评测证据。
练习
- 证明在一个小批次、一轮次(epoch)的情况下,GRPO 的更新等于第 6 章使用组归一化优势的 REINFORCE 更新。然后说明使用两个小批次时有什么变化。第二个小批次中的哪些 token 可能被裁剪?
- 改变一个假设:用
make_base_agent(tok, kinds=('random', 'blind') * 8 + ('oracle',))(十七条示范中有一条有依据示范)重新运行 9.4 节的默认配置。策略还能学会读代码吗?找出能在 80 步内学会读代码的最小有依据示范比例,并把它与初始策略实测的“先读后正确”概率联系起来。 - 改名时从 gym 全部 30 个函数名中抽取名字,包括留出任务的函数名。把名字池限制为训练任务的函数名,重新测量默认运行的留出通过率。这一差别说明了留出函数名的嵌入起什么作用?
- 把 CISPO(第 20 章)实现为
ratio_level='cispo':逐 token 的损失为 。哪些 token 保留了 PPO 的裁剪会去掉的梯度?在 9.6 节的运行上,把它与裁剪相关的统计量同默认配置做比较。 - 让 KL 项发挥作用:用
kl_coef取 {0.01, 0.1, 1.0} 训练默认配置,记录kl_ref和通过率。系数多大时策略不再学会读代码?第 10 章会解释为什么答案取决于kl_kind。 - 测量 GSPO 预设的裁剪比例及其序列比率的分布范围。论文为长数学输出选定的裁剪范围适用于 12 token 的回合吗?假设 token 对数比率相互独立,推导 的标准差如何随策略 token 数变化。
- 设计评审:某团队的 SWE 智能体在训练仓库上达到 70%,在留出集上只有 35%,并且它的轨迹很少在编辑之前打开文件。根据 9.4 节,列出你会在任务数据和环境中寻找的捷径特征、你会要求查看的行为统计,以及你会考虑的两类修复。