GRPO 后训练系统工程实战:从 RLHF 偏好建模到可验证奖励的全链路架构
执行摘要:大多数人把后训练理解成"拿一堆偏好数据再微调一遍",这是 2023 年的认知。2026 年后训练真正的形态是:一个由采样引擎、奖励系统、策略训练器、权重同步通道四个子系统构成的分布式在线系统,它的瓶颈从来不在 FLOPS,而在采样吞吐、权重同步延迟与奖励信号的信噪比。本文拆开这套系统:为什么 PPO 的 Critic 在 LLM 场景下是负资产、GRPO 用组内基线替代价值网络意味着什么、可验证奖励(RLVR)如何把奖励黑客问题从"对抗"降级为"工程"、以及 rollout-colocate 架构下权重重分片为什么是最容易被低估的稳定性杀手。给出可运行的损失实现、奖励函数模板与生产坑位清单。
一、重新理解后训练:这不是训练问题,是系统问题
监督微调(SFT)是离线问题:数据固定、损失固定、吞吐由数据并行度线性决定。后训练(RLHF/RLVR)是在线问题:数据的分布由当前策略自己生成,策略每更新一步,下次采样的分布就变了。这个闭环带来三个 SFT 里不存在的工程约束:
| 维度 | SFT | 后训练(GRPO/PPO) |
|---|---|---|
| 数据来源 | 静态数据集 | 当前策略在线采样 |
| 瓶颈 | 训练算力 | 采样吞吐(通常占 70~85% 步时间) |
| 显存 | 模型 + 优化器 | 再加一份推理引擎 + KV Cache |
| 失败模式 | 欠拟合/过拟合 | 奖励黑客、熵坍塌、长度爆炸 |
| 可复现性 | 高 | 低(采样非确定性 + 推理引擎版本敏感) |
结论先行:后训练 80% 的工程精力应该花在采样与奖励上,而不是策略更新算法上。 算法层面 2026 年已经高度收敛——去掉 Critic、组内归一化、token 级损失、clip 约束,剩下的差异大多是工程细节。
二、PPO 的 Critic 为什么是负资产
经典 PPO 需要四个模型同时驻留显存:policy、reference、reward、critic(value)。在 LLM 场景下 critic 基本等于第二个 policy,显存和算力直接翻倍。更麻烦的是它的估计质量:
学一个 V(s) 去预测"从当前前缀出发的期望累计奖励",状态空间是整个 token 序列前缀。在 7B+ 模型、数千 token 的序列上,value network 的拟合误差往往和 advantage 本身同一量级。一个信噪比接近 1 的基线,减下去只会放大方差。 这就是为什么很多团队的 PPO 训出来不如 DPO 稳定——不是 PPO 理论有问题,是 Critic 在实践中没能用。
GRPO(Group Relative Policy Optimization)的做法极其朴素:同一个 prompt 采样 G 条轨迹,用组内的奖励均值当基线,用组内标准差归一化。
def grpo_advantages(rewards: Tensor, G: int) -> Tensor:
"""rewards: shape [B*G],同一 prompt 的 G 条轨迹连续排列"""
r = rewards.view(-1, G) # [B, G]
mean = r.mean(dim=1, keepdim=True)
std = r.std(dim=1, keepdim=True)
# 关键:std 为 0 的组(全对或全错)必须置零,否则除零产生 inf
adv = (r - mean) / (std + 1e-4)
adv = torch.where(std < 1e-4, torch.zeros_like(adv), adv)
return adv.view(-1) # 组内零和:sum(adv) == 0
注意 std < 1e-4 这个分支。这不是数值保护,而是语义决定:一组轨迹全对或全错时,组内没有可学习的对比信号,强行归一化只会把一个极小的浮点噪声放大成满幅梯度。生产里建议直接统计"有效组比例"(std 非零的组占多少),这个指标低于 30% 说明题目难度与当前策略不匹配——要么加难题,要么调采样温度。这是最实用的课程学习信号。
三、策略损失:token 级归一化才是正解
def grpo_loss(new_lp, old_lp, ref_lp, adv, mask, clip=0.2, beta=0.0):
"""各张量 shape: [B*G, T],mask 标记真实 token(排除 padding)"""
ratio = torch.exp(new_lp - old_lp) # 重要性采样比
unclipped = ratio * adv[:, None]
clipped = torch.clamp(ratio, 1 - clip, 1 + clip) * adv[:, None]
pg = -torch.min(unclipped, clipped) # 悲观下界
if beta > 0: # KL 惩罚项
kl = torch.exp(ref_lp - new_lp) - (ref_lp - new_lp) - 1.0
pg = pg + beta * kl
# 陷阱点:分母必须是本 batch 的总 token 数,而不是序列数
return (pg * mask).sum() / mask.sum().clamp(min=1)
分母的选择是长度偏置的根源。如果按序列平均(mean(dim=-1) 再对 batch 平均),长序列里每个 token 的梯度权重被稀释,模型会学到"说短一点更稳"——表现为输出长度持续收缩、正确率反而下降。按 token 求和再除以总 token 数,每个 token 的梯度贡献才是均等的。这条在 DeepSeekMath 及后续大量复现工作中被反复验证。
关于 beta:GRPO 原论文把 KL 放进损失,但工程实践中很多团队设 beta=0,靠 clip 加单步内更新(on-policy,num_inner_epochs=1)约束漂移。理由是 ref 模型前向要多花一份算力,而 clip 已经足够。我的判断:做通用对话对齐时保留小 beta(0.001~0.01),做数学/代码 RLVR 时可以置零——后者的奖励本身有硬边界,策略跑偏会被奖励直接拉回。
四、奖励工程:把"对抗问题"降级为"工程问题"
RLHF 最难的部分从来不是优化器,是奖励模型会被策略找到漏洞钻。神经奖励模型(RM)本质上是一个会被分布外样本击穿的分类器,策略只要发现"写长一点、用更自信的语气、加几个礼貌用语"能稳定拿高分,就会迅速收敛到这些表面特征。
可验证奖励(RLVR)换了个思路:奖励不由模型给,由可执行的判定器给。
def math_reward(completion: str, answer: str) -> float:
pred = extract_boxed(completion) # 取 \boxed{} 内容
if pred is None:
return 0.0 # 格式不合规:直接零分
return 1.0 if symbolic_equal(pred, answer) else 0.0
def code_reward(completion: str, tests: list[str]) -> float:
src = extract_code_block(completion)
if src is None:
return 0.0
try:
r = sandbox.run(src, tests, timeout_s=4) # gVisor/seccomp 沙箱
except Timeout:
return 0.0 # 超时不是 -1,是 0
return r.passed / r.total # 连续奖励,梯度更平滑
几个实战观点:
- 沙箱是奖励系统的一部分,不是外部依赖。 代码类 RLVR 的吞吐瓶颈经常在沙箱并发数上。建议独立部署、池化连接、按语言分池,超时统一返回 0 分而不是报错——超时的样本在组内是"最差样本",本身就有学习价值。
- 稀疏奖励不是问题,判定器噪声才是。 0/1 奖励完全可用,前提是判定器准确。宁可让 20% 的样本因判定失败拿 0(随机噪声,可被组平均吸收),也不要让 5% 的样本拿到错误的 1(系统性偏差,会被策略精准利用)。
- 二进制奖励加组内标准差归一化,等价于在学"排序"而非"打分"。 这解释了为什么 G 的选择很关键:G=8 时组内对比信号弱,G=64 时采样成本翻 8 倍。工程上常见配置是 G=16,配合动态采样(丢弃无效组后补足)。
五、Rollout:后训练真正的瓶颈
一次 GRPO 步的时间分布,实测大致是:采样 70~85%、奖励计算 5~10%、前向反向 10~15%、权重同步 3~8%。这意味着优化训练侧(比如换更快的算子)收益极小,优化采样才有杠杆。
# verl 风格的配置,重点是采样侧的四个开关
rollout:
n: 16 # 每个 prompt 采样 G 条
temperature: 1.0 # 低于 0.8 组内多样性不足,std 频繁为 0
max_tokens: 4096
engine: vllm
gpu_memory_utilization: 0.35 # 与训练侧共享显存时的上限
enable_chunked_prefill: true # 长短混合 batch 下收益显著
actor:
num_inner_epochs: 1 # on-policy:不要多轮复用同一批采样
clip_ratio: 0.2
loss_agg_mode: token-mean # 见第三节
colocate 还是分离? 把推理引擎和训练进程放在同一批 GPU 上(colocate)能省掉跨节点权重传输,代价是显存要在两者间腾挪,且每步都要做一次"训练态权重到推理态权重"的重分片(TP/PP/EP 切分往往不同)。这是最容易被低估的稳定性杀手:
- 权重同步必须是确定性的。 推理引擎侧若按不同 layout 加载,会引入静默的数值偏差,on-policy 假设被破坏,损失曲线看起来正常但实际在训错目标。建议每 N 步做一次"训练前向 vs 推理前向"的 logprob 对拍,差值超过 1e-3 就报警。
- 显存碎片。 vLLM 的 KV Cache 池和训练侧的激活显存会互相挤压。生产上把
gpu_memory_utilization压到 0.3~0.4,并预留 8~10GB 给峰值激活。 - 长尾序列。 采样阶段 5% 的超长序列能吃掉 40% 的时间。设置分档
max_tokens加部分 rollout(超长的留到下一步继续)比统一截断效果好得多——截断会污染奖励信号。
六、生产坑位清单
| 现象 | 根因 | 解法 |
|---|---|---|
| 奖励涨、人工评测降 | 奖励黑客 / 判定器漏洞 | 冻结策略做红队回放;增加格式与长度惩罚项 |
| 输出长度单调收缩 | 损失按序列平均 | 改 token-mean;检查 mask 是否误含 padding |
| 熵迅速归零,输出模板化 | 温度过低 / G 过小 / clip 过紧 | 温度 1.0、G≥16、加最小熵约束 |
| 损失 NaN | std≈0 组除零 / ratio 溢出 | 掩码零 std 组;对 old_lp 做 detach 与 clamp |
| 吞吐上不去 | 沙箱并发或长尾序列 | 沙箱池化;分档 max_tokens |
| 可复现性差 | 推理引擎版本/算子差异 | 锁定引擎版本;记录 logprob 对拍基线 |
七、结论
- GRPO 的价值不是"更好的算法",而是"去掉了一个训不好的 Critic"。 它把后训练从四模型降为三模型(policy/ref/reward),且在 RLVR 场景下 ref 也可省。
- 奖励的质量上限决定策略的上限。 把精力从调优化器转移到打磨判定器与难度分布,回报率高一个数量级。
- 盯住"有效组比例"这个指标。 它同时反映难度匹配、采样温度和奖励判别力,是后训练里最有信息量的单一健康度信号。
- 把后训练当在线系统而非训练脚本。 采样、判定、训练、同步四个子系统各自有 SLO,先测量再优化。
如果你的场景是数学、代码、SQL、工具调用这类有明确正确性判定的任务,GRPO 加 RLVR 目前是性价比最高的路径;如果是开放式对话质量,神经奖励模型依然不可替代,但 GRPO 的组内基线思路同样适用——把 RM 打分换成组内两两比较即可。

发表评论 取消回复