做LLM对齐训练最怕的从来不是效果差,而是训着训着GRPO直接崩给你看。loss一条直线拉上天花板,下一秒就是inf/NaN;或者更憋屈的,loss在掉、reward也在掉,策略模型当场坍缩成复读机;再狠一点,训练指标一切正常,一评测发现模型已经被reward model带沟里去了。这些都是我真实踩过的坑,也是为什么后来我把一套三层防御方案固化成默认配置:GEPO、OPEFO、QUATRO。
这三个名字不是什么论文里的正式方法,是我自己按解决层级起的工作名。GEPO管梯度信号的质量,OPEFO管优化器状态的稳健性,QUATRO管整个训练流程的兜底。把它们拆开看,每一层只做一个事情;合起来用,就是从“梯度进反传之前”到“优化器更新之后”再到“数据批次进入之前”做了一个全链路的手术。这篇文章把它们的设计思路、关键细节和实际参数都展开讲,如果你是刚被GRPO的训练崩溃折磨过,或者正准备从PPO迁移到GRPO,这套方案可以直接抄作业。
1. 先搞清楚GRPO为什么会崩:它不是“调参没调好”,是结构上就存在三个雷区
1.1 GRPO的梯度信号天然比PPO更“脆”
GRPO相比PPO最大的区别就是去掉了critic,用组内多个采样response的reward均值替代value baseline。省了显存、省了critic训练,但代价也很明显:你用来估计优势值的样本,只有同一个prompt下采样的那G个response。当G只有8、16,最多32的时候,组内均值和方差估计的噪声相当大。更麻烦的是,一旦reward model在某块区域饱和,或者组内reward几乎没差异,优势值分母(组内标准差)就会逼近0,z-score算出来的advantage立刻变得极大或极不稳定。
这就是GRPO崩溃的第一层隐患:不是你的学习率太大,而是优势值的尺度本身在剧烈抖动。PPO有critic网络兜底,value误差还能平滑一下;GRPO直接把采样噪声灌进了policy的梯度,它对超参数和reward分布的敏感度都要高一个量级。
1.2 三种崩溃路径:先判断死法,再决定下哪把刀
我见过的GRPO训练崩溃,按表现基本可以归成三类。第一类是硬崩溃:训练中途loss突然变成inf/NaN,日志里grad norm从个位数瞬间爆到上千,这种情况通常出在advantage计算异常、混合精度下优化器状态数值溢出、或者生成阶段的logprob出现极端值。第二类是熵坍缩:loss在降,entropy也在降,生成结果越来越重复单调,reward指标反而一点不动甚至倒退,这是策略探索性被“压死”了。第三类是静默失效:训练曲线看着一切正常,loss下降、reward上升,但拿去评测效果很差,模型学会了利用reward model的bug刷分,比如疯狂加格式、凑长度、写重复的套话。
硬崩溃你还能靠日志发现问题,熵坍缩和静默失效才更坑,因为它们发生在你盯着训练曲线“以为快成功”的时候。而这三种死法,恰好分别对应我下面要讲的三个层级:硬崩溃优先查梯度信号和优化器状态,熵坍缩靠训练流程约束,静默失效则要从样本质量和reward使用方式上做防御。
2. 第一层GEPO:在梯度进入反传之前,先修正优势值信号
2.1 用MAD和tanh软裁剪替代“裸z-score”
GEPO全称我习惯叫它Gradient Engineering for Policy Optimization,核心思路是在计算policy loss之前,把每一条token的advantage都当成“待质检的原料”,先做一轮清洗再做标准化。很多人在写GRPO时就直接用了(rewards - group_mean) / (group_std + 1e-8),处理常规情况没问题,但只要组内出现一个离群高分或者离群低分,组均值就被带偏,组方差也做大,其他正常样本的优势值被压缩到接近0,这一整个batch的梯度信号等于被那条离群样本绑架了。更危险的是方差特别小的组,除以一个小数,整个组优势值直接爆掉。
我的做法是分两步。第一步,标准化之前先用中位数和MAD(median absolute deviation)做稳健中心化,替代均值和标准差。MAD对离群点的稳定性比std强很多,公式很简单:median_r取组内reward中位数,mad = median(|reward - median_r|),再把reward换成(reward - median_r) / (1.4826 * mad)。其中1.4826是让MAD在正态分布下近似等于标准差。第二步,标准化完成之后,再过一层tanh软裁剪:adv = clip_scale * tanh(adv / clip_scale)。因为tanh在输入很大时会饱和,极端优势值会被压回有限范围内,但它又不像硬裁剪那样直接把超出范围的梯度砍成常数,仍然保留了相对大小和方向。
代码做出来不复杂,实际使用效果非常明显。这里给一个我在8B模型上用的参考实现:
def group_advantage_engineer(rewards, clip_scale=3.0): # rewards: [batch, group_size] median_r = rewards.median(dim=-1, keepdim=True).values mad = (rewards - median_r).abs().median(dim=-1, keepdim=True).values mad = mad * 1.4826 + 1e-8 adv = (rewards - median_r) / mad adv = clip_scale * torch.tanh(adv / clip_scale) return advclip_scale取2.5到4.0之间,我默认用3.0。太小的tanh压缩会削弱正常样本的梯度表达,太大的话起不到压制极端值的作用。经验是先在干净的小规模任务上跑几十步,看一眼advantage分布再定。
2.2 梯度监控与micro-batch跳过:把异常“掐”在优化器之前
光改优势值计算还不够,因为GRPO的生成阶段本身就可能产生噪点。策略模型采样时有概率落到某条“坏轨迹”上,产生一组极端的logprob或一个异常的大梯度。传统做法是gradient clipping,把grad norm限制到1.0或5.0。但clipping对“常见的大梯度”是有效的,对“偶发的病态梯度”其实很笨:一旦这一step的梯度分布已经被污染,clip完照样会把噪声信息写进参数。
所以我另加了一个机制:给每个micro-batch计算grad norm,同时维护若干步grad norm的EMA。如果当前grad norm超过EMA的5倍以上,并且绝对值大于某个硬阈值(比如5.0),就直接跳过这个micro-batch的优化器更新,不clip、不硬扛,只记录日志。做这一步背后的逻辑是:一个正常收敛的训练过程里,梯度norm有波动,但很少会出现数量级的突变,出现数量级突变意味着本步的loss信号本身不可信。跳过一步,相当于给自己一个缓冲,等下一步的数据回到正常分布,再继续更新。代价很小,收益是换来整个训练的稳定性。
为了让这套逻辑可观测,我还建议每个至少50个micro-batch打印一次grad norm、advantage的mean/std、max abs advantage,以及entropy。很多崩溃不是突然发生的,是有先兆的——advantage std缓慢抬升、entropy快速走低——如果日志里有这些量,就能提前一个epoch发现问题,而不是等它爆炸后再回头翻loss曲线。GEPO这层解决的是“进优化器之前的信号质量问题”,但信号就算洗干净了,优化器自身的状态也可能出问题,这就是第二层OPEFO要治的。
3. 第二层OPEFO:别让优化器状态成为第二个坑
3.1 AdamW在RL长训练里的隐藏杀手:二阶矩下溢和状态污染
我们默认用的优化器基本都是AdamW,它维护两个状态:一阶矩exp_avg和二阶矩exp_avg_sq。更新公式里,参数更新量等于lr * m_t / (sqrt(v_t) + eps)。这套机制在监督学习里非常稳,但在GRPO这种“奖励信号本身就剧烈波动”的场景里有个隐蔽问题:当策略在某个维度上连续很多步都收到一个很小的梯度时,exp_avg_sq会被压得很低。混合精度训练下,很低的二阶矩状态容易被数值下溢清成0,一旦这个维度突然来一个正常梯度,sqrt(v_t)如果接近0,分母就只剩一个eps,参数更新步长会被放大到一个不合理的量级,这一下就可能把模型推出收敛域。
另一个问题是状态污染。GRPO训练中如果某个micro-batch产生了inf梯度,优化器会先把inf写进exp_avg和exp_avg_sq。即使你这一步把loss设成0跳过了更新,Adam的状态也已经被污染了,后面几十步的更新都会被这个残留的inf/A的“阴魂”干扰。这种情况我遇到不止一次,每次都要回滚checkpoint重来,非常浪费时间。
3.2 OPEFO的做法:二阶矩下限保护 + 残差反馈 + 定期状态快照
OPEFO是我起的Optimizer-state Protection with Error Feedback Optimization,它做的事情可以在现有AdamW流程上以很小的代价包一层,不需要换优化器。第一件事是加二阶矩下限保护。每隔固定的步数(我一般设100步),扫描每个参数组的exp_avg_sq,统计其中极小值数量的占比。如果超过阈值,就把所有exp_avg_sq中低于该参数组p10分位数的值抬升到p10水平,避免分母过小。这个操作相当于给优化器做一次“体检”,防住了数值下溢导致的大步长风险。
第二件事是error feedback。当GEPO层的检测判定某个micro-batch要跳过时,我们不直接把这一步的梯度删掉,而是把被抑制的梯度残差按照一定比例放进一个环形缓冲区。后面4步,每步把缓冲区中的残差按0.3、0.3、0.2、0.2的比例回补到梯度上。这样做的理由是:被跳过的梯度不一定全是噪声,也可能包含了有效的策略改进信号,只是它触发了安全阈值。把它切碎、分步地放回去,比一次性应用温和得多,既保留了信息又不会让优化器受到二次冲击。残差回补前需要按当前梯度的平均尺度做一次归一化,不然一个norm为20的残差碎片混进norm为2的正常梯度里,又会变成新的异常。
第三件事是状态快照和自动回滚。每500步把optimizer state和模型权重做一次checkpoint。如果OPEFO检测到exp_avg_sq或loss已经出现inf/NaN,自动恢复到最近一次健康快照,并把学习率临时降为原来的0.3倍、再运行100步“恢复期”,等梯度尺度稳定后再调回原学习率。这个机制我觉得是三层方案里最“傻瓜但救命”的设计,它把一次需要人工介入几小时的崩溃恢复,压缩成了自动触发、自动恢复的几十秒。
设置一个参数参考表,如果你用的也是AdamW加混合精度,可以照着起步:
| 参数项 | 建议值 | 说明 |
|---|---|---|
| state_check_interval | 100步 | 扫描exp_avg_sq的频率 |
| v_floor_percentile | 10% | 低于这个分位值的二阶矩会被抬升 |
| residual_buffer_size | 4步 | 梯度残差分几步回补 |
| residual_schedule | [0.3, 0.3, 0.2, 0.2] | 每步回补比例 |
| optimizer_backup_every | 500步 | 优化器状态快照间隔 |
| anomaly_backtrack_steps | 1个checkpoint周期 | 触发异常时回滚的保守范围 |
这套优化器层面的防护解决的是“更新过程”的稳定性。但就算梯度信号干净、优化器状态健康,GRPO还有一个绕不开的流程问题:你喂给模型的数据,每一轮都是当前策略亲自采的,一旦策略往坏的方向滑了一点,下一轮rollout数据就会更坏,形成闭环。所以还需要第三层QUATRO,从数据流和训练目标层面做结构性约束。
4. 第三层QUATRO:从训练流程上兜底,不让策略掉进自证预言的陷阱
4.1 四重机制的用途:回放、锚定、熵下限、数据过滤
QUATRO四重机制,目的是让GRPO的“当前策略采样→训练→再采样”的闭环不再那么脆弱。第一重是rollout经验池回放。我在训练循环里维护一个小型缓冲区,容量大约是最新8个micro-batch的数据。每次取训练batch时,按大约30%的旧数据加70%新数据的比例混入。别小看这30%的旧数据,它的作用是打破“当前策略坏→新rollout坏→策略更坏”的正反馈循环。当一个micro-batch里混入一部分来自相对更早、更多样化策略的样本时,模型不会因为一批坏数据就发生剧烈偏移,训练过程会平滑很多。
第二重是参考模型KL锚定。GRPO为了控制策略模型漂移,通常在loss里加一个和参考模型的KL惩罚项,但固定kl_coef的问题在于:策略模型退化的速度不是恒定的。我在QUATRO里把kl_coef改成自适应调节:每N步统计一次当前batch的平均per-token KL,对比预设的target(比如0.02);KL高于target,就把kl_coef按1.2倍上调,收紧约束;KL低于target,再按0.8倍下调,放开探索。同时给kl_coef设一个全局上下限,防止它过大或过小导致训练目标失衡。有了这个锚定,策略模型偏离参考模型的速度会被按住,熵坍缩的概率小很多。
第三重是entropy floor机制。这个只针对“策略逐渐变得过于确定”的情况。我用一个rolling窗口计算生成模型的token熵,如果当前熵低于设定下限(我习惯取任务训练初期熵值的0.8倍),就自动在loss里增加一个entropy bonus,把熵拉回来。同时临时降低这个阶段的clip ratio,限制单步更新量,给模型一个“喘息”的空间。这层机制的作用不是提升上限,而是保证探索下限,防止策略过早锁死在某个局部模式里。
第四重是rollout质量过滤。GRPO的数据完全由模型自己生成,质量参差不齐。我在把rollout送入训练前加了几道过滤规则:reward出现z-score绝对值大于3.5的样本直接丢弃;response长度落在正常区间之外的丢弃;与prompt重复率过高的“复读机型输出”丢弃;组内reward标准差为0的整组直接跳过。这些规则不追求高召回,只求拦掉那些明显会给训练注入噪声的样本。过滤掉的数据虽然会让有效batch size缩水一点,但换来的是每一步的梯度信号都更干净,整体收益远大于损失。
4.2 三层不是并列关系,是递进关系
GEPO、OPEFO、QUATRO在逻辑上是有先后顺序的:GEPO保证进入优化器的梯度本身是确定的,OPEFO保证优化器更新过程是稳健的,QUATRO保证整个训练流程不会进入病态轨迹。少了任何一层,其他两层都能在某种程度上兜住一部分问题,但长期跑下来就总会从最薄弱的环节崩出来。我自己的分工方式是这样的:排查NaN优先看GEPO和OPEFO,排查熵坍缩和reward hacking优先看QUATRO。下面这张表是我在实际训练里总结的“对号入座”经验:
| 崩溃现象 | 主嫌疑层 | 辅助检查点 |
|---|---|---|
| loss突变为inf/NaN | GEPO(advantage异常)、OPEFO(状态污染) | 生成logprob、reward model输出 |
| grad_norm持续抬升 | GEPO(梯度裁剪与跳过) | 优势值std变化 |
| entory持续下降、reward停滞 | QUATRO(entropy floor+KL锚定) | 生成多样性 |
| 指标正常但eval崩 | QUATRO(rollout过滤) | 生成文本的长度/格式分布 |
| 训练一段时间后重启又崩 | OPEFO(优化器状态回滚) | 学习率是否需要降 |
5. 三层方案的完整参数速查与部署顺序
5.1 直接可抄的参考参数表
我把这套方案常用的初始化参数整理成一张表,适用于7B到14B左右的模型,学习率在1e-6到3e-6区间,rollout的group size在8到16之间。你可以在这个基础上调整,但建议一次只动一个变量:
| 配置项 | 默认值 | 适配范围 | 关键说明 |
|---|---|---|---|
| advantage clip scale | 3.0 | 2.0~4.0 | 越小对极端值压制越狠,但太小会钝化正常信号 |
| MAD scale factor | 1.4826 | 固定 | 将MAD校正为标准差的无偏估计 |
| grad_norm EMA beta | 0.99 | 0.95~0.999 | 越大越稳定,但对突发异常反应越慢 |
| skip grad_norm倍数阈值 | 5倍EMA | 4~8倍 | 触发micro-batch跳过 |
| global grad_norm硬阈值 | 5.0 | 3.0~10.0 | 和EMA倍数条件同时满足才触发跳过 |
| state check interval | 100步 | 50~200 | 越大越省开销,越大越滞后 |
| v_floor percentile | 10% | 5%~20% | 太小保护不足,太大影响正常更新 |
| optimizer checkpoint interval | 500步 | 200~1000 | 频繁影响IO,太疏回滚成本高 |
| replay ratio | 0.3 | 0.2~0.5 | 太高会让训练滞后于最新策略 |
| replay buffer容量 | 8个micro-batch | 4~16 | 越大越平滑,但数据新鲜度下降 |
| kl_target(per token) | 0.02 | 0.01~0.05 | 视任务复杂度调整,复杂任务可以略大 |
| kl_coef调节因子 | 1.2 / 0.8 | 1.1~1.5 / 0.7~0.9 | 调节幅度不宜过大,否则KL震荡 |
| kl_coef上下限 | [0.001, 0.2] | 按需 | 防止自适应调节失控 |
| entropy floor | 初始熵的0.8倍 | 0.6~0.9 | 太低防不住坍缩,太高限制收敛 |
| rollout过滤z-score阈值 | 3.5 | 3.0~4.0 | 和reward分布有关,建议先观察一轮 |
5.2 部署顺序:先埋监测,再开干预,最后调训练目标
三条经验可以分享。第一,先跑50步不带任何干预的“裸GRPO”,记录loss、grad_norm、advantage std这些指标作为baseline。不做这步,你没法判断后续到底有没有效果。第二,先开GEPO和OPEFO跑一轮,确认硬崩溃被压住以后,再开QUATRO的回放和过滤,最后再调KL锚定和entropy floor。顺序很重要,QUATRO里KL adaptive的调节因子如果一开始就叠加在GEPO上,出了问题很难分清是谁的锅。第三,无论哪一层生效,保持至少每200步把grad_norm、advantage mean/std、entropy、KL四个量打印到日志。GRPO的崩溃几乎都不是瞬时事件,它一定有预告。你能早一步看到预告,就能少一次checkpoint回滚。
6. 常见问题与排查实录
6.1 加了GEPO还是NaN,问题出在哪
出现这种情况时,不要急着调大或调小GEPO参数,先确认NaN源头。我遇到过好几次,Advantage已经处理得很干净,但policy loss里出现了NaN,最后定位到是生成阶段logprob出现了负无穷:策略模型输出某个token的概率极端小,log后变成-inf,算ratio时自然就NaN了。解决方式是给logprob加一个下限,例如logprob = torch.clamp(logprob, min=-100),并且排查attention mask是否覆盖了padding位。还有一次是reward model的输出本身就带了NaN,前向没有炸,到了loss才炸。所以我建议你把“打印每步reward、logprob、ratio的finite状态”写进训练诊断代码里,确认全链路都finite再谈训练稳定性。
6.2 loss正常但KL在涨、reward在掉,怎么判断是哪一层失效
这是典型的熵坍缩前兆。先看entropy曲线,如果entropy也在掉,优先确认QUATRO的entropy floor有没有触发。如果触发但KL依然涨,把kl_target调小一点,或者把kl_coef调节因子的上限调大,让它更狠地拉回参考模型。如果entropy没有掉,但reward在掉、KL在涨,那就大概率不是政策坍缩,而是reward model本身在“钻空子”:生成序列可能开始出现一些奇怪格式,让reward model给出异常高分,但实际质量变差。这时候QUATRO里的rollout质量过滤要严格一些,比如把z-score阈值从3.5降到3.0,过滤掉那些reward异常突出的样本,再观察下一轮。
6.3 显存OOM和优化器state损坏怎么办
GRPO本身比PPO省显存,但它同样会因为生成显存峰值和训练前向叠加导致OOM。这种情况除了常规的gradient checkpointing、减小micro batch size之外,还提醒我们OPEFO的optimizer state快照重要性:在显存接近极限时,一旦OOM中断,进程里的optimizer state立刻丢失,重启时如果直接加载模型权重而不重建state,会迎来一个“无状态Adam启动期”,这时候很容易触发梯度异常。建议训练脚本里把“保存到临时目录再重命名”的checkpoint写入习惯,防止意外中断后只能从头加载。自动重启脚本也要把optimizer state路径带上,否则等于每中断一次就丢失一段历史。
6.4 训完了才发现效果不对,这算不算崩溃
算,而且是最贵的一种。如果整个训练过程loss和reward都很漂亮,但最终评测效果不行,大概率是reward hacking。我的排查经验是:把训练集里reward最高和最低的几批rollout分别拿出来看,高reward样本里如果大量出现重复句式、无意义加长、或者明显不该得高分的格式,那么QUATRO的过滤规则当前没有拦住它们。这时需要补充针对性的过滤规则,比如对重复性用self-BLEU做硬过滤,对长度异常加惩罚项,而不是单纯依赖z-score过滤。提前布置评测智能体,在训练过程中每隔固定步数跑一次小规模评测,比训完再发现问题省太多了。
写在最后的实际体会
这套GEPO/OPEFO/QUATRO三层方案,我用在好几个不同规模的GRPO任务上,最直接的感受是:它解决的不是“让模型跑得更快”或者“让效果更好”,而是“让训练过程变得可预期”。我现在的习惯是,任何新任务开训前,先把三层机制都埋进去,然后用两层验证:第一层看50步内有没有NaN,第二层看200步内grad norm和entropy是否在合理范围。如果都过了,再把其中一两层关掉做对比实验,确认它们确实在起作用,而不是白白增加复杂度。训练崩溃这种事情,经历多了你就会发现,它很少是因为某一个神秘参数引起的,往往是在几个层面同时出现轻微失衡,最后叠加成灾难。三层方案的意义,就是不让这些小问题有互相叠加的机会,让每一步训练都在可控的边界内推进。