TheUnknownBlog

Back

理解整个 RL 系统

我过去主要做大模型 Pretraining Infrastructure。在过去学习 LLM Reinforcement Learning 时,我花了大量时间研究 PPO、GRPO 的 Loss 公式:Advantage 为什么要归一化?Importance Ratio 的分子和分母是什么?为什么需要 min 和 clip?这些数学符号看起来都能逐个解释,但我始终没有真正建立起对 RL 训练过程的完整认识。

事实上,更有效的思路 turns out 恰好相反:先理解 模型究竟生成了什么数据、谁在计算概率、什么时候更新了权重、更新后的模型又如何改变下一轮数据,再从这些具体问题推出 loss。我发现遵循这样的学习路径,能够以最 intuitive 的方式理解 RL:

  1. Pretraining Cross-Entropy 与 RL 的 objective 有什么区别?
  2. REINFORCE、Baseline、Advantage。
  3. Importance Sampling、PPO Clipping(到底在 clip 什么?) 和 GRPO。
  4. 理解 DAPO、Dr.GRPO、GSPO 修改了什么。
  5. 什么导致了训推不一致?区分 Policy Staleness 与 Numerical Mismatch。
  6. 通过 Qwen3.5-0.8B 在 GSM8K 上的两次 Slime Run,学习真正的 RL 指标。

贯穿全文的关键思想是:RL 把模型本身变成了训练数据的生成器。

Pretraining 与 RL 的 objective

Pretraining:样本已经给定

Pretraining 的目标通常写成:

LPT(θ)=−E(x,y)∼D[∑t=1Tlog⁡πθ(yt∣x,y<t)].\mathcal L_{\mathrm{PT}}(\theta)=-\mathbb E_{(x,y)\sim D}\left[\sum_{t=1}^{T}\log\pi_\theta(y_t\mid x,y_{<t})\right].

读这条公式时,不妨从最里面向外看:θ\theta 代表模型中所有需要学习的参数,yty_t 表示第 tt 个目标 token,y<ty_{<t} 表示它前面的目标 token,也就是模型在预测当前位置时已经知道的前缀。这个公式里最关键的一项是 πθ(yt∣x,y<t)\pi_\theta(y_t\mid x,y_{<t}):它表示在给定上下文 xx 和前缀 y<ty_{<t} 时,参数为 θ\theta 的模型给正确的下一个 token yty_t 分配的概率。∑t=1T\sum_{t=1}^{T} 把这一序列各位置的 logprob 加起来,E(x,y)∼D\mathbb E_{(x,y)\sim D} 再对训练样本求期望(实际训练用 batch 平均近似)。由于概率越大,logprob 越大,最前面的负号使“提高目标 token 的概率”对应于“降低 Loss”。

LPT(θ)\mathcal L_{\mathrm{PT}}(\theta) 是要最小化的 Pretraining Loss。DD 是训练数据的分布;(x,y)∼D(x,y)\sim D 表示从中取一条样本,其中 xx 是已给定的上下文,y=(y1,…,yT)y=(y_1,\ldots,y_T) 是要预测的目标 token 序列,TT 是目标序列长度。

你可能会疑惑:Pretraining 的 Objective 通常被称之为交叉熵。然而,交叉熵衡量的是两个分布之间的距离,为什么这个公式看起来只有一个条件概率?事实上,这里依然存在两个分布——模型预测的概率分布,以及真实标签对应的 One-hot 分布(即 yty_t 概率为 11,其余全为 00)。由于概率为 00 的项在求和中全部消去,公式最终就退化成了只包含 yty_t 条件概率的形式。此事在 3B1B 讲解交叉熵的视频中亦有记载。

目标 token 来自相对固定的数据集 DD。模型更新会改变它对这些 token 的概率,却不会立即改变下一个 batch 里的真实 token 是什么。总而言之,Pretraining 的 objective 决定了我们让模型去拟合一个已有数据的分布。

RL:模型生成训练样本

对数学 RLVR(Reinforcement Learning with Verifiable Rewards),我们给模型一道题 xx,由模型自己生成回答 yy,再通过答案检查器、单元测试等给它一个奖励 R(x,y)R(x,y):

J(θ)=Ex∼Dx, y∼πθ(⋅∣x)[R(x,y)].J(\theta)=\mathbb E_{x\sim D_x,\ y\sim\pi_\theta(\cdot\mid x)}[R(x,y)].

这里的 J(θ)J(\theta) 不是需要最小化的 Loss,而是要最大化的 RL Objective。DxD_x 表示题目(prompt)的采样分布,x∼Dxx\sim D_x 表示先抽一道题;πθ(⋅∣x)\pi_\theta(\cdot\mid x) 表示模型给定 xx 这道题时所有可能回答的概率分布,其中的“⋅\cdot”可以理解成一个通配符;y∼πθ(⋅∣x)y\sim\pi_\theta(\cdot\mid x) 表示再按这个分布实际采样一条回答。R(x,y)R(x,y) 是 verifier 对“题目 xx + 回答 yy”给出的 reward,E\mathbb E 表示对抽到的题目和回答求期望。换句话说:希望模型自己采样出来的回答,平均能拿到更高的奖励。

这一目标是在 最大化期望 reward。关键变化是 yy 不再来自静态数据,而来自当前策略 πθ\pi_\theta。所以每次更新后,下一轮 rollout 的分布也会变化:

RL 的反馈回路:Prompts 进入 Rollout Engine 采样并执行工具,Verifier 计算 Reward 与 Advantage,Training Engine 更新 Policy 权重后,同步回 Rollout Engine 再生成数据。

注意:这里的 rollout 引擎你可以看到是 vLLM / SGLang,因为我们需要采样 y∼πθ(⋅∣x)y\sim\pi_\theta(\cdot\mid x),也即,调用推理引擎,让模型真正去解题。另外,你可以发现这是一条反馈回路。某个错误倾向、过长的推理模式、采样偏差或者训练—推理数值偏差,又或者说,rollout 使用的模型的分布和训练的模型分布有区别,都有可能影响下一轮数据,继而被 放大 。这也是 RL 非常容易训崩的原因之一。因此,你可能会听到大家讨论 Off-policy,Async RL 的稳定性之类的名词。这也是本文稍后会讨论的内容。

Rollout 不是 Training Forward

在大模型 RL 中,Rollout(推理采样)、Training Forward(训练前向) 和 Training Backward(训练反向) 实际上跑在三套不同的 Kernel 上:

  • RL Rollout 调用的是 SGLang / vLLM 等推理引擎:走的是 Prefill + Autoregressive Decode 路径,依赖 Paged KV Cache、Continuous Batching 等专门面向低延迟或高吞吐推理优化的算子;
  • Training Forward 调用的是 Megatron / FSDP 等训练引擎:拿到已经生成的完整 token 序列后,像 Pretraining 一样做整段序列并行的前向计算,来重算 logprob 并构造 Loss;
  • Training Backward 则是第三套专门用于求导、激活重算与梯度归约的反向传播算子。(当然,如果 Forward 和 Backward 都由 torch 代劳的话,那会方便很多)

因为底层算子、分块策略与归约顺序完全不同,这三套 Kernel 中任何一个环节出现非确定性或数值偏差,都会导致计算出的概率或梯度发生精度漂移。这意味着:哪怕是完全同步的 RL——即推理和训练使用的是完全相同的模型权重 θ\theta——训练前向算出来的 token 概率,也往往不等于当时推理引擎采样它时的真实概率。 这种天然的 Train-Inference Mismatch(训推不一致),正是本文后半部分要深挖的主线。

从 REINFORCE 到 Advantage

Policy Gradient 的最简推导

为了最大化期望奖励 J(θ)J(\theta),我们需要对参数 θ\theta 求梯度 ∇θJ(θ)\nabla_\theta J(\theta)。这里我们先固定一道题目 xx,把期望按定义展开成对所有可能的完整回答 yy 求和:

J(θ)=Ey∼πθ(⋅∣x)[R(x,y)]=∑yπθ(y∣x)R(x,y).J(\theta)=\mathbb E_{y\sim\pi_\theta(\cdot\mid x)}[R(x,y)] = \sum_y\pi_\theta(y\mid x)R(x,y).

但直接对这个式子求导并计算会遇到两个麻烦:

  1. 奖励函数 R(x,y)R(x,y) 不可导:判题器(正则表达式、单元测试等)是个黑盒,只针对离散文本打分,没法直接对 RR 反向传播;
  2. 求和空间 ∑y\sum_y 无法穷举:一条几百 token 的回答有天文数字般的组合可能,工程上不可能遍历所有 yy,只能通过采样几条回答来估计梯度。

为了把梯度重新写回“可以通过采样 y∼πθy\sim\pi_\theta 来估计的期望形式”,REINFORCE 用到了对数求导恒等式 ∇θlog⁡πθ=∇θπθπθ\nabla_\theta \log \pi_\theta = \frac{\nabla_\theta \pi_\theta}{\pi_\theta}(即 ∇θπθ=πθ∇θlog⁡πθ\nabla_\theta\pi_\theta=\pi_\theta\nabla_\theta\log\pi_\theta):

∇θJ(θ)=∑y∇θπθ(y∣x) R(x,y)=∑yπθ(y∣x) ∇θlog⁡πθ(y∣x) R(x,y)=Ey∼πθ[R(x,y)∇θlog⁡πθ(y∣x)].\begin{aligned} \nabla_\theta J(\theta) &=\sum_y\nabla_\theta\pi_\theta(y\mid x)\,R(x,y)\\ &=\sum_y\pi_\theta(y\mid x)\,\nabla_\theta\log\pi_\theta(y\mid x)\,R(x,y)\\ &=\mathbb E_{y\sim\pi_\theta}\left[R(x,y)\nabla_\theta\log\pi_\theta(y\mid x)\right]. \end{aligned}

看中间那一行:通过把 ∇θπθ\nabla_\theta\pi_\theta 拆成 πθ⋅∇θlog⁡πθ\pi_\theta \cdot \nabla_\theta\log\pi_\theta,我们重新凑出了概率分布 πθ(y∣x)\pi_\theta(y\mid x),于是对所有 yy 的加权求和 ∑yπθ(y∣x)[… ]\sum_y \pi_\theta(y\mid x)[\dots] 就顺理成章地变回了期望 Ey∼πθ[… ]\mathbb E_{y\sim\pi_\theta}[\dots]。

这个变形一举解决了上面的两个难题:

  • 不需要对 R(x,y)R(x,y) 求导:梯度算子 ∇θ\nabla_\theta 只作用在模型自身的生成概率 log⁡πθ(y∣x)\log\pi_\theta(y\mid x)(称为 score function)上,R(x,y)R(x,y) 只是一个标量权重;
  • 可以用采样代替穷举:既然是期望 Ey∼πθ\mathbb E_{y\sim\pi_\theta},我们只需要让模型自己 rollout 采样几条回答 yy,算出它们的 ∇θlog⁡πθ(y∣x)\nabla_\theta\log\pi_\theta(y\mid x) 并乘上对应的奖励 R(x,y)R(x,y),就能无偏地估计出梯度方向。

完整回答的 logprob 可以分解为:

log⁡πθ(y∣x)=∑t=1Tlog⁡πθ(yt∣x,y<t).\log\pi_\theta(y\mid x)=\sum_{t=1}^T\log\pi_\theta(y_t\mid x,y_{<t}).

这里左边的 πθ(y∣x)\pi_\theta(y\mid x) 是整条回答的联合概率,右边把它展开成每次生成下一个 token 的条件概率;这是概率的链式分解。后面为了书写简洁,我们会把“prompt xx 加上前 t−1t-1 个生成 token”统称为 ht=(x,y<t)h_t=(x,y_{<t}),于是 πθ(yt∣ht)\pi_\theta(y_t\mid h_t) 就是同一个条件概率。对比 Pretraining 的 objective

LPT(θ)=−E(x,y)∼D[∑t=1Tlog⁡πθ(yt∣x,y<t)].\mathcal L_{\mathrm{PT}}(\theta)=-\mathbb E_{(x,y)\sim D}\left[\sum_{t=1}^{T}\log\pi_\theta(y_t\mid x,y_{<t})\right].

这两者似乎惊人的相似,差别在于两点:(1)REINFORCE 看起来就像对模型自己生成的 token 做 reward-weighted Cross-Entropy;(2)这里我们是用采样估计 policy gradient,不是对不可微的抽样操作直接反向传播。

错误回答会被惩罚吗?

如果 correctness reward 为 R∈{0,1}R\in\{0,1\},直接用单样本目标 −Rlog⁡πθ(y∣x)-R\log\pi_\theta(y\mid x),当回答错误、R=0R=0 时:

∇θL=−R∇θlog⁡πθ(y∣x)=0.\nabla_\theta\mathcal L=-R\nabla_\theta\log\pi_\theta(y\mid x)=0.

错误的答案没有直接梯度。 它不是“奖励为零,所以模型仍然会降低错误答案的概率”。我们更想要的信号是:比预期好的回答应该被鼓励,比预期差的应该被压低。所以这自然引出了我们后面要讲的 Advantage 概念。

Advantage 的由来

定义 A(x,y)=R(x,y)−b(x)A(x,y)=R(x,y)-b(x),其中 b(x)b(x) 只依赖 prompt,不能依赖当前抽样回答本身。则:AA 是 Advantage(优势值),衡量回答的 reward 比参考水平高还是低;b(x)b(x) 就是这个参考水平,即 Baseline。它不必等于 0,也不一定来自另一个模型;例如可以是对这道题的预期 reward。写成 b(x)b(x) 而不是 b(x,y)b(x,y),是为了强调:固定题目后,不能随当前 sampled response 的取值而改变,否则下面“减掉它不改变期望梯度”的推导不成立(你会看到,如果 bb 和分布有关,则不能把它提出去)

Ey∼πθ[b(x)∇log⁡πθ(y∣x)]=b(x)∑y∇πθ(y∣x)=b(x)∇1=0.\begin{aligned} \mathbb E_{y\sim\pi_\theta}[b(x)\nabla\log\pi_\theta(y\mid x)] &=b(x)\sum_y\nabla\pi_\theta(y\mid x)\\ &=b(x)\nabla 1=0. \end{aligned}

所以 E[R∇log⁡π]=E[(R−b)∇log⁡π]\mathbb E[R\nabla\log\pi]=\mathbb E[(R-b)\nabla\log\pi]。合适的 baseline 不改变期望 policy gradient,却能降低估计方差。

  • A>0A>0:提高这条采样回答的 logprob。
  • A<0A<0:降低这条回答的 logprob。
  • A=0A=0:该样本没有 policy-gradient 信号。

GRPO 估计 Advantage 的方法

Actor-Critic 可以训练 Vπ(x)V^\pi(x) 当作 baseline(PPO 是这样做的),但 GRPO 不需要额外的 Critic。对每个 prompt 采样 GG 条回答,使用 group reward 的均值和标准差:

Ai=Ri−RˉσR+ϵ.A_i=\frac{R_i-\bar R}{\sigma_R+\epsilon}.

这里 GG 是同一道 prompt 采样的回答条数,i∈{1,…,G}i\in\{1,\ldots,G\} 是回答编号;Ri=R(x,yi)R_i=R(x,y_i) 是第 ii 条回答的 reward。Rˉ=(1/G)∑i=1GRi\bar R=(1/G)\sum_{i=1}^{G}R_i 是这个 group 的平均 reward,σR\sigma_R 是这 GG 个 reward 的标准差;分子 Ri−RˉR_i-\bar R 表示比同组平均水平好多少。ϵ\epsilon 是防止 σR=0\sigma_R=0 时除零的一个很小常数(注意:它和后面 PPO clipping 的 ϵ\epsilon 虽然同名,但用途完全不同)。所以这条公式做了两步:先求相对表现,再用组内波动大小调整尺度。

例如 8 条回答中 2 条正确、6 条错误:

Rˉ=0.25,σR=0.25×0.75≈0.433.\bar R=0.25,\qquad\sigma_R=\sqrt{0.25\times0.75}\approx0.433.
回答仅减均值 R−RˉR-\bar R再除标准差后的 GRPO Advantage
正确+0.75+0.75+1.732+1.732
错误−0.25-0.25−0.577-0.577

所以我们既能鼓励正确回答,也能直接抑制错误回答。

值得注意的是,对二值 reward 的全对/全错 group,σR=0\sigma_R=0 且所有 centered advantage 都是零;实现中要妥善处理分母的 ϵ\epsilon,但它不能制造学习信号,所以你可以在后续看到 DAPO 是怎么处理这件事的。

Importance Ratio 和 PPO Clipping

为什么“旧 Policy 采样,新 Policy 训练”需要修正?

目标是当前 Policy 下的期望,但一个 batch 的样本往往来自采样时的旧 Policy πold\pi_{\mathrm{old}}。对于固定 prefix hh 和 token aa:

Ea∼πθ[f(a)]=∑aπold(a∣h)πθ(a∣h)πold(a∣h)f(a)=Ea∼πold[r(a∣h)f(a)],where r(a∣h)=πθ(a∣h)πold(a∣h).\begin{aligned} \mathbb E_{a\sim\pi_\theta}[f(a)] &=\sum_a\pi_{\mathrm{old}}(a\mid h)\frac{\pi_\theta(a\mid h)}{\pi_{\mathrm{old}}(a\mid h)}f(a)\\ &=\mathbb E_{a\sim\pi_{\mathrm{old}}}[r(a\mid h)f(a)],\\ \text{where }r(a\mid h)&=\frac{\pi_\theta(a\mid h)}{\pi_{\mathrm{old}}(a\mid h)}. \end{aligned}

这次只考虑某个固定的生成前缀 hh:aa 是接下来可能生成的一个 token(或 action),πold(a∣h)\pi_{\mathrm{old}}(a\mid h) 是采样时的旧策略给它的概率,πθ(a∣h)\pi_\theta(a\mid h) 是当前策略给它的概率。f(a)f(a) 是我们想对它求期望的任意量;∑a\sum_a 是把词表中所有可能 token 的贡献加起来。r(a∣h)r(a\mid h) 就是 Importance Ratio:新策略认为该 token 有多可能,相对于旧策略有多可能。等式的关键是“乘上旧概率、再除以旧概率”,从而能用旧策略的样本估计新策略下的期望;它要求分母在相关位置非零。

举例:旧 Policy 给一个 token 的概率是 0.10.1,当前 Policy 是 0.20.2,那么 r=2r=2,旧分布中的该样本在估计新分布期望时需要被放大两倍。前提是旧分布覆盖了新分布的支持集。

但注意:整条自回归 trajectory 的严格重要性比率是所有 token ratio 的乘积:

πθ(y∣x)πold(y∣x)=∏t=1Tπθ(yt∣ht)πold(yt∣ht).\frac{\pi_\theta(y\mid x)}{\pi_{\mathrm{old}}(y\mid x)} =\prod_{t=1}^{T}\frac{\pi_\theta(y_t\mid h_t)}{\pi_{\mathrm{old}}(y_t\mid h_t)}.

∏t=1T\prod_{t=1}^{T} 是连乘(对应前面 ∑\sum 的连加);每个 hth_t 都是该 token 之前的真实 prefix。由于整条回答的概率等于各 token 条件概率的乘积,整条 trajectory 的重要性比率也要将各 token 的比率相乘。

为什么实际训练不直接用这个连乘比率?想象一条长 T=1000T=1000 个 token 的推理回答:哪怕每个 token 的新旧概率比只波动 1%1\%,连乘之后也会变成 1.011000≈209591.01^{1000}\approx 20959 或 0.991000≈0.000040.99^{1000}\approx 0.00004,导致梯度方差爆炸或直接消失。因此,PPO/GRPO 放弃了严格的整条轨迹连乘,退而求其次,把重要性采样近似到了单个 token 级别——每个位置只看自己的单步比率 rt=πθ(yt∣ht)/πold(yt∣ht)r_t=\pi_\theta(y_t\mid h_t)/\pi_{\mathrm{old}}(y_t\mid h_t)。

PPO 的 Clipping 到底裁剪什么?

即使改成了单 token 比率 rtr_t,如果我们直接最大化未裁剪的目标 rtAtr_t A_t,依然会出问题:只要某个 token 的优势 At>0A_t>0,模型为了拿高分就会拼命把 rtr_t 往无穷大推,换言之,模型有动力尽可能远离原始的分布;但 πθ\pi_\theta 一旦偏离 πold\pi_{\mathrm{old}} 太远,基于旧样本估计出的梯度就不再可信了。

因此,我们需要一个见好就收的刹车机制:当 rtr_t 偏离 11 超过容忍幅度 ϵ\epsilon(例如 ϵ=0.2\epsilon=0.2,即合法区间为 [0.8,1.2][0.8, 1.2])时,就停止继续给它提供梯度。这就是 PPO 最大化的 clipped surrogate objective:

LtCLIP(θ)=min⁡(rtAt, clip⁡(rt,1−ϵ,1+ϵ)At).\boxed{L^{\mathrm{CLIP}}_t(\theta)=\min\big(r_tA_t,\ \operatorname{clip}(r_t,1-\epsilon,1+\epsilon)A_t\big).}

先理解一下符号:

  • rt=πθ(yt∣ht)/πold(yt∣ht)r_t=\pi_\theta(y_t\mid h_t)/\pi_{\mathrm{old}}(y_t\mid h_t) 是第 tt 个 token 的新旧策略概率比;
  • AtA_t 是分配给该 token 的 Advantage(在 outcome-level GRPO 中,同一条回答里的所有 token 通常共享同一个 AiA_i);
  • clip⁡(rt,1−ϵ,1+ϵ)\operatorname{clip}(r_t, 1-\epsilon, 1+\epsilon) 会把超出 [1−ϵ,1+ϵ][1-\epsilon, 1+\epsilon] 的 rtr_t 强行截平为边界常数。注意一旦函数被截平为常数,它对参数 θ\theta 的梯度就变成了 00;
  • LtCLIPL_t^{\mathrm{CLIP}} 是要最大化的每 token 目标(代码里用梯度下降实现时,会在最外面加个负号变成 Loss)。注意:这是最大化!因为观察式子,是 rtAtr_t A_t,也即每个 token 加权后的 advantage.

初看这个公式,最容易产生两个疑问:

  1. 为什么要把 clip 套在 rtr_t 上,而不是直接截断整项 rtAtr_t A_t? 因为我们要限制的是新旧策略的偏离幅度(即比率 rtr_t 不能离 11 太远),而不是限制 Advantage 本身的大小。
  2. 既然已经有了 clip⁡\operatorname{clip},为什么外面还要套一层 min⁡\min? 如果只用 clip⁡(rt,1−ϵ,1+ϵ)At\operatorname{clip}(r_t, 1-\epsilon, 1+\epsilon)A_t,那么无论 rtr_t 是往“好方向”改过了头,还是往“坏方向”跑偏了,只要超出 [1−ϵ,1+ϵ][1-\epsilon, 1+\epsilon] 梯度都会变成 00——这意味着一旦模型不小心把好 token 的概率降得太低(比如 At>0A_t>0 但 rt=0.5r_t=0.5),梯度也没了,模型再也无法纠错。 外层套上 min⁡\min(取悲观下界)后,就实现了一个精巧的单向刹车:只有当策略朝着有利方向越界时,才触发截断(梯度归零);如果策略朝着错误方向越界,则保留原项 rtAtr_t A_t 的梯度把它拉回来。 没听懂?没关系,看一下这个例子,取 ϵ=0.2\epsilon=0.2:
AdvantageRatio 越过哪里发生什么
A>0A>0r>1.2r>1.2偏离太多,停止继续鼓励该 token
A>0A>0r<0.8r<0.8概率错误地下降了,仍需鼓励提高
A<0A<0r<0.8r<0.8已经降低太多,停止继续惩罚该 token
A<0A<0r>1.2r>1.2错误 token 反而更常见,仍需推动下降

Understand?对于正负 Advantage, clip 控制的方向是相反的!对于正的 Advantage, 我们要避免让 rr 过大,而对于负的 Advantage, 我们要避免让 rr 过小。

PPO 为什么要用 Clipping?

Okay 学到这里你可能会想,PPO 为什么要引入 clipping?我们回想一下我们是为什么要引入 rtr_t?是因为“旧 Policy 采样,新 Policy 训练”,导致两个分布不完全一致。如果训练和采样的 Policy 完全一致,那就根本不需要引入 rtr_t 啊?难道那个时候大家已经开始研究 Async RL 了?事实上并不是。

PPO 典型的设计允许在同一批 rollout 数据上反复做多个 optimization epochs / minibatch updates,即使训练流程完全同步:

同步 PPO 的多轮更新:旧权重 θ0 生成一批样本,Update 1、2、3 依次把权重更新到 θ1、θ2、θ3,三次更新始终复用 θ0 采样的同一批数据,之后才用新权重开始下一次 Rollout。

分母始终是采样时的 πθ0\pi_{\theta_0},所以第二次开始就已经是 r≠1r\ne1 了,因为参数已经经过了一次更新。这时 clipping 防止旧样本连续推动 Policy 偏离得太远。

其中 θ0\theta_0 是采样时的模型参数快照,θ1,θ2,θ3\theta_1,\theta_2,\theta_3 是后续 optimizer updates 后得到的参数。即使分子变成 πθ2\pi_{\theta_2},分母也仍然应该保持为 πθ0\pi_{\theta_0},否则 ratio 衡量的就不是“当前策略相对生成这些样本的旧策略变化了多少”。

但是如果我们保证严格同步、训推一致、每 batch 只做一次更新。 更新前 rt(θ0)=1r_t(\theta_0)=1,clipping 不发挥作用。但梯度不是零:

∇θrt(θ)∣θ0=∇θlog⁡πθ0(yt∣ht),\left.\nabla_\theta r_t(\theta)\right|_{\theta_0} =\nabla_\theta\log\pi_{\theta_0}(y_t\mid h_t),

所以:

∇θLtCLIP∣θ0=At∇θlog⁡πθ0(yt∣ht).\left.\nabla_\theta L_t^{\mathrm{CLIP}}\right|_{\theta_0} =A_t\nabla_\theta\log\pi_{\theta_0}(y_t\mid h_t).

这里竖线下标 ⋅∣θ0\left.\cdot\right|_{\theta_0} 表示:先对参数求导,再把参数代入采样时的 θ0\theta_0。虽然这时 rtr_t 的数值是 1,但它对 θ\theta 的导数并不是零,右边仍然有 AtA_t 倍的 logprob 梯度。因此首次单步更新的梯度局部等价于 Advantage-weighted Policy Gradient。

GRPO 之后:DAPO、Dr.GRPO、GSPO 在修改什么?

读完 GRPO 和 PPO Clipping,再看 2025 年之后涌现的 DAPO、Dr.GRPO、GSPO 等一众论文,我们可以从“他们解决了 GRPO 中的什么痛点”的角度去理解。把前面几节的组件拼起来,GRPO 实际优化的完整目标是:

JGRPO=1G∑i=1G1Ti∑t=1Tiℓi,t,ℓi,t=min⁡(ri,tAi, clip⁡(ri,t,1−ϵ,1+ϵ)Ai),J_{\mathrm{GRPO}}=\frac1G\sum_{i=1}^{G}\frac1{T_i}\sum_{t=1}^{T_i}\ell_{i,t}, \qquad \ell_{i,t}=\min\big(r_{i,t}A_i,\ \operatorname{clip}(r_{i,t},1-\epsilon,1+\epsilon)A_i\big),

其中 GG 就是 group,TiT_i 是第 ii 个 sample 的 token 数,Ai=(Ri−Rˉ)/(σR+ϵ)A_i=(R_i-\bar R)/(\sigma_R+\epsilon) 是组内相对 Advantage,ri,t=πθ(yt∣ht)/πold(yt∣ht)r_{i,t}=\pi_\theta(y_t\mid h_t)/\pi_{\mathrm{old}}(y_t\mid h_t) 是逐 token 的新旧策略比率。稍微细细想一下,对着这条公式逐项检查,有四个隐患:

  • 对称的 clip 区间 [1−ϵ,1+ϵ][1-\epsilon,1+\epsilon]:探索空间过早坍塌。 一条罕见但正确的推理路径被正 Advantage 强化时,token 概率快速上升,ri,tr_{i,t} 很快碰到 1+ϵ1+\epsilon 的上界、梯度归零——模型刚探索到的新能力被过早刹车,策略容易集中到少数固定套路。
  • AiA_i 的分子 Ri−RˉR_i-\bar R:全对/全错的 group 没有信号。 组内 reward 完全相同时 σR=0\sigma_R=0、所有 Ai=0A_i=0,整组 rollout 烧掉的算力不产生任何梯度,训练被白白浪费。
  • 归约里的 1Ti\frac1{T_i}(以及 σR\sigma_R):梯度权重失衡。 长回答的每个 token 被自己的长度稀释,短回答的单 token 权重反而被放大;除以 σR\sigma_R 还会悄悄改变不同难度题目的相对权重。
  • 逐 token 的 ri,tr_{i,t}:长序列下 Token 级概率比率的剧烈噪声。 单步比率方差大,缺乏序列级的协调性。

DAPO、Dr.GRPO、GSPO 的各项改动,正好分别落在这四个位置上。

DAPO:Clip-Higher

回顾前面的 PPO,裁剪区间 [1−ϵ,1+ϵ][1-\epsilon, 1+\epsilon] 是上下对称的(例如统一取 ϵ=0.2\epsilon=0.2)。DAPO 做的第一个改动,就是把这个对称裁剪拆成了上下两个独立的边界 ϵlow\epsilon_{\mathrm{low}} 和 ϵhigh\epsilon_{\mathrm{high}}:

LDAPO,t=min⁡(rtAt,clip⁡(rt,1−ϵlow,1+ϵhigh)At).L_{\mathrm{DAPO},t} =\min\left(r_tA_t,\operatorname{clip}(r_t,1-\epsilon_{\mathrm{low}},1+\epsilon_{\mathrm{high}})A_t\right).

这里的 ϵlow\epsilon_{\mathrm{low}} 和 ϵhigh\epsilon_{\mathrm{high}} 分别控制比率 rtr_t 可以向下、向上偏离 11 多远。二者不必相等:DAPO 保持 ϵlow\epsilon_{\mathrm{low}} 不变,单独把上界 ϵhigh\epsilon_{\mathrm{high}} 调大(即所谓的 Clip-Higher)。注意,这并没有直接放大正 Advantage AtA_t 的数值,而是延后了“当 At>0A_t>0 且 rtr_t 向上增长时,触碰上界并失去梯度”的时刻。

举个具体的例子,取 ϵlow=0.2\epsilon_{\mathrm{low}}=0.2,ϵhigh=0.28\epsilon_{\mathrm{high}}=0.28:

  • 对于正优势样本(A=+1A=+1),当 r=1.25r=1.25 时:传统 PPO 的上界是 1.21.2,此时梯度已经归零;而 DAPO 的上界放宽到了 1.281.28,仍然允许模型继续强化这个 token。
  • 对于负优势样本(A=−1A=-1),当 r=0.5r=0.5 时:两者的下界都是 0.80.8,都已经触发刹车、停止继续向下打压。

为什么要对正向路径“网开一面”?模型在解一道难题时,好不容易探索出一条罕见但正确的推理路径,这往往代表着模型刚萌芽的新能力(这种轨迹往往和之前偏移更大)。如果因为它的新旧概率比 rtr_t 涨得快,就按老规矩在 1.21.2 处早早把梯度截断掉,就浪费了极其珍贵的学习信号。放宽上界能给这些不常见的正确路径留出更大的上调空间,从而缓解策略过早集中到少数固定套路(即 Entropy Collapse)。

不过,理解 Clip-Higher 时有2个细节:

  • Clip-Higher 不会在 r=1r=1 时放大正向初始梯度:它没有给正优势乘更大的系数,仅仅是拓宽了后续多次更新时正向梯度不被截断的跑道。
  • 放宽上界并不保证 Entropy 一定上升:虽然抬高稀有优质路径的概率有助于增加多样性,但如果被继续强化的恰好是本就占主导地位的高概率正确路径(注意:rr 大只代表偏移大,并不代表这条轨迹一定很罕见。倘若 πθ(a∣h)=0.6\pi_\theta(a\mid h)=0.6, πold(a∣h)=0.5\pi_{\mathrm{old}}(a\mid h)=0.5 此时 rr 也是 1.2),分布反而会变得更尖锐,使 Entropy 下降。此外,一条正确的长回答里往往也夹杂着冗余步骤,Trajectory-level 的正 Advantage 会连带把这些废话一起强化。

DAPO:Dynamic Sampling

我们在前面讲 GRPO 的 Advantage 时提到过一个情况:如果同一个 Prompt 采样出的 GG 条回答全部正确或全部错误,组内标准差 σR=0\sigma_R=0,居中后的分子 Ri−RˉR_i-\bar R 也全为 00——整组样本算完没有任何 Policy Gradient 信号。

我们可以算一个简单的概率题:对某道题,假设每次采样相互独立、单次答对的概率为 pp,每组采样 GG 条。那么这组采样“至少有一条对、且至少有一条错”(即能产生有效梯度)的概率是:

P(useful group)=1−pG−(1−p)G.\boxed{P(\mathrm{useful\ group})=1-p^G-(1-p)^G.}

取 G=8G=8 画出 P(useful group)P(\mathrm{useful\ group}) 随 pp 的变化:当题目太难(p→0p\to0)或太简单(p→1p\to1)时,绝大多数 Group 都是全错或全对,只有难度适中的题目最容易产生有效信号:

有效 Group 概率随单题正确率 p 的变化(G=8):两端迅速跌向 0,p 在 0.2 到 0.8 之间的有效率超过 83%,p=0.5 时达到峰值 99.22%。

DAPO 的 Dynamic Sampling 做的事情非常直接:在收集训练 Batch 时,直接过滤掉这些全对或全错、Reward 毫无差异的 Group,让推理引擎继续采样新题,直到凑够指定数量的有效 Group 才启动一次训练更新。这样做相当于把训练步预算,全部分配给了当前模型“跳一跳够得着”、有时回答正确、有时错误的题目。随着模型能力提升,原本全错的难题 pp 逐渐上升,自然会进入有效区间,从而形成了隐式的题目难度 scheduler,题目的分布自然变成了:初始简单,而后续简单题变少,难题增多。

但我们也要看清它的局限与代价:

  • 它不是显式的“由易到难”调度器:如果一道超难题对当前模型来说始终是 p=0p=0(每次都全错),它就会被永远过滤掉,模型永远学不到它。对于这类题目,往往还需要配合难度自适应采样、过程奖励(Partial Reward)、任务拆解或中间过渡任务。
  • 提升样本效率,不等于省了 Rollout 算力:这是系统视角下最关键的一点。假设当前题库里只有 10%10\% 的 Group 有效,为了凑齐 128128 组有效样本送去训练,推理侧期望需要采样 12801280 个 Group;按每组 88 条算,就是整整 10,24010,240 次回答生成,而不是平时的 10241024 次。训练步的样本效率提升了,但推理侧的 GPU 算力消耗可能成倍增加——我们只是达成了“相同 step 数量下,我的样本效率更高” 而不是 “相同时间 budget 下,我的效率更高”。

DAPO: Loss Reduction

在 RL 里,你在做 Loss Reduction(归约求平均)时先除以什么、后除以什么,会直接改变不同长度回答在总梯度中的话语权。 回看开头 JGRPOJ_{\mathrm{GRPO}} 的归约部分 1G∑i=1G1Ti∑t=1Ti\frac1G\sum_{i=1}^{G}\frac1{T_i}\sum_{t=1}^{T_i}:GG 是回答条数,TiT_i 是第 ii 条回答实际生成的有效 token 数(不含 Prompt 和 Padding)。这种原始 GRPO 常见的 Response-level Reduction 分两步平均:先算 1Ti∑t\frac1{T_i}\sum_t,把每条回答压成一个均值;再算 1G∑i\frac1G\sum_i,让每条回答平起平坐。

这会带来什么后果?假设一个 G=2G=2 的组里有两条回答:回答 A 很精炼,只有 100100 个 tokens;回答 B 极其啰嗦,有 10,00010,000 个 tokens。在 JGRPOJ_{\mathrm{GRPO}} 中,回答 A 里每个 token 前面的系数是 12×100=1200\frac{1}{2\times 100}=\frac{1}{200},而回答 B 里每个 token 的系数是 12×10000=120000\frac{1}{2\times 10000}=\frac{1}{20000}。也就是说,短回答 A 中单个 token 的归一化权重是长回答 B 的 100 倍,尽管两条回答作为整体的总归一化权重是一样的。

为了消除这种“按回答长度稀释单 token 权重”的现象,DAPO 改成了与预训练更接近的 Token-level Reduction:

JDAPO=∑i∑t=1Tiℓi,t∑iTi.J_{\mathrm{DAPO}}=\frac{\sum_i\sum_{t=1}^{T_i}\ell_{i,t}}{\sum_iT_i}.

这里分母不再是回答数 GG,而是整个 Batch 里所有有效生成 token 的总数 Ntokens=∑iTiN_{\mathrm{tokens}}=\sum_iT_i。换句话说,DAPO 先把所有回答的 token 倒进同一个大池子里,再让每个 token 平分权重。

这样一来,A 和 B 里每个有效 token 的权重完全相等;但代价是,长达 10,00010,000 个 token 的回答 B,其整条轨迹贡献的总权重变成了短回答 A 的 100100 倍。这很容易得出一个粗糙的结论:“GRPO 偏袒短回答,DAPO 偏袒长回答”。其实远没有这么简单:Advantage 有正有负,加权方式会同时作用于好的和坏的轨迹:

  • 在 GRPO(Response-level) 下,如果模型生成了一条又臭又长的错误回答(A<0A<0,且 TiT_i 很大),由于分母除以了巨大的 TiT_i,里面每个错误 token 受到的惩罚反而被严重稀释了。结果就是:模型写错并不可怕,只要写得足够长,每个坏 token 挨的板子就很轻——这反而可能导致错误回答越训越冗长。
  • 反过来,在 DAPO(Token-level) 下,如果模型生成了一条很长、虽然最终答对但中间充斥着大量废话的回答(A>0A>0),这条长回答就会在 Batch 总梯度中占据压倒性的比重,这同样有鼓励冗余的隐患。

此外,从跨 Batch 角度看,如果两个 Batch 的有效 token 总数分别是 8K 和 64K,DAPO 因为除以了各自的 ∑iTi\sum_i T_i,只要两批 token 的平均梯度向量大致相近,算出来的总梯度尺度就会保持稳定,不会因为某个 Batch 吐出的字数多就凭空放大 8 倍(当然,真实梯度在各维度上还会互相抵消,这里只是说明量级关系)。

DAPO: Overlong Reward Shaping

在长思维链训练中,经常遇到一种情况:模型其实走在正确的推理方向上,推导过程也很有价值,但因为思考得太细致,还没来得及写出最终答案就撞上了 max_tokens 被强行截断。如果因为提取不到最终答案,就直接按“完全答错”给它记 0 分甚至重罚,给模型的信号未免过于粗暴。

为了缓解这种硬截断带来的奖励突变,DAPO 引入了平滑的超长惩罚(Overlong Reward Shaping)。例如原论文在上限 20,48020,480 之前,设置了一个从 16,38416,384 到 20,48020,480(宽度为 40964096 tokens)的缓冲区间:

Rlen(T)={0,T≤16384,−T−163844096,16384<T≤20480,−1,T>20480.R_{\mathrm{len}}(T)= \begin{cases} 0,& T\le 16384,\\ -\dfrac{T-16384}{4096},&16384<T\le20480,\\ -1,&T>20480. \end{cases}

这里 TT 是回答生成的 token 数,Rlen(T)R_{\mathrm{len}}(T) 是一个附加的长度惩罚项(取值非正):在 16,38416,384 以内完全不罚;进入缓冲区后,惩罚随着长度线性加重(比如到了缓冲区中点 18,43218,432 时,附加惩罚为 −0.5-0.5);超过 20,48020,480 则封顶为 −1-1。这项惩罚会直接加到任务本身的 Correctness Reward 上,而不是另起炉灶搞一个新的 Policy Loss。

要注意,Overlong Shaping 的目的不是无差别打压长推理,而是避免模型在跨过最大长度红线的那一刻遭遇断崖式的奖励跳变。至于 16,38416,384 和 20,48020,480 这两个具体数字,只是原论文实验里的一套超参 Recipe,实际用时需要根据你自己的任务长度上限来调整。

Dr.GRPO:剔除归一化

如果说 DAPO 是在工程与采样策略上做加法,那么 Dr.GRPO 的核心主张就是做减法:尽可能剔除人为引入的归一化项,还原最接近无偏 Policy Gradient 的优化目标。

它对标准 GRPO 动了两刀。第一刀,砍掉了 Advantage 分母上的组内标准差 σR\sigma_R,只保留减均值(居中):

AiDr.GRPO=Ri−Rˉ.A_i^{\mathrm{Dr.GRPO}}=R_i-\bar R.

第二刀,砍掉了 Loss 归约时依赖当前采样长度的动态分母(无论是每条回答自己的 TiT_i,还是当前 Batch 的总长度 ∑iTi\sum_i T_i),统一改成与实际生成多长无关的固定常数(例如 GLmax⁡G L_{\max}):

JDr.GRPO=1GLmax⁡∑i∑tℓi,t.J_{\mathrm{Dr.GRPO}}=\frac{1}{G L_{\max}}\sum_i\sum_t\ell_{i,t}.

这里的 Lmax⁡L_{\max} 是预先设定的最大生成长度上限(或其他固定长度常数),注意不要和前面的代理目标 LtCLIPL_t^{\mathrm{CLIP}} 混淆。对于给定的训练配置,GLmax⁡G L_{\max} 是一个彻头彻尾的常数,分子则只对实际生成的有效 token 求和。

为什么要把 std 拿掉?对比两个二值 reward(对为 11、错为 00)的 Group,设 G=8G=8:

  • 难题 Prompt A:8 条回答里只有 1 条正确。均值 Rˉ=1/8\bar R=1/8,总体标准差 σ=7/8\sigma=\sqrt{7}/8。代入标准 GRPO 公式后,那条唯一正确的回答拿到的 Advantage 高达 +7≈+2.646+\sqrt{7}\approx+2.646,其余错误回答则是 −1/7≈−0.378-1/\sqrt{7}\approx-0.378。
  • 中等题 Prompt B:8 条回答里有 4 条正确。均值 Rˉ=1/2\bar R=1/2,标准差 σ=1/2\sigma=1/2。此时正确和错误回答的 Advantage 分别是温和的 +1+1 与 −1-1。

由于除以了 σR\sigma_R,标准 GRPO 在难题(pp 接近 00)上会给罕见的成功赋予极大的正向权重;对称地,在极简单的题(pp 接近 11)上,它也会对罕见的失误施以重罚。从代数上看,只要一个 Group 不是全对或全错,用总体标准差归一化后,组内所有 Advantage 的平方和恒为定值 ∑iAi2=G\sum_i A_i^2 = G——也就是说,无论这道题原本处于什么难度区间,它产生梯度的整体模长都被强行拉平了。

如果像 Dr.GRPO 一样去掉 σR\sigma_R 呢?此时二值奖励下的平方和变成了 ∑iAi2=Gp(1−p)\sum_i A_i^2 = Gp(1-p):上面难题 A 组的值是 0.8750.875,而中等题 B 组是 22,权重自然向中等难度的题目倾斜。我们可以用一个极简的 Toy Model 来看看为什么这是“原始”的梯度尺度:假设模型只有一个可学习的标量 logit zz,用来控制“答对”这一动作的概率 p=σ(z)=11+e−zp=\sigma(z)=\frac{1}{1+e^{-z}}(σ(⋅)\sigma(\cdot) 为 Sigmoid 函数),那么期望奖励就是 J(z)=pJ(z)=p。它对参数 zz 的真实导数恰好是:

∂J∂z=p(1−p).\frac{\partial J}{\partial z}=p(1-p).

(这里 ∂J/∂z\partial J/\partial z 是对标量参数 zz 求偏导,和前面针对整个参数向量的 ∇θJ\nabla_\theta J 方向一致。)如果在估计梯度时强行除以 σR=p(1−p)\sigma_R=\sqrt{p(1-p)},期望梯度的尺度就会从 p(1−p)p(1-p) 被扭曲成大约 p(1−p)\sqrt{p(1-p)},从而人为改变了不同难度题目之间的相对权重——这就是 Dr.GRPO 所批评的 Question-level Difficulty Bias(题目难度偏差)。当然,这只是用来说明直觉的简化模型,有限采样下的样本标准差和真实深度网络的梯度向量还要复杂得多。

不过,我个人并不认为“给难题里的罕见成功更高权重”就一定是件坏事。如果你的训练目标是突破能力边界,难题里撞大运试出来的一次成功本来就价值连城。问题不在于能不能加权,而在于这种加权应该是我们心知肚明的,而不是在套用公式时被分母里的 σR\sigma_R 不知不觉地代劳了。 甚至,我们可以把这两种选择统一写成一个带超参数 α\alpha 的谱系:

Ai(α)=Ri−Rˉ(σR+ϵ)α,0≤α≤1.A_i^{(\alpha)}=\frac{R_i-\bar R}{(\sigma_R+\epsilon)^\alpha},\quad 0\le\alpha\le1.

这里的 α\alpha 是人为调节归一化强度的旋钮:取 α=0\alpha=0 就退化为 Dr.GRPO,取 α=1\alpha=1 就是标准 GRPO,取在 (0,1)(0,1) 之间则是折中方案。(声明一下,这个式子只是为了帮你看清设计空间,并不是一个已经过大规模验证的新算法;而且对于 p≈0p\approx 0、根本采不出正确答案的极难题,α\alpha 调得再大也无济于事。)

同样地,Dr.GRPO 改用固定分母 GLmax⁡G L_{\max} 也不是没有代价的:假设 Batch B 的有效生成 token 总数是 Batch A 的 88 倍,且每个 token 的平均梯度方向差不多,那么在固定分母 GLmax⁡G L_{\max} 下,Batch B 算出来的整体梯度就会是 Batch A 的 88 倍;而 DAPO 的 ∑iTi\sum_i T_i 动态分母反而会让这两个 Batch 的梯度尺度保持接近。所以,它们本质上是在调整不同长度样本的权重分配与跨 Batch 的有效学习率尺度,并不是非黑即白的对错之分。所以 Dr.GRPO (他们称之为 GRPO Done Right) 到底有没有真正的 done right? 其实我觉得也不好说。

GSPO:Sequence Ratio

我们在前面讲 Importance Sampling 时留了一个伏笔:理论上,整条自回归轨迹的重要性比率应该是所有 token 比率的连乘 ∏t=1Trt\prod_{t=1}^T r_t;因为连乘在长序列下必然数值爆炸或归零,PPO 和 GRPO 才妥协成了每个位置只看自己的单步比率:

rt=πθ(yt∣ht)πold(yt∣ht).r_t=\frac{\pi_\theta(y_t\mid h_t)}{\pi_{\mathrm{old}}(y_t\mid h_t)}.

但逐 token 独立计算比率并做裁剪,在长序列尤其是 MoE 模型训练中又会带来新的麻烦:单个 token 的概率比噪声很大,同一条回答里有的 token 被截断、有的没被截断,容易破坏整条序列更新的协调性。

于是,GSPO(Group Sequence Policy Optimization) 提出把视角重新拉回整条序列——但它不是用会爆炸的连乘,而是先对整条回答所有 token 的 log⁡rt\log r_t 求算术平均,再取指数还原:

rGSPO=exp⁡(1T∑t=1Tlog⁡rt).\boxed{r_{\mathrm{GSPO}}=\exp\left(\frac1T\sum_{t=1}^{T}\log r_t\right).}

拆开来看,因为对数的平均等于乘积开 TT 次方,所以 rGSPO=(∏t=1Trt)1/Tr_{\mathrm{GSPO}}=\left(\prod_{t=1}^{T}r_t\right)^{1/T}——它其实就是整条回答所有 token 概率比的几何平均数。这样一来,不管是多长的回答,rGSPOr_{\mathrm{GSPO}} 都稳定在和单步比率相同的数值量级(接近 11 的正数),GSPO 再用这个序列级比率去统一衡量并裁剪整段回答的相对变化。原论文表明,这种做法有效降低了长序列下逐 token 比率的噪声,显著改善了 Qwen 系列(尤其是 MoE 模型)RL 训练的稳定性。

但这里要注意:开 TT 次方后的几何平均比率 (∏trt)1/T\left(\prod_t r_t\right)^{1/T},在数学上绝不等于严格无偏的完整轨迹重要性比率 ∏trt\prod_t r_t!两者的方差特性与对应的代理目标完全不同,千万不要把 GSPO 误解成“终于实现了严格无偏的序列级 Importance Sampling”。

沿着这个脉络往外看,2025 年还有不少有意思的探索:比如 CISPO 选择直接对 Importance Weight 本身做裁剪,而不是裁剪整个 PPO Surrogate 的梯度;VAPO 则选择重拾带 Value/Critic 网络的 PPO 路线,针对长思维链做专门的偏差与方差优化。此外,GSPO 在部分实现下还能减少对训练侧逐 token 重算旧策略 logprob 的依赖(具体取决于训练 Recipe)。这些工作共同说明了一件事:GRPO 并没有终结 RL 算法的设计,大家依然在“偏差—方差—长序列稳定性”的不可能三角里寻找更优的工程折中。

同步与异步 RL

四个容易混淆的 Policy

去读 RL 训练框架的代码时,你大概率很快就会发现:同样是“算某个策略的 logprob”,代码里居然有四套不同的变量名:

名称含义用在哪里
πb\pi_b / rollout_logprobs真正采样生成 token 的 Behavior PolicyImportance Weight 分母、训推一致性监控
πold\pi_{\mathrm{old}} / old_logprobs本轮 PPO 更新的旧策略基线(常由训练引擎重算)PPO / GRPO ratio 的分母
πθ\pi_\theta / current_logprobs正在做前向和反向求导的当前 Training PolicyPPO / GRPO ratio 的分子
πref\pi_{\mathrm{ref}} / ref_logprobs冻结不动的 Reference Policy(通常是 RL 初始模型)Reference KL 可以提供“相比初始”偏移多少的指标

看下标其实就能分清它们的身份:bb 代表 behavior(真正在推理引擎里采样出数据的那个策略),old\mathrm{old} 是本轮 PPO 优化周期固定下来的比较基线,θ\theta 是当前正在被优化器更新的参数,而 ref\mathrm{ref} 则是作为锚点的 reference 模型。当然,它们在模型结构上完全一样——除了 πref\pi_{\mathrm{ref}},其余几个往往只是不同时间点的权重快照,或者是同一份权重在不同引擎(推理 vs 训练)上算出来的结果,并不需要真的在显存里同时塞下四个模型。

我们重点需要搞清楚这两件事:这两个概率用的到底是不是同一个版本的模型权重? 以及 它们到底是不是在同一个 Kernel 上算出来的? 搞不清这两件事,看训练日志里的各种 “KL” 就会张冠李戴:Reference KL(比较 πθ\pi_\theta 和 πref\pi_{\mathrm{ref}})、PPO KL(比较 πθ\pi_\theta 和 πold\pi_{\mathrm{old}})、以及 Train–Inference KL(同一份权重,比较训练引擎和推理引擎的输出),虽然在日志里可能都顶着 kl 的名字,但它们衡量的其实是三对完全不同的分布。

Synchronous RL

很多人直觉上觉得:只要我是严格 Synchronous RL——也就是老老实实等一整个 Batch 的数据全部生成完,再开始训练——那么训练时的概率比 rtr_t 就应该等于 1。

其实不然。我们在前面讲 PPO 时已经提到过,标准的 PPO 设计允许在同一批 Rollout 数据上反复做多个 Epoch 或 Minibatch 的更新。只要第一次梯度更新把参数从 θ0\theta_0 推到了 θ1\theta_1,从第二次更新开始,你就已经处于“用旧策略 θ0\theta_0 采样、拿新策略 θ1\theta_1 训练”的 Off-policy 状态了,PPO Clipping 也就会立刻开始工作。另外更有可能发生的是,由于训推不一致(浮点数的 non-associativity),rollout 的 policy 和训练 forward 计算得到的 policy 不一致,导致 rtr_t 不等于 1。

换句话说,只有满足“权重版本完全相同、训推数值完全一致、且每批数据只做一次 Optimizer Update”这三条更苛刻的条件时,计算梯度那一刻的 ratio 才会严格等于 1。而且我们马上就会看到,哪怕你把这三条全满足了,工程实现上依然有别的坑等着你。

Fully Async 的收益与代价

为什么大家现在都在卷 Async RL?因为长思维链和 Agent 环境下的 Rollout 有着极其严重的长尾效应。在同步模式下,如果你想凑齐 1024 条轨迹再开始训练,只要其中有一小撮题目生成了 256K tokens 甚至 1M 的超长回答,或者某几个代码沙箱(Sandbox)卡住了,整个集群的 GPU 都得干等着这几条慢样本跑完,算力利用率非常低。

异步系统的核心思路,就是把 Rollout、Reward/Verifier 判题、Training 以及权重同步拆成流水线,让各个环节各跑各的、互不空等:

异步 RL 流水线:Rollout workers 依次用 θ100、θ100/101、θ101 生成样本,Training workers 错开地更新到 θ101、θ102、θ103,Verifier 与队列持续接收完成样本、计算 reward 并排队调度。

这样做能显著拉高 GPU 利用率、缩短端到端的训练时间,但天下没有免费的午餐,它必然会引入 Policy Staleness(策略滞后):你看图里,某条样本明明是旧权重 θ100\theta_{100} 生成出来的,等它在队列里排上号、真正被训练端消费时,训练侧的模型权重可能已经一路更新到 θ103\theta_{103} 了。更极端一点,如果系统允许在生成一条回答的中途热更新推理权重,那么同一条 Trajectory 的前半截和后半截甚至是由不同版本的模型拼出来的!这时候你如果还想把重要性采样算对,就必须按 token 粒度去记录当时真正的 Behavior Policy,连前半截老权重留下的 KV Cache 能不能直接给新权重接着用,在语义上都得重新打个问号。

从系统调度范式上看,目前主流方案大致分为三档:Synchronous(生成完一批再训一批)、Pipelined(训推分池、按批次做流水线重叠)和 Fully Async(推理端持续生成、训练端持续消费,显式管理版本滞后)。顺便提一句,“训推是否分卡部署(Disaggregated vs. Colocated)”和“调度上是同步还是异步”是两件独立的事,在实际系统里完全可以自由组合。

Numerical Mismatch 与 Policy Staleness

聊到这里,我们终于可以把“RL 训练为什么会变成 Off-policy”拆解成两个正交的根源了。对于固定前缀 hth_t 下采样出的同一个 token yty_t,我们不妨定义三个概率值:

pb=πθbrollout(yt∣ht),po=πθbtrain(yt∣ht),pc=πθctrain(yt∣ht).\begin{aligned} p_b &=\pi^{\mathrm{rollout}}_{\theta_b}(y_t\mid h_t),\\ p_o &=\pi^{\mathrm{train}}_{\theta_b}(y_t\mid h_t),\\ p_c &=\pi^{\mathrm{train}}_{\theta_c}(y_t\mid h_t). \end{aligned}

怎么读这三个量?看上标和下标就够了:上标 rollout\mathrm{rollout} 和 train\mathrm{train} 区分的是在哪个引擎上算(比如 SGLang/vLLM 还是 Megatron/FSDP),下标 θb\theta_b 和 θc\theta_c 区分的是用哪个版本的模型权重(θb\theta_b 是当时生成这条数据的 Behavior 权重,θc\theta_c 是现在训练消费它时的 Current 权重)。所以,pbp_b 是推理引擎采样时算出来的真实概率,pop_o 是把同一份旧权重 θb\theta_b 拿到训练引擎里重算出来的概率,而 pcp_c 则是新权重 θc\theta_c 在训练引擎下算出的概率。注意,这三个数评估的始终是同一个已采样 token yty_t 在相同上下文下的条件概率——它们是三个标量,不是重新采样了三次。

有了这三个定义,问题瞬间就清晰了:

  • 哪怕推理和训练用的是完全同一份权重 θb\theta_b,仅仅因为两边底层 Kernel、量化精度、浮点舍入顺序、Attention 实现或 MoE 路由的细微差异,算出来的概率也会出现 pb≠pop_b\ne p_o。这部分纯粹由软硬件执行路径不同带来的偏差,就是 Numerical Mismatch(训推数值不一致);
  • 而在同一个训练引擎内部,由于模型权重从 θb\theta_b 更新到了 θc\theta_c 导致的 po≠pcp_o\ne p_c,则是纯粹的 Policy Staleness(策略版本滞后)。

于是,当前训练策略相对真实采样策略的总概率比 pcpb\frac{p_c}{p_b},就可以干脆利落地拆成两项的乘积:

pcpb=popb⏟rnum⋅pcpo⏟rstale.\boxed{\frac{p_c}{p_b}=\underbrace{\frac{p_o}{p_b}}_{r_{\mathrm{num}}}\cdot\underbrace{\frac{p_c}{p_o}}_{r_{\mathrm{stale}}}.}

其中,rnumr_{\mathrm{num}} 专门衡量同权重、跨引擎的数值偏差,而 rstaler_{\mathrm{stale}} 专门衡量同引擎、跨版本的策略漂移。如果对两边取对数,乘法就变成了加法:

log⁡pcpb=(log⁡po−log⁡pb)+(log⁡pc−log⁡po).\log\frac{p_c}{p_b}=\left(\log p_o-\log p_b\right)+\left(\log p_c-\log p_o\right).

但这里有个数学细节:上面相加的是带正负号的 logprob 差值,而不是绝对值!因为三角不等式的存在,∣Δnum+Δstale∣|\Delta_{\mathrm{num}}+\Delta_{\mathrm{stale}}| 一般并不等于 ∣Δnum∣+∣Δstale∣|\Delta_{\mathrm{num}}|+|\Delta_{\mathrm{stale}}|。我们在分析实验日志时,绝不能简单粗暴地拿“异步训练的平均绝对误差”减去“同步训练的平均绝对误差”,去倒推 Staleness 有多大。

理解了这个拆分,我们就能摆正两者的关系:在异步训练里,Staleness 是追求高吞吐必然要付出的代价,但同权重下的 Numerical Mismatch 却是工程上可以消除的噪音。 只有先在底层把数值一致性做好,我们才能拿到一个干净准确的旧策略基线(让 po=pbp_o = p_b),然后再把真正的版本滞后交给上层的 TIS / MIS 等算法去修正(我们稍后就会提到);反过来,即使你做到了极致的逐位一致,也并不能替你消除异步训练带来的 rstaler_{\mathrm{stale}}。

一个例子

你可能会觉得:“训练和推理算出来的概率差一点点有什么关系?反正大概方向对就行了吧?” 我们不妨算一个非常具体的例子。

假设我们在做严格同步的 RL,模型权重还是初始的 θ0\theta_0,一步都没更新过。但是因为训推不一致,Rollout 引擎和 Training 引擎对某两个 token 算出来的概率出现了偏差。此时如果我们直接把 Rollout 记录的 rollout_logprobs 当作 PPO ratio 的分母:

TokenRollout Engine (pbp_b)Training Engine (pop_o)
Correct Token (A=+1A=+1)0.40.6
Wrong Token (A=−1A=-1)0.60.4

假设好 token 的优势 Acorrect=+1A_{\mathrm{correct}}=+1,坏 token 的优势 Awrong=−1A_{\mathrm{wrong}}=-1,PPO 的裁剪区间设为标准的 [0.8,1.2][0.8, 1.2]。我们来算一下训练第一步的 Clipped Surrogate Objective:

  • 对 Correct Token:比率 r=0.6/0.4=1.5r = 0.6 / 0.4 = 1.5,套入公式得到 L=min⁡(1.5×1, 1.2×1)=1.2L = \min(1.5 \times 1,\ 1.2 \times 1) = 1.2 —— 被 Clip,梯度归零!
  • 对 Wrong Token:比率 r=0.4/0.6≈0.667r = 0.4 / 0.6 \approx 0.667,套入公式得到 L=min⁡(0.667×(−1), 0.8×(−1))=−0.8L = \min(0.667 \times (-1),\ 0.8 \times (-1)) = -0.8 —— 被 Clip,梯度也归零!

模型明明连一次梯度更新都还没做过,仅仅因为训练和推理两个引擎算出的概率对不上,这两个 token 在第一步就双双触发了 PPO 的 clip,彻底丧失了学习信号! 如果这时候再叠加异步训练(Async RL)呢?假设 pb=0.40, po=0.60, pc=0.66p_b=0.40,\ p_o=0.60,\ p_c=0.66,那么:

rnum=0.600.40=1.5,rstale=0.660.60=1.1,rtotal=0.660.40=1.65.r_{\mathrm{num}}=\frac{0.60}{0.40}=1.5,\qquad r_{\mathrm{stale}}=\frac{0.66}{0.60}=1.1,\qquad r_{\mathrm{total}}=\frac{0.66}{0.40}=1.65.

你看,真实的策略滞后其实只有温和的 rstale=1.1r_{\mathrm{stale}}=1.1,本来完全在 [0.8,1.2][0.8, 1.2] 内,根本不该被截断;纯粹是因为前面乘了一个 1.51.5 的数值偏差,才冤枉地让其被 clip 了。

在工程实践中,当然还有一种更隐蔽的“掩耳盗铃”做法:有些框架在训练开始时,会先用训练引擎跑一次前向重算出 old_logprobs(即 pop_o),并把它当作 PPO 分母。这样一来,PPO 看到的比率就变成了 pc/po=1.1p_c / p_o = 1.1,TensorBoard 日志里的 ppo_kl 和 clipfrac 看起来岁月静好、健康无比。但实际上呢?真正的采样比率明明是 pc/pb=1.65p_c / p_b = 1.65!这种做法并没有消除偏差,它只是把真实的采样分布偏差偷偷藏到了 PPO ratio 的监控视野之外。哪怕你做的是“严格同步 + 每批只更新一次”,只要训练侧用自己重算的 pop_o 当分母,表面上 ratio 恒等于 11,背地里模型优化的依然不是实际吐出这些样本的那个分布。

顺着上面的拆分再多想一步:既然 rtotal=rnum⋅rstaler_{\mathrm{total}} = r_{\mathrm{num}} \cdot r_{\mathrm{stale}},那如果某一个 token 恰好遇到 rnum=1.5r_{\mathrm{num}}=1.5,同时因为版本滞后又有 rstale=0.7r_{\mathrm{stale}}=0.7 呢?

两者一乘,总比率 rtotal=1.05r_{\mathrm{total}} = 1.05!如果你在监控面板上只看最终合成的 Total Ratio,你会误以为当前训练和采样策略高度一致(只差了 5%5\%);可实际上,底层的数值偏差和版本滞后都已经偏离得很厉害了,只是碰巧一正一负抵消了而已。这就是为什么做 RL 系统诊断时,必须把同版本的训推数值差、策略版本滞后步数、新旧策略漂移拆开来单独记录,绝不能糊里糊涂揉成一个平均数了事。

TIS 与 MIS

既然在真实系统里,训推偏差和异步滞后很难彻底归零,那我们在算法层面能怎么补救?

目前最常见的手段是 TIS(Truncated Importance Sampling,截断重要性采样) 与 MIS(Masked / Filtered Importance Sampling)。

  • TIS 的思路很直接:既然真实生成样本的是 πb\pi_b,那我就显式算出训练策略相对真实采样策略的比率 wt=πtrain(yt∣ht)/πb(yt∣ht)w_t = \pi_{\mathrm{train}}(y_t\mid h_t) / \pi_b(y_t\mid h_t),把它作为重要性权重乘到第 tt 个 token 的梯度上;同时为了防止某些离群的 wtw_t 把梯度方差搞炸,再给 wtw_t 设一个截断上限——本质上是引入一点点截断偏差,来换取方差的稳定。
  • MIS 则更干脆:如果某条样本或某个 token 的偏离程度超过了阈值,直接把它 Mask 掉(过滤掉),不让它参与梯度更新。在异步 Agentic RL 系统里,大家还会进一步结合轨迹级信任域(Trajectory-level Trust Region)、最大版本滞后限制(Staleness Limit)以及过期样本丢弃等策略。

读代码或看日志时,一定要注意区分 TIS 的权重 wtw_t 和前面讲的 PPO Ratio:在构建 wtw_t 时,分母 πb\pi_b 必须是推理引擎当时实际生成该 token 的概率;wt>1w_t > 1 代表当前训练策略觉得这个 token 比采样时更有可能出现,因此需要放大它的权重,wt<1w_t < 1 则代表需要缩小。而且,TIS 的截断(直接给权重 wtw_t 封顶以压低方差)和 PPO 的截断(结合 Advantage 正负号在 Surrogate Objective 里做单向刹车)完全是两码事,日志里的 tis_clipfrac 和 pg_clipfrac 千万不能混为一谈。

因此,现代 RL 基础设施最推崇的是让每一层只解决自己该管的问题:

  1. 数值与算子:尽量消除同一份权重在训练和推理引擎之间的 Numerical Mismatch;
  2. 系统调度:控制异步流水线的版本 staleness,并准确记录每个 token 对应的版本;
  3. 算法:用 TIS / MIS 和 PPO Clipping 兜底,控制 Off-policy 更新的偏差与方差;

更广义的系统进展与 Agentic RL

如果我们把目光从单个 Loss 公式挪开,放眼整个 2025–2026 年的 LLM RL Infra 演进,会发现大家发力的核心方向非常明确:异步训练框架(如 AReaL、Slime、SkyRL)、长思维链的 Rollout 投机采样加速(如在严格保持采样分布无损的前提下引入 Speculative Decoding / MTP)、MoE 模型的训推路由一致性(如 R3,Rollout Routing Replay),以及多轮工具调用与 Agentic RL。

我们可以看几个有代表性的工程数据(当然,这些加速比高度依赖具体的模型规模、硬件配置与任务负载):比如 AReaL 在其 2025 年论文的实验环境下,相比同步系统拿到了最高约 2.57× 的训练吞吐提升(不过,我在去年使用 AReaL 的时候体验相当相当的糟糕);2026 年 NVIDIA NeMo RL 的投机采样工作,在特定的 8B 推理模型配置下实现了 1.5–1.8× 的 Rollout 吞吐提升,并将同步端到端 RL 训练加速了最高 1.4×。

而在 Agentic RL 场景下,连训练数据本身的形态都发生了质变。在传统的数学题或单轮对话 RL 里,一条数据只是简单的 Prompt → Response → Reward;但在 Agent 任务中,一条轨迹变成了多轮交互的链条:

State→Action→Observation→⋯→Reward.\text{State}\to\text{Action}\to\text{Observation}\to\dots\to\text{Reward}.

在这里,State 是当前的外部环境与上下文状态,Action 是模型生成的推理片段或发出的工具调用指令,Observation 是外部工具、代码解释器或沙箱环境执行后返回的真实结果,如此循环往复多轮(…\dots),直到任务结束才由验证器给出一个最终(或分步)的 Reward。比如一个 Coding Agent 在修 Bug 时,可能会先读文件、跑一次 Shell 命令、看一眼报错日志,再去修改代码并重跑单测。

这种形态直接给底层系统抛出了一连串全新的工程难题:

  • 信用分配:如果一个 Agent 连续调用了 30 次工具,最后一步因为超时失败拿了 0 分,难道前面 29 步排查操作也全都是错的吗?
  • 环境确定性与资源瓶颈:如何保证代码沙箱和外部环境的可复现性?当成千上万个沙箱并发跑测试时,瓶颈往往从 GPU 转移到了 CPU、网络 I/O 和长尾沙箱调度上。
  • 轨迹极长:对于 Agentic tasks, 轨迹长度会从传统的数学任务的 32k 增长到 256K 甚至 1M. 此时 rollout 的压力将会非常大,若不加控制,也会进一步放大 staleness.

这也再次印证了前面的观点:评价一套 RL Infra 的好坏,绝不能只看静态的 tokens/sec 吞吐,而要看每个 GPU-hour 到底能换来多少实打实的模型能力提升。“GPU Hours 对应多少性能提升” 事实上已经成为了大家所公认的优化标准,例如我后面要讲的 vLLM 团队 “Finish RL Training Faster” 以及前阵子腾讯混元团队 https://hunyuan.tencent.com/research/100116 ↗ 的这篇文章。

vLLM 的案例分析

在上一节提到的四层架构里,最底层的地基就是“在数值层消除同版本的训推 Mismatch”。这一层具体在工程上要怎么落地?我们可以拿 vLLM 团队 2026 年发表的《Finish RL Training Faster — Bitwise-Consistent, High-Performance, Agent-Driven》作为绝佳的案例来剖析。如果你想阅读原文,点这里 ↗。

浮点运算的 non-associativity

一切问题的起点,都是计算机科学里最基础的一条常识:浮点数加法不满足结合律。

(a+b)+c≠a+(b+c).(a+b)+c\ne a+(b+c).

我们可以举一个一眼就能看懂的 FP32 例子:取 a=−224, b=224, c=1a=-2^{24},\ b=2^{24},\ c=1。如果你先算 (a+b)+c(a+b)+c,正负 2242^{24} 先抵消成 00,再加上 11,最终结果是 11;但如果你换个顺序先算 b+cb+c,由于 FP32 的尾数精度只有 24 位,224+12^{24}+1 里的那个 11 会直接因为精度不够被舍入掉(1677721716777217 恰好卡在 1677721616777216 与 1677721816777218 正中间,会按最近的偶数尾数舍入到 1677721616777216),结果还是 2242^{24},最后再加上 a=−224a=-2^{24} 就变成了 00!

而在矩阵乘法 Y=XWY=XW 里,底层干的恰恰全都是这种大规模累加:设输入 XX 的形状为 M×KM\times K(MM 是当前处理的 token 行数),权重 WW 为 K×NK\times N(NN 是输出特征维度),那么输出矩阵 YY(形状为 M×NM\times N)里的每一个元素,都是一次长度为 KK 的内积求和 Ym,n=∑k=1KXm,kWk,nY_{m,n}=\sum_{k=1}^{K}X_{m,k}W_{k,n}。这就意味着:哪怕两个 Kernel 喂进去的是完全相同的 FP8 输入、用的是完全相同的权重、甚至内部都用高精度的 FP32 寄存器做累加,只要它们把这 KK 项加起来的先后顺序 有一丁点不同,算出来的浮点数在最后几个比特(ULP,Unit in the Last Place)上就可能不一样。

在普通的推理服务里,末位差几个 ULP 根本无伤大雅。但在 RL 训练里,这些算出来的 logprob 是要拿去相除算 Importance Ratio 的;更重要的是,现代大模型里到处都是 MoE Top-K 路由、Sparse Attention 的 Top-K 索引(例如,DSA, CSA, CSA v2 等)这样的离散选择——一旦刚好卡在排序的临界点上,就可能导致选出来的 Expert 或 Attention 位置直接换了一批,下游输出的差异瞬间就会被放大。

Bitwise Consistency

正因如此,vLLM 团队在文章里把“真正的训推逐位一致”拆解成了层层递进的五条要求:

  1. Kernel Determinism:同一个 Kernel 喂入完全相同的输入,反复跑多少次,输出都必须一模一样。
  2. Batch Invariance:一条请求算出来的结果,绝不能因为“和它拼在同一个 Batch 里的其他请求是谁、有多少条”而发生改变。
  3. Stage Consistency:同一个 token 无论是走 Prefill 路径还是走 Decode 路径,算出来的结果必须完全一致。
  4. Distributed Consistency:跨卡通信的归约顺序,或者 TP / EP / CP 等并行切分方式的改变,不能影响被比较路径上的数值结果。
  5. 共同的 Numerical Contract:训推两端的量化方式、舍入规则、中间计算精度、算子分块、离散排序的 Tie-breaking 以及归约合并顺序,必须全部对齐。

Bitwise Consistency 指的是在每次参数更新前,固定同一份权重版本和相同的 token 前缀,把推理引擎采样时记录的 rollout_logprob 与训练端重算出来的 old_logprob 相减都必须精确等于 0,而不是传统意义上“误差小于 10−510^{-5} 就算对齐”。(当然,等做完 Minibatch 更新把权重改了之后,策略本身已经变了,自然不在这个定义范围内。)

你可以看到,上面这五条是环环相扣、谁也替代不了谁的:单算子有确定性,不代表换了 Batch 大小结果还一样;做到了 Batch-invariant,也不代表 Prefill、Decode 和训练前向能对齐;哪怕前向做到了逐位一致,也更不代表反向传播的梯度就天然精确。

Batch Invariance

在 vLLM 原本的默认实现里,遇到 M<32M<32 的小 Batch 时会调用针对低延迟优化的 FlashInfer Kernel,而遇到较大的 Batch 时则会切换到高吞吐的 DeepGEMM;除此之外,MoE Router、小矩阵乘法以及 mHC 切分也都有各自基于形状的动态分支(Heuristics)。在推理的 Continuous Batching 机制下,同一条请求在每一个 Decode Step 里和多少个请求拼在一起是随时在变的;而到了训练阶段,我们又习惯把所有序列拼成一个巨大的 Packed Batch 一次性做前向计算,这通常是不变的。结果就是,同一个 token 在推理和训练时根本没有跑在同一个 GEMM Kernel 上,累加顺序自然千差万别。

vLLM 团队的改法:强制开启 DeepGEMM 的 Batch-invariant 模式,固定每一行内积的累加切分方式;同时把 Router 统一固定为输出 FP32 的 GEMM,固定小矩阵的归约策略,并将 mHC 的 split 固定为 1。当然,这样做的代价是牺牲了一部分极小 Batch 下的推理延迟优化,而且训练端每次跑 Forward 时都得重新对主权重做一次量化。

Stage Consistency

搞定了矩阵乘法,接下来就是 Attention。虽然我们在纸上写的 Attention 公式永远只有一个:

O=softmax⁡(QK⊤/d)V,o=∑jesj−mvj∑jesj−m.O=\operatorname{softmax}(QK^\top/\sqrt d)V, \qquad o=\frac{\sum_j e^{s_j-m}v_j}{\sum_j e^{s_j-m}}.

(复习一下符号:左边是大家熟悉的矩阵形式,Q,K,VQ,K,V 分别是 Query、Key、Value 矩阵,dd 是每个 Head 的向量维度;右边则是把目光聚焦到某一个具体 Query 的输出向量 oo 上,其中 sj=q⊤kj/ds_j=q^\top k_j/\sqrt d 是它对第 jj 个历史位置打出的注意力分数,m=max⁡jsjm=\max_j s_j 是为了防止指数溢出减掉的最大值,esj−me^{s_j-m} 是未归一化的权重,分子对 Value 加权求和,分母做归一化。)

公式看着简单,但在 GPU 上执行时,FlashAttention 内部用的是分块的 Online Softmax(如果你不知道 FlashAttn 是怎么实现的话,TL;DR 就是:为了避免上述计算时的 Softmax 要反复存取 HBM,所以使用的是在线的算法):怎么切块、要不要做 Split-KV 并行归约、乃至底层用哪条指令近似计算 exp⁡\exp,都会直接影响中间的浮点舍入。更麻烦的是,在推理引擎里,Prefill 阶段是一口气并行处理一整段序列的许多 Query,而 Decode 阶段则是每次只拿着当前新生成的 1 个 Query 去 Paged KV Cache 里找——这两个阶段在主流框架里压根就跑在两个完全不同的 Kernel 上!所以,就算你的 KV Cache 存储本身没有任何精度损失,同一个 token 走 Prefill 算出来的输出(对应训练时的整序列并行 Forward)和走 Decode 算出来的输出(对应推理时的逐 token 生成),也照样对不上。

在 DeepSeek-V4-Flash 的实现里,这个矛盾尤为突出:它的 Prefill 路径是先把 FP8 的 KV Cache 反量化回 BF16,再调稀疏 Attention 算子;而 Decode 路径为了省带宽,直接调用了一个原生的 FP8 FlashMLA Kernel。为了强行对齐这两条路径,vLLM 一致性版本的做法是:让 Decode 也放弃原生 FP8 算子,统一改成 从 FP8 Cache 读取 → Gather 并反量化到临时的 BF16 Workspace → 调用与 Prefill 完全相同的 flash_mla_sparse_fwd 算子。

这样做的好处是保住了 FP8 KV Cache 省显存的核心收益(文章实测显存占用依然只有 BF16 KV 的 57% 左右),代价则是 Decode 阶段多付出了一步 Gather 和反量化的开销。而训练端也需要配合:明明训练 forward 是一次性算完的,根本不需要自回归的 KV Cache,但训练端还是人为开辟了一块私有的 FP8 Cache 缓冲区,强行让训练时的 KK 和 VV 也经历一次一模一样的“量化成 FP8 再反量化回 BF16”的操作,只为复刻推理时的舍入逻辑。

不过,要把前向的量化算子和反向的求导拼接起来,对于经历了一次“量化再反量化”的近似张量 K~=dequant⁡(quant⁡(K))\widetilde K=\operatorname{dequant}(\operatorname{quant}(K)),由于量化是离散的阶梯函数,它在数学上的雅可比矩阵几乎处处为 00,反向传播时梯度到这里就会直接断掉。为了把梯度传过去,文章采用了量化训练里经典的 STE(Straight-Through Estimator,直通估计器),在反向传播时直接近似令 ∂K~/∂K≈I\partial\widetilde K/\partial K\approx I,相当于假装量化和反量化不存在,把上游梯度原封不动地透传给 KK。对于少数无法直接自动求导的融合算子,他们则在 PyTorch 里单独搭了一条平行的可微计算图专门用来算梯度,而前向激活值则替换为推理 Kernel 的输出。

Distributed Consistency

到了 MoE 层,训推两端的不一致会变得更加棘手。首先,两边在内存里组织 Expert Token 的布局就完全不同。要想让同一个 token 在这两种截然不同的内存布局下算出逐位相同的结果,必须同时固定三个细节:

  • 每一行输入在做 FP8 动态量化时,缩放因子与舍入规则必须完全一致;
  • 专家层的 DeepGEMM 必须开启 Batch-invariant 模式,确保某一行 token 的计算结果不受它上下紧挨着哪些邻居 token 的影响;
  • 激活函数层计算 SiLU(gate) * up 时,必须在 FP32 精度下一口气算完乘法,最后只做一次向 BF16 的舍入,绝不能先在 SiLU 输出时转一次 BF16、乘完 up 再转一次。

解决了单卡算子,还有跨卡通信的归约难题。在 DeepSeek-V4-Flash 的实验配置里,Rollout 端用的是 EP8,而 Training 端用的则是 EP32 + CP4。两边连并行切分度数都不一样,怎么可能算出逐位相同的结果?他们的实验结果以及解决办法是:

  1. 只要不开 TP,就避开了最容易因为跨卡归约顺序不同而引入浮点误差的 TP All-Reduce;
  2. 专家并行的 EP All-to-All 通信只负责把 token 原封不动地搬运到目标 GPU 上,中间不做任何跨卡加法归约;等各个 Expert 算完输出、通过 All-to-All 传回该 token 原本所属的 GPU 后,再由本地 GPU 严格按照固定的 Top-K 顺序在本地用 FP32 完成累加;
  3. 上下文并行 CP 在这里也只负责传递和拼接 KV 状态,不涉及改变比较结果的跨卡求和。

跑两个极简的 RL

前面建立的系统直觉,我们现在来看看在真实的 run 当中,staleness 如何,数值误差有多少,以及“什么样的 run 是健康的”。我用 slime 跑了两个实验:同样的 Qwen3.5-0.8B 在 GSM8K 上做 GRPO,一次 Sync、一次 Async,各 120 个 step。

超参数设置

相同的配置:

  • Qwen3.5-0.8B / GSM8K / GRPO,Slime 框架,temperature 1.0,每步 rollout 1024 条回答;num_steps_per_rollout=1(每批数据只做一次 optimizer update);use_rollout_logprobs=false(PPO ratio 的分母不用推理引擎给的 logprob,而是训练引擎用同版本权重重算一遍);use_kl_loss=true 但 kl_loss_coef=0.0:Reference KL 只计算,不进梯度。

不同的配置:

  • Sync Run:单节点,同一批 GPU 交替跑推理和训练,单条回答上限 1024 tokens;
  • Async Run:同样单节点,4 张专职 Rollout、4 张专职 Training,回答上限放宽到 2048 tokens,并启用了 TIS;

注意:这两次 Run 不是单变量对照。 长度上限 Async run 高于 Sync run,所以 Async 后期 reward 更高是正常的。

Reward, Eval

先看 Reward:

两次 Run 的 Reward 走势:Sync 的训练 reward 前 20 步从 0.54 跳到 0.63 后进入平台期;Async 从 0.48 一路爬到 0.82,但逐 step 的散布明显更宽(单步峰值 0.96);虚线是 Sync 每 20 步一次的 GSM8K 离线评测,从 0.71 涨到 0.83。

总体而言,这两次 run 的 reward 都还算健康,总体保持上升,没有出现 reward 崩的情况。

  • Sync 的 reward 前 20 步有明显涨幅(0.54 → 0.63),之后 100 步都停在 0.61–0.63 的平台期。这是“前期学得快、后期边际收益递减”,而不是训崩(训崩应当表现为 reward 一直保持在较低水平使得模型无法得到奖励信号,或者 reward 下降)。事实上,eval 的结果可以看到后期仍然在继续爬升。这部分可以解释成为模型 pass@1 更加稳定带来的收益。
  • Async 的 reward 从 0.48 一路爬到 0.82,没有平台期。但细线的散布一眼就能看出噪声大:最后 20 步的单步标准差有 0.074(Sync 只有 0.021),单步在 0.63–0.95 之间震荡。rollout reward 的噪声较大,这也进一步说明仍然需要下游评测任务来观测模型的走势。

Entropy, KL

优化健康的两个信号:左图为 Entropy,都从 0.5–0.6 缓降到 0.3 附近(Sync 最低 0.27,Async 最低 0.21);右图为相对 Reference 的 KL,都升到约 0.25 后走平。

  • Entropy 下降是模型对推理步骤从迷茫走向笃定,属正常下降。判断 Entropy Collapse 要联合几个指标看:如果 Entropy 断崖式归零、以及 reward 同步崩溃、组内解法千篇一律,那大概就是训崩了。
  • Reference KL 缓慢升到 0.25 后走平。RL 的目标本来就是偏离初始模型,KL 拉开是必然,所以 reference KL 有增长是没太大问题的。(这不代表训推不一致!)

Length, Truncation

回答长度与截断率:Sync 的平均回答长度从 371 tokens 涨到 610 而 reward 停在平台期;两次 Run 的截断率都在收敛,Sync 从开局约 25% 回落到末期 8%,Async 从 36% 降到 4%。

截断率都在收敛。在 GSM8K 里撞上长度天花板,答案没写出来就被判 0 分,所以截断率太高是不好的。但是 GSM8K 是非常非常简单的题目,所以可以看到设置当前的最大长度是合理值,模型最终大都能在最大长度内把答案做出来。

全对与全错的组数:左图为 Sync 每 step 被 Dynamic Sampling 丢弃的组数,右图为 Async 的 zero-std 组数;实线为全对组、虚线为全错组。全对组一路暴涨——Sync 从 14 涨到 400,Async 从 11 涨到 85(峰值 113);全错组在 Sync 侧始终 30 上下徘徊,Async 侧从 33 跌回个位数。

除了 reward, 还值得看的是全对/全错组数的走势。因为 reward 只能反映出模型在所有题目上表现如何,但是注意:GRPO 里一个组全对或全错,意味着组内 σR=0\sigma_R=0,整组不产生任何梯度信号(回忆 DAPO 的 Dynamic Sampling 一节)。左图里 Sync 的对应是 dynamic_filter/drop_zero_std_1(每步因全对而被丢弃的组数,Sync 开了 Dynamic Sampling 过滤),从 14 涨到 400:训练后期为了凑够一批有效的组,推理端每步要扔掉几百个全对组——这正是 Dynamic Sampling 在难题不够多后需要 rollout 显著更多 sample 的情况。这道曲线持续走高时,就该考虑换更难的题库或调整采样分布了。

另外,老生常谈的 grad_norm 也应当关注下,不过我这次两边 grad_norm 都平稳,无尖刺、无 NaN。

Staleness 与 TIS

Async 的日志里多出一组 Sync 没有的指标,正好对应前面的 Staleness 与 TIS 理论:

Async 的额外指标:左图为训推 logprob 绝对差——Sync 全程钉在约 0.011(纯 Numerical Mismatch),Async 叠加版本滞后后放大到约 0.026,第 20 步还有个 0.090 的尖峰,虚线的 tis_abs 与它几乎重合;右图为 Async 的版本滞后,数据平均落后训练端约 3.3 个权重版本、最老 7 个。

  • rollout/staleness/mean ≈ 3–3.5:每条数据从被采样到被训练消费,训练端平均已经多更新了 3 个版本;单步最高 4.7,staleness/max 峰值到过 7。这就是 Fully Async 用吞吐换来的版本滞后。
  • train/tis / train/tis_abs / train/tis_clipfrac:TIS 权重 w=πtrain/πbw=\pi_{\mathrm{train}}/\pi_b 的均值几乎精确等于 1.0000。但均值等于 1 绝不代表没有漂移——0.9 和 1.1 的均值同样是 1。真实的漂移幅度要看 tis_abs(即 mean⁡(∣w−1∣)\operatorname{mean}(|w-1|),全程约 2%–3.6%);tis_clipfrac 峰值也只有 0.5%,说明极端离群的权重很少。
  • 左图里可以看到 Async 相比 Sync 的 logprob_abs_diff 更高。这个值在 Async 下是 Numerical Mismatch 与 Policy Staleness 的叠加。

效率:Async 到底快在哪

稳态单步耗时拆解(Step 20–119 平均):Sync 的 step_time 约 45.9s,其中训练 13.8s、等待 32.1s(占 69.9%);Async 约 25.0s,其中训练 12.2s、等待 12.8s(占 51.2%)。

单步快了 45.9/25.0≈1.84×45.9/25.0\approx 1.84\times,训练本身几乎没变快(13.8s → 12.2s),省下来的几乎全是等待:训练端消化上一批数据时,推理端在持续生产下一批。注意到,Async 的生成长度甚至比 Sync 更长,然而整体的 Step 还更快,说明确实还是有很大收益。

结语

把整套 RL 流程还原为真实的系统闭环,就是这样一条清晰的流水线:

一轮 RL 的完整链条:模型给 prompt 采样多个答案,Reward 给出结果,Advantage 判断好坏,Importance Ratio 与 Clipping/TIS 处理旧样本和分布漂移,Loss Reduction 决定梯度权重,Megatron 更新模型权重,新权重同步回 SGLang/vLLM,下一轮样本分布随之改变。

用直觉理解 RL 系统
https://theunknownth.ing/blog/llm-rl
Author TheUnknownThing
Published at October 20, 2026
Comment seems to stuck. Try to refresh?✨