跳到主要内容

数据截至 (上游 commit 67dfbe211a07)

04 · 在线 RL:GRPO、RLOO 与 PPO

这一章讲什么: 在线 RL 是 TRL 里数据流最长的一条线:模型边生成、边被打分、边更新。主线讲 GRPO(DeepSeek-R1 用的那族算法),顺带对比 RLOO 的「留一法」基线和被挪进 experimental 的经典 PPO。读完你会理解一次 GRPO 训练步里每个张量的来龙去脉。


1. 它要解决的小问题

离线偏好方法(DPO)吃静态标注数据。但要让模型在数学、代码这类有可验证答案的任务上变强,更好的信号是:让模型自己答题,用程序判对错,把「答对的采样变大概率」。这就是在线 RL。

它的工程难点比离线方法多出一截:

  1. 每步都要生成——生成慢,训练就慢;需要 vLLM 这类高吞吐引擎。
  2. 训练和生成是两份权重——模型每更新一次,生成引擎里的权重就过时了。
  3. 优势怎么算——没有价值网络时,要用「同一题的一组回答互相比较」来估计。
  4. 训推不一致——vLLM 和训练引擎算出的 logp 有系统差,不修正会偏移优化目标。

2. 顶层流程:一次训练步

对应 _generate_and_score_completionstrl/trainer/grpo_trainer.py:2343)+ _compute_losstrl/trainer/grpo_trainer.py:3109):

① dataloader: RepeatSampler 让每个 prompt 连续出现 num_generations 次

② rollout: 同步权重到 vLLM → 每 prompt 生成 G 个回答

③ 打分: 各 reward_func 给每个回答打分 → rewards_per_func(B*G, F)

④ 优势: 按 prompt 分组,advantage = (r - 组均值) / 组标准差

⑤ 重算 logp: 训练引擎对生成的 completion 算 per-token logp
(β≠0 时参考模型也算一份;多步复用时还存一份 old logp)

⑥ loss: min(ratio·A, clip(ratio)·A) + β·KL,反向传播

⑦ 下一个 generation batch 回到 ②

怎么读这条线: ②⑤⑦ 是「在线」的代价(生成、双引擎、权重搬运);③④ 是 GRPO 区别于 PPO 的核心(函数打分代替奖励模型,组内归一代替价值网络);⑥ 是从 PPO 继承的骨架。


3. RepeatSampler:把「组」和「复用」编码进 dataloader

GRPO 对数据有两个结构要求,都由采样顺序保证:

  • 组内比较:同一 prompt 的 G 个回答必须落在相邻位置,才能按组算优势。
  • 数据复用:一次生成可以做 steps_per_generation × num_iterations 步梯度更新(μ 次策略迭代),采样器要让同一块数据重复出现。

实现是 RepeatSamplertrl/trainer/utils.py:697),三个参数各管一件事(_get_train_samplertrl/trainer/grpo_trainer.py:1278-1283):

参数取值管什么
mini_repeat_countnum_generations每个 prompt 连续重复 G 次 → 组
batch_sizegeneration_batch_size // num_generations每个生成块含多少不同 prompt
repeat_countnum_iterations * steps_per_generation整块重复 → 复用生成结果做多步更新

源码里有一张很好的 ASCII 注释图(trl/trainer/grpo_trainer.py:1256-1273),画出了 steps_per_generation=4、梯度累积 2 时,前 4 步如何复用同一次生成。

这是 TRL 在线 RL 最巧妙的一处设计:把「off-policy 复用」这个需要经验回放缓冲区的东西,折叠成了采样器里的两个计数器。复用的代价(数据变旧、off-policy 漂移)由 §6 的重要性采样比率兜底。


4. Rollout:三种生成路径与权重同步

_generate_single_turntrl/trainer/grpo_trainer.py:1816)有三条生成路径:

路径开关说明
vLLMuse_vllm=True(默认配合 vllm_mode="colocate"最高吞吐;v1 起 colocate 是默认(MIGRATION.md:7-9
transformers 连续批处理use_transformers_continuous_batching不装 vLLM 时的较快路径(grpo_trainer.py:1840-1860
transformers 普通 generate默认回退最慢但最通用(grpo_trainer.py:1863-1912

4.1 vLLM 的两种部署

VLLMGenerationtrl/generation/vllm_generation.py:111)封装:

  • colocate:vLLM 作为库嵌在训练进程里,和训练共用 GPU。权重同步是直接调 load_weights 把训练侧参数灌进去(trl/generation/vllm_generation.py:510-511)。
  • server:vLLM 跑在独立进程/机器,训练侧通过 VLLMClient 发 HTTP 生成请求;权重同步走「主进程逐参数广播」——第一次先传元数据(名字/dtype/shape),之后流式发参数(trl/generation/vllm_generation.py:497-507)。

4.2 权重何时同步

每次生成前检查「训练步数有没有变」——变了才同步(trl/trainer/grpo_trainer.py:1822-1826):

# 摘自 trl/trainer/grpo_trainer.py:1823-1826
if self.state.global_step != self._last_loaded_step:
with profiling_context(self, "sync_weights"):
self.vllm_generation.sync_weights()
self._last_loaded_step = self.state.global_step

配合 §3 的数据复用:同一个 generation batch 内多步更新之间重新生成,所以权重同步以「换 generation batch」为粒度。


5. 奖励与优势:GRPO 的算法核心

5.1 奖励就是普通函数

_calculate_rewardstrl/trainer/grpo_trainer.py:1632)接受三类 reward function:

  • 字符串/PreTrainedModel:一个序列分类奖励模型,按 num_labels=1 加载,打分即 logits[:, 0]grpo_trainer.py:1660-1673)。
  • 同步函数:拿到 prompts/completions 加数据集其余列,返回 float 列表。
  • 异步函数:收集起来用 asyncio.gather 并行跑(grpo_trainer.py:1698-1717)——网络型奖励(调 API 判分)不被串行拖死。

两个细节:

  • 函数可以返回 None 表示「这条不归我管」(多任务混训时按题路由奖励),内部转成 NaN 后续用 nan 感知计算(grpo_trainer.py:1681-1682)。
  • 数据集的所有额外列都会作为 kwargs 传给奖励函数(grpo_trainer.py:1637-1638)——所以数学题的标准答案列直接可用。

5.2 优势 = 组内 z-score

GRPO 不用价值网络,基线就是「同题其他回答的平均分」(trl/trainer/grpo_trainer.py:2787-2809):

# 摘自 trl/trainer/grpo_trainer.py:2787-2809
mean_grouped_rewards = torch.nanmean(rewards.view(-1, num_generations), dim=1)
mean_grouped_rewards = mean_grouped_rewards.repeat_interleave(num_generations, dim=0)
...
advantages = rewards - mean_grouped_rewards # 去基线
if self.scale_rewards == "group":
advantages = advantages / (std_rewards + 1e-4) # 组内标准差归一

view(-1, num_generations) 能正确分组,全靠 §3 的采样器保证同一 prompt 的 G 个回答相邻。scale_rewards 还支持按全 batch 标准差("batch")或不除标准差("none",Dr.GRPO 的主张)。

全错的组怎么办: 一组 G 个回答奖励全相同(全对或全错)时标准差为 0、优势全 0——这一组不贡献梯度。frac_reward_zero_std 指标(grpo_trainer.py:2852)就是监控这个比例:太高说明题目对当前模型太易或太难。

5.3 原理演示

# 示意,非源码:一组的优势计算
rewards = torch.tensor([1.0, 1.0, 0.0, 0.0]) # 一题 4 个回答的得分
adv = rewards - rewards.mean() # [ 0.5, 0.5, -0.5, -0.5]
adv = adv / (rewards.std() + 1e-4) # 归一化(可选)
# 答对的 token 被往上推,答错的往下压,幅度对称

6. 损失:PPO 骨架 + 三层修正

_compute_losstrl/trainer/grpo_trainer.py:3109)可以拆成骨架和三层修正。

6.1 骨架:裁剪的重要性采样目标

# 摘自 trl/trainer/grpo_trainer.py:3169-3209(已精简)
log_ratio = per_token_logps - old_per_token_logps
coef_1 = torch.exp(log_importance_weights) # 比率 π/π_old
coef_2 = torch.clamp(coef_1, 1 - self.epsilon_low, 1 + self.epsilon_high)
per_token_loss = -torch.min(coef_1 * advantages, coef_2 * advantages)
  • importance_sampling_level(默认 "token"grpo_config.py:753)选比率粒度:token 级(每个 token 一个比率)或 sequence 级(整条序列一个几何均值比率,即 GSPO 的主张)。
  • old_per_token_logps 在 on-policy(单次生成单次更新)时直接复用当前 logp 的 detach 副本、省一次前向(grpo_trainer.py:3151-3156 的注释);用 vLLM 时总是重算,因为生成引擎的 logp 和训练引擎的不一致。

6.2 修正一:KL 项(k3 估计)

GRPOConfig.beta 默认 0.0——即默认不加载参考模型、不加 KL(grpo_config.py:674-681,注释里指出 DeepSeek-R1 用 0.001)。β≠0 时用 k3 形式的无偏 KL 估计(grpo_trainer.py:3188-3191):

# 摘自 trl/trainer/grpo_trainer.py:3189-3191
per_token_kl = (
torch.exp(ref_per_token_logps - per_token_logps) - (ref_per_token_logps - per_token_logps) - 1
)

e^x - x - 1(x = log π_ref − log πθ):恒非负、在 x=0 处为 0,是 KL 的低方差逐 token 估计。

6.3 修正二:vLLM 训推不一致的重要性采样

vLLM(快速生成)和 transformers(训练前向)算同一 token 的 logp 有系统差——采样实际来自 π_vllm 而非 π_old。修正方法是把两边 logp 的比率乘进 loss(trl/trainer/grpo_trainer.py:3236):

# 摘自 trl/trainer/grpo_trainer.py:3236
per_token_loss = per_token_loss * inputs["importance_sampling_ratio"]

比率本身在生成侧算出,并有截断/掩码两种处理模式(vllm_importance_sampling_modegrpo_trainer.py:2687-2718)。这是 2025 年后 RL 训练圈的共识性修复(训推不一致会让优势符号都错掉),TRL 把它做成了默认开启的开关。

6.4 修正三:归一化方式(loss_type)

GRPOConfig.loss_type 默认 "dapo"grpo_config.py:794-801)。各变体的差别只在最后一步怎么把 per-token loss 聚合成标量

loss_type归一化方式针对的问题
grpo每条序列内取均值再对 batch 取均值原始形式;有长度偏置
dapo(默认)全体 token 求和 ÷ 活跃 token 总数消除长度偏置
bnpo求和 ÷ batch 内 token 总数类似 dapo,分母口径不同
dr_grpo求和 ÷(batch × 最大长度)常数Dr.GRPO:除掉优势缩放偏置
cispo / sapo / luspo / vespo各自的比率裁剪/软门控变体更新的论文

grpo 那一档的 help 文本写得很坦白:"Not recommended due to length bias"(grpo_config.py:798-800)——默认值的变迁本身就是这个领域 2024→2026 的演进史。


7. RLOO:同一条线上的另一个点

RLOOTrainertrl/trainer/rloo_trainer.py:109)和 GRPO 共享整条 rollout/reward 管线,差异只有两处:

  1. 基线是「留一法」而非组均值:每个回答的基线 = 同组其他 G−1 个回答的平均分(trl/trainer/rloo_trainer.py:1595-1597):
# 摘自 trl/trainer/rloo_trainer.py:1595-1597
baselines = (grouped_sum - grouped_rewards) / (scorable_counts - 1)
baselines = baselines.view(-1)
advantages = rewards - baselines
  1. 损失在序列级:整条 completion 的 logp 先求和再算比率,一个序列一个比率(trl/trainer/rloo_trainer.py:1730-1738),而不是 GRPO 的 token 级。

把两者并排读,「GRPO vs RLOO」这篇论文级别的争论就落成二十行代码差异——这正是 TRL 的存在意义。


8. PPO 去哪了

经典 PPO(四个模型:actor、critic、ref、reward)仍在,但住在 trl/experimental/ppo/ppo_trainer.py:297。特征:

  • 自己实现 train() 主循环(ppo_trainer.py:604),不复用 Trainer.train——因为要手动交错「生成 → 打分 → GAE → 多 epoch 更新」。
  • 策略和价值网络包进一个 PolicyAndValueWrapperppo_trainer.py:275)共享基座。
  • 奖励来自一个外部奖励模型(构造函数必填 reward_model),而非 GRPO 的函数打分。

它被挪进 experimental 的原因和 GRPO 的崛起是同一件事:在有可验证奖励的任务上,「函数打分 + 组内基线」把 critic 和 reward model 两个模型都省掉了。

想离线异步跑 GRPO(生成分布式抢先跑),experimental 里还有 AsyncGRPOTrainertrl/experimental/async_grpo/async_grpo_trainer.py:648)。


9. 关键细节与坑

  • num_generations 要整除生成批。 组靠采样器的相邻性拼出来,generation_batch_size 必须是 num_generations 的整数倍(grpo_trainer.py:1279 的整除写法隐含这一点)。
  • β=0 是默认,不是省略参考模型的 bug。 R1 之后的社区共识是小 β 或零 β;但对开放域任务,没有 KL 约束模型可能跑偏。
  • 奖励函数千万别有 NaN 之外的脏数据约定。 返回个数不等于 prompt 数会直接报错(grpo_trainer.py:1684-1690);某条样本被所有函数返回 None 会 warning 并把它的优势置 0(unscorable_maskgrpo_trainer.py:2781)。
  • 复用步数(num_iterations)越大越 off-policy。 TRL 的应对是重算 old logp + 裁剪比率,但 μ>1 时仍要盯 clip_ratio 指标。
  • 多轮工具调用是另一条支线。 _tool_call_loopgrpo_trainer.py:1981)支持生成中途执行工具再续写,工具返回的 token 用 tool_mask 从 loss 里摘掉——agent 训练看这条路。
  • 规模上限。 这套「单进程教练 + vLLM 后端」到几十 B 模型、几台机器就到头了;更大规模的训推分离、流水线调度是 verl 的领土。

10. 代码地图(本章)

主题文件路径符号名
GRPO 主流程trl/trainer/grpo_trainer.pyGRPOTrainer._generate_and_score_completions
GRPO 损失trl/trainer/grpo_trainer.pyGRPOTrainer._compute_loss
分组/复用采样器trl/trainer/utils.pyRepeatSampler;调用点 GRPOTrainer._get_train_sampler
生成路径分派trl/trainer/grpo_trainer.py_generate_generate_single_turn
vLLM 封装与权重同步trl/generation/vllm_generation.pyVLLMGeneration.generateVLLMGeneration.sync_weights
奖励函数管线trl/trainer/grpo_trainer.py_calculate_rewards
优势计算trl/trainer/grpo_trainer.py_generate_and_score_completionsadvantages
多轮工具调用trl/trainer/grpo_trainer.py_tool_call_loop
RLOO 损失与留一基线trl/trainer/rloo_trainer.pyRLOOTrainer._compute_loss_generate_and_score_completions
经典 PPOtrl/experimental/ppo/ppo_trainer.pyPPOTrainerPolicyAndValueWrapper
异步 GRPOtrl/experimental/async_grpo/async_grpo_trainer.pyAsyncGRPOTrainer