RLHF Book · 大模型后训练  /  Nathan Lambert
CHAPTER 06

强化学习:策略梯度方法

把「这条回复好不好」这一个标量,变成模型几十亿个参数上的一次梯度更新——以及为了让这件事在语言模型上真跑得起来,人们发明的全部技巧。

原章节:06-policy-gradients.md 对应讲座:lec3 + lec4(第 6 章上/下) 英文原文

0. 本章导读

到目前为止,后训练流水线上的每一步都还是监督学习:SFT 是在给定的回复上做交叉熵,奖励模型是在给定的偏好对上做 Bradley-Terry 分类。它们都有一个共同前提——你得先有正确答案。而 RLHF 真正的分水岭在这一章:模型自己生成回复,某个打分器给一个标量,然后我们要用这个标量去更新参数。没有「正确答案」,只有「刚才那次做得比平均好还是差」。

这件事在数学上并不平凡。SFT 的梯度是现成的:损失对 logits 求导,链式法则一路回传。但强化学习的目标函数是

$$ J(\theta) = \E_{\tau \sim p_\theta}\big[R(\tau)\big] $$

——期望本身是对一个依赖 $\theta$ 的分布取的。你要优化的参数同时决定了采样分布,所以不能简单地「对被积函数求导」。整个策略梯度理论就是为了绕开这一点:用一个叫 log-derivative trick 的恒等式,把「对分布的梯度」变成「可以从采样中估计的期望」。这一步一旦跨过去,剩下的全部工作就变成一件事:这个估计的方差太大了,怎么把它压下来。

本章讲的所有算法——REINFORCE、RLOO、PPO、GRPO、Dr. GRPO、GSPO、CISPO、DAPO、SAPO——都是同一个梯度形式的不同实例化:

$$ \Delta \theta \;\propto\; \Psi_t \, \nabla_\theta \log \pi_\theta(a_t \mid s_t) $$

右边这个 $\nabla_\theta \log \pi_\theta$ 回答「哪些参数让这个 token 更可能被生成」,左边这个标量 $\Psi_t$ 回答「刚才那次到底好不好」。差别只在于:$\Psi_t$ 怎么估(蒙特卡洛回报、学出来的价值函数、组内 z-score),以及更新怎么被约束住(不裁剪、裁目标函数、裁重要性权重、软门控)。把这两条轴认清楚,这一章的三十来个缩写就塌缩成一张表。

本章在全书中的位置:第 4 章的 SFT 模型是这里的初始策略 $\pi_{\text{init}}$ 和参考策略 $\pi_{\text{ref}}$,第 5 章的奖励模型提供 $r(x,y)$,而第 7 章的推理模型训练(RLVR)用的正是本章一模一样的算法,只是把奖励模型换成了一个验证函数。换句话说,这一章是后训练领域的技术心脏:从 InstructGPT 到 DeepSeek-R1,中间变的是数据和奖励,不变的是这里的损失函数。

核心结论
  • 策略梯度定理的全部内容是一个恒等式:$\nabla_\theta p_\theta(\tau) = p_\theta(\tau)\nabla_\theta \log p_\theta(\tau)$。它把不可采样的 $\nabla_\theta p_\theta$ 变成可采样的期望,这就是为什么代码里永远只出现 log_probs。
  • 环境转移概率 $p(s_{t+1}\mid s_t,a_t)$ 和初始分布 $d_0$ 都不含 $\theta$,求导后消失。策略梯度不需要环境模型——这是它能用在语言模型上的根本原因。
  • baseline $b(s)$ 只要不依赖被采样的动作,减掉它不改变期望梯度(因为 $\sum_a \nabla_\theta \pi_\theta(a\mid s) = \nabla_\theta 1 = 0$),但能大幅降低方差。REINFORCE→RLOO→GRPO 的演进本质上就是 baseline 越来越聪明。
  • PPO 的 clip 是单边生效的:只在策略「已经朝正确方向走过头」时才截断梯度,从不阻止纠错方向的更新。在信任域内部,PPO 和普通策略梯度完全一样。
  • GRPO 用「同一个 prompt 采 $G$ 条回复,组内 z-score」替代了学出来的价值函数,省掉一整个模型副本;代价是 std 归一化引入了对低方差问题的偏好偏置——这正是 Dr. GRPO 要修的。
  • Lambert 的判断:在多数 RLHF 场景里,数据质量和奖励信号质量占主导,算法选择主要决定的是稳定性、效率和工程负担,而不是最终效果的上限。
  • 实现细节比算法差异更容易毁掉一次训练:损失聚合方式(per-sequence / per-token / fixed-length)会直接改变长度偏置;掩码、EOS 处理、stale logprob 这类静默 bug 不会报错,只会让你训出一个错误的模型。
  • 典型超参量级:clip $\varepsilon = 0.1$–$0.2$,GAE $\lambda = 0.95$,KL 系数 $\beta = 0.01$–$0.1$(RLVR 常直接设 0),学习率 $1\times10^{-6}$–$5\times10^{-6}$(比 SFT 低一个数量级),batch 256–1024 条 prompt,每批 1–4 次梯度步。

1. 问题设定:把语言模型看成一个 MDP

RLHF 训练循环里到底有几个模型

先把整个循环摆出来,后面所有公式都挂在这张图上。

RLHF 训练循环:prompt → 策略生成 completion → 奖励模型打分 → 参考模型算 KL → 策略梯度更新
注意这张图里有三个模型在跑:被训练的策略 $\pi_\theta$(唯一有梯度的)、冻结的参考模型 $\pi_{\text{ref}}$(只用来算 KL 惩罚)、冻结的奖励模型 $r_\psi$(只用来打分)。PPO 还要再加第四个——学出来的价值函数 $V_\phi$。图中「combined reward」这一步很关键:奖励模型给的是序列级的一个标量,而 KL 惩罚是逐 token 的,两者要先合并成 per-token 的塑形奖励,才能喂进后面的优势估计。
模型作用是否更新7B / fp16 显存
策略 $\pi_\theta$生成 completion,被优化的对象是~14 GB
价值函数 $V_\phi$(仅 PPO)预测每个 token 位置的未来回报,当 baseline是~14 GB
参考策略 $\pi_{\text{ref}}$KL 惩罚的锚点,通常就是 RL 开始前的 SFT checkpoint冻结~14 GB
奖励模型 $r_\psi$给 completion 打分(RLVR 里换成验证函数,不占显存)冻结~14 GB

四个模型加起来光权重就 56 GB,还没算优化器状态、激活值和梯度。这是一个纯工程数字,但它解释了这一章后半段的全部动机:GRPO 之所以流行,很大一部分原因就是它砍掉了价值函数那一行;RLVR 之所以常常连 KL 都不要,是因为砍掉参考模型又能再省 14 GB。算法的演化史在很大程度上是一部显存节约史。

两个「旧策略」,千万别混

初学者最常见的混淆是把 $\pi_{\theta_{\text{old}}}$ 和 $\pi_{\text{ref}}$ 当成一回事。它们完全不同:

$\pi_{\theta_{\text{old}}}$$\pi_{\text{ref}}$
是什么上一次 rollout 时的策略RL 开始时的策略(SFT checkpoint)
更新频率每个 batch(或每 $K$ 步)永不更新
用来干嘛算重要性采样比 $\rho_t$算 KL 惩罚
如果不要它每批只能走 1 次梯度步(严格 on-policy)有奖励攻击(reward hacking)的风险

还有一个实现层面的细节:很多代码里的「old logprobs」并不是一个单独的模型副本,而只是生成那批数据时顺手算出来并缓存下来的一组 logprob 张量。模型只有一份权重,它在那之后被更新了而已。这个区别在后面讲异步训练和截断重要性采样时会变得非常重要。

语言模型作为 episodic MDP

要用 RL 的工具,先得把语言生成翻译成 RL 的语言:

MDP 概念语言模型里对应什么
状态 $s_t$prompt + 已生成的 token:$(x, y_{<t})$
动作 $a_t$下一个 token $y_t$(动作空间就是词表,$|\mathcal{A}| = |V| \approx 10^5$)
转移 $p(s_{t+1}\mid s_t,a_t)$确定性:把 token 拼到序列末尾,概率为 1
策略 $\pi_\theta(a_t\mid s_t)$模型在该位置的 next-token 分布(softmax 后的那个长度 $|V|$ 的向量)
episode一个 prompt → 一条完整 completion
终止奖励奖励模型分数或验证器输出,打在 EOS 那个位置
折扣因子 $\gamma$几乎总是 1.0(不折扣)

「转移是确定性的」这一条比看起来重要。经典 RL 里方差的一大来源是环境随机性——同一个动作可能导致完全不同的后续状态。语言模型里没有这回事:唯一的随机性来自采样本身(temperature > 0 时从分布里抽 token)。这让很多经典 RL 的推导在这里退化成更简单的形式,比如贝尔曼方程从 $Q^\pi(s_t,a_t)=\E[r_t + \gamma V^\pi(s_{t+1})]$ 直接变成 $Q(s_t,a_t) = r_t + \gamma V(s_{t+1})$,不用取期望。

直觉:为什么 $\gamma = 1$ 经典 RL 里折扣是必需的:agent 要在「马上拿分」和「以后拿更多分」之间权衡,$\gamma$ 就是这个权衡的旋钮。但 RLHF 里优化的单位是整条 completion 的质量——奖励打的是整个回复,不是单个 token。如果对早期 token 打折扣,等于毫无理由地说「回复开头的那几个词不太重要」,这没有任何依据。所以哪怕在 token 级的 MDP 视角下,$\gamma$ 也设成 1.0。

唯一可能变的场景是 agentic RL:当模型真的在做多步工具调用、代码执行、网页浏览时,那些是真正意义上不同的顺序决策,长期后果确实不同,折扣可能重新有意义。这是一个开放的方向。

符号约定

本章会在两套记号之间来回切换,这是原书刻意保留的,因为文献里两套都有:

  • $(s, a)$ 记号:来自 RL 文献,状态-动作。更一般,反映的是逐 token 的梯度计算。
  • $(x, y)$ 记号:prompt-completion。反映的是序列级的奖励——很多 RLHF 实现把整条 completion 当成一个动作。

另外约定:$r_t$ 是每步奖励,$R(\tau)$ 是整条轨迹回报,$\rho_t$ 专门留给重要性采样比(不用 $r$,避免和 reward 混),$G$ 有两个含义要靠上下文区分——$G_t$ 是回报(return),$G$ 单独出现时是 GRPO 的组大小。$|a_i|$ 是第 $i$ 条 completion 的 token 数。

一句话预告:本章要讲的那个式子

$$ \Delta \theta \;\propto\; \Psi_t \, \nabla_\theta \log \pi_\theta(a_t \mid s_t) $$

拆开看是两个问题同时被回答:

  1. $\nabla_\theta \log \pi_\theta(a_t \mid s_t)$ —— 是哪个动作?参数空间里哪个方向会让动作 $a_t$ 更可能发生。这是一个和参数量等长的向量:某一维为正,意思是「调大这个参数会让这个 token 更可能被生成」。
  2. $\Psi_t$ —— 这次做得怎么样?一个标量。正数表示好,负数表示差,绝对值表示程度。

乘起来就是更新方向:$\Psi_t > 0$ 把参数往「让 $a_t$ 更可能」推,$\Psi_t < 0$ 往反方向推。真实的更新是在一整个 batch 上平均掉这些向量,最后活下来的是全 batch 的净投票。这一章剩下的全部内容,就是在讨论怎么挑一个聪明的 $\Psi_t$,以及怎么在挑好之后不让训练炸掉。

2. 策略梯度定理:从不可导到可采样

目标函数与它的麻烦

RL 要最大化的是期望回报。先定义回报本身——从时刻 $t$ 开始的折扣奖励之和:

$$ G_t = r_t + \gamma r_{t+1} + \cdots = \sum_{k=0}^{\infty} \gamma^k r_{t+k} $$

它有一个递归形式,后面推 GAE 时会反复用到:$G_t = r_t + \gamma G_{t+1}$。价值函数就是回报的条件期望:$V(s) = \E[G_t \mid S_t = s]$。

整个优化目标写成对轨迹分布的期望:

$$ J(\theta) = \E_{\tau \sim p_\theta}\big[R(\tau)\big] = \int_\tau p_\theta(\tau)\, R(\tau)\, \mathrm{d}\tau $$

其中 $\tau = (s_0, a_0, s_1, a_1, \ldots)$ 是一条轨迹,$p_\theta(\tau)$ 是由策略 $\pi_\theta$ 和环境动力学共同诱导的轨迹分布。参数更新的形式很朴素:$\theta \leftarrow \theta + \alpha \nabla_\theta J(\theta)$。

问题出在求梯度这一步。直接对积分求导:

$$ \nabla_\theta J(\theta) = \int_\tau \nabla_\theta p_\theta(\tau)\, R(\tau)\, \mathrm{d}\tau $$

这个式子在数学上没错,但在计算上没用。我们能从 $p_\theta(\tau)$ 里采样(那就是跑一遍生成),但 $\nabla_\theta p_\theta(\tau)$ 不是一个概率分布,你没法从它里面采样。这就是整个推导要绕过的障碍。

推导:log-derivative trick

唯一需要的工具是对数的链式法则:

$$ \nabla_\theta \log p_\theta(\tau) = \frac{\nabla_\theta p_\theta(\tau)}{p_\theta(\tau)} \quad\Longrightarrow\quad \nabla_\theta p_\theta(\tau) = p_\theta(\tau)\, \nabla_\theta \log p_\theta(\tau) $$

把它代回去:

$$ \begin{aligned} \nabla_\theta J(\theta) &= \int_\tau \nabla_\theta p_\theta(\tau)\, R(\tau)\, \mathrm{d}\tau && \text{对目标求导} \\ &= \int_\tau p_\theta(\tau)\, R(\tau)\, \nabla_\theta \log p_\theta(\tau)\, \mathrm{d}\tau && \text{log-derivative 恒等式} \\ &= \E_{\tau \sim p_\theta}\big[R(\tau)\, \nabla_\theta \log p_\theta(\tau)\big] && \text{期望的定义} \end{aligned} $$

最后一步用的是期望的定义:对任意函数 $f$,$\E_{\tau\sim p_\theta}[f(\tau)] = \int_\tau f(\tau)p_\theta(\tau)\mathrm{d}\tau$。

这一步的意义值得停下来品一下。$p_\theta(\tau)$ 这个因子并没有消失,它变成了期望的采样分布。于是在代码里你永远看不到显式的 $p_\theta(\tau_i)$ 这一项——因为更可能的轨迹本来就会在采样出来的 batch 里出现得更频繁。分工是这样的:

  • rollout 阶段负责 $\tau_i \sim p_\theta$ 这一半(也就是调 model.generate());
  • loss 代码负责 $R(\tau_i)\nabla_\theta \log p_\theta(\tau_i)$ 这一半;
  • 蒙特卡洛估计把期望换成平均:$\E_{\tau\sim p_\theta}[f(\tau)] \approx \frac{1}{N}\sum_{i=1}^{N} f(\tau_i)$。

展开轨迹的对数概率:环境模型消失了

轨迹概率按马尔可夫性质因子分解:

$$ p_\theta(\tau) = d_0(s_0) \prod_{t=0}^{\infty} \pi_\theta(a_t\mid s_t)\, p(s_{t+1}\mid s_t, a_t) $$

取对数,乘积变求和:

$$ \log p_\theta(\tau) = \log d_0(s_0) + \sum_{t=0}^{\infty} \log \pi_\theta(a_t\mid s_t) + \sum_{t=0}^{\infty} \log p(s_{t+1}\mid s_t, a_t) $$

然后对 $\theta$ 求梯度,逐项检查:

  • $\nabla_\theta \log d_0(s_0) = 0$ —— 初始状态分布(也就是 prompt 从哪个数据集来)跟策略参数无关;
  • $\nabla_\theta \log p(s_{t+1}\mid s_t,a_t) = 0$ —— 环境转移动力学跟策略参数无关;
  • 只有 $\nabla_\theta \log \pi_\theta(a_t\mid s_t)$ 活下来。
$$ \nabla_\theta \log p_\theta(\tau) = \sum_{t=0}^{\infty} \nabla_\theta \log \pi_\theta(a_t\mid s_t) $$
核心结论:为什么策略梯度是 model-free 的 中间那条「环境动力学求导为 0」是策略梯度方法的立身之本。它意味着你完全不需要知道环境怎么运作——不需要转移模型,不需要仿真器可微。你只需要能采样,以及能对自己策略的 log-prob 求导。对语言模型来说这更彻底:转移是「拼接 token」这个确定性操作,本来就没有参数。所以整个策略梯度在 LM 上塌缩成一件极其熟悉的事——对一堆 token 的 log-prob 加权求和,然后 backward()。

这就是为什么下面这三行会在本章反复出现(几乎每个实现里都有它的变体):

seq_log_probs = (token_log_probs * completion_mask).sum(dim=-1)
loss = -(seq_log_probs * advantages).mean()
loss.backward()   # autodiff 自动给出 ∑ Ψ_t ∇ log π

你写的是 log-prob 的加权和,autodiff 替你算出了策略梯度。这两者之所以等价,全靠上面那条推导。

合起来:策略梯度定理

把这个结果代回期望里:

$$ \nabla_\theta J(\theta) = \E_{\tau \sim p_\theta}\left[\sum_{t=0}^{\infty} R(\tau)\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

更常见的写法把权重项抽象成 $\Psi_t$:

$$ g = \nabla_\theta J(\theta) = \E_{\tau \sim p_\theta}\left[\sum_{t=0}^{\infty} \Psi_t\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

这就是策略梯度定理。本章后面每一个算法,都是在这个式子里填一个具体的 $\Psi_t$,再加上一层对更新幅度的约束。注意这个期望的三个要素:期望对轨迹取、轨迹从当前策略 $p_\theta$ 采样(这就是 on-policy 的含义)、梯度只对策略参数 $\theta$ 求。三者中任何一个被破坏,估计就有偏——后面讲重要性采样和异步训练时,破坏的正是第二条。

实践中的一个小修正:return-to-go

上面式子里,每个时刻 $t$ 的 log-prob 都被整条轨迹的回报 $R(\tau)$ 加权。但 $t$ 时刻的动作显然影响不了 $t$ 之前已经拿到的奖励。所以可以把 $R(\tau)$ 换成 return-to-go $G_t = \sum_{t'=t}^{T} r_{t'}$:

$$ \nabla_\theta J(\theta) = \E_{\tau \sim p_\theta}\left[\sum_{t=0}^{T} G_t\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

这不改变期望梯度(被去掉的那部分与当前动作独立,期望为 0),但把「当前动作根本无法影响的历史奖励」这部分噪声剔除了。这是方差削减的第一招,也是从 $\Psi_t$ 选项 1 到选项 2 的那一步。

3. 方差从哪来,baseline 为什么免费

朴素策略梯度的病

直接用 $\Psi_t = G_t$ 的版本叫 vanilla policy gradient(朴素策略梯度):

$$ \nabla_\theta J(\theta) = \E_{\tau \sim p_\theta}\left[\sum_{t=0}^{T} G_t\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

它是无偏的——期望上完全正确。问题是方差极大,大到实际上几乎不能用。方差的来源有三层:

  1. 蒙特卡洛估计本身:我们用有限条 rollout(RLHF 里常常一个 prompt 只有 4–16 条)去估一个期望。样本少,估计就抖。
  2. 采样随机性:temperature > 0 时,同一个 prompt 同一个模型,两次生成可能天差地别。
  3. 稀疏奖励:这一条在 RLVR 里尤其致命。如果奖励非 0 即 1(答对/答错),那么样本的回报不是聚在一起的,而是分裂成两个极端。分布越两极,方差越大。

还有一个更微妙的问题:原始回报把「动作的好坏」和「状态的好坏」混在一起了。一个坏动作在简单题上可能仍然得高分,一个好动作在难题上可能仍然得低分。用 $G_t$ 加权,模型收到的信号是「这条轨迹总体怎么样」,而不是「这个动作相对于该状态下的平均水平怎么样」。后者才是我们真正想要的。

baseline:不改期望,只砍方差

解决办法是减掉一个 baseline(基线)$b(s_t)$,把信号居中:

$$ \Psi_t = G_t - b(s_t) $$

关键约束是:$b$ 只能依赖状态,不能依赖被采样的那个动作。只要满足这一条,减掉它就不引入偏差。

推导:为什么 baseline 不引入偏差

把期望拆成两项:

$$ \E\big[(G_t - b(s))\nabla_\theta \log \pi_\theta(a\mid s)\big] = \E\big[G_t \nabla_\theta \log \pi_\theta(a\mid s)\big] - \E\big[b(s)\nabla_\theta \log \pi_\theta(a\mid s)\big] $$

第一项就是原来的估计量。第二项恒为零:

$$ \begin{aligned} \E_{a\sim\pi_\theta(\cdot\mid s)}\big[b(s)\nabla_\theta \log \pi_\theta(a\mid s)\big] &= b(s)\sum_a \pi_\theta(a\mid s)\,\frac{\nabla_\theta \pi_\theta(a\mid s)}{\pi_\theta(a\mid s)} \\ &= b(s)\sum_a \nabla_\theta \pi_\theta(a\mid s) \\ &= b(s)\, \nabla_\theta \underbrace{\sum_a \pi_\theta(a\mid s)}_{=\,1} \\ &= b(s)\cdot \nabla_\theta 1 = 0 \end{aligned} $$

第一步把 $b(s)$ 提出来(它不依赖 $a$,这是唯一用到的假设),然后用 log-derivative trick 反向展开,再交换求和与求导,最后利用归一化概率分布的梯度和为零。

这个证明短得像个魔术,但结论极强:你可以随便减掉任何一个状态的函数,期望梯度纹丝不动,方差却能塌下去一大截。这就是为什么后面所有算法都在琢磨「怎么构造一个更好的 baseline」——它是免费的午餐。

常见误区 「baseline 不影响期望」不等于「baseline 随便选都行」。
第一,$b$ 必须与采样出的动作独立。RLOO 之所以要「留一」,正是为了保证第 $k$ 条样本的 baseline 里不含它自己的奖励——如果用包含自己的组均值,估计量严格来说就有偏了(虽然实践中偏差随组大小 $1/G$ 衰减,常被忽略)。
第二,除以标准差不是 baseline。减均值是 baseline(不引入偏差),除以 std 是对不同 prompt 重新加权,它改变了各个 prompt 对总梯度的相对贡献——这是 GRPO 的一个真实偏置,第 7 节会详细算。

$\Psi_t$ 的六种选择

Schulman 等人在 GAE 论文里给出的分类,现在已经是标准参考:

#$\Psi_t$说明方差偏差
1$R(\tau) = \sum_{t=0}^{T} r_t$整条轨迹的总奖励最高无
2$\sum_{t'=t}^{T} r_{t'} = G_t$从 $t$ 起的 return-to-go高无
3$G_t - b(s_t)$减了基线的回报较低无
4$Q^\pi(s_t,a_t)$状态-动作价值函数中取决于估计质量
5$A^\pi(s_t,a_t) = Q - V$优势函数最低(若 $V$ 准)无
6$r_t + \gamma V^\pi(s_{t+1}) - V^\pi(s_t)$时序差分(TD)残差低有($V$ 不准时)

第 5 行是理论上的最优点:如果能准确计算优势函数,就能得到理论上可能的最低方差。所以本章大部分算法都建立在优势形式的策略梯度上:

$$ \nabla_\theta J(\theta) = \E_{\tau \sim p_\theta}\left[\sum_{t=0}^{T} A^{\pi_\theta}(s_t, a_t)\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

优势函数的三种等价写法

优势的定义是「这个动作比该状态下的平均水平好多少」:

$$ A(s_t, a_t) = Q(s_t, a_t) - V(s_t) $$

对随机策略,$V^\pi(s_t) = \E_{a_t\sim\pi(\cdot\mid s_t)}[Q^\pi(s_t,a_t)]$——所以优势天然是「以 $V$ 为零点的 $Q$」,在动作上求期望恒为 0。这个性质很有用:健康的训练里 advantage 的均值应该在 0 附近,这是调试时第一个要看的指标。

对语言模型这种确定性转移的环境,贝尔曼方程简化为 $Q(s_t,a_t) = r_t + \gamma V(s_{t+1})$,于是:

$$ A(s_t, a_t) = r_t + \gamma V(s_{t+1}) - V(s_t) $$

这正好就是表格里的第 6 行 TD 残差。也就是说,用一个学出来的 $\hat V$ 去估优势,最自然的形式就是 TD 误差。这条式子同时是价值函数的训练信号(它衡量预测和实际的差距)和策略的更新权重——PPO 的两个损失就是从这里分叉出去的。

直觉:三个量的分工 $V(s)$ 回答「在这个状态下我预期能拿多少分」;$Q(s,a)$ 回答「在这个状态下选了这个动作我预期能拿多少分」;$A(s,a) = Q - V$ 回答「这个选择比默认预期好多少」。
翻译成 RLHF 的语言:$V$ 是「这道题一般能得几分」,$Q$ 是「这条回复能得几分」,$A$ 是「这条回复相对这道题的平均水平好多少」。后面 GRPO 的整个设计就是:既然我可以对同一个 prompt 采 8 条回复,那我直接用这 8 条的均值当 $V$,根本不用学一个模型来估它。

4. REINFORCE 与 RLOO:最简单的那一族

REINFORCE 这个名字

REINFORCE 大概率是个硬凑的缩写(backronym),但它拆开之后恰好把策略梯度的三个部件说清楚了。Williams 1992 年的原文写道:

The name is an acronym for "REward Increment = Nonnegative Factor × Offset Reinforcement × Characteristic Eligibility."

三个部件对应到现代记号:

  1. Nonnegative Factor(非负因子):学习率 $\alpha$,必须是正数。
  2. Offset Reinforcement(偏置后的强化量):$r - b$,也就是减了 baseline 的奖励。这一项存在的唯一理由就是稳定性。
  3. Characteristic Eligibility(特征资格):Williams 记作 $e$(不是指数函数),意思是「把这个标量奖励归因到产生该动作的那些参数上」。现代记号就是 $\nabla_\theta \log \pi_\theta(a_t\mid s_t)$。

于是更新规则长成这样:

$$ \Delta_\theta = \alpha\,(r - b)\,e $$

换成现代记号和一般化的回报 $G$:

$$ \nabla_\theta J(\theta) = \E_{\tau\sim p_\theta}\left[\sum_{t=0}^{T} \big(G_t - b(s_t)\big)\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

而 $G_t - b(s_t)$ 本身就是一个优势估计,所以这个式子和第 3 节最后的优势形式是同一个东西。REINFORCE 就是用蒙特卡洛估计梯度的 vanilla policy gradient,没有更多内容。

REINFORCE 用于语言模型的基本架构
这张图要注意的是 shaped reward 那一步:奖励模型给的分数和参考模型算出的 KL 惩罚在进入优势估计之前就被合并了。这是「KL 放在奖励里」的做法(PPO/REINFORCE/RLOO 的传统),和后面 GRPO「KL 放在损失里」形成对比。整章的架构图都是在这个骨架上加东西。

常用 baseline

REINFORCE 的 baseline 可以很土:

  • batch 内平均奖励——最简单,也最常见;
  • 最近若干步奖励的滑动平均——比 batch 均值稳,但引入延迟;
  • 学出来的价值函数 $V_\phi(s)$——方差最低,但要多训一个模型,这一步跨过去就走向 actor-critic 和 PPO 了。

代码上,最朴素的 REINFORCE 只有四行:

# rewards:        (B,)  每条序列一个标量奖励
# seq_log_probs:  (B,)  completion token 的 log-prob 之和

baseline   = rewards.mean()           # 最简单的 batch 基线
advantages = rewards - baseline

loss = -(advantages * seq_log_probs).mean()   # 负号:我们在最小化

参考实现(_src/code/policy_gradients/loss.py 的 ReinforceLoss)是逐 token 版本,本质一样:

class ReinforceLoss(nn.Module):
    """经典策略梯度:-log(pi) * advantage"""
    def forward(self, log_probs, experience, **kwargs):
        loss = -(log_probs * experience.advantages)
        # 先在 token 维做掩码平均,再在 batch 维平均
        loss = masked_mean(loss, mask=experience.action_mask, dim=-1).mean(dim=0)
        return loss

注意 experience.advantages 的形状是 (B, 1)——整条 completion 共享一个优势值,广播到每个 token。这是所有「无价值函数」算法的共同特征,也是 bandit 视角的直接体现。

RLOO:把 baseline 变聪明

REINFORCE Leave-One-Out(留一法 REINFORCE,RLOO)只改了一件事:baseline 用同一个 prompt 下其他样本的平均奖励,而不是整个 batch 的平均。

给定同一 prompt $s$ 的 $K$ 条采样 $a_1,\dots,a_K$,第 $k$ 条的 baseline 定义为:

$$ b(s, a_k) = \frac{1}{K-1}\sum_{i=1,\, i\neq k}^{K} R(s, a_i) $$

优势就是:

$$ A(s, a_k) = R(s, a_k) - b(s, a_k) $$

等价的另一种写法(实现里更常用,因为可以向量化):

$$ A(s, a_k) = \frac{K}{K-1}\left(R(s, a_k) - \frac{1}{K}\sum_{i=1}^{K} R(s, a_i)\right) $$

这两个「为什么要留一」的理由分别是:

  1. 无偏性:把自己的奖励排除在自己的 baseline 之外,baseline 就与被评估的动作严格独立,第 3 节那条无偏性证明才能原封不动地用上。
  2. per-prompt 而非 per-batch:这是更实际的收益。batch 里既有简单题也有难题,用全 batch 均值当 baseline,难题上的所有回复都会拿到负优势(哪怕其中有一条特别好),简单题上的所有回复都会拿到正优势。per-prompt baseline 天然捕获了题目难度。

一个手算例子

同一个 prompt 采 $K=4$ 条,奖励分别是 $[0.8, 0.3, 0.6, 0.5]$:

completion奖励baseline(其余三条的均值)优势
10.8$(0.3+0.6+0.5)/3 = 0.467$$+0.333$
20.3$(0.8+0.6+0.5)/3 = 0.633$$-0.333$
30.6$(0.8+0.3+0.5)/3 = 0.533$$+0.067$
40.5$(0.8+0.3+0.6)/3 = 0.567$$-0.067$

最好的那条被强化,最差的被压制,中间两条几乎不动。注意优势之和恰好为 0——这是留一 baseline 的一个良好性质:组内是一场零和的相对竞争。这个直觉在 GRPO 那里会被推到极致。

代码(改写自 _src/code/policy_gradients/utils.py 的 compute_loo_advantages,等价于 TRL 的实现):

# rewards: (K, N) —— 每一列 j 是 prompt j 的 K 个奖励
#   注意布局:同一个 prompt 的 K 条 "兄弟" 必须挨在一起

baseline = (rewards.sum(dim=0, keepdim=True) - rewards) / (K - 1)
advantages = rewards - baseline

# 等价的紧凑写法(参考实现用的就是这个):
#   advantages = (K / (K - 1)) * (rewards - rewards.mean(dim=0, keepdim=True))
RLOO 架构:一个 prompt 多条 completion,互为 baseline
和 REINFORCE 的图相比,唯一的结构变化是最左边——一个 prompt 分叉出多条 completion。整个 RLOO(以及后面的 GRPO)都建立在这个便宜的分叉上:对语言模型来说,「同一个初始状态采多条轨迹」几乎是免费的(KV cache 可以共享 prompt 部分),而在机器人这类任务里,让环境回到完全相同的状态反而很难。这是 LM 上的 RL 和经典 RL 的一个重要结构性差异。

序列级优势 vs. token 级优势

RLOO 和其他不用价值网络的算法,都是把同一个序列级优势广播给每个 token。用价值网络的算法(PPO)则给每个 token 单独一个值,从 EOS 位置的最终奖励往前折算。

这也影响 KL 惩罚怎么进入计算,三种做法都存在:

算法KL 怎么进来后果
RLOO把逐 token KL 聚合成一个标量,折进序列奖励优势仍是序列级,广播到全部 token
PPO逐 token 从逐 token 奖励里减去 KL,再算 $A_t$token 级信用分配
GRPO优势保持序列级,KL 作为独立的一项加进损失KL 和优势估计互不干扰
Lambert 的判断:KL 的时代变化 随着后训练从 RLHF 转向 RLVR(可验证奖励的强化学习),KL 惩罚的普及度整体下降了,很多推理训练的代码干脆完全关掉。逻辑是:KL 存在的理由是奖励模型是个学出来的代理,策略会去攻击它的缺陷;而验证器给的是 ground truth,可攻击的面小得多。DeepSeek-R1 这类工作甚至直接扔掉了参考模型,顺便省下一份显存。奖励信号越可靠,正则化的需求越低——这是一条相当干净的规律。

参考实现的默认 config 里 beta: 0.0,正是这个判断的体现:在 spell_backward 这种可验证任务上,KL 默认是关的。

最后值得强调:RLOO 的优势估计完全可以和 PPO 的裁剪拼在一起用。这些算法之间的边界远比论文标题让人以为的模糊——真正的设计空间是「优势怎么估」×「更新怎么约束」这两个正交的维度。

5. PPO:重要性采样比与 clip 的几何

REINFORCE 那么简单,为什么还要 PPO

本章所有算法都是 on-policy 的:每批都用当前策略现采 rollout,更新完就扔掉(对比之下 DQN 这类 off-policy 方法会把老经验存进 replay buffer 反复用)。这意味着 rollout 是昂贵且一次性的——生成几千条长回复的成本,往往远超那几次梯度步本身。

于是有两个诉求:

  1. 一批数据想多榨几次梯度。但走完第一步之后,数据就不再是当前策略产生的了,估计变得有偏。
  2. 步长必须被约束住。朴素策略梯度对步长极其敏感:太大策略直接崩,太小训练慢到不可接受。

TRPO(Trust Region Policy Optimization,信任域策略优化)用一个硬性的 KL 约束解决了第二点,但代价是要做二阶优化,工程上很重。PPO 的全部贡献就是:用一个裁剪过的目标函数,拿到 TRPO 级别的稳定性,同时保持一阶优化的简洁;而因为更新被限制得足够保守,第一点也顺带解决了——你可以安全地在同一批数据上走好几步。

重要性采样:为什么会冒出一个比值

重要性采样的基本恒等式是:

$$ \E_{p}[f(x)] = \E_{q}\left[f(x)\,\frac{p(x)}{q(x)}\right] $$

其中 $p$ 是目标分布,$q$ 是实际采样的分布,$p(x)/q(x)$ 是重要性权重。在策略梯度里,$p = \pi_\theta$ 是当前要优化的策略,$q = \pi_{\theta_{\text{old}}}$ 是产生这批数据的策略。于是定义策略比值:

$$ \rho_t(\theta) = \frac{\pi_\theta(a_t\mid s_t)}{\pi_{\theta_{\text{old}}}(a_t\mid s_t)} $$

读法很直白:$\rho_t = 1$ 表示新旧策略对这个 token 的看法一致;$\rho_t > 1$ 表示新策略更愿意生成它;$\rho_t < 1$ 表示更不愿意。对任意一批数据,第一次梯度步时 $\rho_t$ 恒等于 1(因为 $\pi_\theta$ 此刻就是 $\pi_{\theta_{\text{old}}}$),之后才开始偏离。

把它插进优势形式的策略梯度,得到代理目标(surrogate objective):

$$ J(\theta) = \E_t\big[\rho_t(\theta)\, \hat A_t\big] $$

实现上永远是从 log-prob 之差取指数来算比值——直接相除会在概率极小时数值爆炸:

ratio = torch.exp(new_per_token_logps - old_per_token_logps)   # (B, L)

裁剪:PPO 的核心

不加约束地最大化上面那个代理目标,会出问题:$\rho_t$ 可以跑到离 1 任意远的地方,而重要性采样的估计只在两个分布接近时才可靠。PPO 的做法是把比值裁到 $[1-\varepsilon, 1+\varepsilon]$:

$$ J(\theta) = \E_t\left[\min\Big(\rho_t(\theta) A_t,\ \text{clip}\big(\rho_t(\theta), 1-\varepsilon, 1+\varepsilon\big) A_t\Big)\right] $$

$\varepsilon$ 典型取 0.1–0.2。逐 token 展开就是实践中真正算的东西:

$$ J(\theta) = \frac{1}{|a|}\sum_{t=0}^{|a|} \min\left(\frac{\pi_\theta(a_t\mid s_t)}{\pi_{\theta_{\text{old}}}(a_t\mid s_t)} A_t,\ \text{clip}\left(\frac{\pi_\theta(a_t\mid s_t)}{\pi_{\theta_{\text{old}}}(a_t\mid s_t)}, 1-\varepsilon, 1+\varepsilon\right) A_t\right) $$

这里前面那个 $\frac{1}{|a|}$ 是实现惯例,不是推导出来的——它就是第 9 节要讲的「损失聚合」问题的源头,Dr. GRPO 那篇论文专门指出过这一点。

注意:为什么从这里开始只写目标不写梯度 从 PPO 起,本章不再写显式的梯度表达式,只写目标函数。原因是加了 $\min$ 和 $\text{clip}$ 之后,梯度没有一个好看的解析形式——按写法不同会分裂成 4 个左右的分段项。写目标函数是表达这类算法更清晰的方式,代码里也是这么做的:写出目标(取负号变成损失),剩下交给 autodiff。

clip 的几何:六种情况

PPO 目标函数关于策略比值的可视化,分正负优势两栏
横轴是策略比值 $\rho(\theta)$,纵轴是目标 $J(\theta)$。左右两栏分别是正优势和负优势。真正要看的是斜率:斜率非零的区间才有梯度,平的地方梯度为 0、参数不动。可以看到平坦区在两栏中出现在相反的一侧——正优势时平在右边,负优势时平在左边。这就是「单边裁剪」的几何含义。

把 $\min$ 展开,按优势的符号和 $\rho$ 落在哪个区间,一共六种情况:

优势$\rho$ 区间未裁项裁剪项目标取值梯度发生什么
$A_t > 0$$\rho < 1-\varepsilon$$\rho A_t$$(1-\varepsilon)A_t$$\rho A_t$$\neq 0$正常更新,提高概率
$1-\varepsilon \le \rho \le 1+\varepsilon$$\rho A_t$$\rho A_t$$\rho A_t$$\neq 0$正常更新,提高概率
$\rho > 1+\varepsilon$$\rho A_t$$(1+\varepsilon)A_t$$(1+\varepsilon)A_t$$= 0$不更新(已经够了)
$A_t < 0$$\rho < 1-\varepsilon$$\rho A_t$$(1-\varepsilon)A_t$$(1-\varepsilon)A_t$$= 0$不更新(已经够了)
$1-\varepsilon \le \rho \le 1+\varepsilon$$\rho A_t$$\rho A_t$$\rho A_t$$\neq 0$正常更新,降低概率
$\rho > 1+\varepsilon$$\rho A_t$$(1+\varepsilon)A_t$$\rho A_t$$\neq 0$正常更新,降低概率

把这张表压成一句话:

核心结论:clip 只在「已经走过头」时刹车 裁剪把梯度归零,只发生在策略已经朝正确方向移动超过信任域边界的两种情况:
· 正优势 + $\rho > 1+\varepsilon$:这个动作在新策略下已经明显更可能了,别再过度强化;
· 负优势 + $\rho < 1-\varepsilon$:这个动作在新策略下已经明显更不可能了,别再过度压制。

其余四种情况全部走普通策略梯度。裁剪从不阻止「纠错方向」的更新——如果一个坏动作反而变得更可能了(负优势 + $\rho > 1+\varepsilon$),PPO 会毫不留情地继续压它。这一点经常被误解成「PPO 双边限制更新」,其实是按优势符号单边生效的。

代码里的三行

PPO 的策略损失核心就三行(来自原书示例,与 loss.py 的 PPOLoss 一致):

pg_losses1 = -advantages * ratio                                   # (B*G, L)
pg_losses2 = -advantages * torch.clamp(ratio, 1.0 - eps, 1.0 + eps)
pg_loss_max = torch.max(pg_losses1, pg_losses2)                    # 注意是 max

pg_losses1 是普通的优势加权策略梯度损失;pg_losses2 是同样的公式但比值被夹住。关键在 torch.max:因为损失前面带负号,我们在最小化一个负目标,所以取 max 恰好等价于对目标取 min——也就是选那个更悲观、产生更小更新的分支。

参考实现里的完整版(_src/code/policy_gradients/loss.py)额外支持了非对称裁剪,为 DAPO 的 clip-higher 留了口子:

class PPOLoss(nn.Module):
    def forward(self, log_probs, experience, values, **kwargs):
        # ---- 价值损失(带裁剪)----
        returns = experience.advantages + experience.values_old   # G_t = A_t + V(s_t)
        values_clipped = torch.clamp(values,
                                     experience.values_old - self.clip_eps_val,
                                     experience.values_old + self.clip_eps_val)
        val_loss = torch.max(0.5 * (returns - values) ** 2,
                             0.5 * (returns - values_clipped) ** 2)

        # ---- 策略损失(带裁剪)----
        policy_ratio = (log_probs - experience.log_probs_old).exp()
        policy_loss = -torch.min(
            policy_ratio * experience.advantages,
            policy_ratio.clamp(1 - self.clip_eps_lo, 1 + self.clip_eps_hi) * experience.advantages,
        )

        loss = policy_loss + self.vf_coef * val_loss
        return masked_mean(loss, mask=experience.action_mask, dim=-1).mean(dim=0)

注意价值函数也有自己的裁剪(clip_eps_val,参考 config 里取 0.4),逻辑和策略裁剪一样:不让 critic 在一次更新里跳离 rollout 时的预测太远。这一项容易被忽略,但在 critic 冷启动阶段挺重要。

什么时候裁剪才会真的生效

这是一个实践上极容易搞错的点。裁剪只有在 $\pi_\theta$ 已经偏离 $\pi_{\theta_{\text{old}}}$ 时才可能触发,而造成偏离的原因有两个:

  • minibatching:把一个 rollout batch 切成多个 minibatch,在第一个 minibatch 上更新完之后,$\pi_\theta$ 就变了——所以同一个 epoch 内后面的 minibatch 已经看到 $\rho_t \neq 1$。即使 $K=1$,裁剪也可能生效。
  • 多轮 epoch:在同一批数据上循环 $K$ 次。典型 $K = 2$–$4$;超过 6 左右策略就太 off-policy 了。

而如果 $K = 1$ 且不做 minibatching,那么 $\pi_\theta \equiv \pi_{\theta_{\text{old}}}$,比值恒为 1,裁剪永远不会触发,PPO 退化成带 GAE 的朴素策略梯度。这不是理论玩笑:原书明确指出,语言模型上的 PPO/GRPO 常常就是每批只走一步,于是 PPO 自带的正则化根本没起作用,真正在起作用的是 KL 惩罚。

形式化地说,当每批只走一步时($[\cdot]_\nabla$ 表示 stop-gradient):

$$ J(\theta) = \frac{1}{G}\sum_{i=1}^G \left(\frac{\pi_\theta(a_i\mid s)}{\big[\pi_\theta(a_i\mid s)\big]_\nabla} A_i - \beta\, \KL(\pi_\theta \| \pi_{\text{ref}})\right) $$

比值在数值上恒为 1,但它的梯度不为零(分母被 detach 了),所以这个写法仍然给出正确的策略梯度——只是 clip 和 min 那套逻辑可以整段删掉。这让实现优雅很多。

Lambert 的判断:双重正则化 本章里有两种正则化:一种内建在算法里(PPO 的步长约束),一种是相对 RL 起点的 KL 距离惩罚。作者的观点是:对微调语言模型而言,第二种远比第一种重要,因为微调本来就只在预训练模型附近做很局部的移动;而 PPO 这类算法当初被发明出来,是为了控制从零训练的 agent。

「所以 PPO(有内部步长正则)和 REINFORCE(简单,在某些超参下 PPO 就退化成它)之间的差别,对微调语言模型来说,意义远小于从零训练 agent。」

但也别一刀切:DAPO 每批走 16 次梯度步,Tülu 3 在 8B/70B 上用 4 次、到 405B 时降回 1 次以保持稳定。批内梯度步数是一个真实的调参维度,监控 clip fraction 就能知道它有没有在起作用。

6. 价值函数与 GAE:把一个标量摊到每个 token 上

价值函数在 PPO 里干什么

PPO 的价值函数 $V_\phi$ 是模型的另一份副本(通常是同架构、换一个输出维度为 1 的头),预测每个 token 位置的未来回报。它是 REINFORCE 那个简单蒙特卡洛 baseline 的进化版:从「一个标量」变成「一个随位置变化的学出来的函数」。

在 RLHF 里,它预测的是扣掉 KL 惩罚之后的未来回报——因为传统实现里 KL 是折进逐 token 奖励的。

价值函数训练:用 on-policy rollout 算目标,MSE 回归
这张图把 PPO 的两个损失的关系画清楚了:价值网络在每个 token 位置输出 $V_t$,用回报目标 $\hat V_t$ 做 MSE 回归;同时 $A_t = \hat V_t - V_t$ 这个差值被 detach 出来,去给策略梯度加权。同一个量既是 critic 的训练误差,又是 actor 的更新权重——这就是 actor-critic 的核心结构。

从一个序列级标量到逐 token 奖励

奖励模型只给整条 completion 一个分数 $R(x,y)$。PPO 需要的却是逐 token 的奖励序列。桥梁是 KL 塑形:

$$ r_t = \begin{cases} R(x,y) - \beta\,\text{KL}_t & t = T\ \text{(最后一个 token)} \\ -\beta\,\text{KL}_t & \text{其他位置} \end{cases} \qquad \text{KL}_t = \log \pi_\theta(a_t\mid s_t) - \log \pi_{\text{ref}}(a_t\mid s_t) $$

也就是说:中间每个 token 只承担自己的 KL 代价,最终得分只打在结尾那一个位置上。然后 GAE 把这些逐 token 奖励沿序列反向传播,变成逐 token 的优势。

注意:奖励要打在「最后一个生成的 token」上,不是 [:, -1] 这是最经典的静默 bug 之一。batch 里各条序列长度不同,右侧全是 padding。写 rewards[:, -1] = rm_score 会把奖励打到 padding 上,而 padding 被 mask 掉了——于是奖励信号整个消失,训练照跑不误,就是不学东西。正确写法是先算出每条序列最后一个有效 action 的下标,再 scatter_ 进去:
last_idx = completion_mask.long().cumsum(-1).argmax(-1, keepdim=True)
rewards = torch.zeros_like(values).scatter_(-1, index=last_idx, src=rm_score)
同类问题还有:截断(没生成出 EOS 就被 max_new_tokens 砍掉)的回复送去打分,会让奖励模型进入分布外,给出不可预测的分数。标准解法是只在 eos_token 存在时才计分,否则给一个长度惩罚。

n 步优势估计的偏差-方差谱

有了逐 token 奖励和价值预测,优势可以有很多种估法。先定义 $n$ 步优势估计:

$$ \hat A_t^{(n)} = \begin{cases} r_t + \gamma V(s_{t+1}) - V(s_t), & n = 1 \\ r_t + \gamma r_{t+1} + \gamma^2 V(s_{t+2}) - V(s_t), & n = 2 \\ \ \ \vdots \\ r_t + \gamma r_{t+1} + \gamma^2 r_{t+2} + \cdots - V(s_t), & n = \infty \end{cases} $$

这是一条清晰的谱:

  • $n$ 小:更多依赖学出来的 $V$ → 方差低,偏差高($V$ 不准的话,误差直接进优势);
  • $n$ 大:更多依赖真实采到的奖励 → 方差高,偏差低($n=\infty$ 就是蒙特卡洛,无偏但最抖)。

GAE 不选某一个特定的 $n$,而是对所有 $n$ 做指数加权平均。

推导:GAE 的封闭形式

先定义 TD 残差:

$$ \delta_t^V = r_t + \gamma V(s_{t+1}) - V(s_t) $$

注意 $\hat A_t^{(n)} = \sum_{l=0}^{n-1}\gamma^l \delta_{t+l}^V$(中间的 $V$ 项会望远镜式抵消)。引入混合参数 $\lambda$,对各阶估计做几何加权:

$$ \begin{aligned} \hat A_t^{\text{GAE}(\gamma,\lambda)} &= (1-\lambda)\left(\hat A_t^{(1)} + \lambda \hat A_t^{(2)} + \lambda^2 \hat A_t^{(3)} + \cdots\right) \\ &= (1-\lambda)\Big(\delta_t^V + \lambda(\delta_t^V + \gamma\delta_{t+1}^V) + \lambda^2(\delta_t^V + \gamma\delta_{t+1}^V + \gamma^2\delta_{t+2}^V) + \cdots\Big) \\ &= (1-\lambda)\Big(\delta_t^V(1 + \lambda + \lambda^2 + \cdots) + \gamma\delta_{t+1}^V(\lambda + \lambda^2 + \cdots) + \cdots\Big) \\ &= (1-\lambda)\left(\delta_t^V\frac{1}{1-\lambda} + \gamma\delta_{t+1}^V\frac{\lambda}{1-\lambda} + \gamma^2\delta_{t+2}^V\frac{\lambda^2}{1-\lambda} + \cdots\right) \\ &= \sum_{l=0}^{\infty} (\gamma\lambda)^l\, \delta_{t+l}^V \end{aligned} $$

第三行是把同一个 $\delta$ 的所有系数收集到一起,第四行用等比级数求和。前面那个 $(1-\lambda)$ 就是为了让权重归一化,最后被约掉。

结果非常漂亮:GAE 就是把 TD 残差按 $(\gamma\lambda)^l$ 指数衰减地往前累加。

$\lambda$行为方差偏差
0纯 TD(1 步)最低最高
0.95LLM 微调的常用默认值平衡平衡
1蒙特卡洛优势 $G_t - V(s_t)$最高无

参考实现的 ppo.yaml 用的是 $\lambda = 0.98$,$\gamma = 1.0$。要提醒的是,上表的排序假设 $V_\phi$ 是准的——一个训坏的 critic 会让低 $\lambda$ 的估计变得完全不可靠,这时候反而是大 $\lambda$(更依赖真实奖励)更安全。

GAE 伪代码逐行讲

# GAE(token 级),用于 LM RLHF
#
# 输入:
#   rewards:   (B, L)  扣完 KL 的逐 token 奖励
#   values:    (B, L)  当前的 V_theta(s_t)
#   done_mask: (B, L)  终止位置(EOS 或被惩罚的截断)为 1.0,其余 0.0
#   gamma:     float,LM 上通常 1.0
#   lam:       float,[0,1] 之间,即公式里的 lambda
#   (终止位置之后的 padding 必须 rewards=0, values=0)

B, L = rewards.shape
advantages = torch.zeros_like(rewards)
next_v = torch.zeros(B, device=rewards.device, dtype=rewards.dtype)
gae    = torch.zeros(B, device=rewards.device, dtype=rewards.dtype)

for t in reversed(range(L)):                       # 必须倒着走
    not_done = 1.0 - done_mask[:, t]
    delta = rewards[:, t] + gamma * not_done * next_v - values[:, t]   # TD 残差
    gae   = delta + gamma * lam * not_done * gae                       # 指数累加
    advantages[:, t] = gae
    next_v = values[:, t]                          # 为下一次迭代(即 t-1)准备 V(s_{t+1})

targets    = advantages + values      # 价值回归的目标 y_t
advantages = advantages.detach()      # 给策略损失用,必须 detach

逐行读:

  • for t in reversed(...):必须倒序,因为 $\hat A_t$ 依赖 $\hat A_{t+1}$。递推关系是 $\hat A_t = \delta_t + \gamma\lambda\,\hat A_{t+1}$,这正好是上面封闭形式的递归展开。
  • not_done:在终止 token 处为 0,起两个作用。一是不从「未来状态」自举(那里没有未来了),二是把 GAE 累加器清零。因为循环是倒着走的,终止 token 会干净地切断指数累加——这让这段实现天然是 packing 友好的:多条序列拼在一行里也能正确处理。
  • next_v = values[:, t]:每轮结束时把当前的 $V(s_t)$ 存下来,下一轮($t-1$)就用它当 $V(s_{t+1})$。
  • targets = advantages + values:这一步的推导是 $\hat A_t = \hat G_t - V(s_t) \Rightarrow \hat G_t = \hat A_t + V(s_t)$。GAE 给出的优势加回当前价值,就是一个比 critic 自己当前预测更好的回报估计,拿它当回归目标。
  • .detach():优势必须切断梯度,否则策略更新会反传进价值网络,两个损失互相污染。

价值函数损失

不用 GAE 的最简单版本(蒙特卡洛回报 + PPO 式价值裁剪)长这样:

# 1) 逐 token 的蒙特卡洛回报(在终止处重置)
returns = torch.zeros_like(rewards)
running = torch.zeros(B, device=rewards.device, dtype=rewards.dtype)
for t in reversed(range(L)):
    running = rewards[:, t] + gamma * (1.0 - done_mask[:, t]) * running
    returns[:, t] = running
targets = returns                      # y_t = G_t(已扣 KL)

# 2) PPO 式的价值裁剪(可选但常见)
v_clip = torch.clamp(values, old_values - epsilon_v, old_values + epsilon_v)
vf_loss_tok = torch.max(0.5 * (values - targets) ** 2,
                        0.5 * (v_clip  - targets) ** 2)

# 3) 掩码后聚合
denom = completion_mask.sum(dim=1).clamp_min(1)
value_loss = ((vf_loss_tok * completion_mask).sum(dim=1) / denom).mean()

# 4) 给策略损失用的优势:A_t = G_t - V(s_t)
advantages = (targets - values).detach()

# 最终:total_loss = policy_loss + vf_coef * value_loss

vf_coef 典型取 0.5–1.0。.clamp_min(1) 这类除法保护不是可选项——遇到一条立刻输出 EOS 的空 completion,分母为 0 会直接产生 NaN,然后污染整个 batch。

价值函数怎么初始化:一个真实的坑

价值网络必须从一开始就给出大致合理的估计,否则 GAE 算出的优势全是噪声,早期训练会一片混乱。三种做法:

初始化方式说明代价
从奖励模型初始化InstructGPT 立下的标准,Tülu 3 的 RLVR 沿用。RM 本来就学过「什么回复得高分」,价值预测一上来就接近真实奖励尺度要求 RM 和策略架构兼容
从 SFT 模型 + 随机价值头更省事,也是参考实现的做法(val_model.lm_head 换成一个输出维度 1 的 Linear)早期不稳定
完全随机初始化的 LM可行但少见收敛慢很多

另一个常见工程手段是 value function warmup:先在已有奖励标注的数据上只训 critic 若干步,等它稳定了再开始走策略梯度。这个技巧在论文里几乎没人写,但在实际代码库里很常见。

PPO 架构:加入了学出来的价值模型和 GAE
相比 REINFORCE 那张图,PPO 多出来的是右侧那条价值函数支路,以及由它带来的 GAE 计算。注意价值模型是训练中会被更新的(图上和策略一样是「活」的),这也是它成为整个 pipeline 里最脆弱一环的原因:一个不收敛的 critic 会静默地毁掉优势估计,而 reward 曲线可能要过很久才看出问题。
Lambert 的判断:为什么值得付 PPO 的复杂度 PPO 给出的三个真实收益:更低的方差(GAE + 学出来的价值函数)、token 级信用分配(每个 token 有自己的优势,而不是全序列共享一个)、样本复用(靠重要性采样,一批数据能走多步)。

代价是:四个模型进显存、价值函数初始化很脆、超参数多。作者同时提醒了一个历史事实——在 2010 年代末到 2020 年代初,PPO 是被理解得最透彻、开发得最完善的 RL 算法,在语言模型以外的任务上远优于 REINFORCE。换句话说,GRPO 那一派的简化能成立,靠的是语言模型这个场景的特殊性(同 prompt 多采样便宜、转移确定、episode 短),而不是「PPO 本来就没用」。

7. GRPO:用一组同门师兄弟替代价值模型

核心想法

Group Relative Policy Optimization(组相对策略优化,GRPO)出自 DeepSeekMath,后来被 DeepSeek-V3 和 DeepSeek-R1 用到了台面上,如今是语言模型 RL 的默认选择。它保留 PPO 的裁剪目标,但整个价值函数被扔掉了。

动机很实在,两条:

  1. 规避「用 LM 骨干学价值函数」这个没人真正搞定的问题。研究界对此还没有确立最佳实践——上一节讲的初始化、warmup、裁剪全是经验性的补丁。
  2. 省显存。从「策略 + 参考 + 价值」三份权重降到两份(关掉 KL 的话只剩一份)。

替代方案是回到蒙特卡洛:对同一个 prompt $s$ 采 $G$ 条 completion $\{a_1,\dots,a_G\}$,拿到奖励 $\{r_1,\dots,r_G\}$,用这组奖励的统计量当 baseline。

$$ A_i = \frac{r_i - \text{mean}(r_1, r_2, \cdots, r_G)}{\text{std}(r_1, r_2, \cdots, r_G)} $$

这是一个组内 z-score:高于平均的 completion 拿正优势,低于平均的拿负优势。completion $i$ 里的每个 token 都拿到同一个 $A_i$(序列级优势),但重要性比值仍然是逐 token 的。

目标函数

$$ J(\theta) = \frac{1}{G}\sum_{i=1}^G \left(\min\left(\frac{\pi_\theta(a_i\mid s)}{\pi_{\theta_{\text{old}}}(a_i\mid s)}A_i,\ \text{clip}\left(\frac{\pi_\theta(a_i\mid s)}{\pi_{\theta_{\text{old}}}(a_i\mid s)}, 1-\varepsilon, 1+\varepsilon\right)A_i\right) - \beta\, \mathcal{D}_{\text{KL}}(\pi_\theta \| \pi_{\text{ref}})\right) $$

展开成逐 token 形式:

$$ \begin{aligned} J(\theta) = \frac{1}{G}\sum_{i=1}^G \frac{1}{|a_i|}\sum_{t=1}^{|a_i|} \Bigg( &\min\!\left(\frac{\pi_\theta(a_{i,t}\mid s_i)}{\pi_{\theta_{\text{old}}}(a_{i,t}\mid s_i)}A_{i,t},\ \text{clip}\left(\frac{\pi_\theta(a_{i,t}\mid s_i)}{\pi_{\theta_{\text{old}}}(a_{i,t}\mid s_i)}, 1-\varepsilon, 1+\varepsilon\right)A_{i,t}\right) \\ &- \beta\, \mathcal{D}_{\text{KL}}\!\left(\pi_\theta(\cdot\mid s_i)\|\pi_{\text{ref}}(\cdot\mid s_i)\right)\Bigg) \end{aligned} $$

和 PPO 相比,形式上就两处不同:优势的算法,以及 KL 出现在损失里而不是奖励里:

$$ L = L_{\text{policy gradient}} + \beta \cdot \mathcal{D}_{\text{KL}} $$

而 PPO 传统上是 $r = r_\theta - \beta\,\mathcal{D}_{\text{KL}}$。这个位置差异的实际后果是:GRPO 里 KL 不参与优势估计,两者互不干扰;PPO 里 KL 先进奖励、再经 GAE 传播,会影响每个 token 的优势值。

GRPO 架构:组内归一化优势,无价值模型
和 PPO 那张图对比,最显眼的是价值模型整条支路消失了,取而代之的是左侧一个 prompt 分叉出的 $G$ 条 completion 和它们之间的归一化。KL 那条线也从「进奖励」改道成了「进损失」。这张图基本上就是 RLVR 时代的标准系统图。

直觉:一场组内的相对竞赛

GRPO 的更新在做的事情,说人话就是:对同一道题给出的多个答案互相比较,让模型更像被判对的那些,更不像其他的。

这带来一个和 PPO 很不一样的采样哲学。PPO 和 vanilla policy gradient 的设计目标是准确估计每条 completion 的绝对回报——在某些情况下,多采几条对价值估计几乎没有帮助。而 GRPO 的优势完全关于「相对于同门师兄弟的价值」,所以每个 prompt 采的样本数通常远高于 PPO 时代(8、16、甚至 64 条)。

更重要的是,这套机制特别贴合语言模型这个工具的形状:从同一个 prompt 生成多条回复几乎是免费的(prompt 的 KV cache 可以共享,推理引擎天然支持 $n>1$)。而在机器人任务里,让环境精确回到同一个状态再试一次,是一件非常昂贵甚至不可能的事。GRPO 的成功很大程度上是「算法形状匹配了硬件和任务形状」的结果。

std 归一化引入的偏置

这是本节最值得推敲的地方。除以组内标准差不是一个无偏的 baseline 操作,它改变了不同 prompt 对总梯度的相对权重。

推导:一个具体的数值例子

假设二元奖励(对 = 1,错 = 0),$G = 8$:

  • Prompt A(几乎全对):7 对 1 错。$\mu = 0.875$,$\sigma \approx 0.331$。那条错的优势是 $(0-0.875)/0.331 \approx -2.64$。
  • Prompt B(一半一半):4 对 4 错。$\mu = 0.5$,$\sigma = 0.5$。对的优势 $= +1.0$,错的 $= -1.0$。
  • Prompt C(只有一条对):1 对 7 错。$\mu = 0.125$,$\sigma \approx 0.331$。那条对的优势是 $(1-0.125)/0.331 \approx +2.64$。

可以看到:方差低的组(A 和 C)被放大了 2.6 倍,方差高的组(B)被压到 1.0。而 A、C 这种「几乎全对」或「几乎全错」的题,恰恰是学习信号最稀薄的——A 说明题太简单,C 说明题太难。GRPO 却给了它们最大的梯度权重。

Liu 等人的 Understanding R1-Zero-Like Training 因此提出去掉 std 项,这就是 Dr. GRPO(GRPO Done Right):

$$ \tilde A_i = r_i - \text{mean}(r_1, \cdots, r_G) = r_i - \frac{1}{G}\sum_{j=1}^G r_j $$

但原书特意补了一句权衡:去掉 std 也会把「全错里蹦出一条对」的那种情况的权重压下去,而那可能恰恰是最宝贵的学习信号——高方差的 prompt 往往正是最难的样本,只有少数几条采样找到了正确路径。所以这不是一个「Dr. GRPO 就是对的」的简单结论,而是一个真实的取舍。

Dr. GRPO 与 RLOO 的等价性

去掉 std 之后,Dr. GRPO 的优势和 RLOO 的优势只差一个常数因子。

推导:$\frac{G}{G-1}\tilde A_i = A_i^{\text{RLOO}}$ $$ \begin{aligned} \frac{G}{G-1}\tilde A_i &= \frac{G}{G-1}\left(r_i - \frac{1}{G}\sum_{j=1}^G r_j\right) \\ &= \frac{G}{G-1}r_i - \frac{1}{G-1}\sum_{j=1}^G r_j \\ &= \frac{G}{G-1}r_i - \frac{1}{G-1}\sum_{j\neq i} r_j - \frac{1}{G-1}r_i \\ &= r_i\left(\frac{G}{G-1} - \frac{1}{G-1}\right) - \frac{1}{G-1}\sum_{j\neq i} r_j \\ &= r_i - \frac{1}{G-1}\sum_{j\neq i} r_j \;=\; A_i^{\text{RLOO}} \end{aligned} $$

第三行把 $j=i$ 那一项从求和里单独拆出来,第四行合并 $r_i$ 的系数得到 $\frac{G-1}{G-1}=1$。

因为实现里通常还会做 advantage 白化(把优势归一化到零均值单位方差),这个常数因子在实践中根本不影响任何东西。所以结论是:Dr. GRPO 的优势估计 $\equiv$ RLOO 的优势估计。两篇隔了七八年的论文,在去掉包装之后是同一个式子。这是本章最能说明「算法家族其实很窄」的一个例子。

反过来看这条等价性还有另一层意义:Dr. GRPO 去掉 std 之后优势的数值尺度变大了(不再被小 $\sigma$ 放大,但也不再被大 $\sigma$ 缩小),这等价于对答案分数有方差的样本调高了学习率。

代码

GRPO 的完整实现只是 PPO 减掉价值部分、换掉优势计算:

# B: batch(prompt 数), L: 序列长, G: 每个 prompt 的 completion 数

# 组内统计
mean_grouped = rewards.view(-1, G).mean(dim=1)
std_grouped  = rewards.view(-1, G).std(dim=1)
mean_grouped = mean_grouped.repeat_interleave(G, dim=0)   # (B*G,)
std_grouped  = std_grouped.repeat_interleave(G, dim=0)

advantages = ((rewards - mean_grouped) / (std_grouped + 1e-4)).unsqueeze(1)  # (B*G, 1)

# 重要性比值(逐 token)
ratio = torch.exp(new_per_token_logps - per_token_logps)   # (B*G, L)

# PPO 式裁剪目标
eps = 0.2
pg_losses1  = -advantages * ratio
pg_losses2  = -advantages * torch.clamp(ratio, 1.0 - eps, 1.0 + eps)
pg_loss_max = torch.max(pg_losses1, pg_losses2)

# GRPO 特有:KL 加在损失里(PPO 传统上加在奖励里)
per_token_loss = pg_loss_max + beta * per_token_kl

loss = ((per_token_loss * completion_mask).sum(dim=1) / completion_mask.sum(dim=1)).mean()

注意 advantages 的形状是 (B*G, 1),靠广播乘到 (B*G, L) 的 ratio 上——这一行就是「序列级优势 + token 级比值」的字面实现。

参考实现(_src/code/policy_gradients/loss.py)把它写成一个模块,并且用 clip_eps_lo / clip_eps_hi 分开两侧的裁剪边界:

class GRPOLoss(nn.Module):
    def forward(self, log_probs, experience, **kwargs):
        ratio = (log_probs - experience.log_probs_old).exp()
        unclipped_term = ratio * experience.advantages
        clipped_term = ratio.clamp(1 - self.clip_eps_lo,
                                   1 + self.clip_eps_hi) * experience.advantages
        policy_loss = -torch.min(unclipped_term, clipped_term)

        if self.beta:      # KL 可选,默认 config 里 beta=0
            kl_loss = get_approx_kl(self.kl_estimator, log_probs,
                                    experience.log_probs_ref, experience.action_mask)
        else:
            kl_loss = torch.tensor(0.0, device=log_probs.device)

        loss = policy_loss + self.beta * kl_loss
        return masked_mean(loss, mask=experience.action_mask, dim=-1).mean(dim=0)

而 Dr. GRPO 在这份代码里复用同一个 loss 类,只在优势计算处分叉——这本身就说明了两者的差别有多小:

# _src/code/policy_gradients/utils.py

def compute_standardized_advantages(rewards, eps=1e-8):      # GRPO / GSPO / CISPO / SAPO / DAPO
    return (rewards - rewards.mean(dim=0, keepdim=True)) / (rewards.std(dim=0, keepdim=True) + eps)

def compute_nonstandardized_advantages(rewards):             # Dr. GRPO
    return rewards - rewards.mean(dim=0, keepdim=True)

def compute_loo_advantages(rewards):                         # RLOO
    K = rewards.shape[0]
    return (K / (K - 1)) * (rewards - rewards.mean(dim=0, keepdim=True))

GRPO vs. PPO vs. RLOO

PPOGRPORLOO
价值函数学出来的 $V_\phi$无无
优势逐 token(GAE)序列级,组内 z-score序列级,留一均值
更新方式PPO 裁剪比值PPO 裁剪比值REINFORCE(不裁剪)
KL 位置进奖励(算优势之前)进损失(默认,可选)可选,进奖励
显存中的模型数43(无 KL 时 2)3(无 KL 时 2)
典型场景通用 RLHF推理 / RLVRRLHF,简洁基线

最后补一句:GRPO 的优势估计也可以不配 PPO 的裁剪,直接套到 REINFORCE 上,只是这不是它的标准形式。同样,过程监督(process supervision)场景下 GRPO 的优势要改成「后续各推理步归一化奖励之和」,而不是上面这个单一标量。

8. 变体家族:GSPO / CISPO / DAPO / SAPO 各改了什么

GRPO 之后涌出一批变体。它们看上去缩写各异,但改动都集中在两个位置:重要性采样比在什么粒度上算,以及怎么约束它。把这两条轴认清,这一节就没有难点。

GSPO:把比值提到序列级

PPO 和 GRPO 在 token 级做重要性采样,并通过裁剪目标函数来稳定。这有一个微妙的失效模式:当某个 token 的比值跑出裁剪区间,这个 token 就拿到零梯度。对那些罕见但关键的 token——比如模型一开始给了很低概率的关键推理步——这种「丢 token」现象会阻止模型学会更可靠地产生它们。

更实际的问题是数值稳定性。长序列上逐 token 的比值会表现得非常不稳:单个比值很大的 token 就能主导整个更新,或者一条回复内部大量 token 各自独立地被裁掉,学习信号被打得七零八落。这在现代 MoE(Mixture-of-Experts,混合专家)这类大而稀疏的模型上尤其严重——同样的输入,路由到的专家稍有不同,logprob 就会有明显偏移。

Group Sequence Policy Optimization(组序列策略优化,GSPO)的做法是:每条回复只算一个重要性权重。回复概率本来就是自回归因子分解的:

$$ \pi_\theta(a\mid s) = \prod_{t=1}^{|a|}\pi_\theta(a_t\mid s, a_{<t}) $$

直接用这个连乘的比值会因为长度而爆炸或塌缩($|a|$ 上千时,哪怕每个 token 只差 1%,整体也差了 $1.01^{1000}\approx 2\times10^4$ 倍)。所以 GSPO 用几何平均做长度归一化:

$$ \rho_i(\theta) = \left(\frac{\pi_\theta(a_i\mid s)}{\pi_{\theta_{\text{old}}}(a_i\mid s)}\right)^{\frac{1}{|a_i|}} = \exp\left(\frac{1}{|a_i|}\sum_{t=1}^{|a_i|}\log\frac{\pi_\theta(a_{i,t}\mid s, a_{i,<t})}{\pi_{\theta_{\text{old}}}(a_{i,t}\mid s, a_{i,<t})}\right) $$

目标函数照抄 GRPO,只是把 $\rho_{i,t}$ 换成 $\rho_i$:

$$ J_{\text{GSPO}}(\theta) = \E_{s\sim\mathcal{D},\,\{a_i\}\sim\pi_{\theta_{\text{old}}}}\left[\frac{1}{G}\sum_{i=1}^G \min\big(\rho_i(\theta)A_i,\ \text{clip}(\rho_i(\theta), 1-\varepsilon, 1+\varepsilon)A_i\big)\right] $$

因为做了长度归一化,$\varepsilon$ 现在作用在每 token 平均的尺度上,不同长度的回复受到的实际约束是可比的。实现上,这个序列级权重被均匀施加到该回复的所有 token。

一句话总结:GSPO = 「重要性采样的粒度对齐奖励的粒度」的 GRPO。既然奖励是序列级给的(RLHF 和 RLVR 都是),修正也应该在序列级做。

# _src/code/policy_gradients/loss.py 的 GSPOLoss(核心两行)
seq_logprobs = masked_mean(log_probs - experience.log_probs_old,
                           mask=experience.action_mask, dim=-1, keepdim=True).exp()
policy_loss = -torch.min(
    seq_logprobs * experience.advantages,
    seq_logprobs.clamp(1 - self.clip_eps_lo, 1 + self.clip_eps_hi) * experience.advantages,
)

注意 masked_mean(...).exp() 就是 $\exp\left(\frac{1}{|a_i|}\sum_t \log\rho_{i,t}\right)$——先在 log 空间取平均再指数化,正是几何平均,也顺便避开了直接连乘的数值问题。

CISPO:裁权重而不是裁目标

Clipped Importance Sampling Policy Optimization(裁剪重要性采样策略优化,CISPO)走了完全不同的一条路:不裁目标函数,而是裁重要性权重本身,同时保留所有 token 的梯度。

$$ J_{\text{CISPO}}(\theta) = \E\left[\frac{1}{\sum_{i}|a_i|}\sum_{i=1}^K \sum_{t=1}^{|a_i|} \text{sg}\big(\hat\rho_{i,t}(\theta)\big)\, A_{i,t}\, \log \pi_\theta(a_{i,t}\mid s, a_{i,<t})\right] $$

其中 $\text{sg}(\cdot)$ 是 stop-gradient(用它的数值但不对它求导),裁剪后的比值是:

$$ \hat\rho_{i,t}(\theta) = \text{clip}\big(\rho_{i,t}(\theta),\ 1-\varepsilon_{\text{low}},\ 1+\varepsilon_{\text{high}}\big) $$

注意这个目标的形式:$\text{sg}(\hat\rho)\cdot A \cdot \log\pi_\theta$ ——这是 REINFORCE 的形状,不是 PPO 的形状。梯度只从 $\log\pi_\theta$ 流出,比值退化成一个固定的加权系数。

核心结论:CISPO 与 PPO 的关键区别 裁权重(CISPO)和裁目标(PPO/GRPO)看起来只差一点,后果完全不同:
· PPO 裁目标 → 落在裁剪区外的 token 梯度为零,信号被完全丢弃;
· CISPO 裁权重 → 每个 token 都还有梯度,正比于它的优势;权重只是限定了这个信号能被重要性比值放大或抑制到什么程度。

这是一个偏差-方差取舍:裁权重引入偏差,但换来了受控的方差,并且关键地避免了整段丢弃 token 梯度。
# _src/code/policy_gradients/loss.py 的 CISPOLoss
with torch.no_grad():                                   # ← stop-gradient
    ratio = (log_probs - experience.log_probs_old).exp()
    clipped_ratio = ratio.clamp(1 - self.clip_eps_lo, 1 + self.clip_eps_hi)
policy_loss = -clipped_ratio * experience.advantages * log_probs
#              ^^^^^^^^^^^^^ 只是权重          梯度只从这里 ^^^^^^^^^ 流出

CISPO 也支持非对称边界($\varepsilon_{\text{low}} \neq \varepsilon_{\text{high}}$),和 DAPO 的 clip-higher 一个思路:让模型想上调的 token 有更大的上调空间,鼓励探索。

值得注意的是,GSPO 和 CISPO 都出自在超大 MoE 模型上推极限的组织(Qwen 团队和 MiniMax)。这类模型以数值问题著称,逐 token 的重要性比值会给梯度注入巨大方差。反过来说:这两个算法在大规模模型上可能影响显著,但在小规模的学术实验里研究得少、收益也小——这是原书明确给出的判断,选型时值得记住。

DAPO:为长推理链打的四个补丁

Decoupled Clip and Dynamic sAmpling Policy Optimization(解耦裁剪与动态采样的策略优化,DAPO)对 GRPO 提了四项改动,全部针对「长推理轨迹 + 需要提高低概率新 token」这个场景:

  1. Clip-Higher:拆成 $\varepsilon_{\text{low}}$ 和 $\varepsilon_{\text{high}}$ 两个超参,让 logratio 正侧可以迈更大的步子,换取更好的探索。参考 config 里是 clip_eps_lo: 0.2 / clip_eps_hi: 0.28。
  2. Dynamic Sampling(动态采样):把组内奖励全为 0 或全为 1 的样本整组丢掉——那些组的优势全是 0,一点学习信号都没有,纯粹浪费算力。
  3. Token 级损失:用第 9 节的 Strategy 2 而不是 GRPO 默认的 per-sequence 归一化。
  4. 超长软惩罚:对过长的样本给一个平滑的惩罚,避免从被截断的答案里学东西。

参考实现把第 2、4 两条落在了 rollout 和奖励侧,第 1、3 两条落在 loss 侧:

# rollout.py:动态采样过滤(整组丢弃)
def _dapo_filter(self, exp):
    correctness = exp.rewards["correctness"]
    all_min = (correctness == self.cfg.accuracy_min_reward).all()
    all_max = (correctness == self.cfg.accuracy_max_reward).all()
    if all_min or all_max:
        return None            # 全对或全错 → 这组没有对比度,扔掉
    return exp

# utils.py:超长软惩罚
def dapo_length_penalty(completion_len, l_cache, l_max):
    safe_len = l_max - l_cache
    if completion_len <= safe_len:
        return 0.0                                   # 安全区,不罚
    if completion_len <= l_max:
        return (safe_len - completion_len) / l_cache  # 缓冲区,线性递增的惩罚
    return -1.0                                       # 超了,满额惩罚

# loss.py:DAPOLoss —— token 级归一化,且没有 KL 项
per_token_loss = -torch.min(unclipped_term, clipped_term)
mask = experience.action_mask.to(per_token_loss.dtype)
return (per_token_loss * mask).sum() / mask.sum().clamp(1e-8)   # ← 全局按 token 数归一

注意最后一行和 GRPO 的 masked_mean(..., dim=-1).mean(dim=0) 的区别:DAPO 是把整个 batch 的 token 一起求和再除以总 token 数,GRPO 是先按序列平均再按 batch 平均。这两行代码的差别就是第 9 节整节要讲的东西。

SAPO:把硬裁剪换成软门控

Soft Adaptive Policy Optimization(软自适应策略优化,SAPO)的观察是:硬裁剪是一个不连续的开关——比值刚好在 $1+\varepsilon$ 两侧,梯度从「完整」跳到「零」。SAPO 用一个温度控制的 sigmoid 门替代它,得到一个连续的信任域:靠近 on-policy 的 token 保留信号,越 off-policy 的 token 权重越平滑地衰减。

# _src/code/policy_gradients/loss.py 的 SAPOLoss
ratio = (log_probs - experience.log_probs_old).exp()

# 非对称温度:负优势用更紧的曲线(参考 config: pos=1.0, neg=1.05)
temps = torch.where(experience.advantages > 0, self.sapo_temp_pos, self.sapo_temp_neg)

soft_gate = torch.sigmoid(temps * (ratio - 1)) * 4 / temps
policy_loss = -soft_gate * experience.advantages

把 soft_gate 在 $\rho = 1$ 处做一阶展开可以看出它的设计:$\sigma(0)=0.5$,$\sigma'(0)=0.25$,所以 $\text{gate}(1) = 0.5\cdot 4/\tau = 2/\tau$,而 $\frac{\mathrm{d}}{\mathrm{d}\rho}\text{gate}\big|_{\rho=1} = 0.25\tau\cdot 4/\tau = 1$。也就是说在 on-policy 点处,这个门的斜率恰好是 1,和不裁剪的 $\rho A$ 完全一致;离开这个点之后才平滑地饱和。这个 $4/\tau$ 的系数就是为了做这件事而设的。

横向对比表

方法IS 粒度约束方式优势估计改了什么 / 为什么
REINFORCE无无蒙特卡洛 + baseline基线;每批只能走一步
RLOO无无留一均值per-prompt baseline,天然捕获题目难度
PPOtoken裁目标(双边)学出来的 $V_\phi$ + GAEtoken 级信用分配 + 样本复用;代价是四个模型
GRPOtoken裁目标(双边)组内 z-score砍掉价值模型;std 归一化带来低方差组偏置
Dr. GRPOtoken裁目标(双边)组内减均值(不除 std)修掉难度偏置;等价于 RLOO(差常数)
GSPO序列(几何平均)裁目标(双边)组内 z-score比值粒度对齐奖励粒度;解决长序列 / MoE 的数值不稳
CISPOtoken裁权重(stop-grad)组内 z-score保住每个 token 的梯度,不丢关键低概率 token
DAPOtoken裁目标(非对称)组内 z-scoreclip-higher + 动态采样 + token 级损失 + 超长惩罚
SAPOtoken软 sigmoid 门组内 z-score连续信任域,不在边界处硬切梯度
TOPR序列裁权重(按奖励符号非对称)—正奖励不做 IS 修正,负奖励比值裁到 $[0,1]$;支持稳定的 off-policy
DPO不是 RL 算法——绕过奖励模型,直接从偏好对优化,见第 8 章

关于这张表,有几条统一的说明:

  • 上面所有策略梯度算法在推导上都是 on-policy 的,但实践中几乎都被稍微 off-policy 地使用。DPO 及其他直接对齐算法则默认就是 off-policy。
  • 所有算法都能配学出来的奖励模型或可验证奖励,只有 PPO 需要学一个价值函数。
  • REINFORCE 和 RLOO 没有重要性采样比——其余算法各自引入一个,差别只在粒度和裁剪策略。

损失函数速查

把核心损失 $\mathcal{L}(\theta)$ 摆在一起,家族关系一目了然:

$$ \begin{aligned} \textbf{REINFORCE:}\quad & -\frac{1}{T}\sum_{t=1}^{T}\log \pi_\theta(a_t\mid s_t)\,\big(G_t - b(s_t)\big) \\[6pt] \textbf{RLOO:}\quad & -\frac{1}{K}\sum_{i=1}^{K}\sum_t \log \pi_\theta(a_{i,t}\mid s_{i,t})\left(R_i-\frac{1}{K-1}\sum_{j\neq i}R_j\right) \\[6pt] \textbf{CISPO:}\quad & -\sum_{i,t} \mathrm{sg}(\hat\rho_{i,t})\, A_{i,t} \log \pi_\theta(a_{i,t}\mid s_{i,t}),\quad \hat\rho_{i,t} = \mathrm{clip}(\rho_{i,t}, 1-\varepsilon, 1+\varepsilon) \\[6pt] \textbf{PPO:}\quad & -\frac{1}{T}\sum_{t=1}^{T}\min\big(\rho_t A_t,\ \mathrm{clip}(\rho_t,1-\varepsilon,1+\varepsilon)A_t\big),\quad \rho_t = \frac{\pi_\theta(a_t\mid s_t)}{\pi_{\theta_{\text{old}}}(a_t\mid s_t)} \\[6pt] \textbf{GRPO:}\quad & -\frac{1}{G}\sum_{i=1}^{G}\min\big(\rho_i A_i,\ \mathrm{clip}(\rho_i,1-\varepsilon,1+\varepsilon)A_i\big),\quad A_i = \frac{r_i-\mathrm{mean}(r_{1:G})}{\mathrm{std}(r_{1:G})} \\[6pt] \textbf{GSPO:}\quad & -\frac{1}{G}\sum_{i=1}^{G}\min\big(\rho_i A_i,\ \mathrm{clip}(\rho_i,1-\varepsilon,1+\varepsilon)A_i\big),\quad \rho_i = \left(\frac{\pi_\theta(a_i\mid s)}{\pi_{\theta_{\text{old}}}(a_i\mid s)}\right)^{1/|a_i|} \\[6pt] \textbf{DPO:}\quad & -\E_{(x,y^{w},y^{l})}\left[\log \sigma\big(\beta[\Delta\log \pi_\theta(x)-\Delta\log \pi_{\mathrm{ref}}(x)]\big)\right] \end{aligned} $$

这张表本身就是本章的浓缩版:差别全在 $\Psi$ 的算法和 $\rho$ 的处理上,梯度骨架从头到尾没变过。

9. 损失聚合:三种归一化和它们的长度偏置

这一节讲的东西在论文里通常只占一句话,但它对训练动态的影响比换算法还大。问题很朴素:拿到逐 token 的损失 $\ell_{i,t}$(样本 $i$ 的第 $t$ 个 token),怎么把它们压成一个标量?

设 batch 大小为 $B$,第 $i$ 条 completion 长度为 $|a_i|$。有三种主流做法。

策略 1:per-sequence 归一化(标准 GRPO)

$$ L = \frac{1}{B}\sum_{i=1}^{B}\frac{1}{|a_i|}\sum_{t=1}^{|a_i|}\ell_{i,t} $$
sequence_loss = ((per_token_loss * completion_mask).sum(dim=1) /
                 completion_mask.sum(dim=1)).mean()

每条序列对 batch 损失的贡献相等,与长度无关。

策略 2:per-token 归一化(DAPO)

$$ L = \frac{\sum_{i=1}^{B}\sum_{t=1}^{|a_i|}\ell_{i,t}}{\sum_{i=1}^{B}|a_i|} $$
token_loss = (per_token_loss * completion_mask).sum() / completion_mask.sum()

每个 token 贡献相等,长序列因为 token 多,对梯度的总影响就成比例地更大。

策略 3:固定长度归一化(Dr. GRPO)

$$ L = \frac{1}{B}\sum_{i=1}^{B}\frac{1}{L_{\max}}\sum_{t=1}^{|a_i|}\ell_{i,t} $$
fixed_len_loss = ((per_token_loss * completion_mask).sum(dim=1) / L_max).mean()

$L_{\max}$ 是整个训练过程中的全局常数(通常就是最大生成长度)。它把逐 token 的尺度对齐了,同时仍然让长序列贡献更多总梯度(因为求和里有更多非零项)。

三段代码里的 completion_mask 都是 0/1 矩阵,prompt 部分置 0——我们不想让模型从「预测 prompt token」上学任何东西。

为什么这会有影响:一个手算例子

直觉上策略 1 最合理:我们关心的是结果,不是单个 token,所以每条回复应该权重相等。但它悄悄引入了长度偏置。

seq_1_losses = [1, 1, 1, 1, 10]                  # 5 个 token,均值 = 2.8
seq_2_losses = [1, 1, 1, 1, 1, 1, 1, 1, 1, 10]   # 10 个 token,均值 = 1.9
策略batch 损失短序列每 token 梯度长序列每 token 梯度
策略 1(per-sequence)$(2.8+1.9)/2 = 2.35$$1/5 = 0.20$$1/10 = 0.10$
策略 2(per-token)$(14+19)/15 = 2.2$$0.067$$0.067$
策略 3($L_{\max}=10$)$(1.4+1.9)/2 = 1.65$$0.10$$0.10$

关键在后两列:策略 1 下,短序列里每个 token 拿到的梯度是长序列的两倍。这会把模型往「回复短一点」的方向推——或者说,压制那些天生需要更多 token 的策略(长链推理正是其中之一)。策略 2 则相反,模型从长回答里拿到的梯度信号更多,可能鼓励啰嗦。策略 3 在两者之间。

用 autograd 亲自验证一遍

原书给了一段可以直接跑的脚本,把梯度打出来看:

from typing import Optional
import torch

def masked_mean(values, mask, axis: Optional[int] = None):
    if axis is not None:
        return (values * mask).sum(axis=axis) / mask.sum(axis=axis)
    return (values * mask).sum() / mask.sum()

def masked_sum(values, mask, axis=None, constant_normalizer: float = 1.0):
    if axis is not None:
        return (values * mask).sum(axis=axis) / constant_normalizer
    return (values * mask).sum() / constant_normalizer

ratio = torch.tensor([[1., 1, 1, 1, 1, 1, 1],
                      [1., 1, 1, 1, 1, 1, 1]], requires_grad=True)
advs  = torch.tensor([[2, 2, 2, 2, 2, 2, 2],
                      [2, 2, 2, 2, 2, 2, 2]])
masks = torch.tensor([[1, 1, 1, 1, 0, 0, 0],    # 生成 1:4 个 token
                      [1, 1, 1, 1, 1, 1, 1]])   # 生成 2:7 个 token
max_gen_len = 7

masked_mean(ratio * advs, masks, axis=1).mean().backward()
# ratio.grad = [[0.2500, 0.2500, 0.2500, 0.2500, 0.0000, 0.0000, 0.0000],
#               [0.1429, 0.1429, 0.1429, 0.1429, 0.1429, 0.1429, 0.1429]]
ratio.grad.zero_()

masked_sum(ratio * advs, masks, axis=1, constant_normalizer=max_gen_len).mean().backward()
# ratio.grad = [[0.1429, 0.1429, 0.1429, 0.1429, 0.0000, 0.0000, 0.0000],
#               [0.1429, 0.1429, 0.1429, 0.1429, 0.1429, 0.1429, 0.1429]]
ratio.grad.zero_()

masked_mean(ratio, masks, axis=None).mean().backward()
# ratio.grad = [[0.0909, 0.0909, 0.0909, 0.0909, 0.0000, 0.0000, 0.0000],
#               [0.0909, 0.0909, 0.0909, 0.0909, 0.0909, 0.0909, 0.0909]]

第一组(策略 1):短序列每 token 梯度 0.25,长序列只有 0.14。第二、三组把它们拉平了。这就是三行代码差异的全部物理含义。

注意:梯度累积会翻转这个结论 上面的分析假设一次 backward() 就走一个优化步。如果用了梯度累积(把多个 minibatch 的梯度加起来再更新,参考 config 里 batch_acc: 4 就是这么干的),结果可能大不相同——短序列和长序列的平衡甚至可能反转。所以别把上面那张表当成绝对真理,在你自己的训练设置里实测一次是唯一可靠的办法。

实践中的选择原则通常很务实:哪个数值更稳、损失方差更小就用哪个。

更深的问题:MDP 视角 vs. bandit 视角

损失聚合的选择其实连着一个更根本的建模分歧。

MDP(token 级)视角bandit(序列级)视角
动作是什么每个 token $a_t$ 是一个动作,状态是当前前缀整条 completion 是一个动作
优势逐 token,靠 $V(s_t)$ 和 GAE 算一个标量 $A_{\text{seq}}$,广播给所有 token
KL逐 token 施加聚合成标量,折进奖励
代表算法PPO + 价值网络RLOO、GRPO 系
# === bandit 风格(序列级)===
reward   = torch.tensor([3.0, 1.0])      # (B,) 奖励模型分数
baseline = reward.mean()
advantage_seq = reward - baseline        # (B,)
advantages = advantage_seq[:, None].expand(-1, seq_len)   # (B, L)
# tensor([[ 1.,  1.,  1.,  1.],    ← 所有 token 同一个优势
#         [-1., -1., -1., -1.]])

# === MDP 风格(token 级)===
advantages = gae(per_token_rewards, values, done_mask, gamma=1.0, lam=0.95)
# tensor([[ 0.2,  0.5,  0.8,  1.5],    ← 随位置变化
#         [-0.3, -0.5, -0.8, -1.4]])

实际的 RLHF 几乎总是混合的:奖励在序列级给出,梯度在 token 级计算。PPO 式的 RLHF 从一个序列级的 RM 分数出发,靠 KL 塑形和 GAE 把它摊成 token 级信用;GRPO 式的实现则常常用 bandit 风格的优势,同时在损失里加一个逐 token 的 KL 项。两种约定在实践中都存在,读代码时要先分清楚在看哪一种。

这也回过头来解释了 $\gamma = 1$:在 bandit 视角下折扣根本没有位置;而即使在 token 级 MDP 视角下,优化的归纳偏置也是「整条回复的质量」,对早期 token 打折扣没有原则性依据。

Lambert 的判断:怎么发现自己踩了长度偏置 损失聚合的影响最常通过训练过程中序列长度的变化被观察到,在 RLVR 里尤其明显。所以监控面板上一定要有 generation/length:稳定或缓慢上升是健康的,单调持续上升往往是长度 hack(模型发现写长一点就能拿更多梯度/更高分),而急剧下降则可能是 per-sequence 归一化在挤压长回答。

这条监控指标的价值在于:它是你能最早看到「优化目标和你想要的东西已经分家了」的信号,比 eval 分数掉下来早得多。

10. 实现细节:KL 估计器、白化、掩码与静默 bug

作者在讲座里对这一段的定位很明确:「最难的 bug 不是数学错误,而是静默的实现错误——掩码错、缓存过期、形状不对。」训练照跑,损失照降,模型学的是别的东西。这一节把这些坑集中列出来。

KL 的三种估计器

精确计算两个语言模型之间的 KL 散度需要在整个词表上求和,代价太高,所以实践中用蒙特卡洛近似。设 $\ell = \log\pi_\theta(a_t\mid s_t) - \log\pi_{\text{ref}}(a_t\mid s_t)$ 为 log-ratio,三种估计器分别是:

估计器公式无偏?非负?方差
k1$-\ell$是否(可能为负)高
k2$\ell^2/2$否(有小偏差)是低
k3$(e^{\ell}-1)-\ell$是是低

为什么 k1 会为负?因为 KL 的定义是 $\E_{x\sim p}[\log p - \log q]$,期望非负,但单个样本的 $\log p - \log q$ 完全可以是负的。用它做惩罚项时,会出现「惩罚项变成奖励」的诡异情况,训练曲线上表现为 KL 在 0 附近剧烈震荡。k3 同时做到无偏和逐样本非负,是现在的默认选择(参考实现的所有 config 里都是 kl_estimator: kl3)。

# _src/code/policy_gradients/loss.py
def approx_kl1(log_probs, log_probs_ref, action_mask):
    log_ratio = (log_probs - log_probs_ref) * action_mask
    return -log_ratio                       # 无偏但可能为负

def approx_kl2(log_probs, log_probs_ref, action_mask):
    log_ratio = (log_probs - log_probs_ref) * action_mask
    return (log_ratio ** 2) / 2             # 非负,低方差,有偏

def approx_kl3(log_probs, log_probs_ref, action_mask):
    log_ratio = (log_probs - log_probs_ref) * action_mask
    return (log_ratio.exp() - 1) - log_ratio   # 无偏 + 非负 —— 默认选它

k3 的构造思路值得一提:$e^\ell - 1$ 的期望(在 $\pi_{\text{ref}}$ 下)恰好为 0,所以把它加到 $-\ell$ 上不改变期望,却把整个函数变成了处处非负的凸函数(因为 $e^x - 1 - x \ge 0$ 对所有 $x$ 成立)。这是一个「加一个期望为零的控制变量」的经典方差削减技巧。

KL 放哪里

前面反复提到,这里集中对比一次:

# PPO 风格:KL 进奖励
rewards.scatter_(1, last_idx, rm_score)     # 分数打到最后一个有效 token
rewards = rewards - beta * per_token_kl     # 逐 token 扣 KL
advantages = gae(rewards, values, ...)      # KL 会经 GAE 传播

# GRPO 风格:KL 进损失
advantages = z_score(rewards)               # 优势只从原始奖励算
loss = pg_loss + beta * per_token_kl        # KL 作为独立一项

目标相同(限制相对参考模型的漂移),但 GRPO 的做法避免了 KL 和优势估计之间的相互作用。参考实现里这个分叉写在 apply_reward_kl 里:只有 ppo/rloo/reinforce 三种损失会把 KL 折进奖励,其余走损失项。

advantage 白化

把 batch 内的优势归一化到零均值、单位方差:

valid_adv = advantages[completion_mask.bool()]
advantages = ((advantages - valid_adv.mean()) /
              (valid_adv.std() + 1e-8)) * completion_mask

为什么要做:稳定不同 batch 之间的梯度量级。不白化的话,一个整体奖励偏高或偏低的 batch 会产生异常大的梯度,把训练带偏。注意 valid_adv 是先按掩码取出有效位置再算统计量的——如果把 padding 也算进去,均值和方差就被稀释了,这又是一个安静的 bug。

相关的还有奖励归一化(把 RM 输出压到 $[0,1]$)和奖励白化。三者可以叠加,都是纯稳定性手段。

数值上的六条铁律

  1. 永远在 log 空间算:用 log_softmax + gather,不要 softmax 之后再 log。softmax 会把小概率压成极小的浮点数,再取 log 时精度损失被放大。
  2. 掩码必须精确:completion_mask 只在 completion token 上为 1,排除 prompt token 和 EOS 之后的 padding。聚合之前先乘掩码。
  3. EOS 的处理要一致:算不算进损失都行,但全流程必须统一。处理不一致是最经典的静默错误之一。
  4. 变长序列要小心归一化:见第 9 节。
  5. baseline / 优势必须 detach:advantages.detach(),不要把梯度反传进优势的计算图。
  6. 除法要加保护:凡是除以 mask.sum() 或序列长度的地方,都要 .clamp_min(1) 或 + eps。一条立刻输出 EOS 的空回复就能产生 NaN,然后污染整个 batch。

参考实现的 masked_mean 就是把第 2、6 条固化下来了:

def masked_mean(tensor, mask, dim=None, keepdim=False, eps=1e-8):
    if mask is None:
        return tensor.mean(dim=dim, keepdim=keepdim)
    return (tensor * mask).sum(dim=dim, keepdim=keepdim) / \
           mask.sum(dim=dim, keepdim=keepdim).clamp_min(eps)   # ← 除零保护

训练循环里还有一层保护:if not loss.isfinite(): continue ——遇到 NaN 直接跳过这个 minibatch,而不是让它毁掉整次训练。

rollout 阶段要存什么

PPO 的一步需要用到一堆张量,其中一部分必须在 rollout 时算好存下来,另一部分每个训练步重算:

张量形状谁用缓存还是重算
token ids(B, L)全部rollout 时存
completion mask(B, L)全部rollout 时存
旧 log-probs $\log\pi_{\theta_{\text{old}}}$(B, L)IS 比值分母rollout 时存
旧价值 $V_{\phi_{\text{old}}}$(B, L)critic 裁剪rollout 时存
参考 log-probs $\log\pi_{\text{ref}}$(B, L)KL 惩罚rollout 时存
奖励(B,) 或 (B, L)优势计算rollout 时存
当前 log-probs $\log\pi_\theta$(B, L)IS 比值分子每步重算
当前价值 $V_\phi$(B, L)价值损失每步重算

比值 $\rho_t = \pi_\theta / \pi_{\theta_{\text{old}}}$ 里一个是新鲜的、一个是缓存的——这正是能在同一批 rollout 上跑多个 epoch 的原因。参考实现里这套东西被打包在 Experience 这个 dataclass 里(_src/code/policy_gradients/buffer.py),rollout 阶段一次性填满,训练阶段只重算 log_probs 和 values。

静默 bug 清单

常见误区:这些 bug 不会报错
  • prompt token 进了策略损失 —— 损失数值虚高,梯度浪费在模型改不了的 token 上(prompt 是给定的,提高它的概率毫无意义)。
  • 奖励打到了错误的位置 —— 写 [:, -1] 而不是「最后一个生成的 token」。奖励落在 padding 上被 mask 掉,训练信号完全消失。
  • 把过期的 log-prob 当成「当前」的 —— 比值恒为 1,裁剪永不触发。表现是 clip_frac 恒等于 0,看起来「很稳定」,其实是根本没在做 PPO。
  • GRPO 的零方差组 —— 组内所有 completion 拿到相同奖励 → std = 0 → 优势变 NaN。参考实现靠 + 1e-4 / + eps 兜底,DAPO 则直接把整组丢掉。
  • 价值损失或策略损失算到了掩码外 —— 在 padding 上训练。
  • 训练用的模板和推理引擎渲染的模板不一致 —— logprob 对不上,重要性比值直接失控(见第 4 章)。

训练时该盯哪些指标

面板健康不健康
reward/mean稳定上升尖刺、震荡、长期平台
kl/mean缓慢上升(0 → 2–5)爆炸(>10)或恒为 0
loss/policy下降发散或 NaN
metrics/clip_frac5%–30%0%(裁剪没生效)或 >50%(策略变化太快)
generation/length稳定或缓升单调持续上升(长度 hack)
策略熵缓慢下降骤降到 0(模式坍缩)
advantage 统计量均值 ≈ 0(若已白化),std 稳定均值漂移、std 塌陷
value_loss(PPO)下降不降 → critic 没在学

这些数值区间是示意性的,会随模型规模、任务和算法变化。除此之外还要做两件事:跑留出集的评测,以及亲眼读几条采样输出——很多问题(复读、格式崩坏、讨好式回答)只有肉眼能第一时间发现。

为什么 RL 里 batch size 更要紧

监督学习里梯度噪声是中等的,临界 batch size 通常在几千。RL 里梯度噪声尺度高出几个数量级,因为梯度来自蒙特卡洛 rollout 而不是标注数据。几个有代表性的经验事实:

  • OpenAI 的 Dota 2 项目,临界 batch size 达到了百万级 transition(见 McCandlish 等人 An Empirical Model of Large-Batch Training)。
  • PPO 不是 batch-size 无关的:裁剪把 batch size 和有效步长耦合在了一起,所以你没法靠调学习率来补偿 batch size 的变化。
  • PPO 的平台期常常源于噪声太大的损失估计,只有加样本才能解决。

大 batch 让梯度方差按 $1/N$ 下降。在 RLHF 里,这是最便宜的稳定手段,比大多数超参调优都有效。典型量级:256–1024 条 prompt(注意是 prompt 数,每条还要乘以 $G$ 条 completion)。

11. 异步 RL 系统与截断重要性采样

严格 on-policy 是做不到的

策略梯度的理论推导要求所有动作严格 on-policy——模型始终是最新的 rollout 结果对应的那个模型。实践中维持严格 on-policy 会大幅拖慢训练,而且完美同步在技术上本来就不可能。所以近期所有语言模型上的实证结果,严格来说都跑在理论证明之外。这不是偷懒,这是「先设计出真能工作的东西」。

同步与异步 RL 训练的生成-更新阶段对比
三行分别是:完全同步(生成时训练 GPU 闲着,训练时生成 GPU 闲着)、生成/训练分离但仍需等待(on-policy 约束下依然有气泡)、完全异步(两边重叠,利用率拉满)。中间那条竖着的空白就是钱——推理模型动辄一条 10K–100K token,生成阶段会成为绝对瓶颈,同步训练的浪费极其可观。

actor / learner 架构

通用解法是:推理和训练常驻在不同的 GPU 节点上,各自用最适合的软件栈。

  • learner(学习者):负责走策略梯度步的那些 GPU,跑训练框架(FSDP、DeepSpeed 等)。
  • actor(执行者):负责采样的那些 GPU,跑高效推理引擎(vLLM、SGLang)。
  • 中间用 Ray 之类的分布式进程管理库传数据,周期性地把权重从 learner 同步到 actor。
分布式 RL 系统:learner 与 actor 之间由两个队列传递数据
两个队列(prompt 队列和结果队列)是这类系统的核心抽象:actor 从 prompt 队列取任务、把 rollout 塞进结果队列,learner 反过来。权重同步的频率就是「off-policy 程度」的旋钮——同步得越少,训练越快、但数据越陈旧。图中还隐含了一个工程难点:actor 之间的负载严重不均,一条需要 100K token 的回复会拖住整批。

做异步的两个核心挑战是:保持训练稳定和保住学习信号。这些系统的设计前提是「近似 on-policy 的数据就够用了」。

还有一个具体的长度不匹配问题:同一批里某个 prompt 的回答可能耗时长得多(更多 token 或更多工具调用),导致大部分算力空等它完成。一个解法是 sequence-level packing(序列级打包)——把短样本用巧妙的掩码堆进同一个 batch,既能让模型继续 rollout,又能让长度归一化在 batch 内分布得更均匀。

推理模型出现之后,人们进一步往完全 off-policy 走:训练批次直接由「多个实例最近完成的 rollout」填充,不再等待。完全异步还能让 RL 训练更容易跨数据中心扩展,因为 learner 和 actor 之间的权重同步间隔可以拉长(INTELLECT-2 就是这个方向的公开尝试)。

你的框架偷偷把你变成了 off-policy

这是本节最反直觉、也最重要的一点。

即使 sampler 和 learner 用的是完全相同的参数 $\theta$,它们算出的 token 分布也会不一样。原因是推理引擎(vLLM)和训练框架(FSDP)用的是不同的 kernel、不同的精度、不同的并行策略。同一个 $\theta$,在两个系统上就是两个略有差异的分布。

所以要区分:

$$ \rho_t^{\text{learner}} = \frac{\pi_\theta^{\text{learner}}(a_t\mid s, a_{<t})}{\pi_\theta^{\text{sampler}}(a_t\mid s, a_{<t})}, \qquad \tilde\rho_t^{\text{learner}} = \min\big(\rho_t^{\text{learner}},\ C\big) $$

这就是截断重要性采样(Truncated Importance Sampling, TIS):用 $\min(\rho, C)$ 给权重加一个上限,拿一点点偏差换有界的方差。

核心结论:两个比值,两个理由 现代 RL 框架里同时存在两个重要性采样修正,它们的存在理由完全不同:
· 策略比值 $\rho_t^{\text{policy}} = \pi_\theta / \pi_{\theta_{\text{old}}}$ —— 修正一个 RL batch 内多次梯度步造成的策略漂移;
· TIS 比值 $\tilde\rho_t^{\text{learner}}$ —— 修正推理后端和训练后端之间实现层面的数值差异。

两者互补,可以同时施加。另外注意 TIS 用的是单边上限:比值可以自由地掉到 1 以下,但被截断在 $C$ 以上——这和 PPO/CISPO 的双边裁剪(把比值约束在 1 附近)不是一回事。

两种情况下的组合方式:

情况一:REINFORCE + TIS(每批一次梯度步)。没有策略漂移($\pi_\theta = \pi_{\theta_{\text{old}}}$),唯一的失配就是 learner 和 sampler 之间:

$$ \nabla_\theta J \approx \E_{a\sim\pi_\theta^{\text{sampler}}}\left[\tilde\rho_t^{\text{learner}}\cdot A_t \cdot \nabla_\theta \log \pi_\theta^{\text{learner}}(a_t\mid s, a_{<t})\right] $$

情况二:PPO/GRPO + TIS(每批多次梯度步)。两个比值都在起作用。严谨的实现会在 learner 上重算 old logprobs,让策略比值只反映纯粹的策略漂移,TIS 单独修正后端失配:

$$ J_{\text{PPO+TIS}}(\theta) = \E\left[\min\left(\rho_t^{\text{policy}}A_t,\ \text{clip}\left(\rho_t^{\text{policy}}, 1-\varepsilon, 1+\varepsilon\right)A_t\right)\cdot \tilde\rho_t^{\text{learner}}\right] $$

如果框架偷懒,直接把 sampler 的 logprob 当作 $\pi_{\theta_{\text{old}}}$,那么策略比值本身就已经包含了后端失配,不需要额外的 TIS——但这时候 clip 作用在一个更嘈杂的比值上,它在还没走任何梯度步时就已经不等于 1.0 了。这就是 Yao 等人那句「你的高效 RL 框架偷偷给了你 off-policy RL」的含义。

# 形状 (B*G, L)
C = 2.0                                             # TIS 上限

logratio = learner_logprobs - sampler_logprobs
logratio = logratio.clamp(-10.0, 10.0)              # 仅为数值安全,不是 TIS 本身
tis_weight = torch.exp(logratio).clamp(max=C)       # ← 单边截断,这才是 TIS

per_token_pg_loss = per_token_pg_loss * tis_weight.detach()   # 当作固定权重

[-10, 10] 这个 clamp 只是为了指数运算前不溢出;真正的截断步骤是 clamp(max=C)。

实践中,围绕这些 logprob 的簿记——生成时保存 sampler logprob、在旧 checkpoint 上用 learner 重算一遍、训练时再算当前的——占了分布式 RL 框架相当大一块脚手架代码。TIS 已经被主流开源框架普遍采纳(VeRL、TRL、OpenRLHF、SkyRL、OAT、Open Instruct,其中 Open Instruct 用 $C = 2$),而且推理链越长它越重要:每个 token 的微小差异会在几千个 token 上累积。

和 GSPO 不同,TIS 是 token 级的——因为它修正的是 token 级的数值失配,不是序列级的奖励粒度。

开源代码库

框架定位
TRLHuggingFace 生态,PPO/GRPO/DPO 齐全。入门最佳起点
Open InstructAllen AI,多算法。做研究和复现最佳
veRL字节,RLVR 时代非常流行
OpenRLHF从 RLHF 起家,RLVR 场景也很常用

12. 选型、超参与长尾话题

RL 一个没被充分记录的好处

拒绝采样(第 9 章)和直接对齐算法(第 8 章)都比 RL 简单得多,那为什么还要忍受 RL 的基础设施复杂度?作者给出的答案里有一条不太常被写下来:

Lambert 的判断:RL 能「修边角」 「实现 RL 的基础设施复杂得多,但它提供的梯度更新一般会让模型好很多。」这很难量化,但具体表现为:
· RL 能修掉模型的粗糙边角——让输出更稳健、格式更规整、和 vLLM 这类服务框架更兼容;
· RL 作用在一个很窄的 prompt 分布上,倾向于不「压扁」通用能力——它可以被外科手术式地用来改进特定行为,而不损伤其他方面。

总的判断是:语言模型上的 RL 损失稳健、可扩展、有效、灵活,正是这几条打开了后来巨大的实验空间。这也是为什么 RL 从「锦上添花」变成了训练最强模型时的承重步骤。

还有一个更结构性的原因:RL 是训练时算力扩展的入口。o1 那条曲线(RL 训练算力越多,推理性能越好)和 R1 之后的整个推理模型浪潮,用的正是本章这些算法,只不过奖励从「奖励模型打分」换成了「验证器判对错」(RLVR)。第 7 章会详细展开。

超参数的实际取值范围

下表是常见 LLM RLHF 设置的示意性范围,不是普适默认值:

超参典型范围说明
裁剪 $\varepsilon$0.1–0.2信任域宽度;DAPO 把上界推到 0.28
GAE $\lambda$0.95(参考实现 0.98)优势的偏差-方差旋钮
价值损失系数 vf_coef0.5–1.0critic 损失的权重
KL 系数 $\beta$0.01–0.1;RLVR 常取 0参考模型约束强度
每批梯度步数 $K$2–6(也有取 1 或 16 的)off-policy 预算
学习率$1\times10^{-6}$ – $5\times10^{-6}$比 SFT 低一个数量级
batch size256–1024 条 prompt越大方差越低
每 prompt 采样数 $G$PPO 时代 1–4;GRPO 常用 8–64组对比度的直接来源
temperature0.6–1.0太低组内没对比,太高格式崩

参考实现的 grpo.yaml 是一个可以在单卡上跑的迷你版本:lr: 5e-6、temperature: 0.6、prompts_per_step: 4、num_rollouts: 8、train_batch_size: 2、batch_acc: 4、max_norm: 1.0、max_new_tokens: 512。注意 ppo.yaml 里 num_rollouts: 1——PPO 不需要组内对比,它的 baseline 来自价值函数,这一行配置差异就把两种算法的哲学差异写清楚了。

其他值得知道的算法

这些没有成为主流最佳实践,但代表了有意思的方向:

算法核心想法
P3O(Pairwise PPO)直接在 PPO 式更新里用成对偏好数据,不训中间的奖励模型
CoPG(Contrastive Policy Gradient)off-policy 策略梯度,是 IPO 和朴素策略梯度的推广;Cohere 的 Command A 用过
ReMax专为「奖励模型推理带来的不确定性」设计的 baseline 归一化
MDPO(Mirror Descent PO)镜像下降是优化方法而非策略梯度算法,但能很容易地替换进现有 RL 基础设施。Apple Intelligence 基础模型、Kimi k1.5 用过变体
VAPO把 DAPO 的优化(clip-higher、token 级损失、不同长度归一化)和 Value-Calibrated PPO 的洞见结合:预训练价值函数 + 长度自适应 GAE,说明基于价值的方法相对 GRPO 仍有空间
TOPR(Tapered Off-Policy REINFORCE)像 CISPO 一样裁权重,但在序列级操作,并按奖励符号非对称:正奖励不做 IS 修正,负奖励把比值裁到 $[0,1]$,从而支持稳定的 off-policy 学习
MaxRL参考实现里也有一份:用二元奖励 $r = \text{correctness}\times\text{format}$,优势按组内正确率归一化(compute_maxrl_advantages)

怎么选

综合全章,给一个实用的决策路径:

  1. 先问奖励是什么。可验证奖励(数学、代码)→ GRPO 系是自然选择,KL 可以关掉;学出来的奖励模型 → KL 必须留着,PPO 或 RLOO 都可以。
  2. 看能不能对同一 prompt 多采样。能(几乎总是能)→ 用 RLOO/GRPO 这类组内 baseline,白送的低方差 baseline 没有理由不要。
  3. 显存紧不紧。紧 → 直接排除 PPO(它多一整个模型副本)。
  4. 序列有多长、模型有多稀疏。长推理链 + 大 MoE → 考虑 GSPO(序列级比值)或 CISPO(保住所有 token 的梯度);小模型短序列上这两者收益有限。
  5. 先把采样调对,再动优化器。这是最重要的一条——见下面的动手实验。
核心结论:算法不是瓶颈 「在多数 RLHF 设置里,数据质量和奖励信号质量占主导;算法选择主要决定的是稳定性、效率和工程负担。」

所有方法优化的是同一个策略梯度目标,差别只在三处:$\Psi_t$ 是什么、更新怎么被约束、要几个模型进显存。如果训练不 work,先怀疑数据和奖励,再怀疑实现细节,最后才怀疑算法选型。顺序反了会浪费很多时间。

本章小结

一条主线

整章只有一个式子在变形:

$$ \nabla_\theta J(\theta) = \E_{\tau\sim p_\theta}\left[\sum_t \Psi_t\, \nabla_\theta \log \pi_\theta(a_t\mid s_t)\right] $$

推导路径:目标是期望回报 → 直接求导不可采样 → log-derivative trick 转成期望 → 展开轨迹对数概率,环境项消失 → 只剩策略 log-prob 的和 → 用 return-to-go 和 baseline 削方差 → 得到优势形式。

算法速查

算法$\Psi_t$ 怎么来怎么约束更新显存中模型数一句话
REINFORCE蒙特卡洛回报 − 简单 baseline无2–3策略梯度的最小实现
RLOO组内留一均值无2–3per-prompt baseline,天然捕获难度
PPOGAE + 学出来的 $V_\phi$裁目标(双边)4token 级信用分配,代价是四个模型
GRPO组内 z-score裁目标(双边)2–3用同门师兄弟替代价值模型
Dr. GRPO组内减均值(不除 std)裁目标(双边)2–3去掉难度偏置;$\equiv$ RLOO
GSPO组内 z-score裁目标,比值取序列级几何平均2–3比值粒度对齐奖励粒度
CISPO组内 z-score裁权重 + stop-grad2–3每个 token 都保住梯度
DAPO组内 z-score非对称裁剪 + 动态采样2为长推理链打的四个补丁
SAPO组内 z-score软 sigmoid 门2连续信任域,不硬切梯度

要记住的十条

  1. log-derivative trick 是整个理论的支点:$\nabla_\theta p = p\nabla_\theta \log p$。它把不可采样的量变成可采样的期望。
  2. 环境动力学求导为 0,所以策略梯度是 model-free 的。这是它能用在语言模型上的根本原因。
  3. baseline 是免费的:只要它不依赖被采样的动作,减掉它不改变期望梯度,却大幅降方差。但除以 std 不是 baseline,那是重新加权。
  4. 优势 = 这个动作比该状态的平均好多少。健康训练里它的均值应该在 0 附近。
  5. PPO 的 clip 单边生效:只在「已经朝正确方向走过头」时截断,从不阻止纠错。信任域内部,PPO 就是普通策略梯度。
  6. 如果每批只走 1 步梯度且不 minibatch,clip 永远不触发,PPO 退化成带 GAE 的朴素策略梯度。看 clip_frac 就知道有没有在起作用。
  7. GAE 就是 TD 残差按 $(\gamma\lambda)^l$ 倒着累加,$\lambda$ 是偏差-方差旋钮。语言模型上 $\gamma = 1$。
  8. GRPO = PPO − 价值函数 + 组内 z-score。它的成功来自「LM 上同 prompt 多采样很便宜」这个结构性事实。
  9. 损失聚合方式直接决定长度偏置:per-sequence 偏好短回答,per-token 偏好长回答,fixed-length 居中。监控 generation/length。
  10. 现代 RL 框架里有两个重要性比值:策略比值修策略漂移,TIS 修推理/训练后端的数值失配。别混。

动手实验

作业代码在 homework/hw3-pg/,基于 code/policy_gradients/。默认配置在 Qwen/Qwen3-1.7B 上跑 reasoning-gym 的 spell_backward 任务(把一个单词倒着拼出来)。选这个任务是有讲究的:失败和部分进展都非常容易肉眼检查——你一眼就能看出模型是拼错了、格式坏了、还是根本没在推理。

实验一:用 GRPO 跑通词反转任务

cd homework/hw3-pg/
uv run python -m policy_gradients.train --config policy_gradients/configs/grpo.yaml

该盯哪三个指标:

指标含义怎么读
avg_correctness环境给出的正确性得分(dataset.score_answer)这是真正的任务信号。应该缓慢上升
avg_format格式奖励:<think> / </think> / <answer> / </answer> 各 0.25 分通常最先饱和到 1.0——格式比内容容易学得多
avg_binary正确性 且 格式满分才计 1最严格的指标,最能反映真实进展

但第一个该问的问题不是这些,而是:每个 prompt 组里有没有「对比度」。

回忆 GRPO 的优势:$A_i = (r_i - \mu_G)/\sigma_G$。如果一组 8 条 completion 全对或全错,那么 $\mu_G$ 等于每个 $r_i$,所有优势都是 0,这一组对梯度的贡献是零(还要靠 +1e-4 兜底才不至于 NaN)。组内相对法是靠差异吃饭的,没有差异就没有信号。

所以调试的第一步是打印或记录组内奖励的方差。参考实现每步都会打印一次 rollout 样本和各奖励分量的均值,直接看:

╭─ Rollout Results ────────────────────────────────╮
│ total: 0.83   correctness: 0.25   format: 1.00    │
│ penalty: 0.00   binary: 0.25                      │
╰───────────────────────────────────────────────────╯

如果 correctness 长期贴着 0 或贴着 1 不动,问题不在优化器,在采样设置或任务难度。

实验二:对比组内 baseline 和单样本估计

三个起点匹配的配置各跑一遍:

uv run python -m policy_gradients.train --config policy_gradients/configs/reinforce.yaml
uv run python -m policy_gradients.train --config policy_gradients/configs/rloo.yaml
uv run python -m policy_gradients.train --config policy_gradients/configs/grpo.yaml

对比两件事:正确性信号上升得多快,以及损失曲线有多抖。REINFORCE 用的是 batch 级 baseline,RLOO 和 GRPO 用的是 per-prompt baseline——这个对比会让「组内 baseline 到底有什么用」这件事,比任何公式都直观。预期是 RLOO/GRPO 的损失方差明显更小。

顺带可以做的对照:drgrpo.yaml(去掉 std 归一化)对比 grpo.yaml,验证第 7 节讲的等价性和偏置;dapo.yaml 对比 grpo.yaml,观察动态采样把「零对比度组」丢掉之后有效样本数怎么变。

实验三:扫「对比度旋钮」

复制 grpo.yaml,分别改动这四个参数:

参数调小会怎样调大会怎样
num_rollouts(组大小 $G$)组内对比度下降,越来越多的组退化成零方差信号更稳,但每步算力线性上升
temperature采样坍缩,8 条 completion 几乎一模一样 → 无对比度格式错误的回复暴增,噪声压过信号
data.size更快重复见到同一批题,容易过拟合覆盖更广,但收敛更慢
format_weight格式奖励占比低,模型可能一直不学格式模型可能只优化格式、放弃解题(一种奖励 hack)

这个实验的意义在于:它是理解「为什么 RLVR 的配方常常在动优化器之前,花大量精力调采样设置」的最短路径。$G$、temperature、题目难度共同决定了组内有没有可学的差异,而这三者和你用 GRPO 还是 DAPO 一点关系都没有。

实验四:从玩具奖励走向数学

想做 GSM8K 之类的实验,建议先从 code/reward_models/train_orm.py 和 code/rejection_sampling/ 入手,把奖励侧跑通,再去接一个新的在线 RL 环境。一个不错的贡献方向是:写一个能在 1B 以下 Qwen 上跑起来的 reasoning-gym 或 GSM8K 策略梯度配置,并报告同样的组内对比度诊断指标。

调试建议 如果什么都不动,第一次跑就出问题,按这个顺序查:
1) 采样出来的文本长什么样(打印几条,看是不是完全没格式 / 复读 / 空回复);
2) avg_format 有没有在动(格式最容易学,它不动说明梯度根本没流对地方);
3) 组内奖励方差是不是恒为 0;
4) 才是去看损失和梯度范数。
反过来先看损失曲线,通常会浪费很多时间——策略梯度的损失值本身几乎没有解释性(它是优势加权的 log-prob,不是任何意义上的「误差」),损失下降不代表模型变好。

延伸阅读

基础与推导

核心算法

GRPO 的变体与修正

实现细节与系统

规模与工程经验

  • Tülu 3 (2024) — 完整开源后训练配方;价值函数从 RM 初始化、不同规模用不同的批内梯度步数(8B/70B 用 4,405B 降到 1)等实践细节都在里面。
  • InstructGPT (2022) — 「价值网络从奖励模型初始化」这个标准的立法者。
  • An Empirical Model of Large-Batch Training (McCandlish et al., 2018) — 梯度噪声尺度与临界 batch size,解释了为什么 RL 需要那么大的 batch。
  • LlamaRL (2025) / AReaL (2025) — 两个完全异步的大规模 RL 系统,读它们的架构图比读综述有用。
  • INTELLECT-2 (2025) — 跨数据中心的去中心化 RL 训练,异步的极限形态。
  • Making RL Fast (Finbarr Timbers) — RL 训练性能优化的实战笔记。

速查

  • RL Cheatsheet — 本章所有核心损失函数的一页纸参考,写代码时开着它。