news 2026/10/20 8:54:34

LLM强化学习代码复现:GRPO与DPO损失、KL约束与参考模型缓存

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LLM强化学习代码复现:GRPO与DPO损失、KL约束与参考模型缓存

这篇是“reinforcement-learning-in-llm 代码解读复现”系列的第三篇。前两篇我分别把强化学习在LLM里的基础概念、整体训练流程、以及PPO中最容易被忽略的advantage估计细节过了一遍,这篇打算直接进到代码实现层面,专门拆几个核心模块:GRPO与DPO的损失实现、KL散度约束、参考模型缓存,还有我在复现时踩过的几个比较典型的坑。

如果你现在正处于“论文看了大概、公式能推个七七八八,但一打开代码库还是不知道从哪看起”的状态,这篇应该能帮你省一些时间。你不需要一台很大的机器去复现完整训练,只要能跑通小规模实验即可,代码我会拆到函数级别,尽量说清楚每一行在干什么,以及为什么这么写。

1. 为什么第三篇还要继续啃代码

1.1 算法文档与工程代码的巨大断层

市面上几乎所有关于LLM强化学习的论文,都会给出漂亮的目标函数和伪代码,但真正开工复现时你会发现,论文里只写了核心更新公式,剩下的全是大坑:数据格式怎么构造、batch怎么切分、参考模型logits怎么缓存、KL penalty到底加在哪儿、advantage怎么归一化、loss出现NaN如何排查……这些信息通常散落在几个开源代码库里,没有一个权威的、逐行注释的版本。

我最初按论文公式手写了一个DPO损失,训练到第三步loss就直接飞到几百,后来才发现问题根本不在损失函数本身,而是logits与labels的序列对齐出了问题。这种经验只有下过场才会记住。

所以第三篇我决定换个思路:不仅讲算法原理,更关注代码仓库里真实的数据张量形状、模型调用顺序、算子的位置,把“从数学到工程”的最后一段路补上。

1.2 这一篇适合谁看

这篇的内容定位比较明确:适合已经跑通过1到2个开源RLHF项目、但想彻底弄懂内部实现的人;也适合准备自己从零手写一个小型显存友好的LLM强化学习训练器的人。如果你刚接触强化学习,建议先补一下PPO和RLHF的整体概念,或者先把本系列前两篇翻一翻,再来读代码细节会更顺。

2. 代码库整体模块设计

2.1 目录结构先理清

无论你下载的是哪个开源仓库,只要它做的是LLM强化学习,目录结构大体逃不出下面这套模式:

. ├── configs/ # yaml或json配置 │ ├── grpo_config.yaml │ └── dpo_config.yaml ├── data/ # 数据预处理与collator │ ├── prompt_dataset.py │ └── preference_dataset.py ├── models/ # 策略模型、参考模型、奖励模型封装 │ ├── policy_model.py │ ├── reference_model.py │ └── reward_model.py ├── trainers/ # 训练循环 │ ├── grpo_trainer.py │ └── dpo_trainer.py ├── losses/ # 核心损失函数 │ ├── grpo_loss.py │ ├── dpo_loss.py │ └── kl_helpers.py ├── utils/ # 日志、分布式工具、tokenization工具 │ ├── distributed.py │ └── logging.py └── main.py

这里最核心的目录不是trainers,而是losses。很多开源仓库会把损失函数写得极其简单,看起来十几行,实际上真正的复杂度已经提前分散在了数据迭代器、模型输出处理和日志记录里。所以解读代码时,我建议你按这个顺序读:先读collator如何拼张量,再读模型返回的logits怎么落到损失函数,最后才看训练循环里的更新逻辑。

2.2 各种“模型”在运行时扮演什么角色

代码运行起来,你会同时看到四个模型对象:策略模型(policy model)、参考模型(reference model)、奖励模型(reward model)、以及PPO时代才需要的价值模型(critic model)。

在GRPO和DPO实现中,价值模型基本退场了,但参考模型依然存在。参考模型的作用非常关键:它并不是参与梯度计算,而是提供一个稳定的logits基线,用来度量策略模型每次更新后的“偏移程度”。这个偏移程度就是KL散度的来源。

我最初看参考模型的定位时,把它想象成学生考试时候的“标准答案”,策略模型是“答题卡”。每更新一次,要看答题卡和标准答案差距多大。如果差距太大说明学飞了。这个类比在实现上有时候不完美,但足够帮助你把代码在脑子里跑通。

3. GRPO的核心代码实现

3.1 Advantage计算比想象中朴素

GRPO(组相对策略优化)最大的特点是不再依赖价值网络估计每个token的advantage,而是通过在一个组内生成多条回答,互相比较奖励的相对大小来确定优势值。

这是一个非常“省事”的设计,因为价值网络通常和策略模型一样大,训练的时候额外占一份显存,还容易不稳定。而GRPO把advantage计算简化成了下面这组操作:

def compute_group_advantages(rewards, group_size, eps=1e-4): # rewards: [batch_size * group_size] # 将rewards分组 rewards = rewards.view(-1, group_size) mean = rewards.mean(dim=1, keepdim=True) std = rewards.std(dim=1, keepdim=True) + eps # 组内归一化 advantages = (rewards - mean) / std return advantages.view(-1)

核心动作其实就三行:算组均值、算组标准差、做z-score归一化。注意这个归一化并不是为了加快收敛,而是为了让不同组的奖励尺度可比。

为什么不像PPO那样除以全局奖励标准差?我实测发现,如果把所有样本混在一起算全局归一化,奖励值大的组会持续获得正优势,模型容易朝向少数高分样本快速收敛,导致多样性下降。组内归一化则保证了每一个batch内都恰好有一半的样本获得正优势,另一半获得负优势,这样更新信号始终存在,不会因为整批分数偏高或偏低导致梯度消失。

3.2 KL散度到底加在哪个位置

GRPO损失函数里除了策略梯度项之外,还包含一个KL惩罚项。这个KL惩罚的常规写法是基于每个token计算的:

def estimate_kl(log_probs, ref_log_probs): # 两种常见写法 # 方式1:k1估计 k1_kl = torch.exp(ref_log_probs - log_probs) - 1 - (ref_log_probs - log_probs) # 方式2:直接近似 approx_kl = (ref_log_probs - log_probs).mean(dim=-1) return k1_kl.mean()

第一种k1估计在偏离较小时表现更稳定,第二种近似写法速度更快。代码里通常直接使用ref_log_probs - log_probs这种形式。注意不是所有token都无差别惩罚,一般只在answer区域计算KL,prompt部分的logits会被mask掉,因为prompt本身是固定前缀,策略模型和参考模型对这一段的预测几乎没有差异,加了反而稀释有效梯度。

我之前把prompt区域也纳入KL计算,结果训练步数多了以后,模型输出前缀的分布反而逐渐偏离参考模型,产生了一种“回答开头不断变化但答案质量没有提升”的假噪声。

3.3 GRPO损失函数的完整合并

最终loss的代码逻辑如下:

def grpo_loss(policy_log_probs, ref_log_probs, rewards, mask, group_size, kl_coef=0.02): advantages = compute_group_advantages(rewards, group_size) # 计算KL惩罚,mask只看answer区域 per_token_kl = torch.exp(ref_log_probs - policy_log_probs) - 1 - (ref_log_probs - policy_log_probs) kl_penalty = (per_token_kl * mask).sum(dim=-1) / mask.sum(dim=-1) # 如果用PG目标,乘以policy_log_probs pg_loss = -(policy_log_probs * advantages.unsqueeze(-1) * mask).sum(dim=-1) / mask.sum(dim=-1) loss = pg_loss + kl_coef * kl_penalty return loss.mean()

在这个实现里还有一个隐含的细节:advantages目前是按“每个回答”为单位,而policy_log_probs是每个token的概率。所以需要把advantage广播到该回答覆盖的所有token上,一个回答下所有token共享同一个advantage值。

实际复现时会发现,如何选择KL系数是一个非常敏感的事。kl_coef太大,模型几乎学不动,生成结果始终贴着参考策略;太小则容易让模型只去迎合奖励模型,说话颠三倒四也在所不惜。我的经验是先从0.01起步,观察KL曲线的走势,如果KL在稳步上升但loss还在下降,可以接受;如果训练到一半KL突然窜高,立刻降系数,同时回滚到最近一个checkpoint。

4. DPO代码复盘与踩坑记录

4.1 DPO是怎么避开强化学习过程的

DPO(直接偏好优化)把“先学奖励模型、再用强化学习优化策略”的两阶段过程,改写为从偏好数据直接学习策略目标。这意味着训练时不再需要奖励模型,也不再需要在线采样多条回复,只需固定一份偏好数据,反复让模型拟合人类偏好排序。

DPO的核心loss实现如下:

def dpo_loss(pi_logps, ref_logps, yw_logps_ref, yl_logps_ref, beta=0.1): """ 这四个输入分别代表: pi_logps: 策略模型对chosen和rejected拼接样本的log prob ref_logps: 参考模型对同样拼接样本的log prob yw_logps_ref / yl_logps_ref: 可以省略 """ pi_yw_logps, pi_yl_logps = pi_logps.chunk(2, dim=0) ref_yw_logps, ref_yl_logps = ref_logps.chunk(2, dim=0) log_ratio = (pi_yw_logps - ref_yw_logps) - (pi_yl_logps - ref_yl_logps) loss = -F.logsigmoid(beta * log_ratio) return loss.mean()

这里最大的便利是它不需要当期采样,所以显存占用要比GRPO低很多。整个DPO训练过程更接近普通sft训练:每条样本只需要多一次参考模型的前向计算。

4.2 参考模型logits如何正确缓存

DPO中最重要的工程细节之一是参考模型logits的缓存。很多复现卡在这一步是因为对显存理解有偏差。参考模型本身不更新梯度,但如果每次都实时前向一次,相当于训练时所有前向计算量翻倍,对于7B模型来说时间成本极高。

主流仓库的解决方式是:在训练第一步之前,先把所有偏好数据过一遍参考模型,把每条样本的参考logprob存成.npz或.pt文件,之后训练循环直接加载这些预计算值即可。

# 预处理阶段伪代码 for sample in dataset: chosen_seq = tokenizer(sample["chosen"]) rejected_seq = tokenizer(sample["rejected"]) with torch.no_grad(): ref_chosen_logps = reference_model(chosen_seq) # shape: [seq_len] ref_rejected_logps = reference_model(rejected_seq) cache_ref[sample_id] = (ref_chosen_logps, ref_rejected_logps)

这个缓存文件一旦生成,后续训练的reference_model甚至可以只在CPU上做校验使用,不需要再上GPU。

我最初没做预缓存,而是把参考模型和数据一起放GPU训练,结果7B学生模型加上7B参考模型把48G显存吃满,batch size只能设为1。改成预缓存后,batch size直接翻倍到2,训练吞吐提升非常明显。

4.3 DPO复现时最容易被忽视的“过拟合”问题

很多人会盯着训练集上的DPO loss,但忘记监控模型在参考分布上的偏移。DPO训练很容易出现一种现象:偏好数据里的chosen和rejected区分度很高,loss一直下降,但模型对未参与训练的prompt,却开始产生和参考模型差别很大的输出,甚至重复、空泛。

我自己的解决方式是每200步保存一次参考KL快照,具体计算方式很简单:加载当前模型与参考模型,在同一条校验集prompt上统计KL散度。如果KL在最近1000步内增长超过警告线,就需要考虑降低beta或者增加训练数据的多样性。

我在一个58k条偏好数据的模拟项目X上尝试时,beta设为0.5时,训练集准确率爬到92%,但校验KL爆炸到12.8;把beta降到0.1后,训练集准确率回落到83%,校验KL稳定在3.1。最终评估得分反而更高。Beta调小,本质上是对参考模型更加“尊重”,有时候越强的对齐信号反而带来更差的泛化。

5. 奖励信号设计与参考模型缓存细节

5.1 奖励来源不是只有Rule-Based

GRPO中使用的奖励可以来自一个独立奖励模型,也可以是公式化规则奖励。比如在数学或文生图这类任务中,常见做法是让规则计算出一个0到1之间的分数。规则奖励的优点是稳定、可复现;缺点是无法覆盖开放性生成。

代码实现层面,最关键的是奖励的shape。理想情况下,一个回答应该得到一个标量奖励,所以reward网络前向后的输出应当做reduce处理:

def reward_scalarize(logits, attn_mask): # logits: [batch, seq_len, num_classes] seq_scores = logits.squeeze(-1) # 某些仓库直接输出一个值 scores = seq_scores * attn_mask return scores.sum(dim=-1) / attn_mask.sum(dim=-1)

很多刚开始看代码的人容易犯的错,是把seq_len维度的分数直接当作奖励传入compute_group_advantages,结果就是每个组内的归一化对象变成了“同一回答的多个时间步”,完全失去了组间相对比较的意义。

5.2 参考模型前向的显存问题

我前面提到过参考模型logits预缓存。在GRPO训练中,因为策略每次更新后参考模型的输出应当始终指向“初始未更新的模型”,所以参考模型权重在训练期间必须冻结。这个冻结不只是requires_grad=False,还要严格走torch.inference_mode(),避免计算图累积。

另外一个内存陷阱是中间激活值。参考模型虽然不做反向传播,但如果你使用标准transformer forward,很可能会保存每层激活用于梯度计算,这在inference_mode下不会发生,所以我建议所有参考模型前向都单独封装在@torch.inference_mode()装饰器中,而不是简单放在torch.no_grad()下。二者语义有细微区别,后者在某些编译优化场景下依然会构造图,前者明确放弃一切追踪。

6. 训练时那些让人失眠的报错

6.1 loss曲线一飞冲天

GRPO训练最常出现的一个诡异现象是,刚开始loss正常下降,在某个step突然翻了十倍。

我排查后定位到一个很容易被忽略的地方:生成采样阶段用的是top_p=0.9, temperature=1.0,但transformer在计算logprob时,用的概率分布和采样时的分布不一致。采样时加了温度缩放,而训练阶段直接对原始logits求log_softmax,导致advantage很高的回答在策略目标中概率被低估,更新幅度异常放大。

解决方式:在采样时保留生成token的logprob,而不是训练时重新计算一次。也就是说,GRPO训练代码里,policy_log_probs应当全部来自rollout过程中记录下来的logprob,而不是事后回到模型前向计算。

这个坑在open-source仓库中非常典型,很多复现者从loss曲线中发现不了问题,只能从梯度范数异常看出来。

6.2 生成长度一步步坍塌

另一个让人头疼的问题是,强化学习训练中途模型生成结果越来越短,最后几乎变成1到2个token。这在数学任务中非常致命。

原因一般是奖励对长度有隐式惩罚,或者规则奖励没有对不完整回答进行足够负反馈。代码上,我们需要在rollout阶段对生成序列做长度奖励修正:

if len(output) < min_len: reward = -1.0 # 显式惩罚

还有一种很隐蔽的情况:KL惩罚加得太大,模型倾向于减少整个序列概率分布的偏移,但为了不影响能获得高奖励的核心token,它选择把答案长度压缩,以此减少总KL惩罚的累加。解决思路是把KL系数从固定值改为动态可控的数值,比如按KL均值的滑动窗口调整:

kl_mean = kl_tracker.ema if kl_mean > target_kl * 1.5: kl_coef *= 1.1 else: kl_coef *= 0.97

我实际测试过这种动态调节,最终length collapse比例从接近25%降到了7%。

6.3 NaN和Inf排查路径

如果训练中loss出现NaN,首先检查三处:

  1. log_softmax输入里有没有inf。很多实现里,生成mask的位置会给极小的负值,比如-1e9,但如果模型输出本身就存在极值,log_softmax之后可能出现-inf。
  2. std是否有零值,尤其在组内只有1条回答的时候。GRPO实现中,group_size如果为1,标准差必然为0,加上eps也救不回来。
  3. 混合精度下的损失出现inf,通常需要给advantage做clip。代码中常见的做法是advantages = advantages.clamp(-2, 2),或在损失外层再包一层torch.clip。

我建议在任何修复之前先做一轮“梯度范数监控”,把每个参数的梯度L2范数打印出来,能快速定位是哪一个loss分支在制造无穷大。

7. 复现实验配置与调参经验

7.1 我最后用的一套稳定配置

我在复现过程中最终稳定运行的配置如下,适合8卡、单卡24G显存的B200系列服务器(这里指代通用GPU,不要对号入座):

参数数值备注
基础模型7B级别decoder-only底座模型与官方评测对齐更方便
优化器AdamWweight decay 0.1
学习率1e-6强化学习阶段必须很小
学习率调度constant with warmup前50步warmup
GRPO组大小8KL稳定和显存平衡
KL系数0.02动态调整,目标KL区间 [1.0, 2.5]
Batch size128即16个prompt乘以8条样本
参考模型模式预缓存一次性生成所有参考logprob
梯度裁剪1.0防止优势异常时梯度爆炸

这套配置在规模较小的数据集上训练约3000步,最终评测分数比SFT基线高了11个百分点,同时没有观察到明显回答长度坍塌。

7.2 调参优先级建议

如果有人问我从零复现给什么建议,我会建议按以下顺序调参:

第一优先检查数据顺序与分组,而不是调beta或kl。有一次GRPO训练重启后分数下降,原因是新版本代码把prompt分组维度从batch改到了seq,导致组内互相比较的样本根本不是同一个prompt下生成的,整个优势函数失去意义。

第二是核对生成与训练时是否用了同一个tokenizer配置。add_special_tokens不一致会让logprob错位,这个问题在DPO里更隐蔽。

第三才是调beta或kl。很多人在模型效果不行时急着改损失函数,其实数据流的问题没解决,改什么都白搭。

7.3 从GRPO切DPO时的迁移技巧

如果项目同时跑GRPO和DPO,有一个小技巧可以大幅节省实验成本:先从GRPO训练完成的checkpoint初始化DPO训练。因为GRPO生成的回答质量高于SFT基线,DPO在这些高质量正负样本上可以更快收敛。

不过要注意使用GRPO checkpoint时,策略模型已经偏离原始参考模型较多,此时DPO训练中的参考logprob需要重新计算。如果还使用SFT阶段的参考缓存,会导致目标函数算出来的隐含奖励出现巨大错误,训练出来的模型会出现严重风格突变。

我试过直接复用SFT参考缓存,只改策略模型,结果DPO的loss快速下降但评审分数不升反降。重新用GRPO后的模型作为参考基线并缓存后,才恢复正常。

8. 最后的实操心得

第三篇写到这里,我还是想把一些最有“体感”的东西留在最后。很多人问,是不是理解了PPO算法就能直接看懂这一类LLM强化学习代码库里所有内容?我觉得不够。真正的分水岭在于有没有完整处理过一套真实数据流:从原始偏好数据构建batch、生成采样、掩码构造、logprob记录、奖励归一化、参考logprob缓存,到最后loss拼装与梯度裁剪。任何一个环节的张量形状没对齐,都可能让模型训练出“看似正确但实际无用”的结果。

我在模拟项目X上做小规模测试时,最开始只用了三百条偏好数据就把DPO跑通了。那时候觉得很简单。可一上两万条数据,才发现问题全在数据加载和batcher上,损失函数反而不是重点。所以如果你也在复现,建议先把数据规模缩到能用一个batch跑完的程度,打开log逐行对齐张量形状,确认没问题后,再放大到完整数据集。低成本试错,高成本投入,这步骤省不了。要想真正把这些算法玩明白,手头至少要有一套自己能随时改写的代码,而不是只会在别人的框架里改参数。

后面如果时间允许,我打算单独写一篇关于“奖励模型退化”与“参考模型基准漂移”的代码分析,这两个问题在真实项目里远比算法本身的难度更烦人。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:53:53

动态Cookie生成算法还原:从抓包、断点到JS逆向实战

做逆向实战时&#xff0c;最磨人的往往不是某个加密算法&#xff0c;而是像 ssxmod_dyna 这样每次刷新都会变化的动态Cookie。这个字段看起来不起眼&#xff0c;却是目标站点风控体系的第一道门。最近在一次授权范围内的接口分析中&#xff0c;我遇到一个站点&#xff1a;首页加…

作者头像 李华
网站建设 2026/10/20 8:53:51

2024金融AI智能体投资决策趋势:架构师视角的技术布局

2024年的金融技术圈&#xff0c;最拥挤的赛道不是又一个大模型API包装出来的问答助手&#xff0c;而是把AI智能体放进投资决策的主链路。无论是智能投研、量化信号生成&#xff0c;还是资产配置辅助&#xff0c;几乎每个团队都在谈同一件事&#xff1a;AI智能体。它不是简单的对…

作者头像 李华
网站建设 2026/10/20 8:53:25

量化金融调研报告实战指南:从策略逻辑到实盘落地的关键细节

1. 从一场分享会聊起&#xff1a;为什么量化调研报告值得反复咀嚼第一次看到“似然分享会”这个名头&#xff0c;我以为是某个统计学兴趣小组在讲极大似然估计。后来才反应过来&#xff0c;“似然”在这里更像是一种态度——对市场保持“似然”的判断&#xff0c;不追求绝对确定…

作者头像 李华
网站建设 2026/10/20 8:53:24

告别AI工具焦虑:开发者极简工作流如何让效率提升70%

大概从某段时间开始&#xff0c;我的信息流里全是各种AI工具的安利帖。今天有人说“用了某助手效率翻倍”&#xff0c;明天有人晒“零代码搭了一个自动化流程”&#xff0c;后天又有人整理出一份几十个工具的清单。作为一个常年写代码、带项目、改文档的人&#xff0c;我一度也…

作者头像 李华
网站建设 2026/10/20 8:53:13

Cesium三维GIS场景搭建与视频融合实战:从初始化到性能优化

简介&#xff1a;本资源面向三维GIS开发初学者与WebGL可视化工程师&#xff0c;聚焦Cesium在三维场景搭建与视频融合中的落地实践&#xff0c;帮助读者理解从二维GIS向三维WebGIS迁移的完整思路。压缩包共约2000个文件&#xff0c;整体54.64MB&#xff0c;以js脚本、html页面、…

作者头像 李华
网站建设 2026/10/20 8:52:37

构建可执行、可验证的数据结构与算法PDF文档

简介&#xff1a;这是一份面向算法学习者与LeetCode刷题者的系统性算法题解PDF&#xff0c;覆盖数据结构基础、经典算法思想及高频面试题型。资源共1个141.71MB的PDF文件&#xff0c;内容超1000页&#xff0c;完整整合动态规划、回溯算法、贪心策略、双指针、滑动窗口、前缀和、…

作者头像 李华