My Blog
Agent 学习

Agent 学习第 17 周:强化学习、分布式训练,和一次 3 分钟的 GRPO 训练

在 4060 Ti 上用 GRPO 训练 Qwen2-0.5B 做算术题,3.1 分钟涨了 81 个点,再把涨幅拆成格式与算术两部分归因。

发布于 2026/9/11更新于 2026/9/11阅读约 28 分钟
只问这篇文章 →

第十七周有两大块:强化学习(RL 基础 → RLHF → DPO → GRPO → DeepSeek-R1)和分布式训练(DP / TP / PP、ZeRO)。

作业是用强化学习提升小模型做数学题的能力。我用 Qwen2-0.5B-Instruct + GRPO,在自己的 4060 Ti 上跑了 200 步、3.1 分钟、峰值显存 5.58 GB。

结果是总正确率从 50.8% 涨到 83.8%。但这周真正学到的东西,是怎么把这个涨幅拆开归因——因为不拆开的话,我会把「学会了答题格式」包装成「学会了算术」。


一、RL 的核心概念

优势函数:为什么要减掉基线

A(s,a) = Q(s,a) − V(s)     这一步比局面的平均水平好多少

没有基线时,赢了棋这一局所有走法(包括臭棋)都会被强化。 有了基线,只有超出平均水平的走法才被强化——梯度方差大幅降低。

这一条是后面 GRPO 的直接铺垫。

探索与利用

只「利用」  总走已知最优定式,容易陷入局部最优
只「探索」  随机尝试,永远学不到有效棋感

后面在我自己的实验里,熵坍缩 12 倍就是「探索」这一侧塌掉的实测样子。


二、从 PPO 到 GRPO

RLHF 三阶段与 PPO 的工程痛点

PPO 要同时加载 4 份模型:Actor(更新)、Critic(更新)、Reward Model(冻结)、Reference(冻结)。

三个具体问题:

  • Critic 一样大 —— 70B 模型训练需要 280B+ 参数的显存
  • Critic 训练不稳定 —— 价值估计偏差会累积并传播到策略梯度
  • 工程复杂度高 —— 4 个模型的学习率、更新频率都要单独调

核心问题:能不能去掉 Critic,同时保持训练稳定?

DPO:绕过 RL

把 RM + PPO 合成一个监督学习目标,只需 2 个模型。简单稳定,但只能离线学习。

GRPO:用组内平均代替 Critic

同一个 Prompt 采样 G 条回答,每条拿到奖励 r₁…r_G
advantage_i = (r_i − mean(r)) / std(r)

基线不是学出来的,是同组另外 G−1 条样本给的。

代价是一个很硬的约束——组内 G 条如果奖励全一样,std = 0,advantage 全是 0,这一步完全没有梯度。这条约束后面在我的实验里实实在在地生效了。

DeepSeek-R1-Zero 的 Aha Moment

基础模型是未经 SFT 的 V3-Base,纯 GRPO + 可验证奖励,完全不用人工标注的推理示范数据。

结果模型自发在 think 块里写出:

让我数一数…等等,让我重新数一遍。1×21=21,对,是 21

没有人教模型怎么反思。 在纯 RL 训练过程中,模型自发产生了「等等,让我重新想想」的行为,推理链长度也随训练自然增长。

为什么选纯 RL 而不是 SFT 热启动,课上用了 AlphaZero 类比:

AlphaZero 不依赖人类棋谱,发现了人类从未使用过的走法。 SFT-first 会被人类示范「锚定」,难以发现更短更优的解法。

但 R1 正式版的工作流又说明 RL 与 SFT 相辅相成:R1-Zero 纯 RL → 产出 CoT 数据 → SFT 冷启动 → 再 GRPO。SFT 提供可读性,GRPO 提供推理深度。


三、分布式训练:显存账

先把账算清楚。GPT-3 175B + Adam + BF16:

模型参数     350 GB    175B × 2 字节
梯度        350 GB    与参数等大
优化器状态  2100 GB    FP32 参数副本 + 动量 + 方差,每项 4 字节
─────────────────────
合计       约 2800 GB(还不含激活)

占比:优化器状态 75%、参数 13%、梯度 13%——所以优化的第一刀就砍向优化器状态。

ZeRO 三个 Stage

ZeRO-1  分片优化器状态           节省约 75%
ZeRO-2  再分片梯度               节省约 87%
ZeRO-3  参数也切,用时 AllGather  节省约 97%

64 卡、175B 的每卡占用:2800 GB → 700 → 350 → 29 GB。

ZeRO 的本质:把「每卡都存一份」变成「每卡存 1/N,需要时再聚」——用通信换显存。

三种并行的分工

DP 数据并行TP 张量并行PP 流水线并行
切什么mini-batch权重矩阵维度模型层数
通信频率每步 1 次每层 2 次仅在 stage 边界
显存效果不减单卡显存减到 1/TP减到 1/PP
适用带宽任意需 NVLink低带宽可用

FFN 的张量并行有个很漂亮的设计:Linear₁ 列并行 → GeLU 本地做(零通信)→ Linear₂ 行并行 AllReduce,整个 FFN 前向只通信 1 次。

TP 的局限很实在:GPT-3 96 层算下来总通信量约 175TB,所以 TP 限制在单节点内(NVLink 域),跨节点扩展交给 PP 或 DP。

PP 的核心挑战是 Bubble:p 个 stage 时朴素流水线的 bubble 占比 = (p−1)/p,4 卡时利用率只有 25%。GPipe 用 micro-batch 把它压到 (p−1)/(m+p−1)。


四、作业:先把问题拆成两个

题目是「用 RL 提升模型做数学题的能力」。我拆成两个可以分别测量的问题:

  1. RL 能不能让模型算得更对
  2. RL 学到的是「算术能力」还是「答题格式」

这两件事很容易混在一起,如果不拆开,正确率涨了也说不清是因为什么。

所以奖励函数从一开始就设计成两个独立的函数:

函数满分判据
reward_correct1.0答案对(宽松口径:取输出里最后一个数字)
reward_format0.2输出里有 <answer>数字</answer>

指标也记三套口径:

fmt_rate      有没有按格式答        只看格式
strict_rate   标签里的数字对不对    格式 + 正确
loose_rate    最后一个数字对不对    只看正确,不管格式

后面会看到,这个拆分是这次实验里最有用的一个决定。

题目分级:训练集配比不是随手定的

六个难度全部程序生成,训练只用 L2/L3/L5,L1/L4/L6 一道都不训,留出来测泛化。

配比依据就是 GRPO 那条约束——训练集要放模型「会一点但不稳」的档位:

训练前摸底的 informative_group_rate(组内 8 条有对有错的比例):
L1  47.5%   ← 太简单,组内常常全对
L6   7.5%   ← 太难,组内几乎全错
L2/L3/L5  85% / 82.5% / 77.5%   ← 梯度最密

L1 和 L6 放进训练集,大半步数都是白跑的。


五、主结果

难度                   fmt_rate          strict_rate         loose_rate
L1_add_1digit      0.0%  → 100.0%     0.0%  → 100.0%    100.0% → 100.0%
L2_addsub_2digit   7.5%  → 100.0%     7.5%  →  97.5%     55.0% →  97.5%
L3_addsub_3digit   7.5%  → 100.0%     7.5%  →  90.0%     47.5% →  90.0%
L4_mul_1digit      0.0%  → 100.0%     0.0%  →  97.5%     65.0% →  97.5%
L5_mul_2x1digit    0.0%  → 100.0%     0.0%  →  97.5%     35.0% →  97.5%
L6_mul_2x2digit    2.5%  → 100.0%     0.0%  →  20.0%      2.5% →  20.0%
────────────────────────────────────────────────────────────────────
总计(240 题)     2.9%  → 100.0%     2.5%  →  83.8%     50.8% →  83.8%
                        +97.1pt            +81.2pt            +32.9pt

三个口径涨幅差这么多,说明的是不同的事:

  • fmt_rate +97.1pt —— 格式几乎是从零学到满分
  • loose_rate +32.9pt —— 这才是「算术能力」的真实提升
  • strict_rate +81.2pt —— 数字最好看,但它是前两者的乘积效应

训练前那句「答对了但没按格式写」

9 + 2 = 11    训练前输出:11
              训练后输出:<answer>11</answer>

基线 loose_rate 50.8% 而 strict_rate 只有 2.5%。

如果这次只统计 strict_rate,会得出「基座模型基本不会算术」的结论,这是错的——它会算,只是不按要求的格式写。

L6 是个反例,而且很有用

L6(两位数×两位数)训练后 fmt_rate = 100%,strict_rate 只有 20%。

格式学满了,算术没学会。 这一栏单独证明了两个奖励确实解耦:格式分能独立收敛到满分,不需要算对。

RL 能矫正行为,不能凭空创造能力。


六、训练动态:正确分先收敛,格式分后收敛

区间           正确分    格式分     补全长度      熵     零方差组比例
                (1.0)    (0.2)
1–45 步        0.756    0.028      4.96     0.253      0.29
50–100 步      0.850    0.114      7.39     0.153      0.21
105–150 步     0.877    0.190      9.58     0.064      0.52
155–200 步     0.886    0.196      9.80     0.049      0.61

顺序和我预期的相反

我写代码时在注释里预期的是「格式先收敛、正确率后爬坡」——格式简单,应该先学会。实测正好反过来。

  • 正确分 在前 20 步就从 0.425 冲到 0.89,之后 180 步只再涨 0.03
  • 格式分 前 45 步几乎不动,50 步之后才开始爬,是一条明显的 S 形曲线

原因是奖励尺度:正确分满分 1.0,格式分只有 0.2。advantage 是在两项之和上算的,早期组内答案对错分明,正确分的差异就有 1.0 的量级,格式分那 0.2 完全被盖住。等到正确率涨到 0.85 以上、正确分不再有区分度,格式分才成为唯一还能拉开差距的信号。

复合奖励里,各项的收敛顺序由权重决定,不由难度决定。

补全长度是格式收敛的独立佐证

completions/mean_length 从 4.5 涨到 9.9,涨的区间(50–120 步)和格式分的 S 形上升段完全重合。多出来的 5.4 个 token 就是 <answer> </answer> 这对标签。

这条曲线的价值在于:它不是奖励函数直接优化的对象,却精确复现了格式分的轨迹。 两个来源不同的信号指向同一件事,比单看奖励曲线可信得多。

训练信号在后半程枯竭

零方差组比例   0.29 → 0.61       组内 8 条奖励全一样,advantage 恒为 0
熵             0.253 → 0.049     采样几乎退化成贪心,12 倍的坍缩
loss           −0.069 → +0.0017  梯度贡献趋近于 0

到最后 45 步,六成的组已经产生不了任何梯度。第 160 步还出现过一次 grad_norm = 0——整批四个组全部退化。

这一轮的有效训练在 200 步之前就基本结束了。 继续堆步数不会有收益,要接着提升只能换供给(提难度 / 提温度)。这不是「训练不充分」,是「这批题目已经榨干了」。

epsilon 全程没起过作用

clip_ratio 的五个字段在 41 条日志里全部恒为 0。

因为每批采样只做一次梯度更新,是严格 on-policy 的,重要性采样比恒等于 1,永远落在 [1−ε, 1+ε] 里。

PPO 的裁剪机制在这个配置下是挂着但没生效的。


七、泛化:格式完全迁移,算术能力部分迁移

                  strict_rate                  loose_rate
              训练前   训练后   变化       训练前   训练后   变化
训练过 L2/L3/L5   5.0%   95.0%  +90.0pt     45.8%   95.0%  +49.2pt
没训过 L1/L4/L6   0.0%   72.5%  +72.5pt     55.8%   72.5%  +16.7pt
  • strict_rate 上没训过的涨了 72.5pt——这主要是格式迁移。格式是题目无关的行为,一旦学会就无差别作用在所有难度上。
  • loose_rate 上没训过的只涨了 16.7pt,训练过的涨了 49.2pt——去掉格式因素后,算术能力的迁移明显弱于训练过的难度。

只看 strict_rate 会把格式的功劳记到算术能力头上。


八、几个必须显式覆盖的配置

问题后果
Qwen2-0.5B 的 config 写着 torch_dtype=float16AdamW 的 eps=1e-8 在 fp16 下下溢成 0,除零得 NaN,一步训废
transformers 5.x 下开 gradient checkpointinggenerate 前向输出损坏,采样全是乱码、奖励恒 0
trl 0.21 用了 transformers 的私有函数5.x 改成返回元组,非空元组恒为真 → import 直接崩

这三个都属于「不显式覆盖就静默出错」的类型,光看报错信息定位不到。

一个输出「很合理的错话」的 bug

冒烟测试时模型完全不写标签,strict_rate 前后都是 0。汇总脚本默认拿 strict_rate 算泛化,于是打出:

→ 训练过的都没怎么涨,先看训练曲线是不是根本没学起来

结论是反的——同一次跑里 loose_rate 涨了 16.7pt,训练明明学起来了。四个数全是 0 说明的是「这个指标失效」,不是「模型没学到东西」。

这类 bug 最麻烦的地方在于它输出的是一句读起来很合理的话。如果当时直接跑全量、没做冒烟,这句话很可能就被抄进报告了。

采样只采到了 L1

原来的写法是「前 12 条各存一份」。评测集按难度顺序生成,前 12 条全落在 L1 里——想看 L6 到底错成什么样,一条样本都没有,而最难那一级的失败长相恰恰是最想看的。


这周的收获

1. 涨幅必须拆开归因。

strict_rate 从 2.5% 涨到 83.8% 很好看,可以直接写简历。但拆开看:格式贡献 +97.1pt,算术能力贡献 +32.9pt。不拆开就是把「学会了答题格式」包装成「学会了算术」。

2. 复合奖励的收敛顺序由权重决定。

格式(0.2 分)比正确(1.0 分)简单得多,却晚了 50 步才开始学——因为早期它的信号被完全盖住了。这跟我的直觉完全相反。

3. 有效训练会自己结束。

零方差组比例 0.29 → 0.61、熵坍缩 12 倍——GRPO 那条「std=0 就没梯度」的约束,从纸面变成了曲线。学一个算法的公式,和看到它在自己的训练日志里生效,是两种不同的理解。

4. 找一个不受优化的独立信号来交叉验证。

补全长度不是奖励函数优化的对象,却精确复现了格式分的轨迹。两个来源不同的信号指向同一件事,比单看奖励曲线可信得多。

下周是最后一周——复习与求职。

相关文章