Skip to content

PPO:从 clipped loss 读懂 verl 主线

PPO 是 verl 里最值得先读懂的主线算法。它做的事情可以先说得很朴素:模型先用当前策略生成一批回答,再用 reward 判断哪些回答更好,最后更新 actor,让好回答里的 token 更容易出现、差回答里的 token 更不容易出现。但这个更新必须温和,否则模型会为了 reward 一步跨太大,把原来的语言能力或格式能力冲坏。

在 verl 里,PPO 的核心不是一个单独的 trainer,而是一条数据流:

text
rollout -> reward -> old_log_probs/ref_log_prob/values -> advantages/returns -> critic update -> actor update

先补 RL 先验

LLM post-training 里的一个 response 可以看成一条轨迹。prompt 加已经生成的前缀是状态 s_t,下一个 token 是动作 a_t,reward 告诉我们这条轨迹好不好。

  • policy:actor 模型,给每个下一个 token 一个概率。
  • old policy:采样这批数据时或更新前固定住的 actor,用来做 PPO ratio 的锚点。
  • critic:value model,预测“从这个 token 位置往后大概能拿多少 reward”。
  • advantage:实际结果比 critic 预期好多少。正优势表示“这个 token 应该更常出现”,负优势表示“这个 token 应该少出现”。
  • response_mask:只让 response 的有效 token 参与训练,prompt token、padding token、EOS 后 token 都不该贡献 loss。

PPO clip 公式:论文最大化,源码最小化

PPO 论文常写成最大化:

LCLIP(θ)=Et[min(rt(θ)At,clip(rt(θ),1ϵ,1+ϵ)At)]

其中概率比值是:

rt(θ)=πθ(at|st)πold(at|st)=exp(logπθ(at|st)logπold(at|st))

训练代码要最小化 loss,所以 verl 的 compute_policy_loss_vanilla() 写成:

text
negative_approx_kl = log_prob - old_log_prob
ratio = exp(clamp(negative_approx_kl, -20, 20))
pg_losses1 = -advantages * ratio
pg_losses2 = -advantages * clamp(ratio, 1 - clip_low, 1 + clip_high)
pg_losses = maximum(pg_losses1, pg_losses2)

这里的 maximum 看起来和论文的 min 相反,是因为源码里多了负号:最大化目标 min(...) 等价于最小化 max(-...)。小白读到这里不要慌,方向没有反。

verl 还支持两个 PPO 常见增强:

  • 不对称 clip:clip_ratio_lowclip_ratio_high 可以不同,例如 DAPO-style 常见 0.2 / 0.28
  • dual-clip:当 advantages < 0 时,clip_ratio_c 会给负优势样本再加一道保护,避免负样本在极端 ratio 下产生过猛梯度。

GAE:critic 怎么变成 advantage

PPO 默认用 GAE。GAE 的递推是:

δt=rt+γV(st+1)V(st)At=δt+γλAt+1

core_algos.compute_gae_advantage_return() 中:

  • token_level_rewards 对应公式里的 r_t,形状是 (bs, response_length)
  • values 对应 V(s_t),来自 critic。
  • nextvalues 在反向循环里代表 V(s_{t+1})
  • lastgaelam 代表递推中的下一个 A_{t+1}
  • response_mask 用来跳过 padding 和非有效 response token。
  • returns = advantages + values,critic 训练用它当回归目标。
  • actor 使用 masked_whiten(advantages, response_mask) 后的优势,降低 batch 内尺度波动。

如果把 critic 想成“赛前预测分”,GAE 就是在每个 token 位置问:后面真实拿到的分数,比预测多了多少?

源码实现怎么读

1. 主循环:RayPPOTrainer.fit()

verl/trainer/ppo/ray_trainer.py 里,一轮训练的关键顺序是:

text
gen_output = actor_rollout_wg.generate_sequences(gen_batch)
batch = batch.repeat(rollout.n).union(gen_output)
reward_tensor = extract_reward(batch)
old_log_probs = actor.compute_log_prob(batch)
ref_log_prob = ref_policy.compute_ref_log_prob(batch)
values = critic.compute_values(batch)
token_level_rewards = token_level_scores - beta * KL
advantages, returns = compute_advantage(...)
critic.update_critic(batch)
actor.update_actor(batch)

要注意 old_log_probs 默认会由 actor 重新计算,而不是盲目信任 rollout engine 返回的概率。这是 PPO 的 proximal anchor。启用 rollout correction 的 bypass mode 时才会把 old_log_probs = rollout_log_probs

2. KL reward penalty:apply_kl_penalty()

algorithm.use_kl_in_reward=True 时,verl 会在 reward 侧扣 KL:

text
kld = kl_penalty(old_log_probs, ref_log_prob)
token_level_rewards = token_level_scores - beta * kld

变量对应关系:

源码变量含义
token_level_scoresreward model 或 rule reward 给出的原始分数,常只在最后一个有效 token 非零
old_log_probsactor 对已生成 token 的 log probability
ref_log_probreference model 对同一批 token 的 log probability
betaKL controller 当前系数

这和 actor loss 里的 KL loss 不是同一件事。algorithm.use_kl_in_reward=True 是把 KL 当 reward penalty;actor_rollout_ref.actor.use_kl_loss=True 是在 actor loss 里额外加 KL 正则。

3. PPO policy loss:compute_policy_loss_vanilla()

这个函数的输入基本就是公式变量:

源码变量公式符号含义
old_log_prob`log pi_old(a_ts_t)`
log_prob`log pi_theta(a_ts_t)`
advantagesA_t每个 token 的优势
response_maskmask有效 response token
clip_ratio_low/highepsilon_low/highratio 的下/上 clip 范围
rollout_is_weightsw_trollout correction 的 IS 权重,可选

最后的聚合由 agg_loss() 决定。token-mean 是所有有效 token 平均;seq-mean-token-mean 是每条 response 先平均 token,再对 response 平均;seq-mean-token-sum-norm 常用于 Dr.GRPO/DAPO-style 长度处理。

4. value loss:compute_value_loss()

critic 不是用 PPO policy loss,而是回归 returns

text
vpredclipped = clip(vpreds, values - cliprange_value, values + cliprange_value)
vf_loss = 0.5 * max((vpreds - returns)^2, (vpredclipped - returns)^2)

这和 actor 的 clip 思想类似:critic 也不要一步把 value 改得太离谱。

配置里先看哪些开关

PPO 示例脚本如 examples/ppo_trainer/run_qwen3_8b_fsdp.shrun_qwen3_8b_megatron.sh 里,主线配置是:

text
algorithm.adv_estimator=gae
actor_rollout_ref.actor.clip_ratio=0.2
actor_rollout_ref.rollout.n=${ROLLOUT_N}
critic.* enabled

读配置时建议按这个顺序查:

  1. algorithm.adv_estimator 是否是 gae
  2. critic 是否启用,因为 PPO + GAE 需要 value。
  3. KL 在 reward 侧还是 loss 侧:algorithm.use_kl_in_rewardactor_rollout_ref.actor.use_kl_loss
  4. actor 的 clip_ratio_low/high/c 是否覆盖默认 PPO。
  5. actor_rollout_ref.actor.loss_agg_mode 是否改变了长短 response 权重。
  6. 是否启用 algorithm.rollout_correction,这会改变 old_log_probsrollout_log_probs 的关系。

哪些地方不适合初学者硬啃

  • 不要一开始就读所有 policy loss 变体。先读 vanilla,再读 dppo_*gspocispo
  • 不要把 old_log_probsrollout_log_probsref_log_prob 混成一个东西。它们分别服务 PPO anchor、生成行为策略、reference regularization。
  • 不要只看论文公式就忽略 loss_agg_mode。在 LLM 里,长 response 有多少 token 会直接影响梯度权重。
  • 不要以为 reward 一定是 dense token reward。RLVR 里常见 outcome reward 只落在最后一个 token,再广播或递推成 token-level advantage。

本节参考与延伸阅读

面向源码阅读的 verl 学习文档。