verl 源码解读(四):PPO / GRPO 主循环与核心算法
本文是《verl 源码解读》系列第 4 篇。前三篇分别建立了整体认知、分发机制(WorkerGroup/Dispatch)和数据总线(DataProto)。本篇把它们串起来,讲 verl 的“剧本”:
RayPPOTrainer.fit()主循环,以及core_algos.py里的 advantage 与 policy loss 的具体实现。系列目录:1 架构 · 2 HybridFlow · 3 DataProto · 4 PPO/GRPO · 5 Rollout 混合引擎
1. 两个关键文件
| 文件 | 职责 |
|---|---|
verl/trainer/ppo/ray_trainer.py |
RayPPOTrainer:组织 worker、驱动 fit() 主循环(流程) |
verl/trainer/ppo/core_algos.py |
advantage 估计、policy loss、KL 惩罚等纯算法(无分布式依赖) |
设计上刻意做了分离:core_algos.py 全是纯张量函数,不关心 FSDP/Megatron/Ray,任何分布式策略都能复用;ray_trainer.py 只负责编排。
2. 角色(Role)与按需初始化
trainer/ppo/utils.py 定义了 RL 中的角色:
class Role(Enum):
Actor = 0
Rollout = 1
ActorRollout = 2 # Actor 和 Rollout 合并(最常见)
Critic = 3
RefPolicy = 4
RewardModel = 5
ActorRolloutRef = 6
Env = 7
TeacherModel = 8
并不是每种算法都需要全部角色,trainer 会按配置决定实例化哪些:
need_critic(...):只有用 GAE(即 PPO)时才需要 Critic;GRPO 等没有 Critic。need_reference_policy(...):当use_kl_in_reward或use_kl_loss时才需要 Ref。need_reward_model(...):用模型式奖励时才需要 RM;函数式(可验证)奖励则不需要。
这正是“PPO 比 GRPO 多一个价值网络”在代码层面的体现。
3. fit() 主循环逐段拆解
RayPPOTrainer.fit() 是整个训练的心脏。精简后的结构:
def fit(self):
logger = Tracking(...) # wandb/swanlab/tensorboard
self._load_checkpoint() # 断点续训
if val_before_train:
self._validate() # 训练前先验证一次
for epoch in range(total_epochs):
for batch_dict in self.train_dataloader:
batch = DataProto.from_single_dict(batch_dict)
batch.meta_info["temperature"] = ...
batch.non_tensor_batch["uid"] = np.array([uuid4() ...]) # 每条样本一个 group id
# ① 取生成所需列,并按 n 复制(GRPO 分组采样)
gen_batch = self._get_gen_batch(batch)
gen_batch_output = gen_batch.repeat(repeat_times=rollout_n, interleave=True)
with marked_timer("step", timing_raw):
# ② 生成(Rollout,vLLM/SGLang)
with marked_timer("gen", timing_raw):
gen_batch_output = self.actor_rollout_wg.generate_sequences(gen_batch_output)
batch = batch.union(gen_batch_output) # 回填 responses 等
# ③ 重算 old_log_prob(训练引擎口径)
old_log_prob = self.actor_rollout_wg.compute_log_prob(batch)
batch = batch.union(old_log_prob)
# ④ Ref 的 log_prob(用于 KL)
if self.use_reference_policy:
ref_log_prob = self.ref_policy_wg.compute_ref_log_prob(batch)
batch = batch.union(ref_log_prob)
# ⑤ Critic 估值(仅 PPO)
if self.use_critic:
values = self.critic_wg.compute_values(batch)
batch = batch.union(values)
# ⑥ 奖励(函数式 / 模型式)
reward_tensor = self.reward_fn(batch) # token_level_scores
batch.batch["token_level_scores"] = reward_tensor
# ⑦ 计算 advantage(driver 本地)
batch = compute_advantage(batch, adv_estimator=..., gamma=..., lam=...)
# ⑧ 更新 Critic(仅 PPO)
if self.use_critic:
self.critic_wg.update_critic(batch)
# ⑨ 更新 Actor
if self.global_steps >= critic_warmup:
self.actor_rollout_wg.update_actor(batch)
# ⑩ 权重同步给 Rollout 引擎(见第 5 篇)
self.checkpoint_manager.update_weights(self.global_steps)
logger.log(metrics, step=self.global_steps)
self.global_steps += 1
对照前几篇:
- 每个
xxx_wg.method()都是第 2 篇的分布式分发调用(DP_COMPUTE_PROTO); batch.union(...)/repeat(...)是第 3 篇的 DataProto 操作;- 第 ⑦ 步 advantage 在 driver 本地算,因为它是轻量张量运算,无需分发。
为什么 old_log_prob 要重新算一次?
生成用 vLLM,但 vLLM 输出的概率和训练引擎(FSDP/Megatron)在数值实现上有差异。PPO 的重要性采样比值 ratio = exp(log_prob - old_log_prob) 要求分子分母口径一致,因此 old_log_prob 必须由训练引擎在生成结果上重新 forward 一遍得到。
4. Advantage 估计:core_algos 的“插件化”注册
verl 用注册表 + 装饰器把各种 advantage 估计器做成“插件”:
@register_adv_est(AdvantageEstimator.GAE)
def compute_gae_advantage_return(...): ...
@register_adv_est(AdvantageEstimator.GRPO)
def compute_grpo_outcome_advantage(...): ...
通过 adv_estimator 配置选择用哪个。已内置 GAE、GRPO、Dr.GRPO、RLOO、REINFORCE++、ReMax、OPO、GPG 等十余种。下面看两个最典型的。
4.1 GAE(PPO 用,需要 Critic)
def compute_gae_advantage_return(token_level_rewards, values, response_mask, gamma, lam):
with torch.no_grad():
nextvalues, lastgaelam = 0, 0
advantages_reversed = []
for t in reversed(range(gen_len)):
delta = token_level_rewards[:, t] + gamma * nextvalues - values[:, t]
lastgaelam_ = delta + gamma * lam * lastgaelam
# 只在 response token 上推进(跳过 padding/observation)
nextvalues = values[:, t] * mask[:, t] + (1 - mask[:, t]) * nextvalues
lastgaelam = lastgaelam_ * mask[:, t] + (1 - mask[:, t]) * lastgaelam
advantages_reversed.append(lastgaelam)
advantages = torch.stack(advantages_reversed[::-1], dim=1)
returns = advantages + values
advantages = masked_whiten(advantages, response_mask) # 标准化
return advantages, returns
要点:从后往前递推 GAE;用 response_mask 保证只在真实生成 token 上累积;最后 returns = advantages + values 作为 Critic 的回归目标;masked_whiten 对 advantage 做标准化以稳定训练。这里需要 values,所以 PPO 必须有 Critic。
4.2 GRPO(无 Critic,靠组内归一化)
GRPO 的核心思想:没有价值网络,用“同一 prompt 的 n 条回答”的组内均值作为 baseline。
def compute_grpo_outcome_advantage(token_level_rewards, response_mask, index, epsilon=1e-6,
norm_adv_by_std_in_grpo=True):
scores = token_level_rewards.sum(dim=-1) # 每条回答一个标量 reward
id2score = defaultdict(list)
for i in range(bsz):
id2score[index[i]].append(scores[i]) # 按 group id(uid)分组
for idx in id2score: # 每组算均值、标准差
id2mean[idx] = mean(group); id2std[idx] = std(group)
for i in range(bsz):
if norm_adv_by_std_in_grpo: # 原始 GRPO:减均值除标准差
scores[i] = (scores[i] - id2mean[index[i]]) / (id2std[index[i]] + epsilon)
else: # Dr.GRPO:只减均值
scores[i] = scores[i] - id2mean[index[i]]
scores = scores.unsqueeze(-1) * response_mask # 广播到每个 token
return scores, scores
这里的 index 就是第 3 篇里 repeat(n) 之前给每个 prompt 分配的 uid——同一 prompt 的 n 条回答共享同一个 id,从而构成一个 group。norm_adv_by_std_in_grpo 开关决定是原始 GRPO(减均值除标准差)还是 Dr.GRPO(只减均值)。
对比:GAE 需要逐 token 的 value;GRPO 只用一条回答的总分做组内归一化,省掉了整个 Critic 网络,这就是 GRPO 更省资源的原因。GRPO 在图像生成领域的变体(Flow-GRPO 等)可参见《图像生成与强化学习》。
5. Policy Loss:PPO 的裁剪目标
policy loss 同样是注册表(@register_policy_loss),默认 "vanilla" 就是标准 PPO 裁剪目标。核心片段:
negative_approx_kl = log_prob - old_log_prob
negative_approx_kl = torch.clamp(negative_approx_kl, -20.0, 20.0) # 数值稳定
ratio = torch.exp(negative_approx_kl) # 重要性采样比
pg_losses1 = -advantages * ratio
pg_losses2 = -advantages * torch.clamp(ratio, 1 - cliprange_low, 1 + cliprange_high)
clip_pg_losses1 = torch.maximum(pg_losses1, pg_losses2) # 标准 PPO 裁剪
# dual-clip:对 advantage<0 的情况再加一层下界裁剪,防止过大的负梯度
pg_losses3 = -advantages * clip_ratio_c
clip_pg_losses2 = torch.min(pg_losses3, clip_pg_losses1)
pg_losses = torch.where(advantages < 0, clip_pg_losses2, clip_pg_losses1)
pg_loss = agg_loss(pg_losses, response_mask, loss_agg_mode)
几个细节:
- 非对称裁剪:
cliprange_low/cliprange_high可分别设置(DAPO 的 clip-higher 技巧)。 - dual-clip PPO:当 advantage<0 时多加一层下界
clip_ratio_c,避免梯度爆炸。 - 还内置了 GSPO、CISPO、clip-cov、kl-cov、geo-mean 等多种 loss,换个配置名即可切换。
loss 聚合模式 agg_loss
逐 token 的 loss 矩阵最终要聚成一个标量,loss_agg_mode 控制方式:
token-mean:所有有效 token 求平均(默认,长回答权重更大);seq-mean-token-mean:先序列内平均再序列间平均(每条样本等权);- 等等。这会影响长短回答的梯度权重,是调参的一个点。
6. KL 惩罚:两种加法
KL 用来约束策略别偏离参考模型太远,有两种接入方式:
- 加在 reward 里(
use_kl_in_reward):把-beta * KL(policy || ref)作为 token 级惩罚并入token_level_rewards,再去算 advantage。 - 加在 loss 里(
use_kl_loss,GRPO 常用):直接在 policy loss 上加一项 KL loss。
kl_penalty(logprob, ref_logprob, kl_penalty_type) 支持 kl、abs、mse、low_var_kl(即 k3 估计)等多种估计方式(参考 John Schulman 的 KL 近似博客)。是否需要 Ref 模型,就取决于这两个开关(见第 2 节 need_reference_policy)。
7. 一图总结 PPO vs GRPO
┌─────────── PPO ───────────┐ ┌────────── GRPO ──────────┐
Critic 有(估 value,GAE 需要) 无
Advantage GAE(逐 token,需 value) 组内归一化(每 prompt n 条)
Reference 可选(KL) 常用(KL loss)
采样 每 prompt 可 1 条 每 prompt n 条(repeat + uid 分组)
资源 多一个价值网络 省掉 Critic,更轻
8. 小结
fit()是 single-controller 的“剧本”:取数据 → 生成 → 重算 log_prob → (ref/critic) → reward → advantage → 更新 critic/actor → 同步权重,全程用 DataProto 当数据总线、用 WorkerGroup 做分发。core_algos.py用注册表把 advantage 估计器和 policy loss 做成插件,改配置即可切换算法。- GAE vs GRPO 的差别(要不要 Critic、怎么算 baseline)在代码里非常直白,是理解各类 RL 算法的最佳切入点。
- advantage 在 driver 本地算,重计算密集的 forward/backward 在 worker 上分布式执行——职责划分清晰。
剩下还有一个关键工程问题没讲:Actor 训练完后的新权重,怎么同步给用另一套引擎跑的 Rollout?这就是下一篇“训练推理混合引擎”的主题。