PPO:从 clipped loss 读懂 verl 主线
PPO 是 verl 里最值得先读懂的主线算法。它做的事情可以先说得很朴素:模型先用当前策略生成一批回答,再用 reward 判断哪些回答更好,最后更新 actor,让好回答里的 token 更容易出现、差回答里的 token 更不容易出现。但这个更新必须温和,否则模型会为了 reward 一步跨太大,把原来的语言能力或格式能力冲坏。
在 verl 里,PPO 的核心不是一个单独的 trainer,而是一条数据流:
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 论文常写成最大化:
其中概率比值是:
训练代码要最小化 loss,所以 verl 的 compute_policy_loss_vanilla() 写成:
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_low和clip_ratio_high可以不同,例如 DAPO-style 常见0.2 / 0.28。 - dual-clip:当
advantages < 0时,clip_ratio_c会给负优势样本再加一道保护,避免负样本在极端 ratio 下产生过猛梯度。
GAE:critic 怎么变成 advantage
PPO 默认用 GAE。GAE 的递推是:
在 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 里,一轮训练的关键顺序是:
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:
kld = kl_penalty(old_log_probs, ref_log_prob)
token_level_rewards = token_level_scores - beta * kld变量对应关系:
| 源码变量 | 含义 |
|---|---|
token_level_scores | reward model 或 rule reward 给出的原始分数,常只在最后一个有效 token 非零 |
old_log_probs | actor 对已生成 token 的 log probability |
ref_log_prob | reference model 对同一批 token 的 log probability |
beta | KL 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_t | s_t)` |
log_prob | `log pi_theta(a_t | s_t)` |
advantages | A_t | 每个 token 的优势 |
response_mask | mask | 有效 response token |
clip_ratio_low/high | epsilon_low/high | ratio 的下/上 clip 范围 |
rollout_is_weights | w_t | rollout 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:
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.sh 和 run_qwen3_8b_megatron.sh 里,主线配置是:
algorithm.adv_estimator=gae
actor_rollout_ref.actor.clip_ratio=0.2
actor_rollout_ref.rollout.n=${ROLLOUT_N}
critic.* enabled读配置时建议按这个顺序查:
algorithm.adv_estimator是否是gae。critic是否启用,因为 PPO + GAE 需要 value。- KL 在 reward 侧还是 loss 侧:
algorithm.use_kl_in_reward与actor_rollout_ref.actor.use_kl_loss。 - actor 的
clip_ratio_low/high/c是否覆盖默认 PPO。 actor_rollout_ref.actor.loss_agg_mode是否改变了长短 response 权重。- 是否启用
algorithm.rollout_correction,这会改变old_log_probs与rollout_log_probs的关系。
哪些地方不适合初学者硬啃
- 不要一开始就读所有 policy loss 变体。先读
vanilla,再读dppo_*、gspo、cispo。 - 不要把
old_log_probs、rollout_log_probs、ref_log_prob混成一个东西。它们分别服务 PPO anchor、生成行为策略、reference regularization。 - 不要只看论文公式就忽略
loss_agg_mode。在 LLM 里,长 response 有多少 token 会直接影响梯度权重。 - 不要以为 reward 一定是 dense token reward。RLVR 里常见 outcome reward 只落在最后一个 token,再广播或递推成 token-level advantage。
本节参考与延伸阅读
- verl 源码:
verl/trainer/ppo/core_algos.py的compute_gae_advantage_return()、compute_policy_loss_vanilla()、compute_value_loss()、agg_loss()、kl_penalty()。 - verl 源码:
verl/trainer/ppo/ray_trainer.py的apply_kl_penalty()、compute_advantage()、RayPPOTrainer.fit()。 - verl 官方文档:
docs/algo/ppo.md,尤其 PPO 配置、KL reward penalty、KL loss、dual-clip。 - 示例脚本:
examples/ppo_trainer/run_qwen3_8b_fsdp.sh、examples/ppo_trainer/run_qwen3_8b_megatron.sh。 - 论文:Proximal Policy Optimization Algorithms。
- 论文:High-Dimensional Continuous Control Using Generalized Advantage Estimation。
- 先验材料:OpenAI Spinning Up PPO。