最近好几个做强化学习的朋友问我同一个问题:PPO 和 GRPO 反复刷榜,各种开源项目里都在用,但这两个算法到底在更新什么?是更新奖励函数?更新 Q 值?还是更新那个 critic 网络?刚开始接触 RLHF 和策略梯度的人,很容易被 PPO 那套“actor-critic + GAE + clip”的组合拳绕晕,更别说 GRPO 把 critic 整个砍掉之后,很多人连“它凭什么还能学”都没想明白。
先给一个最直接的回答:PPO 和 GRPO 更新的是同一个东西——策略网络里的参数,也就是让网络输出的动作概率分布发生改变。更直白地说,它们在调整“在某个状态下更可能选哪个动作”的形状。PPO 是把“某个动作比平均好多少”这个信号,换算成对概率分布的拉扯方向;GRPO 走的是同一套逻辑,只不过它用来衡量“好多少”的参照物不是来自价值网络,而是来自同一批样本内部的相对表现。
这个回答虽然一句话就能说完,但要真正理解“更新什么、为什么这么更新、两者差异在哪”,得把策略梯度、优势函数、重要性采样和 clip 机制一层层拆开看。这篇文章就干这件事,顺便把 dual-clip PPO、连续动作场景下的代码实现也一起讲透。
1. PPO 到底在改什么:策略分布与概率比
1.1 策略网络输出的不是“动作”,而是“分布”
先理清一个最容易混淆的点。策略网络 π_θ(a|s) 在离散动作空间里,输出的是每个动作的概率;在连续动作空间里,输出的是高斯分布的均值 μ 和标准差 σ,然后从 N(μ, σ) 里采样得到具体动作。无论哪种情况,网络的输出本质上不是一个“动作值”,而是一个分布的形状参数。
PPO 做的就是调整这些形状参数。比如在某个状态下,动作 A 原本被采到的概率是 0.2,动作 B 是 0.5。如果执行动作 A 之后发现收益很高,PPO 会把 A 对应的概率往上抬,把 B 往下压;如果某次训练里网络把某个动作的概率推到 0.9,但实际收益很差,PPO 会立刻把它拽回来。整个训练过程可以理解成:策略网络是手里抓着一堆“概率旋钮”的操作员,PPO 根据环境反馈决定每个旋钮往哪个方向拧、拧多大力。
很多人看 PPO 的代码时,发现 loss 里有个 ratio = π_θ_new(a|s) / π_θ_old(a|s),然后对着这个 ratio 反复看。这个 ratio 的含义不是“新旧策略的差异程度”,而是“新策略相对于旧策略,在某个动作上的放大倍数”。ratio > 1 说明新策略更容易采到这个动作,ratio < 1 说明新策略更不倾向采这个动作。梯度更新的时候,ratio 乘以优势函数 A,就决定了这个动作的概率是该调大还是调小、调多少。
1.2 为什么不能直接用新的 log 概率算 loss
这里有个很关键的设计动机:如果直接用 π_θ_new 的动作概率去算梯度,那每次更新后都要重新采样一批数据,样本效率极低。PPO 用的是 off-policy 式的重要性采样——用旧策略 π_θ_old 采样一批轨迹,然后通过 ratio 把“旧策略采样计算出的期望”换算成“新策略下的期望”,这样同一批数据可以反复用来更新好几次。
重要性采样本身有个毛病:如果新旧策略差异太大,ratio 的方差会爆炸,梯度更新就会非常不稳定。举个例子,旧策略下某个动作的概率是 0.01,新策略把它调到 0.1,ratio 直接变成 10,这个样本对梯度的贡献就被放大了 10 倍,如果优势函数数值也大,一步更新就可能把策略推歪。clip 机制就是专门来压制这种“极端放大”的。
1.3 连续动作场景下更新什么?更新 μ 和 σ
连续动作是很多人上手 PPO 时最容易写错的地方。离散动作下网络输出一个概率向量,loss 里直接拿交叉熵或 log 概率算就行;连续动作下,网络输出的是 μ 和 σ,动作是从高斯分布里采样出来的,log 概率要按高斯分布公式计算:
log π(a|s) = -0.5 * ((a - μ) / σ)^2 - log(σ) - 0.5 * log(2π)
训练的时候,μ 决定了“网络最倾向输出的动作中心值”,σ 决定了“探索范围”。PPO 更新时,如果某个动作的优势是正的,会把 μ 往这个动作方向推;如果优势是负的,会把 μ 往反方向推。σ 的更新则更有意思——如果处于高优势区域,网络会倾向于缩小 σ,让动作更集中、更确定;如果某个区域的样本优势忽高忽低,不确定性大,σ 会被拉大,保持探索度。
我在实际调连续动作 PPO 时踩过一个大坑:σ 被初始化得太小,导致前期采样动作都挤在 μ 附近,探索不够,策略陷入局部最优。后来把 σ 初始化为 1.0 左右,或者在 loss 里对 σ 加一点熵正则项,情况立刻好转。所以别小看这个 σ,它的更新方向和幅度和 μ 一样重要,甚至更容易出问题。
2. PPO 的 loss 函数:clip 与优势函数在干什么
2.1 loss 的完整长啥样(以及每一块的意图)
PPO 的优化目标通常写成:
L = E[min(ratio * A, clip(ratio, 1-ε, 1+ε) * A)]
拆开看,就是两项取最小值。第一项 ratio * A 是原始的策略梯度目标,想让“好动作出现的概率变大、坏动作出现的概率变小”;第二项 clip(ratio, 1-ε, 1+ε) * A 是裁剪后的目标,限制 ratio 不能在单次更新中跑得太远。
取 min 的意图是:当一个动作的优势 A > 0 时,我们希望 ratio 尽量大,但如果 ratio 已经超过 1+ε,说明这一步更新幅度太大,clip 后的值会被固定住,min 结果就取 clip 项,梯度不再继续鼓励 ratio 变大——这就是在“踩刹车”。当 A < 0 时,我们希望 ratio 尽量小,但如果 ratio 已经跌到 1-ε 以下,再往下减小反而会让 loss 更小、梯度继续推动 ratio 变小,所以 min 在负优势区间几乎不起限制作用,这也是原版 PPO 在极端负优势场景下不稳的根源,dual-clip 解决的问题就在这。
2.2 优势函数 A 是“相对表现”,不是绝对奖励
很多初学者把优势函数直接等同于奖励值,这是理解偏差的起点。优势函数 A(s,a) 的定义是:在状态 s 下选择动作 a 比“按平均策略水平行动”好多少。数学上是 Q(s,a) - V(s),也就是动作价值减去状态价值。正的 A 表示这个动作比平均水平好,负的 A 表示比平均水平差。
为什么要用“相对值”而不是“绝对值”?因为强化学习任务里奖励的绝对数值往往没有太多意义。比如自动驾驶场景,奖励值可能是安全系数、舒适度、时间成本加权求和,数值范围随环境变化很大;用绝对奖励作为更新信号,不同 batch 之间的数值量纲和波动会让训练不稳定。用优势函数等于做了一次“去均值”处理,让更新信号聚焦在“这个动作到底是优于还是劣于平均水平”,而不是被环境本身的数值偏移带跑。
GAE(Generalized Advantage Estimation)是 PPO 里估计 A 的标配工具,它用 λ 参数在“偏差大但方差小”和“偏差小但方差大”之间做平衡。λ 取 0 时只看即时奖励,方差小但忽略了未来影响;λ 取 1 时看完整回报,偏差小但方差可能很大。实际操作里 λ 一般取 0.95 左右,配合 GAE 的递推公式可以大大降低优势估计的方差。
2.3 为什么说 PPO 是在“上一层保险”地更新策略
PPO 能成为主流,核心就是“稳”。它不是发明了新的策略梯度方向,而是把一个容易失控的优化过程加上了两道保险:第一道是重要性采样的 ratio 本身,第二道是 clip 限制。
很多人问我,那为什么不直接用 TRPO 的 KL 散度约束?TRPO 本质上也是做同样的事情,用 KL 散度限制新旧策略的差异,但实现起来涉及二阶 Hessian 矩阵或共轭梯度,工程复杂度高、资源消耗大。PPO 的发明者用 clip 这个一阶近似来代替复杂的二阶约束,效果接近但实现成本低得多。所以 PPO 更新的“保险”不是数学上最严格的,而是工程上最高效的。
3. GRPO:不学 critic,组内相对优势怎么算
3.1 最大的架构差异:没有 critic 网络
GRPO 最抓眼球的设计就是它直接干掉了 critic。传统 PPO 需要两个网络:actor 输出动作分布,critic 输出状态价值 V(s),GAE 的计算依赖 critic 的输出来估计优势。而 GRPO 完全不训练价值网络,那它拿什么当 baseline?答案是:用同一组采样样本的内部统计量当 baseline。
具体操作是:对同一个状态(或同一个 prompt),从旧策略里采样 G 个动作,分别得到奖励 r_1 到 r_G。然后对这 G 个奖励做标准化:
A_i = (r_i - mean(r)) / std(r)
这里 mean 和 std 都是按这 G 个样本内部计算的。这样每个动作的优势就是“它在同组兄弟里的相对位置”。虽然这个估计不如 GA E 那样有理论上的无偏保证,但在 reward model 打分或规则奖励的场景下,组内相对优势恰恰更符合“我们要什么”——我们关心的不是某个回复的绝对得分,而是同一问题上哪个回复更好。
3.2 为什么去掉 critic 反而撑得住
早期做策略梯度,大家不太敢去掉 critic,因为优势估计的方差会变大,训练容易崩。GRPO 能扛住,是因为它的使用场景和普通强化学习不一样。在 RLHF 或大模型对齐这类任务里,同一个 prompt 下采样多个回答的成本相对可控,而且 reward model 的打分分布不一定适合训练一个稳定的 critic。如果 critic 网络估值不准,反而会往策略里注入偏差;GRPO 直接避开了这个坑,用采样的“兄弟样本”做自我对比,天然带上了“同题对比”的语义。
另一个关键点是 GRPO 通常配合 KL 散度正则使用。RLHF 里我们不希望模型在优化奖励时偏离参考模型太远,所以损失会加上 β * KL(π_θ, π_ref) 项。这个 KL 正则相当于给策略更新加了一个“半径”,即使优势估计的方差比 PPO 大,也不会跑飞。
3.3 组大小怎么定?奖励归一化细节
GRPO 里一个非常重要的超参数是 G,也就是每个 prompt 采样的回答数量。G 太小,组内统计量方差大,baseline 不稳定;G 太大,采样成本高。我在实际跑 RLHF 时,G 一般取 8~16 之间。如果 reward model 本身有随机性,G 得大一点,否则优势被噪声污染。
还有一个细节容易被忽略:如果同一组里所有奖励都相同,std 为 0,标准化会出 NaN。这时需要加一个极小值 ε 防止除零,比如 A_i = (r_i - mean) / (std + 1e-8)。还有一个更稳妥的做法是直接用 z-score 加上 clip 限制,防止某个极端奖励把整个组的优势拉爆。
用伪代码展示 GRPO 的核心更新片段:
# 假设同一个 prompt 采了 G 个 response,得到 rewards: [r1, r2, ..., rG] def grpo_advantages(rewards): mean = np.mean(rewards) std = np.std(rewards) + 1e-8 return [(r - mean) / std for r in rewards] # 训练时用旧的 log_prob 和新的 log_prob 计算 ratio for prompt, old_log_probs, new_log_probs, rewards in batch: A = grpo_advantages(rewards) # 组内相对优势 ratio = torch.exp(new_log_probs - old_log_probs) loss = -torch.mean(torch.min(ratio * A, torch.clamp(ratio, 1 - eps, 1 + eps) * A))这段代码里最核心的就是“先组内归一化再算策略损失”,理解了这一点,GRPO 就不再是黑盒。
4. dual-clip PPO:负优势极端情况下的补丁
4.1 原版 PPO 在负优势下为什么可能崩
前面提到过,clip 在 A > 0 区间能有效踩刹车,但在 A < 0 区间几乎起不到限制作用。详细推导是这样:当 A < 0 时,目标函数 min(ratio * A, clip(ratio) * A)。由于 A 是负数,ratio * A 会随 ratio 减小而增大(注意 loss 要取负号才是梯度),这本来符合“负优势动作应该降低概率”的逻辑。但如果某次更新后 ratio 已经小于 1-ε,clip 项就变成了 (1-ε) * A,这是一个固定值;而 ratio * A 仍然随 ratio 减小而增大,取 min 时,如果 ratio * A 更大(也就是数值上更接近正数),min 会选择 ratio * A 而不是 clip 项。
结果是什么?在极端负优势下,ratio 越小,loss 反而越小,梯度继续鼓励 ratio 压得更低,于是陷入“负优势 → 概率骤降 → 极端负优势 → 概率再降”的恶性循环。少数几个极端样本就可能把策略推向崩溃,尤其是奖励稀疏或者 KL 惩罚特别大的场景,ratio 本来就容易波动,更危险。
4.2 dual-clip 的核心思想:多设置一道下界
dual-clip 的思路非常直接:在 A < 0 时给 loss 增加一个额外的下限,防止极小 ratio 对应的梯度继续放大。常见实现如下:
L = min(ratio * A, clip(ratio, 1-ε, 1+ε) * A, 1+ε 或一个固定常数下界对应的项)
具体来说,当 A < 0 时,原版 clip 的公式只在 ratio 太大时起作用,对 ratio 太小没有下界。dual-clip 会引入一个新的 clip 上限(针对负样本的)或者用一个常数下界 c,把 loss 在负优势区域的最低值限制住。这样即使 ratio 已经极度小,梯度也不会继续推着策略往深渊里钻。
从工程视角看,dual-clip 是 PPO 在“长尾风险”上的补丁。它不是每次训练都被激活,但一旦激活,救的就是整个训练流程。
4.3 什么时候一定要上 dual-clip
我自己的经验是三类场景优先考虑 dual-clip:第一,奖励信号特别稀疏,大多数样本优势都是 0 或负,少数极端正样本又特别强;第二,KL 正则系数较大,策略每步都被压向参考模型,ratio 波动明显;第三,训练初期策略分布还没稳定,随手一个 batch 里可能混着好几个异常样本。
举个例子,用 PPO 训练对话模型做 RLHF 时,reward model 对某些安全问题的打分特别极端:个别回答被打了 -5 分,而正常回答都是 0.1、0.2 这种量级。这种情况下 ratio 被大面积压低,负优势的极端样本开始主导梯度,训练曲线会出现莫名其妙的断崖式下跌。加上 dual-clip 后,这种断崖明显减少,虽然有时候更新会显得“钝”一点,但稳定性高了好几个档次。
5. 代码视角:离散与连续动作的 PPO 核心实现
5.1 离散动作版 PPO 的更新核心
离散动作版代码其实非常简洁,核心就是:用策略网络算 logits,取 log_softmax 拿到 log 概率,再算 ratio 和 loss。关键是更新循环里要把“旧策略的 log prob”保存下来,不能重新算。
def ppo_update(policy, optimizer, batch, epsilon=0.2): states = batch["states"] actions = batch["actions"] old_log_probs = batch["log_probs"] advantages = batch["advantages"] logits = policy(states) log_probs = F.log_softmax(logits, dim=-1) action_log_probs = log_probs.gather(1, actions.unsqueeze(-1)).squeeze(-1) ratio = torch.exp(action_log_probs - old_log_probs) clipped_ratio = torch.clamp(ratio, 1 - epsilon, 1 + epsilon) loss = -torch.min(ratio * advantages, clipped_ratio * advantages).mean() optimizer.zero_grad() loss.backward() optimizer.step()注意这里 gather 的用法,离散动作必须用动作索引去取对应位置的 log 概率,而不是把整个 logits 都拿来算。初学者最容易在 log_probs 的形状上裁跟头。
5.2 连续动作版:高斯分布和 log 概率别再写错
连续动作版复杂在 log 概率的计算。网络输出 μ 和 log_std,log_std 可以通过 log(σ) 参数化保证 σ 恒为正。计算 log 概率时要套高斯分布公式:
def gaussian_log_prob(mu, log_std, actions): std = log_std.exp() return -0.5 * (((actions - mu) / std) ** 2).sum(dim=-1) \ - log_std.sum(dim=-1) \ - 0.5 * mu.shape[-1] * np.log(2 * np.pi)写代码时最容易踩的坑就是维度:μ 和 actions 的形状必须一致,通常是 (batch_size, action_dim)。如果 log_std 是一个可学习的参数向量,注意它在 loss.backward() 时会正常收到梯度,不需要手动 detach;但如果用了 tanh 或 sigmoid 压缩动作到固定范围,就需要在 log 概率里加上对应的雅可比修正项,否则策略梯度是有偏的。
5.3 训练循环里那些“默认没人告诉你”的细节
实际训练 PPO 时,我总结过几个值得注意的细节。第一,同一批数据不要更新太多次,PPO 设计上允许 epoch 大于 1,但 10 个 epoch 以上 ratio 很容易越界,通常 2~4 个 epoch 比较合适。第二,batch size 太小时 clip 形同虚设,因为优势估计本身噪声大,clip 只是在噪声之上加了个限幅,底噪太大限幅也没用。第三,学习率和 clip 范围要联动调整,如果你把 ε 从 0.2 降到 0.1,学习率最好也降一点,否则更新步长会被 clip 频繁截断,策略更新效率反而低。
一个很反直觉的经验是:连续动作场景下,如果动作维度很高,比如机械臂的 7 自由度控制,log_std 的初始化特别关键。把 log_std 初始化为 0(即 σ=1)通常是个不错的起点;但如果动作范围本身就很小,比如角度控制不超过 ±1,σ=1 会导致采样动作频繁撞边界,这时反而要把 log_std 初始化成 -1 或 -2,缩小初始探索范围。
6. 常见问题与排查技巧实录
6.1 训练时 loss 全是 NaN
NaN 大概率出现在 log_prob 计算或 advantage 归一化上。连续动作版检查 (actions - mu) 是否出现极端值,特别是动作没有 clip 时,个别异常轨迹可能产生巨大差值;GRPO 版检查 std 是否为 0,建议所有归一化都加上 1e-8 的 epsilon。还有一个隐蔽点:如果用 float16 混合精度训练,log_std.exp() 很容易溢出,可以考虑把 log_std 限制在 [-20, 5] 之类的范围内,防止 σ 太小或太大。
6.2 策略快速收敛到“什么都不做”或“狂按同一个动作”
这是熵崩塌的典型症状。观察策略分布的熵,如果熵值快速掉到 0 附近,说明策略丧失探索能力。解决办法是三选一或组合用:降低学习率、增大 clip 范围让更新更“敢动”、在 loss 里加上熵奖励项 coefficient * entropy。我通常把熵系数初始化为 0.01 左右,然后根据训练曲线动态调整,如果熵下降太快就提高系数,如果最终性能上不去就降低。
6.3 GRPO 组内奖励方差过低,优势几乎全为 0
当 reward model 对同一个 prompt 的多个 response 打分都差不多时,组内标准化后优势接近 0,策略更新信号极弱。这时单纯加大 G 不一定有用,因为 reward model 本身分辨率不够。可以考虑调整 reward 的 scale,比如把奖励从 [0,1] 映射到 [-1,1],或者对奖励做指数化放大;也可以引入更细粒度的过程奖励,而不是只用最终打分。注意不要为了制造方差而人为加入噪声,那会让策略学到错误的偏好。
6.4 该用 PPO 还是 GRPO?我的选择标准
如果你在做一个传统强化学习任务,环境奖励信号来自仿真器,状态价值函数 V(s) 可以被有效学习,那 PPO + GAE 仍然是最稳的选择。如果你的任务是大模型对齐、LLM 的 RLHF,状态空间接近无限、单次采样成本高、奖励来自 reward model,那 GRPO 更省心。原因在于:LLM 场景下 critic 网络很难训好,价值估计不准还不如用组内相对优势;而且同一个 prompt 采样多个回答的成本远比传统 RL 低,因为你只需要调一次批量生成接口。
6.5 一个容易被忽略的“元问题”
最后说一个经常出现但又容易忽略的问题:很多人把 PPO 和 GRPO 的 loss 细节讨论得很细,却没想清楚 reward 信号本身从哪来、是否可信。我见过不少项目,算法细节调得无可挑剔,但 reward model 本身有偏向,最后策略被带到沟里去了。算法只是在更新“概率分布的旋钮”,旋钮往哪边拧取决于你给的信号;信号错了,再稳的更新也是在错误方向上走得更远。先校验 reward 可靠性,再做算法调优,顺序不要搞反。
就我自己的实操体会而言,PPO 和 GRPO 的差别,真正落地时最影响手感的是“信号来源的稳定性”和“评估基线的设计”。PPO 的 critic 是个学习出来的 baseline,状态价值函数在复杂任务里不一定学得准;GRPO 的组内均值是采样出来的 baseline,可靠性取决于组内样本量。两者都有各自适合的战场,没有绝对的优劣。至于 dual-clip 这类补丁,学的时候可以往后放,但真正跑大模型训练时最好提前加上,因为极端样本的破坏力往往比想象中来得更突然。