
说实话第一次跑 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、哪些是 paddingloss 只在真实回答部分上计算。old_log_prob采样时 policy 对每个 token 的对数概率。old_valuecritic 网络在采样时对每个 token 的价值估计。rewards经过 reward model 或规则函数计算后的奖励序列通常会叠加上 KL 惩罚。advantages 和 returns通过 GAEGeneralized 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, 1eps) * 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, reductionnone) 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 计算 advantageA_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 层面的核心差异我把两者的关键差异整理成一个对比表方便快速定位对比项PPOGRPOadvantage 来源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 变成 NaNNaN 最常见的来源是 ratio 爆炸。当 new_log_prob 和 old_log_prob 差距过大时exp(log_ratio) 会溢出到 inf减去 reward 之后自然就是 NaN。另一个来源是 advantage 本身出现了 inf比如某个 group 的 reward 标准差非常小除出来的 advantage 极大。我排查 NaN 时会在 loss 计算前插入几个 assertassert 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 的很多细节封装在代码深处但不代表我们可以把它当黑盒。只有把数据依赖、公式实现、配套参数都摸透才能在它基础上做出真正可控的训练改动。