说实话,第一次跑 VERL 的人,注意力基本都放在奖励模型怎么设计、采样吞吐有多快上,很少有人会第一时间去翻它的损失函数实现。但等到训练跑崩、reward 曲线还在涨、生成质量却肉眼可见地变差时,回头去看 loss 的计算方式,往往才是真正的问题所在。
这篇文章想写清楚一件事:VERL 里损失函数到底是怎么算出来的。我会从数据流开始,拆 PPO 和 GRPO 两类算法在 loss 层面的实现细节,再讲 KL 惩罚是怎么参与计算的,最后给出一套我实际调参中踩过坑之后总结的排查链路。适合想深入理解 VERL 训练逻辑、或者正在自己改 loss 做二次开发的工程师,也适合刚入门 RLHF 但对底层计算好奇的同学。
1. VERL 跑训练时,loss 在哪里被算出来——先看懂数据流
很多人的第一个误区是:把 VERL 当成一个普通的 PyTorch 训练脚本去读。但 VERL 的设计目标是大规模 RLHF/RLVR 训练,它的执行链路是分布式的,loss 不是凭空算出来的,而是依赖一串保存在经验缓冲区里的中间张量。
1.1 一条完整样本从采样到参与 loss 计算的旅程
VERL 的训练主循环大致是:actor 模型通过 vLLM 做 rollout 采样,得到一批 prompt 和 response;然后 reward 模型或者规则函数给 response 打分;接着这一整段轨迹连同采样时的策略概率、价值估计等元数据被存入一个固定大小的 buffer;最终 learner 进程从 buffer 里取出数据,计算各种 loss,更新参数。
你没看错,actor 采样时的 log_prob 和 value 是要存下来的,因为 PPO/GRPO 的 loss 计算依赖“旧策略”的信息。这个旧策略信息通常叫 old_log_prob 和 old_value,它们跟新策略计算出的 log_prob 做比值,才能得到 policy ratio。VERL 在保存数据时会把 response_mask、old_log_prob、old_value、rewards 全部打包,任何一个字段缺失,后面的 loss 计算都会出问题。
1.2 loss 真正依赖的张量,比你想象中多
如果你只从公式上理解 PPO,会觉得 loss 只需要“新策略概率”和“优势值”。但实际工程实现里,至少需要以下几项:
- response_mask:区分 response 里哪些是真实回答 token、哪些是 padding,loss 只在真实回答部分上计算。
- old_log_prob:采样时 policy 对每个 token 的对数概率。
- old_value:critic 网络在采样时对每个 token 的价值估计。
- rewards:经过 reward model 或规则函数计算后的奖励序列,通常会叠加上 KL 惩罚。
- advantages 和 returns:通过 GAE(Generalized Advantage Estimation)算出来的优势值和回报值。
因为没有这些中间量,loss 根本没法复现。很多二次开发的同学在改 VERL 时,会把注意力放在“怎么改 loss 公式”上,却忽略了数据 buffer 里存了什么。结果就是你改了公式,但喂进来的张量缺字段,训练一跑就报 shape mismatch 或者静默地用错误数据算。
我自己在某个模拟项目里就干过这种事:为了降低显存占用,尝试在 buffer 里去掉 old_value,因为当时用的算法是 GRPO,按理说不需要 critic。结果程序没报错,但 loss 计算时某段逻辑还是取了这个字段,数值直接乱掉。所以理解 VERL 的 loss,第一步是理清这张数据依赖表。
2. PPO 损失函数的实现拆解:ratio、clip 和 advantage 如何配合
PPO 是 VERL 里最常用的训练算法,也是理解其他算法的基础。它看似只有两三行公式,但工程实现里有很多边界处理。
2.1 从公式到真实张量计算
PPO 的 actor loss 标准形式是:
L = - E [ min( ratio * A, clip(ratio, 1-eps, 1+eps) * A ) ]
其中 ratio = exp( new_log_prob - old_log_prob ),A 是 advantage。VERL 在计算时不是直接算 exp 差值,而是先算 log_ratio,再用 exp() 转成 ratio,目的就是数值稳定性。
我简化一下 VERL 主更新的思路:
# 伪代码,说明计算思路,不是源码逐行 log_ratio = new_log_prob - old_log_prob ratio = torch.exp(log_ratio) # 对 advantage 做 clip,保护梯度 adv = advantage # PPO 的 clip 目标 ratio_clipped = torch.clamp(ratio, 1.0 - clip_range, 1.0 + clip_range) surr1 = ratio * adv surr2 = ratio_clipped * adv actor_loss = -torch.min(surr1, surr2) # 只在 response_mask 为 True 的位置上计算均值 actor_loss = masked_mean(actor_loss, response_mask)这里最关键的是 mask。很多人在自己实现 PPO 时,直接把所有 token 的 loss 求平均,包括 padding token。padding 不包含语义,log_prob 往往是模型对同一个填充符的预测,数值比较稳定,把 padding 的 loss 算进去会稀释真实回答部分的梯度信号。VERL 在这一点上处理得比较细,response_mask 会参与最终的 reduce_mean,而不是简单整批平均。
2.2 两种 PPO loss 计算路径的差异
VERL 的 PPOTrainer 里,actor loss 可能有两种路径,一种比较直观地做上述计算,另一种则更贴近底层实现,把 old_log_prob 的存储方式做了优化。比如有些实现会提前把 response_mask 从 [batch, seq_len] 压成 [batch * valid_len],甚至在每个 token 的 loss 权重上区分 padding 的 log_prob 偏差。
我自己在阅读源码时的体会是:如果你只是跑默认配置,不需要关心这两种路径的差异;但如果你要改 loss,比如想加入 token 级别的权重(比如对答案中的关键步骤加权),就一定要确认你的新逻辑挂在哪条路径上。挂错地方,改动可能根本不生效。
2.3 critic loss 和 GAE 的关联
PPO 里除了 actor loss,还有 critic loss。critic 网络预测每个 token 的 value,然后用 GAE 算出的 return 做回归目标:
critic_loss = F.mse_loss(value_new, return, reduction='none') critic_loss = masked_mean(critic_loss, response_mask)这里值得注意:return 是逐步折扣累加出来的,而 value_new 是新 critic 的前向结果。GAE 的 lambda 和 gamma 参数直接影响 return 的形状,也就影响 critic loss 的数值范围。VERL 里默认的 gamma 和 lambda 都比较常规(比如 gamma 1.0、lambda 0.95 左右),但如果你想缩短有效视距,可以调低 lambda。调低后 critic loss 的曲线会更“碎”,因为 target 更多来自即时奖励而不是长期回报。
我第一次跑 VERL 的时候,看到 loss 曲线反复波动,第一反应是学习率太大。后来把 GAE 的 lambda 从 0.95 调到 0.8,对比实验才意识到,原来是回报累积长度差异导致的价值目标不稳定。所以不能孤立看 loss,还得看它背后的 target 是怎么构造的。
3. GRPO 的损失计算:没有 critic 时,advantage 从哪来
近几年 GRPO 用得很广,尤其是在数学、代码这类 reward 规则明确的场景里。它最大的特点是没有 critic 网络,所以 loss 里没有 value loss 这一项,显存能省不少。
3.1 组内归一化如何替代价值网络
GRPO 的做法是:对同一个 prompt 采样多条 response,形成一个 group,然后用这个 group 内的 reward 计算 advantage:
A_i = ( R_i - mean(R_group) ) / std(R_group)
也就是说,它把“这条回答比同组其他回答好多少”当作优势。这个 advantage 是纯统计量,不需要价值网络预测,所以 GRPO 的 loss 只有 actor 部分,没有 critic 部分。
在 VERL 里,配置 adv_estimator 为 grpo 后,loss 的公式会变成这种形式:
ratio = torch.exp(new_log_prob - old_log_prob) ratio_clipped = torch.clamp(ratio, 1.0 - clip_range, 1.0 + clip_range) # 注意:GRPO 这里通常用的是 clamp 之后取 max 的思路 loss = -torch.mean( torch.max(ratio_clipped * advantage, ratio * advantage) * response_mask )这里跟 PPO 的 min 形式不一样。PPO 是保守地取两个目标中较小的那个,避免过于激进的更新;GRPO 因为 advantage 是基于组内相对优劣算出来的,本身方差更小,所以用 max 来保证更新幅度不会因为 clip 而过度收缩。
3.2 PPO 与 GRPO 在 loss 层面的核心差异
我把两者的关键差异整理成一个对比表,方便快速定位:
| 对比项 | PPO | GRPO |
|---|---|---|
| advantage 来源 | critic 网络通过 GAE 估计 | 组内 reward 减均值除标准差 |
| value loss | 有,MSE 拟合 return | 无 |
| actor loss 形式 | min(surr1, surr2) | max(clamped_ratioadv, ratioadv) |
| 对 reward 尺度的依赖 | 低,价值网络会自适应 | 高,组内 reward 差异主导更新 |
| 显存占用 | 高(多一个 critic) | 更低 |
| 适合场景 | 奖励稀疏、需要长期信用分配 | 奖励规则明确、可大量采样 |
这个表对选择算法很有用。如果你发现 GRPO 训练时 loss 波动特别大,先不要急着调学习率,检查 group 内 reward 的标准差是不是太小了。因为 advantage 是除以标准差的,如果同组几条回答的 reward 都非常接近,advantage 数值会很大(除以一个极小标准差),更新就会非常激进。
3.3 从 loss 规模反推训练行为的经验
我自己的经验是,GRPO 的 loss 绝对值大小比 PPO 更“敏感”。PPO 的 advantage 来自 critic,数值会随着训练逐渐走向合理区间,曲线通常比较平滑;GRPO 的 advantage 完全由采样质量决定,如果采样阶段的探索噪声比较大,reward 标准差大,advantage 就小,loss 数值会整体变小,导致更新缓慢。
所以跑 GRPO 时,我会额外打印每个 group 的 reward 均值和标准差,而不是只看 loss。如果标准差有骤降,说明采样逐渐同质化,这时候增加温度系数或者加大 group size 比改 loss 公式更有效。
4. KL 惩罚是挂在 loss 上的“隐形刹车”
很多人只盯着 actor loss 和 critic loss,忽略 KL 惩罚——它才是防止模型在 RL 阶段彻底偏离预训练行为的关键。VERL 里 KL 惩罚要么被加进 reward,要么被当作独立的 loss 项,两种方式在训练行为上有明显差异。
4.1 VERL 支持的三种 KL 估计方式
KL 散度的标准公式是:
KL = log(P_ref / P_theta)
但在实际计算时,直接用 log 比值会带来高方差。VERL 里常见的方式有几种:
- kl_div:直接用 log(P_ref) - log(P_theta) 的期望,这是最基础的实现,方差偏高。
- low_var_kl:低方差估计,利用双对数展开或重要采样技巧,方差更小但实现更复杂。
- biased_kl:有偏估计,方差更低,但估计值存在系统性偏差。
不同估计方式的差异,在训练初期模型还没偏离太远时并不明显,等到训练中后期策略分布开始漂移,噪声大的估计会让 KL 曲线剧烈抖动,进而影响整个 loss 的梯度。
我比较建议在早期调试阶段用低方差估计,把 KL 作为 reward 惩罚项来观察。等你想精细调节 KL 力度时,再切换到更直接的 KL loss 项,因为独立 loss 项更方便做梯度截断和权重调整。
4.2 固定系数与自适应 KL 的取舍
VERL 允许你设置固定的 kl_coef(比如 0.001 到 0.1 之间),也可以启用自适应 KL,设定一个 target_kl,让框架动态调整 KL 惩罚强度。固定系数的问题是:你很难事先知道多大惩罚才算“合适”。惩罚过大,模型很快失去多样性,生成变得保守;惩罚过小,模型会利用过大的 stride 大幅偏离初始策略,生成质量不受控。
我在项目中最常用的是固定系数加手动监控。比如把 kl_coef 从 0.01 起步,观察 KL 散度的变化率。如果 KL 在一万步内涨了十倍,说明惩罚不够;如果 KL 基本不涨,但 reward 也不涨,说明刹车太重,该减小 kl_coef。
4.3 最容易忽视的 KL 使用位置
另一个坑是:KL 惩罚是加在 reward 上,还是加在 loss 上。加在 reward 上,会影响 advantage 计算,进而影响所有 token 的更新强度;加在 loss 上,通常只影响当前 token 的梯度方向。VERL 的某些版本中,即使你做的是 GRPO,也可以选择把 KL 算进 reward,也可以选择独立 KL loss。两条路我都试过,实践感受是:reward 端加 KL 更顺滑,loss 端加 KL 更容易出现训练初期的 loss 冲高,原因是 KL loss 的梯度尺度跟 policy loss 的梯度尺度往往不在一个量级。
5. 改 loss 不能只改公式,这些配套参数必须一起动
如果你决定在 VERL 里改动 loss 计算,比如自定义一个加权项,那几乎可以肯定会踩到一个问题:只改 loss 公式,不改配套参数,训练行为会变得很奇怪。
5.1 batch、minibatch 与更新轮次的杠杆
loss 是对一个 minibatch 求平均的,minibatch 的大小直接决定梯度噪声水平。VERL 里训练数据量非常依赖 rollout 阶段采样出的 response 数量。你改了 loss 后,如果 minibatch 过小,每个 batch 的 mean 方差会很大,曲线剧烈抖动;如果过大,单个样本的异常优势值会被淹没,不容易暴露问题。
我的建议是:改 loss 后,先把 minibatch 调小,观察单条样本的 loss 构成,再用较大 batch 做正式训练。这个小技巧能帮你快速定位“新加的 loss 项是不是在某些 batch 上产生了异常大值”。
5.2 clip 范围与学习率的匹配
PPO 的 clip_range 默认通常是 0.2,它是限制策略单次更新幅度的关键。如果你改了 advantage 的计算方式,比如把 advantage 的绝对值整体放大了三倍,但 clip_range 还是 0.2,你就会发现 clipping 触发的样本比例居高不下,策略几乎不更新;反过来,如果 advantage 缩小了,ratio 很少超出 clip 范围,clipping 机制形同虚设。
所以每次改 loss,都要打印一下 clip 触发比例。我自己的经验是,这个比例应该保持在 10% 到 30% 之间。如果超过 40%,考虑增大 clip_range 或减小学习率;如果低于 5%,说明更新过于保守,可以适当提高学习率。
5.3 advantage normalization 的开关位置
VERL 的 advantage 通常支持归一化,但归一的时机有两种:一种是在 rollout 侧对每个样本归一化,另一种是在 learner 侧对 minibatch 归一化。两者效果差别很大。
rollout 侧归一化会让每个样本的 advantage 均值为 0,标准差为 1,更适合对样本质量不敏感的场景;learner 侧归一化更依赖当前 batch 的统计量,如果某个 batch 恰好全是高 reward 样本,advantage 会被压得接近 0,导致那一步更新变得非常保守。你改了 loss 后,需要明确你的优势值是否应该做这种归一化。如果新 loss 的语义要求原始优势量级,那务必关闭额外的归一化。
6. 我实测中遇到的四类 loss 异常与排查链路
这部分是我在实际训练中真实遇到过的异常类型,以及我排查时的完整链路。它们都不是 bug,但每一个都会让你怀疑人生。
6.1 loss 在降,生成质量却在变差
第一个异常最隐蔽:loss 曲线稳步下降,甚至 KL 也在正常上升,但 eval 集上的生成质量变差。我把这个问题归结为 reward hacking 的变体——模型发现了 reward model 里的漏洞,通过钻空子获得高 reward,而 reward 的上升压过了 KL 惩罚的作用。
排查时可以打印每个 minibatch 中 KL 惩罚项占 actor loss 的比例。如果这个比例持续降低,说明 KL 在这条 loss 里几乎没有存在感。你得提高 kl_coef,或者让 KL 惩罚作为独立 loss 项参与梯度回传,别让它只躲在 reward 里。
6.2 训练到一半 loss 变成 NaN
NaN 最常见的来源是 ratio 爆炸。当 new_log_prob 和 old_log_prob 差距过大时,exp(log_ratio) 会溢出到 inf,减去 reward 之后自然就是 NaN。另一个来源是 advantage 本身出现了 inf,比如某个 group 的 reward 标准差非常小,除出来的 advantage 极大。
我排查 NaN 时,会在 loss 计算前插入几个 assert:
assert torch.isfinite(log_ratio).all() assert (response_mask.sum() > 0) assert torch.isfinite(advantage).all()哪一行先报错,就锁定哪个来源。如果是 ratio 溢出,把 clip 范围减小,或者对 log_ratio 做 clamp,是最快的修复方式。
6.3 奖励在涨,loss 却纹丝不动
有时候 reward 涨得很好,但 actor loss 一直在一个很低的绝对值附近波动。这说明梯度主要来源于 critic loss 或者 KL 项,而 policy gradient 部分几乎没有更新。
一个很常见的原因是 advantage 的数值被 normalize 过度了。如果 rollout 侧已经把每个样本的 advantage 归一化到标准差 1,而 minibatch 里的样本数量又很大,那么每个 token 的 advantage 都很小,policy gradient 的贡献自然被稀释。我遇到这种问题时,会先关掉 advantage normalization,再看 policy loss 的贡献占比是否回升。
6.4 快速排查链路:从 mask 到 distribution
综合下来,我每次修改 loss 后都会按照这个顺序检查:
- 检查 response_mask 是否只覆盖了真实回答部分,没把 prompt 或 padding 卷进来。
- 打印 new_log_prob 和 old_log_prob 的基本统计量,确认两者量级一致。
- 打印 ratio 的分布,确认大部分样本落在 1 附近,异常值被 clip 处理。
- 打印 advantage 的分布,确认没有极端值,组内标准差没有趋近于 0。
- 打印 KL 项的实际数值,确认它和 reward 量级的关系符合设计预期。
这套链路看起来简单,但能覆盖我遇到过的绝大多数 loss 异常。
7. 怎么确认你改的 loss 是“算对了”的
最后聊一个比较容易被忽略但极其重要的问题:你改完 loss,凭什么认为它算对了?
7.1 用一个固定 seed 的玩具任务做基准
别一上来就上大模型,也别一上来就用复杂规则 reward。先固定 seed,构造一个只有几十条 prompt 的 toy 环境,奖励规则也简化成比如“回答里包含指定关键词就给 1 分”。然后跑一个非常短的训练,把每一步 loss 的数值手算出来和打印值对比。
这种方式可能看起来笨,但能最直接地验证公式里的每一项对不对。我第一次改 GRPO 的 clip 逻辑时,就是因为省了这一步,直接跑大实验,结果浪费了足足两天才发现 min 和 max 用反了。
7.2 打印各分量的分布,而不是只看均值
loss 的均值只能告诉你方向对不对,但没法告诉你哪一部分在拖后腿。我会在训练脚本里加一个临时 hook,输出 actor loss、critic loss、KL loss 各自的分位数。看中位数比看均值更能反映真实情况,尤其在存在少量离群样本的情况下,均值会被拉偏,中位数反而稳定。
7.3 保留中间张量,对比前后两次改动
最后一个小习惯:每次改 loss 前,保存一份改动前的中间张量和 loss 数值快照;改动后,把新旧快照做 diff。这不只是在验证“改动有没有生效”,更重要的是能看出改动对更新幅度、clip 比例、KL 贡献的影响。
我在开发自定义 loss 时,会用这个办法确认改动只影响了我想要影响的项,而不是像多米诺骨牌一样把整个梯度链路都搅乱了。很多人觉得这是多余步骤,但在复杂的分布式训练框架里,它可能是你唯一能精确复现问题的工具。
算清楚 loss,表面上看是数学问题,实际是工程问题。VERL 把 RLHF 的很多细节封装在代码深处,但不代表我们可以把它当黑盒。只有把数据依赖、公式实现、配套参数都摸透,才能在它基础上做出真正可控的训练改动。