
1. VERL 的损失函数到底在算什么做强化学习训练的人跟损失函数打的交道最多。VERL 这套框架跑起来之后大家最常问的问题就是它的损失函数到底怎么算的很多人把训练脚本跑通了但是看到 loss 曲线里同时出现好几个指标——policy loss、value loss、kl loss、entropy bonus——就有点懵了不知道谁在优化谁、谁在约束谁。我最早接触 VERL 的时候也踩过这个坑。第一次训练跑出一版正常的 loss 曲线我觉得没问题后来换了一个数据集loss 突然出现周期性抖动折腾了两天才发现是 loss 聚合方式的问题不是模型的问题。所以我觉得有必要把 VERL 的损失函数计算方式完整拆一遍尤其是那些文档里一句话带过、但实际代码里相当讲究的细节。先说结论VERL 的损失函数不是单数是复数。它由策略损失、价值函数损失、KL 散度惩罚项、熵奖励等多个部分叠加而成。训练日志里显示的 total loss 只是几个分项的加权求和具体权重由配置文件里的kl_coeff、entropy_coeff、vf_coeff控制。也就是说如果你只盯着 total loss完全没有意义必须拆开看每个分项。这篇文章按我实际使用的顺序来拆先讲清楚 VERL 的整体设计逻辑然后逐个拆解策略损失、价值损失、KL 惩罚和辅助损失再把它们从公式落地到具体张量计算的每一个步骤最后整理一些我在调试过程中遇到的高频问题。1.1 先理解 VERL 的设计思路VERL 这个名字在我参与的代码版本里代表一个可扩展的强化学习训练框架。它最核心的设计思路是把策略模型、参考模型、价值模型、奖励模型四个角色拆开但又允许它们共享底层参数。损失函数自然也要围绕这四个角色分别定义。通俗地说策略模型负责“做决策”参考模型负责“当旧版本的对照物”价值模型负责“估计还能拿多少好处”奖励模型负责“最终打分”。VERL 在计算损失的时候不是简单地拿四个模型各自算各自的损失然后把数值一加完事而是要考虑它们之间的依赖关系。以 PPO 类算法为例train step 的核心顺序是用当前策略采样一批经验状态、动作、旧概率、优势估计值用奖励模型给输出打分数转换成 token 级别的 reward用 GAE 计算优势函数冻结参考模型计算新旧策略的 KL 散度作为惩罚项同时计算策略损失、价值损失、KL 损失、熵奖励反向传播更新策略和价值网络。这个顺序决定了损失函数计算的输入是什么旧策略的 logprob 务必在前向时缓存下来否则后面计算重要性采样比率的时候没得用。VERL 中很多人在迁移自己的策略模型时发现old_logprobs缺失本质就是采样阶段没有把前向结果缓存住。1.2 损失函数在训练链路里的位置loss 的计算不是一个孤立环节。在 VERL 的代码里loss 计算往往发生在train_step()或者compute_loss()这两个入口之后但真正的前置工作已经做了很多。举个例子一次 PPO update 里的核心输入包括queriesprompt 部分responses生成部分old_log_probs旧策略对每个 token 的概率对数值values价值模型输出rewards每个 token 的奖励值advantages优势函数是 GAE 计算之后得到的old_values旧价值网络输出主要用于价值损失的 clip 操作所有这些数据在进入 loss 函数之前都会被整理成统一形状[batch_size, seq_len]的张量。需要强调的是VERL 默认按序列长度维度做 loss 聚合而不是按 batch 维度。也就是说一条样本里可能有 2048 个 token最终损失值是所有有效 token 上的平均而不是所有样本上的平均。这个区别在样本长度差异极大的时候非常显著。你如果只用“每个句子一条数据”的方式来理解 loss遇到多轮对话、长文档生成、海量 padding 的时候会完全看不懂曲线含义。我自己就因为没注意到这个细节把 KL 系数调高之后发现短样本的 loss 被长样本稀释了结果策略崩溃率反而升高。所以理解 token 级别的聚合方式是理解 VERL 损失函数的第一课。2. 策略损失从原始形式到 VERL 的变体策略损失是整个强化学习训练里最核心的部分它的作用是让模型去调整生成策略使得高优势的动作容易被采出来低优势的动作概率被压低。2.1 经典 PPO-CLIP 的原始形态VERL 默认的策略损失基础形态是 PPO-Clip公式如下L_policy - E_t [ min( r_t * A_t, clip(r_t, 1 - epsilon, 1 epsilon) * A_t ) ]其中 r_t π_θ(a_t | s_t) / π_old(a_t | s_t)表示新旧策略在同一个 token 上的概率比值。A_t 是优势函数epsilon 一般是 0.2 左右。这个公式的核心逻辑是限制单次更新不能离旧策略太远。如果不做 clip模型很容易因为一次高优势样本而过度更新导致策略发散。但加了 clip 之后无论优势多大单步更新对概率比的修正上限都被限制在 1 epsilon。这里有一个经常被误解的点clip 操作不是对 log 域做的而是在概率比上做的。如果你手写复现时直接把old_log_probs和new_log_probs做差然后对差值做 clamp那就错了。正确的做法是先做 exp 还原概率比再 clamp。VERL 内部在计算时会做一个ratio torch.exp(new_log_probs - old_log_probs)然后再进行 min 和 clamp 的组合操作。这个顺序千万不能乱。2.2 VERL 实现里的掩码处理实际代码里策略损失不是对序列上的每个 token 一视同仁。VERL 会传入一个mask用于区分哪些 token 参与损失计算哪些不参与。具体来说有三类 token 会被 mask 掉prompt 部分的 token一般不算策略损失因为这里不需要继续生成padding token不参与任何计算否则会把 loss 拉向 0ignore index 对应的 token比如某些特殊符号或人工标注忽略位。在代码里mask 的形状与序列一致值为 1 表示参与计算0 表示忽略。最终损失是 sum(loss_element * mask) / sum(mask)。如果你想让 prompt 部分也参与训练就需要在数据迭代器里修改 mask 配置。默认配置下VERL 把 prompt 部分的贡献置零这是合理的因为 PPO 类算法惩罚的是采样动作的概率偏离prompt 不是采样动作不宜纳入策略更新。还有一点容易被忽略的response 部分内部的特殊 token例如eos_token或pad_token有时会被保留有时会被 mask 掉。你需要看具体的数据配置不能一上来就假设所有非 padding 的 token 都参与梯度计算。在我这边我就见过某个任务的 response 末尾天然带一堆 eos如果不处理模型会被带偏一直生成 eos。2.3 熵奖励是怎么加进去的策略损失之外VERL 通常还会加一个熵奖励entropy bonus用来鼓励探索防止过早收敛到局部最优。entropy 的值是策略分布的信息熵具体计算是H(π) - sum p_i * log p_i把这个值乘上一个权重entropy_coeff后写入策略损失。需要注意最大化熵意味着鼓励策略分布更均匀所以它和策略梯度的方向经常是相反的。在损失函数中通常会写成- entropy_bonus也就是加在损失上的是负熵。实际调试时entropy_coeff太小会让输出多样性变差太大则会让模型不学东西输出变成一坨均匀分布的乱码。我一般从 0.001 起调然后观察熵的曲线有没有逐步下降。如果熵掉得太快说明模型开始死记硬背了这时候需要适当增大 entropy_coeff。另外VERL 里计算熵的时候同样要考虑 mask。padding 部分的熵是无意义的必须 mask。如果忘了掩码得到的熵会被拉低看起来像模型已经收敛实际是 padding 贡献了无意义的确定性分布。3. 价值损失与多任务辅助损失策略损失负责更新 actor 方向但价值损失负责让 critic 能够准确评估状态价值。两者的节奏如果不同步训练曲线会出现非常严重的震荡。3.1 价值网络的目标构造价值网络输出的是一个标量表示当前状态或者当前 token 位置在未来能获得的累计奖励期望。在实际数据构造中这个目标由 GAE 返回的returns提供而不是直接使用 reward 本身。常见做法是使用价值网络输出的旧值与 GAE 目标之间去做均方误差L_value MSE(V_theta(s), V_target)其中 V_target 通常等于returns advantages old_values。为什么要用 advantages 加 old_values而不是单纯用 returns因为在 GAE 计算流程里returns 本身已经被构建为 length 的累计折算直接用它也可以但框架统一把returns显式给出方便后续做价值目标裁剪。关键还在于 clip。VERL 支持价值损失裁剪clipped value loss公式类似 PPO 的策略裁剪限制新旧价值输出之间不要偏离太远L_value 0.5 * max( (V_new - V_target)^2, (clip(V_new, V_old - delta, V_old delta) - V_target)^2 )这里 delta 一般也是 0.2。这个操作能防止价值网络因为单次优势异常值而大跳。3.2 辅助损失参考模型与 KL 惩罚KL 惩罚项在 VERL 的损失函数里占据不小分量尤其是偏好对齐场景。它的作用是约束当前策略不要偏离参考策略太远防止模型在追求任务奖励时把语言能力彻底破坏。计算逻辑很直观让参考模型通常是冻结权重的旧策略或 SFT 模型对同一段 response 输出 logprobs然后比较当前策略和参考策略在 vocabulary 维度上的概率分布散度。KL 散度公式可以写成KL(π_θ || π_ref) sum π_θ * log(π_θ / π_ref)在 token 级别上VERL 通常不做全词表计算而是使用按位置取 logprob 差值的近似方式kl_approx exp( ref_log_probs - new_log_probs ) - ( ref_log_probs - new_log_probs ) - 1这种近似方式在代码里很常见因为只需要两个标量 logprob不需要全词表的 softmax 分布计算效率和显存占用都友好得多。KL 惩罚项最后是kl_coeff * kl_masked_mean。这里的 mask 也非常关键因为 padding 位置根本没有有效概率分布必须 mask。还有一点如果你直接把 KL 加进 total loss会导致策略损失和价值损失共享同一个优化图梯度会同时流到 actor 和 critic。VERL 设计上会把 actor 和 critic 的梯度分开避免锁步更新。你在做自定义实现时务必确认这一步。3.3 辅助损失语言模型与可回滚项除了策略、价值、KL 这三类核心损失VERL 还经常带一个 language model 类型的辅助损失简单来说就是普通的交叉熵损失用于持续预训练或强化微调过程中保持语言能力。这个损失的计算方式是标准的L_lm - sum log p(token_id)它同样按 mask 做平均。如果你开了这项total loss 里就会多一个lm_loss项配置项里对应lm_coeff。这个系数的调节逻辑和 entropy 类似不能太大否则策略梯度会被语言建模梯度淹没。我在实际使用中一般先设lm_coeff0.01然后逐步减小。如果发现模型生成内容开始变得生硬倾向于重复模板就把 lm_coeff 再调大一点找平衡。另外还有一些基于 KL 蒸馏的辅助损失变体比如拿教师模型和学生模型的 logits 做蒸馏。不过这类损失一般需要在数据侧预先准备教师输出VERL 的默认框架里没有强制开需要在配置中显式开启并传入教师 logits 路径。4. 把公式落到张量计算上实操详解到这一节为止公式都还停留在纸面。真正在 VERL 里改代码时最让人觉得头大的是张量维度和聚合顺序。下面我按我自己手推过的过程把一次完整 loss 计算拆开。4.1 前向计算与旧策略缓存一次 loss 计算的第一步是确保手上有三个 logprob 序列当前策略的 logprob由当前策略网络前向得到旧策略的 logprob采样阶段缓存下来参考策略的 logprob由冻结的参考模型计算得到。我写过一个最小的伪代码大致是def compute_policy_loss(new_log_probs, old_log_probs, advantages, mask, eps0.2): ratio torch.exp(new_log_probs - old_log_probs) surr1 ratio * advantages surr2 torch.clamp(ratio, 1.0 - eps, 1.0 eps) * advantages policy_loss -torch.min(surr1, surr2) * mask return policy_loss.sum() / mask.sum()这里要注意的细节是advantages 必须已经乘以 mask否则无意义位置的数值污染分母。常见的错误写法是先对无 mask 位置计算 min再乘 mask这样会导致梯度在 mask 位置之外仍然有值。正确的写法是先把 surr1 和 surr2 都算出来再统一去乘 mask。4.2 优势函数估计与归一化说句实在话loss 的最终质量一半取决于优势函数算得干不干净。VERL 默认使用 GAE公式里包含两个关键参数gamma和lambda。GAE 的递归关系是delta_t r_t gamma * V(s_{t1}) - V(st)A_t delta_t gamma * lambda * A_{t1}代码实现时通常用反向累积def compute_gae(rewards, values, masks, gamma0.99, lam0.95): deltas rewards gamma * values[:, 1:] * masks - values[:, :-1] advantages torch.zeros_like(rewards) gae 0.0 for t in reversed(range(rewards.size(1))): gae deltas[:, t] gamma * lam * masks[:, t] * gae advantages[:, t] gae returns advantages values[:, :-1] return advantages, returns注意masks在这里表示“是否处于非终止状态”也就是序列是否继续存在和前面损失计算的 mask 含义不完全一致。很多人会混用一个 mask 变量结果在结尾 token 上 GAE 算错。建议给两个 mask 分别命名比如loss_mask和gae_term_mask避免后期排查时精神分裂。上诉代码只是表示逻辑实际框架内会用卷积方式批量计算性能更好。但理解这个循环有助于把 GAE 的参数调明白。优势函数求完之后VERL 还会做一个归一化(advantages - advantages.mean()) / (advantages.std() eps)。这一步非常关键不归一化的话策略损失的量级会随 reward 规模变化剧烈导致 PPO 更新幅度时大时小。4.3 价值损失与 KL 损失张量化价值损失计算时需要拿旧价值输出做 clip。伪代码大致是def compute_value_loss(new_values, old_values, returns, mask, clip_value0.2): value_clipped old_values torch.clamp(new_values - old_values, -clip_value, clip_value) value_loss1 (new_values - returns) ** 2 value_loss2 (value_clipped - returns) ** 2 value_loss 0.5 * torch.max(value_loss1, value_loss2) * mask return value_loss.sum() / mask.sum()注意一点这个公式里用torch.max是裁剪式价值损失的标准做法不是取平均很多初学者会写成 min那就把所有样本都送进没裁剪的一侧了价值梯度会过度自信。KL 损失的计算我参考项目里常用的一种高效近似写法def compute_kl_loss(new_log_probs, ref_log_probs, mask): log_ratio ref_log_probs - new_log_probs kl_approx torch.exp(log_ratio) - log_ratio - 1 return (kl_approx * mask).sum() / mask.sum()这里用ref_log_probs - new_log_probs还是new_log_probs - ref_log_probs不同框架有不同约定。VERL 里我看到的版本是ref - new也就是当前策略比参考策略概率高时出去分布差异会变大。无论方向如何最终效果都是当两者接近时 KL 趋近于零。4.4 不同损失项的合并顺序VERL 的 loss 合并不会简单粗暴把所有张量加起来。它遵循几个原则actor 相关的梯度只走策略项和熵项不走价值项critic 相关的梯度只走价值项KL 项影响 actorlm loss 影响共享参数。所以在配置文件中你会看到类似这样的逻辑total_loss ( policy_coeff * policy_loss value_coeff * value_loss kl_coeff * kl_loss entropy_coeff * entropy_bonus )每一项前面的系数都有独立作用。实际执行时如果 actor 和 critic 共享参数total_loss.backward()会把所有梯度一股脑推下去这通常不是我们想要的。为了控制这种耦合VERL 会做梯度隔离价值损失的张量在传播到 actor 共享层之前就切断。如果你自定义改模型建议在 loss 相加之前对价值分支做detach()操作或者干脆用两个独立优化器。换句话说损失函数计算不是简单的公式拼接还要考虑梯度流的控制。4.5 分布式环境下的 loss 统计多卡训练时每个 rank 上有自己的微批次算出来的 loss 只是局部 loss。如果不做任何处理打印出来的 loss 曲线会有很大噪声尤其是数据并行时各卡的数据分布不同。VERL 的常见做法是在每个 step 结束时做一次全局 all-reduce把各卡的 loss 相加后再除以总卡数输出到日志中。同时各卡上的 token 总数也必须参与聚合否则“按 token 平均”就变成了“按 batch 平均”。我曾经遇到一个经典问题在 8 卡环境下训练 loss 曲线平滑得离谱但模型效果变差。后来发现是某个版本的日志聚合直接对所有卡的 loss 取了算数平均没有按有效 token 加权导致长响应多的卡被低估。这个细节在训练日志里看不到需要你手动打印各卡的mask.sum()做校验。5. 常见问题与排查实录以下内容是我实际调试 VERL 时踩过的坑记录下来供参考。5.1 loss 出现 NaN 或 InfNaN 几乎都来自几个环节优势归一化时方差为零导致除以零KL 近似计算里exp(log_ratio)溢出尤其 old_log_probs 极小value target 计算中出现 inf混合精度下 loss 数值范围异常。建议先跑一个小规模、纯 FP32 的验证确认能稳定训练后再开混合精度。如果纯 FP32 正常而 AMP 下 NaN优先检查大 logits 区域是否被回退到 FP32。5.2 KL 惩罚项突然飙升这种情况通常不是 KL 系数设错了而是策略更新步长过大使得某几个 token 的新概率变成极端值导致 ratio 离 1 非常远。此时优先检查学习率和 clip 范围不要一上来就调 kl_coeff。如果 KL 下降很快然后回升往往表示策略在奖励方向走太快后又被 KL 拉回来形成锯齿。这种情况可以把 PPO 的 entropy_coeff 提高一小档让策略分布更平滑。5.3 padding 导致 loss 平均失真我强烈建议在自定义数据管道里显式统计每个批次的有效 token 数量。如果发现 padding 占比超过 30%那么就算 loss 曲线正常模型学习到的信号也要打折扣。正确的做法是动态 padding 到批次内最大长度而不是固定 padding 到全局最大长度。动态 padding 能显著提高 token 利用率同时减少无意义的 mask 位置。5.4 训练曲线正常但效果不对这种问题最难受。loss 和 reward 曲线都在涨但生成的文本质量变差。通常不是损失函数的问题而是奖励模型本身有偏好偏差导致策略模型过度迎合奖励模型的“捷径”。解决办法是把 KL 系数适当加大降低策略对奖励的过度拟合。同时用验证集上的独立指标人工评分、规则评分、困惑度来观察不要只看训练 reward。还有一个常见问题是训练集和验证集的数据分布差异太大。有些 prompt 在训练里反复出现模型把它们背下来了验证集上一碰新 prompt 就暴露。这种问题调损失函数没用要回头处理数据。5.5 双优化器与梯度耦合现代完整实现里actor 和 critic 通常共享 transformer 底座。如果价值损失和策略损失同时反向传播梯度会对共享层产生混合方向的作用。VERL 的处理方式通常是给不同方向设置不同的停止梯度策略。如果你在自己的版本里用了两个优化器建议先冻结其中一个检查另一个训练是否能单独稳定运行。我踩过的坑是同时更新时 critic 学得太快导致优势估计剧烈变化策略网络被带崩。6. 最后再分享一个我自己调试时的习惯现在代码仓库里各种实现越来越多但损失函数这块的坑大多不在公式本身而在数据处理。我个人习惯在每次训练前先写一个小脚本单独把 loss 的输入张量 dump 出来做一个 invalidity check检查 mask 的取值范围、old_log_probs 是否为 NaN、advantages 的标准差是否为零、reference logprobs 是否有缺失值。这组检查只需要几分钟但能省掉后面一整天的排查时间。我在第一次跑通 PPO 训练时就因为忽略了 reference model 的 cache 路径配置错误导致 KL 全为零模型训练了大半天才发现完全没约束。那时候才意识到损失函数写得再花哨数据管道的正确性永远是第一位的。所以如果你正在调试 VERL 的 loss 曲线先不要急着改公式把张量层面的输入状态确认好。然后策略损失、价值损失、KL 项逐个画图看趋势而不是只看 total loss。能用日志精确定位到哪一项异常就离解决问题不远了。