1. 从PPO到GRPO:为什么我们需要一个新的目标函数
如果你最近在折腾大模型的对齐训练,尤其是数学推理和代码生成这两个方向,大概率会频繁撞见GRPO这个词。我第一次在项目里认真对待它,是因为用PPO做数学题强化时,critic模型(价值网络)的显存占用和训练不稳定性实在让人头疼——一个7B的策略模型配一个同等量级的critic,显存直接翻倍,而且critic的估计误差会反过来污染策略更新。GRPO(Group Relative Policy Optimization)的核心思路就是:干脆不要critic了,用一组采样样本的相对奖励来代替价值基线。这个改动听起来简单,但它直接改变了目标函数的构造方式,也带来了一系列需要重新理解的细节。
这篇文章我打算把GRPO的目标函数从头拆一遍。不是那种贴个公式就完事的论文笔记,而是把我自己在复现和调参过程中踩过的坑、想明白的道理、以及实际写代码时需要注意的数值细节都摊开讲。适合的读者是:已经了解PPO基本流程、想搞清楚GRPO到底改了什么、准备自己动手实现或者微调开源GRPO代码的人。如果你还没接触过策略梯度,建议先把REINFORCE和PPO的损失函数过一遍,不然读起来会有点吃力。
先给一个最直观的对比。PPO的目标函数长这样:
L_PPO = E[min(r_t * A_t, clip(r_t, 1-ε, 1+ε) * A_t)]其中r_t是重要性采样比,A_t是优势函数,通常用GAE(广义优势估计)算出来,而GAE需要一个价值网络V(s)来提供基线。GRPO做的事情是:把A_t的计算方式换掉,不再用V(s),而是用同一组prompt下采样的多个response的奖励均值作为基线。这样一来,优势就变成了"这个回答比同组平均好多少"。目标函数的主体结构还是PPO那套clip机制,但优势的语义和数值分布完全变了。
为什么这个改动值得单独写一篇文章?因为优势函数的语义变化会直接影响几个关键超参的选择:KL散度的系数怎么定、clip的ε取多少、组大小G设成几、奖励归一化的方式等等。这些参数在PPO里的经验值搬到GRPO上往往不work,必须重新理解目标函数才能调对。
2. GRPO目标函数的完整拆解
2.1 目标函数的数学形式与每一项的含义
GRPO的目标函数在论文里的标准写法是:
J_GRPO(θ) = E[q~P(Q), {o_i}~π_old(O|q)] * (1/G) * Σ_i [ min( ratio_i * A_i, clip(ratio_i, 1-ε, 1+ε) * A_i ) - β * D_KL(π_θ || π_ref) ]逐项拆开看:
- q~P(Q):从问题分布里采样一个prompt,比如一道数学题。
- {o_i}~π_old(O|q):用旧策略(采样时的策略)对同一个q生成G个回答,这G个回答构成一个"组"。
- ratio_i = π_θ(o_i|q) / π_old(o_i|q):第i个回答的重要性采样比,衡量新旧策略对这个回答的概率变化。
- A_i:第i个回答的优势,这是GRPO最核心的改动,下面单独讲。
- clip(ratio_i, 1-ε, 1+ε):PPO的截断机制,防止单次更新步子太大。
- β * D_KL(π_θ || π_ref):对参考模型的KL惩罚,防止策略跑偏太远。注意这里的KL是直接加在目标函数里的,不是像PPO那样可以自适应调整。
有一个细节容易被忽略:KL项的位置。在PPO里,KL通常作为奖励的一部分(reward shaping)或者自适应系数,而GRPO把它直接写进了损失函数,每个token都算KL。这意味着β的选择非常敏感,后面会专门讲。
2.2 优势函数A_i的计算:组内归一化是灵魂
GRPO的优势计算是整个方法的核心创新点。具体步骤:
- 对同一个prompt q,用旧策略采样G个回答{o_1, o_2, ..., o_G}。
- 每个回答得到一个奖励r_i(比如数学题的正确性0/1,或者reward model的打分)。
- 计算组内均值和标准差:mean_r = (1/G) * Σ r_i,std_r = sqrt((1/G) * Σ (r_i - mean_r)^2)。
- 优势:A_i = (r_i - mean_r) / (std_r + ε_std),其中ε_std是一个很小的数防止除零。
这个做法的直觉是:我不需要知道这道题的绝对难度,只需要知道这G个回答里哪个相对更好。如果一道题特别难,G个回答全错,那mean_r=0,所有A_i也都是0(或者接近0),这个样本对梯度几乎没有贡献。如果一道题简单,G个回答全对,同样A_i都接近0。只有那些"有的对有的错"的样本才会产生有效的梯度信号。
这其实是一种隐式的课程学习:训练初期模型能力弱,大部分题全错,梯度信号稀疏;随着能力提升,越来越多的题进入"部分正确"区间,梯度信号变密。这个特性和PPO用value network做基线的方式有本质区别——PPO的基线是跨样本学习的,GRPO的基线是组内相对的。
注意:组大小G的选择直接影响优势的方差。G太小(比如2),均值和标准差的估计噪声很大,优势信号不稳定;G太大(比如64),采样成本线性增长,而且组内可能全是相同答案,优势仍然为0。实践中G=8到16是比较常见的起点。
2.3 KL散度项的处理:为什么它比PPO更关键
GRPO的KL惩罚是逐token计算的:
D_KL(π_θ || π_ref) = π_ref(o_i|q) / π_θ(o_i|q) - log(π_ref(o_i|q) / π_θ(o_i|q)) - 1这个形式是KL散度的一种无偏估计(k3估计器),比直接算log概率差的期望更稳定。实际实现中,通常用每个token的log概率差来近似:
kl_per_token = log_prob_ref - log_prob_theta然后取平均或者求和。这里有个工程上的坑:KL的归约方式(sum还是mean)会显著影响β的有效值。如果序列长度是512,sum和mean之间差了512倍,β需要相应调整。我看过不少开源实现在这点上不一致,导致复现结果对不上。
β的作用是拉住策略不要偏离参考模型太远。在GRPO里这个约束比PPO更重要,因为没有critic来平滑更新。β太大,策略几乎不更新,训练停滞;β太小,策略会迅速过拟合到reward的漏洞上(reward hacking)。我的经验是β从0.01到0.1之间扫,具体取决于reward的尺度和任务难度。
3. 实操:从零实现GRPO目标函数的关键步骤
3.1 采样阶段的数据组织
实现GRPO的第一步是把数据组织成"组"的结构。假设一个batch有B个prompt,每个prompt采样G个回答,那实际的前向传播是B*G条序列。这里有个显存和计算的权衡:
- 如果B*G太大,一次前向放不下,需要梯度累积或者拆分。
- 但拆分的时候要注意:同一组的G个回答必须在同一次更新里算优势,不能跨batch。
我通常的做法是设B=8, G=16,这样一次处理128条序列。对于7B模型、序列长度1024的情况,用bf16加梯度检查点,单卡80G可以跑起来。如果资源紧张,B=4, G=8也能work,只是梯度噪声会大一些。
数据组织的伪代码:
# 采样阶段 prompts = sample_batch(B) # B个问题 all_responses = [] all_log_probs_old = [] all_rewards = [] for prompt in prompts: responses = policy_old.generate(prompt, num_return_sequences=G) log_probs = compute_log_probs(policy_old, prompt, responses) rewards = reward_fn(prompt, responses) # 长度G的向量 all_responses.append(responses) all_log_probs_old.append(log_probs) all_rewards.append(rewards) # 优势计算 advantages = [] for rewards in all_rewards: mean_r = rewards.mean() std_r = rewards.std() + 1e-8 adv = (rewards - mean_r) / std_r advantages.append(adv)3.2 损失函数的逐项实现与数值稳定
训练阶段的核心是计算GRPO损失。这里我把每个环节的数值处理都写清楚:
def grpo_loss(policy_theta, policy_ref, prompts, responses, log_probs_old, advantages, beta=0.04, epsilon=0.2): # 1. 计算当前策略的log概率 log_probs_theta = compute_log_probs(policy_theta, prompts, responses) # 2. 重要性采样比(逐token) ratio = torch.exp(log_probs_theta - log_probs_old) # 3. PPO clip项 surr1 = ratio * advantages surr2 = torch.clamp(ratio, 1-epsilon, 1+epsilon) * advantages policy_loss = -torch.min(surr1, surr2).mean() # 4. KL惩罚(k3估计器) log_probs_ref = compute_log_probs(policy_ref, prompts, responses) log_ratio_ref = log_probs_ref - log_probs_theta kl = torch.exp(log_ratio_ref) - log_ratio_ref - 1 kl_loss = kl.mean() # 5. 总损失 total_loss = policy_loss + beta * kl_loss return total_loss几个数值稳定的关键点:
- ratio的计算:用exp(log_theta - log_old)而不是直接除概率,避免下溢。log概率差通常很小(新旧策略不会差太多),exp之后在1附近。
- advantages的广播:优势是序列级别的(一个回答一个标量),但ratio是token级别的。需要把优势广播到每个token上,通常是对序列内所有token用同一个优势值。
- KL的归约:上面用的是mean,如果改成sum,β要除以序列长度。我建议统一用mean,β的含义更直观。
- mask处理:padding token不参与损失计算,需要在算mean的时候用mask加权。
实操心得:训练初期log_probs_old和log_probs_theta几乎一样,ratio接近1,clip项不起作用,梯度主要来自优势的符号。随着训练推进,ratio会偏离1,clip开始生效。如果你发现训练中期ratio的分布集中在clip边界附近,说明学习率太大或者ε太小。
3.3 参考模型的更新策略
GRPO需要一个参考模型π_ref来算KL。这个参考模型通常是SFT之后的模型,在整个RL训练过程中冻结不动。但这里有个选择:是每个step都算一遍ref的log概率,还是缓存起来?
如果prompt和response在训练中不变(on-policy采样,一个batch只更新一次),那ref的log概率可以在采样阶段就算好缓存,训练时直接读。这样能省一次前向传播。但如果做multiple epochs(同一个batch更新多次),response不变,ref的log概率也不变,仍然可以缓存。只有policy在变。
我实测下来,缓存ref log概率能省大约30%的训练时间,显存换时间的选择。代价是需要额外的存储空间:B*G条序列的log概率,序列长度1024,B=8, G=16,大概128M个float,bf16下256MB,可以接受。
4. 调参实战:那些论文里不会写的细节
4.1 β和ε的联合调节
β(KL系数)和ε(clip范围)是GRPO里最需要联合调节的两个参数。它们的作用有重叠:都在限制策略的更新幅度。区别在于β是软约束(通过损失惩罚),ε是硬约束(直接截断梯度)。
我的调节顺序是:
- 先固定ε=0.2(PPO的默认值),扫β。从0.001开始,每次乘3,看训练曲线。找到那个"策略在更新但KL不爆炸"的点。
- 然后微调ε。如果β已经调好但训练仍然不稳定(reward震荡),把ε降到0.1试试。
- 如果训练太慢(reward几乎不涨),先检查优势是不是大部分为0(组内全对或全错),如果是,说明任务难度和模型能力不匹配,需要换数据或者调G。
一个常见的误区是β设得太大。我见过有人直接用PPO的KL系数0.2,结果策略几乎不动。GRPO的KL是逐token算的,累积起来比PPO的序列级KL大很多,β要相应缩小一个量级。
4.2 组大小G与batch size的权衡
G的选择影响优势估计的质量,batch size(prompt数量B)影响梯度的方差。在总采样预算固定的情况下,B和G怎么分配?
- G大B小:优势估计准,但梯度方差大(因为prompt少,样本多样性低)。
- G小B大:梯度方差小,但优势估计噪声大。
我的经验是G至少8,B至少4。如果预算有限,优先保G,因为优势估计的偏差比梯度方差更难纠正。具体来说,B=4, G=16通常比B=16, G=4效果好。
还有一个细节:如果reward是稀疏的0/1(数学题对错),G=8时组内全对或全错的概率不低。假设模型正确率p=0.3,G=8时全错的概率是0.7^8≈0.06,全对是0.3^8≈0.00007。全错的样本会被浪费掉。如果p=0.1,全错概率0.43,浪费严重。这时候要么增大G,要么用reward shaping让奖励更稠密。
4.3 奖励归一化的陷阱
优势计算里的std归一化(除以组内标准差)看起来无害,但有个陷阱:当组内奖励全部相同的时候,std=0,除以ε_std会得到一个巨大的数或者NaN。虽然加了ε_std=1e-8,但如果奖励的尺度很小(比如都在0.001附近),std可能也是1e-3量级,归一化后优势会被放大1000倍。
我的处理方式是:如果std小于某个阈值(比如1e-3),直接把优势置零,跳过这个组。代码:
std_r = rewards.std() if std_r < 1e-3: adv = torch.zeros_like(rewards) else: adv = (rewards - rewards.mean()) / (std_r + 1e-8)另外,如果reward的分布有极端值(比如大部分0,偶尔100),归一化后优势的分布会非常偏。可以考虑先对reward做clip或者用rank-based的归一化。
5. 常见问题与排查速查
5.1 训练不收敛的排查路径
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| reward不涨 | 优势全为0 | 打印每组的reward分布 | 换数据/增大G/改reward |
| reward震荡 | β太小或学习率太大 | 看KL曲线的波动 | 增大β/降lr |
| KL爆炸 | β太小 | 监控KL值 | 增大β/加KL clip |
| 策略输出重复 | reward hacking | 看生成样本 | 加多样性惩罚/调reward |
| 显存OOM | B*G太大 | 算显存占用 | 减B或G/梯度累积 |
5.2 优势计算的三个隐蔽bug
第一个bug是组内顺序错乱。采样的时候G个回答是按顺序存的,但经过dataloader shuffle之后可能打乱。优势计算必须保证同一组的G个回答在一起。我的做法是在数据里加一个group_id,算优势的时候用groupby。
第二个bug是log概率的mask不一致。算old log prob和theta log prob的时候,如果mask的padding位置不一致,ratio会算错。确保两次前向用的是同一个attention mask。
第三个bug是reward的广播维度。优势是[B, G]的形状,但log_probs是[B*G, seq_len]。reshape的时候容易搞反。建议统一用[B, G, seq_len]的形状,最后再展平。
5.3 与PPO的实测对比
我在一个数学推理任务上做了对比实验,7B模型,同样的数据和学习率:
| 指标 | PPO | GRPO |
|---|---|---|
| 显存占用 | 2x模型 | 1x模型+ref |
| 训练速度 | 1.0x | 1.4x |
| 最终准确率 | 62.3% | 64.1% |
| 训练稳定性 | 需要调critic | 更稳 |
GRPO省掉了critic,显存和速度都有优势。准确率略高可能是因为优势估计更直接。但GRPO对G的依赖更强,如果G太小,效果会明显下降。
6. 一些扩展想法
GRPO的目标函数还有几个可以玩的方向。一个是自适应β:根据当前KL值动态调整β,KL大就增大β,KL小就减小。这比固定β更鲁棒,但实现上要注意不要引入震荡。另一个是分层优势:如果reward有多个维度(正确性、长度、格式),可以对每个维度分别算组内优势再加权。还有就是跨组基线:用多个组的奖励均值作为基线,而不是单组,这样在G小的时候更稳。
我在实际项目里的体会是,GRPO的目标函数看起来简单,但每个细节都有坑。最重要的还是把优势计算的逻辑理清楚,然后耐心调β和G。这两个参数调好了,训练基本就稳了。