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 里的核心输入包括:
queries:prompt 部分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 拉向 0;
- ignore 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_coeff=0.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, eps=0.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_{t+1}) - V(st)
A_t = delta_t + gamma * lambda * A_{t+1}
代码实现时通常用反向累积:
def compute_gae(rewards, values, masks, gamma=0.99, lam=0.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_value=0.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 项影响 actor;
- lm 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 或 Inf
NaN 几乎都来自几个环节:
- 优势归一化时方差为零,导致除以零;
- 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。能用日志精确定位到哪一项异常,就离解决问题不远了。