ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM强化学习中的KL散度惩罚:原理、实现与调参经验

LLM强化学习中的KL散度惩罚:原理、实现与调参经验

最近在调大语言模型强化学习的时候,我又一次被KL散度惩罚给坑了。Loss曲线看起来挺正常,奖励也在涨,但跑出来的文本却越缩越短、越长越怪,最后发现罪魁祸首就是KL惩罚系数和参考模型的选择出了问题。这篇东西不是教科书式科普,而是我自己在跑RLHF、GRPO这类流程时,对KL散度惩罚的理解、实现细节、调参血泪和排查经验。如果你已经在跑PPO、GRPO,或者正准备把SFT模型拉去做对齐训练,这篇内容应该能帮你少折腾几个晚上。

我默认你懂一点强化学习基本概念,也大概知道PPO里的优势函数、策略梯度是怎么回事。如果你只是听说过“RLHF”,想先看个全貌,那也不用慌,我会把KL散度惩罚为什么存在、它到底在算什么东西、怎么把代码写对、怎么调参,一层层拆开讲。

1. 为什么LLM强化学习离不了KL散度惩罚

1.1 没有KL惩罚,模型会疯狂“薅奖励”

大语言模型强化学习和游戏AI、机器人控制有一个显著区别:我们没有一个能给出完美真实奖励的环境。所谓“奖励模型”,本质上是一个从人类标注上训练出来的打分器,它也有偏见、有盲点、可以被钻空子。如果你只让策略模型最大化奖励模型的打分,它会找到一堆人类看不懂但奖励很高的“捷径”。

最常见的捷径包括:无限堆叠重复内容、无意义地加长输出、刻意使用某些高频表达、把答案强行塞进貌似结构的模板里。更隐蔽的是,如果奖励模型对“长度”有隐式偏好,模型会把答案越写越长,反而丢掉信息密度。这些现象在业界被统称为reward hacking(奖励黑客)。你如果不给策略模型一个“刹车”,它迟早会跑偏到奖励模型的盲区里。

KL散度惩罚就是那个刹车。它不让策略模型随心所欲地远离某个参考策略,而是要求模型在“提升奖励”和“保持与参考策略相近”之间做一个权衡。参考策略通常是SFT模型,也就是经过监督微调、行为还比较“正常”的模型。加了KL惩罚后,模型想走偏可以,但必须付出代价,代价大小由你给定的系数控制。一个标准的PPO目标函数通常会写成:

奖励目标 = 原始奖励 − β × KL(新策略概率 || 参考策略概率)

这个β就是KL散度惩罚的强度系数。β大了,模型就不敢动;β小了,模型就可能放飞自我。后面我会详细讨论怎么调。

1.2 参考策略不是“老模型”,而是安全锚点

很多人第一次接触RLHF时,会把参考策略和策略模型理解成“同一个模型的两个版本”。这样做初看没错,但实操里有很多细节。参考策略通常是训练初始时保存的SFT模型副本,在强化学习全过程中冻结权重,不参与梯度更新。它扮演的是“原本那个还算乖的模型”的角色。策略模型开始可能和参考策略一模一样,随着训练推进,策略模型逐渐偏离参考策略,KL散度就衡量这个偏离程度。

你可以把参考策略想象成风筝线盘,策略模型是风筝。奖励信号是风,让风筝朝着高分方向飞。KL惩罚就是牵引线,防止风筝被吹到看不见的地方。线放得太长,风筝容易失控;线拽得太紧,风筝只能在原地打转。好的RL训练,就是在风力和线长之间找到让风筝飞得高且稳的位置。

在具体代码实现里,策略模型和参考模型通常是同一个结构的两个权重副本。每算一个batch的数据,你需要分别用策略模型和参考模型算出每个token的logprob(对数概率),然后用它俩的差来近似KL散度。参考模型不反传梯度,但必须用和策略模型完全一致的tokenizer和padding方式,否则算出来的KL完全是错的。

2. 核心细节解析:KL散度到底该用哪个方向、怎么算才不出错

2.1 前向KL和反向KL差在哪,选错会出大事

KL散度严格来说不是距离,因为它不对称。KLP||Q和KLQ||P可能是完全不同的值,含义也不一样。在大语言模型强化学习里,我们通常写的是KL(策略 || 参考策略),也就是把策略模型当作P,参考模型当作Q。

这个选择很关键。KL(策略||参考)对“策略在参考模型认为概率很低的地方出现”很敏感。换句话说,如果策略模型在某个token上给出了高概率,但参考模型认为那里应该是低概率,这个方向的KL会迅速变大。这种特性会让策略模型不敢在参考模型完全不确定的位置上冒险,整体行为偏向保守。另一方向KL(参考||策略)的做法在部分变分推断里常见,但用在RLHF里容易让模型出现“mode collapse”式的激进,也就是集中到某个高分模式上,丢失多样性。所以主流的开源实现里都是用前向KL,即KL(pi_theta || pi_ref)。

实现时不需要真的按概率分布公式去累加。对于每个token,我们可以用对数概率做蒙特卡洛估计:

KL_single ≈ log p_ref(token) - log p_policy(token)

然后对一个序列的所有有效token求平均。这里需要注意,我们不能拿“整个序列生成的概率”去做一个总的KL,因为RLHF的奖励信号通常落在最后,但生成的概率是逐步输出的。逐token级别的KL能让模型更细粒度地保持参考模型的语言习惯、风格和知识边界。

2.2 是逐token算还是整句算?粒度决定手感

LLM强化学习中的KL散度惩罚,绝大多数情况下是per-token级计算的。也就是对序列中每个非padding的位置都算一次“参考模型logprob减策略模型logprob”,然后对这些值求平均。这样做的原因有两个:

第一,策略梯度更新本来就是作用在token级别上的。我们最终的目标是提高高优势token的概率、降低低优势token的概率,KL惩罚只有对齐到同一粒度,才好在每个token的loss上直接做减法。

第二,整句级的KL会掩盖局部风险。比如有一半token模型已经完全跑偏,另一半token还非常接近参考模型,平均下来KL可能看起来不高,但实际生成的文本已经开始局部崩坏。per-token的KL平均虽然也会被“摊薄”,但至少每个位置的差异都能进入loss,配合优势函数能更精准地抑制坏位置。

在实现上,你通常会在得到奖励后,把奖励信号转成token级别的回报,然后让每个token的loss里都含有一项:

policy_loss_token = - advantage_token * ratio_token

而KL惩罚是直接作为负项加到策略目标上,或者单独加到总loss里。不同框架的做法略有区别,但本质一样:KL惩罚必须在policy更新方向上起作用,也就是说,当模型在某token上偏离参考模型时,即使优势为正,也可能因为KL惩罚太大而得不偿失。

3. 实操过程:把KL惩罚正确写进训练循环,并调出稳定曲线

3.1 从SFT到RL的标准管线:模型加载和logprob计算

在动手写代码前,先把训练管线理清楚。你要有四个东西:

  • 策略模型(policy model):要被强化学习更新的模型
  • 参考模型(ref model):冻结的SFT基线
  • 奖励模型(reward model):给输出打分的模型
  • 原始prompt数据集:RL训练的起点

如果用的是TRL库,通常只需要传入policy model和ref model;奖励模型可以单独load,也可以在每条样本上推理。但如果你打算自己实现或者复现论文,最好手动把ref model和policy model都加载进来,明确二者tokenizer完全一样。

计算logprob的标准流程是:

  1. 对prompt和模型生成的response拼成一个序列
  2. 只对response部分计算loss和logprob,不更新prompt部分
  3. 分别用policy model和ref model过一遍相同序列,得到response段每个token的对数概率
  4. 用ref logprob减去policy logprob得到per-token KL

这里有一个特别容易踩的坑:两个模型要严格使用相同的padding位置。如果你给ref model用的max length和padding mask和policy model不一致,KL会在padding位置产生无意义的计算。我们通常会给所有涉及KL计算的token位置设置mask,只保留response部分的非padding token。下面是一段简化伪代码:

# 伪代码,只展示核心概念 def compute_kl_penalty(seq, attention_mask, policy_logits, ref_logits): # 取 response 部分的 mask,去掉 prompt 和 padding response_mask = get_response_mask(seq, attention_mask) # log_softmax 得到每个 token 的 logprob policy_logp = log_softmax(policy_logits, dim=-1) # shape: [batch, seq_len, vocab] ref_logp = log_softmax(ref_logits, dim=-1) # 取实际token位置的logprob policy_token_logp = gather_token_logp(policy_logp, seq) ref_token_logp = gather_token_logp(ref_logp, seq) # 逐token KL: logp_ref - logp_policy per_token_kl = ref_token_logp - policy_token_logp # 只对response部分有效token求平均 masked_kl = per_token_kl * response_mask.float() n_tokens = response_mask.float().sum(dim=-1) kl = masked_kl.sum(dim=-1) / n_tokens.clamp(min=1) return kl

这里用logp_ref - logp_policy算的是单点蒙特卡洛估计的KL。它不要求你显著地跑完整分布计算,因为在一个实际采样到的token上,这个差值的期望本身就接近真实KL方向。在PPO里,我们通常希望这个值不要太大,控制在个位数到十几左右。

3.2 PPO和GRPO里KL项的接入方式

PPO的实现里,KL散度惩罚最直接的方式是把它加到总loss中。展开一点:PPO会对所有响应token计算一个importance ratio,并乘以优势函数,同时做一个clip约束防止更新过猛。KL惩罚可以在ratio上做修正,也可以作为策略目标的额外项。TRL的PPO实现中,选择的经验做法是:每个token的loss = -优势 * ratio,然后总loss里额外加一个KL惩罚项,等价于在logprob更新时手动压低偏离。

GRPO则有点不同。它不需要critic网络,优势通过同一组采样输出之间的奖励相对值估计。TRL和很多开源复现里,KL惩罚仍然保留,而且被证明对稳定性至关重要。GRPO里常见的做法是给KL加一个系数β,控制“当前这组样本里,策略模型相对于参考模型的偏移”不要过大。有人会觉得GRPO里已经用了相对奖励,是不是不用KL了?我试过直接把KL系数设成0,训练确实能跑,但生成质量会在几千步后明显下降,表现是重复率上升、句子碎片化。所以哪怕在GRPO里,KL惩罚也不是装饰品。

实际训练中,我会把KL均值记录下来作为重要的早停指标。经验上,一个长度在128到512 token的response,per-token KL均值在0到15之间比较常见。超过20就要警惕,超过50基本已经疯了。你可以设定一个target_kl,然后动态调整β:

  • 如果最近一个窗口的KL均值大于target_kl,就加大β
  • 如果KL均值远小于target_kl,就适当减小β

这也是很多人说的adaptive KL。它比固定β更省心,因为reward scale在不同训练阶段变化很大,固定β很难一直合适。

3.3 超参数选择:β和target_kl到底怎么设

没有任何一组超参能通吃所有任务。但你可以用一个比较稳的起点:

  • β初始值:0.02到0.1之间
  • target_kl:每token平均KL设置在5到10之间
  • 如果reward scale平均在1到5,β用0.05左右通常不至于让KL爆炸

我自己的习惯是先把奖励模型输出的reward做一下标准化,让均值尽量落在0附近。不要直接拿原始reward打分去和KL惩罚相加,因为不同奖励模型的scale可能差别很大。你在本地小模型上做验证时,经常会出现某个奖励模型打分普遍在10以上,另一个打分普遍在0.3附近。如果β不变,前者会让KL惩罚完全不起作用,后者会让KL惩罚重到训练停滞。

做标准化思路很简单:在RL初始化时,先用一小批样本估算reward分布的mean和std,然后对reward做类似z-score的变换,再和β×KL相加。这样β的调解语义就稳定很多。

下面是一个简化的loss计算公式,你可以对照着理解:

# 每个token的最终loss per_token_kl = ref_logp - policy_logp policy_gradient_loss = -advantages * ratio loss = policy_gradient_loss + beta * per_token_kl

需要注意隐藏的顺序:有些实现会把KL惩罚直接放进ratio里,比如把优势减掉KL再乘ratio,效果类似,但梯度分布略有不同。如果发现某一种写法下reward涨但KL疯涨,可以试试换另一种接法,往往能改善。

4. 常见问题与排查技巧实录

4.1 KL散度爆炸:生成文本迅速退化

这是最常见的翻车现场。训练进行几千步后,reward可能还在涨,但你打开几个样本一看,全是重复词、废话或者突然变成另一种语言风格。这时候看KL曲线,大概率是一条向上陡增的线。

排查顺序我推荐这样:

  1. 确认tokenizer一致。手动让policy和ref分别tokenize同一句话,比对input_id是否完全一致。
  2. 确认padding mask正确。有没有把padding位置的KL也当成有效值累计。
  3. 确认logprob取到的是response段,而不是prompt段。有些实现误把prompt部分的KL也算了进去。
  4. 降低学习率。RL training里学习率过高是KL爆炸最常见的原因之一。
  5. 调大β或者调低target_kl。如果用的是adaptive KL,把target_kl从10降到6,往往能在几天前发现危险。

另一种“伪爆炸”是优势标准化没做。如果advantage的scale突然变大,ratio变化剧烈,KL会被间接顶上去。这种情况你会发现KL和advantage均值同步飙升,处理方式不是调β,而是给advantage做更稳的标准化,或者加大PPO clip范围限制。

4.2 奖励不动、KL几乎为0:模型“躺平”了

和爆炸相对的另一种情况是KL一直很小,但奖励也不涨,整个训练像死水一样。很多人以为KL小就是好事,代表模型没跑偏,但实际上可能是“跑不动”。原因通常有三类:

第一,β太大。KL惩罚太重,模型稍微偏离参考就会受到巨大loss惩罚,梯度更新朝“不偏离”的方向走,直接把奖励信号淹没了。你可以把β降到原来的十分之一试试。

第二,参考模型太强,策略模型和参考模型的logprob几乎一致。如果初始策略就是参考模型,而参考模型本身已经很强,模型只在很小的邻域探索,奖励变化自然很小。这种情况可以适当增大KL target,允许模型更大胆尝试。

第三,advantage计算有问题。比如你的baseline估计太强,导致大多数advantage都接近0,更新信号微弱。GRPO里如果你选的group内样本质量都差不多,也会出现优势偏小的问题。可以检查一下生成的多样性,如果beam搜索或temperature太低,group内样本几乎一样,优势自然没有区分度。

4.3 数值问题:NaN、负KL、怪异大数

KL散度理论上是非负的,但实际代码里你会看到负值,尤其是当采用蒙特卡洛估计logp_ref - logp_policy时。因为某个token在策略模型下概率比参考模型高,它就会出现负贡献。这本身不是bug,但如果你把所有负值都强行clip到0,就破坏了无偏估计。正确的做法是保留符号,靠整体均值来反映平均偏离。

NaN问题则需要重点排查:

  • log_softmax溢出:当logits特别大时,用朴素softmax可能溢出。用log_softmax而不是先softmax再log。
  • 奖励模型给了一个极大值,导致advantage极大,接着ratio极大,loss也极大。给reward做截断或者缩放会有帮助。
  • mixed precision训练下,KL的均值在fp16里可能不够稳定。建议KL累加时用fp32,或者单独统计。

4.4 问题排查速查表

症状最可能原因优先操作
KL曲线持续上涨β过小 / 学习率过高 / tokenizer不一致调大β、降低LR、检查tokenizer与mask
KL接近0且reward停滞β过大 / group样本多样性不足调小β、增加采样temperature
loss出现NaNlogits溢出 / reward极端值用log_softmax、reward截断、fp32统计KL
生成重复严重但KL不高逐token平均掩盖局部问题观察分位KL或按token位置绘图,降低采样长度
reward大幅度波动reward scale未标准化对reward做z-score或固定scale
训练正常但reward不涨参考模型过强允许更大KL,提高β宽松度或降低β

这张表是我在跑7B模型时反复用到的“第一直觉清单”。当然,它们不一定百分百覆盖你的问题,但能帮你快速圈定范围。

5. 本地小模型上的实验策略和自研代码建议

5.1 为什么我建议先用小模型验证KL逻辑

很多同学一上来就想在70B上做RLHF,但KL散度惩罚的调试成本非常高,一次实验几百卡时起步。我自己更习惯先在一个1B或3B的本地模型上,用单卡或者双卡把流程完全跑通。小模型不是“缩小版的大模型”,它的reward信号和KL行为和大模型有一定差异,但KL惩罚的核心问题——方向、粒度、系数、mask——完全一致。小模型上跑三小时就能看到KL曲线走向,比大模型上跑三天才发现爆炸要省钱得多。

本地部署的另一个优势是你可以频繁修改代码,打日志、dump中间输出、画KL分布热力图。在集群上搞这些很麻烦。我通常会在小模型上先确认三件事:KL均值随时间变化是否平稳、KL分布是否存在极端离群点、以及reward和KL之间是否有明显相关。确认完再去放大模型,基本不会出大的稳定问题。

5.2 用现成库还是手写一个可控制的实现

如果你刚开始做LLM RL,直接使用成熟的库肯定最快,比如TRL的PPOTrainer、GRPOTrainer,或者DeepSpeed的RLHF框架。这些库已经把KL惩罚、advantage估计、token mask这些基础逻辑封装好了。但缺点是,出问题时你很难知道它内部到底把KL加在哪个位置、有没有做adaptive系数调整。

我个人的做法是:先读一遍开源库中KL相关源码,确认它用的是per-token还是sequence-level,然后跑一个打印日志的小实验。等你理解透了,再在必要的地方魔改。我自己在落地项目时,会单独抽出一个kl_penalty.py模块,里面只负责输入policy logprob、ref logprob、response mask,返回per-token的KL值和最终loss。这样无论上层用PPO还是GRPO,KL策略都能统一更换。

自研的小模块用一个简单接口就够:

class KLController: def __init__(self, init_coef, target_kl, method="adaptive"): self.coef = init_coef self.target_kl = target_kl self.method = method def update(self, recent_kl): if self.method == "fixed": pass elif self.method == "adaptive": if recent_kl > self.target_kl * 1.2: self.coef *= 1.2 elif recent_kl < self.target_kl * 0.8: self.coef *= 0.8 return self.coef

用这个控制器,每若干step更新一次β,比手工改配置更平滑。训练结束后,把KL变化过程和β变化过程画在同一个图上,你会发现它们之间存在完好的“负反馈”对应关系。这也是判断RL训练是否健康的一个快速标准。

把KL惩罚看成和reward并肩的量,而不是一个附属项,很多玄学问题就能解释通了。我踩过最大的坑就是把β当成固定超参,以为训练一次调好就万事大吉。真正稳的做法是让β跟着KL实时走,并严格盯住每一轮的平均KL、分位数KL,以及KL和reward的互相关系。第一次跑通之后,你再看那些开源代码的KL相关逻辑,会突然觉得每个字段都很有道理。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进