1. 从一次训练曲线“假收敛”说起
如果你在做强化学习训练基础设施,大概率遇到过这种让人抓狂的情况:训练日志里 reward 曲线一路平稳上涨,看起来一切正常,但把训好的策略拿去做在线推理,表现却明显掉了一截。更诡异的是,你反复检查了数据管线、reward 函数、超参配置,都没发现问题。最后定位下来,根因往往藏在一个很隐蔽的地方——训练时的策略分布和推理时的策略分布根本不是同一个东西。
这就是标题里说的“训推不一致”。它听起来像是个工程细节,但在 on-policy 算法(比如 PPO、GRPO 这类)里,它足以把一个名义上的 on-policy 训练,悄悄变成事实上的 off-policy 训练。而 off-policy 本身不是不能用,问题在于你以为自己在做 on-policy,于是所有基于这个假设做的调参、诊断、收敛判断全部失真。
这篇内容适合正在搭 RL 训练框架、调 PPO/GRPO 类算法、或者被“训练指标好看但上线拉胯”折磨过的同学。我会先讲清楚训推不一致到底在哪个环节把 on-policy 变成了 off-policy,然后给一个能直接埋进训练循环的诊断指标,最后拆三条不同层次的修法——从最省事的到最彻底的。全文基于常见的工程实践展开,涉及具体数值的地方我会说明计算逻辑,你可以按自己的场景替换。
先说结论:训推不一致的本质,是采样时用的策略和计算重要性权重时假设的策略之间存在系统性偏差。这个偏差可能来自数值精度、可能来自推理引擎和训练引擎的实现差异、也可能来自并行策略导致的计算路径不同。它不会让训练直接崩掉,但会让你的梯度估计悄悄带上偏差,最终反映成上线效果打折。
2. 训推不一致是怎么把 on-policy 变成 off-policy 的
2.1 先厘清 on-policy 的真正前提
很多人对 on-policy 的理解停留在“用当前策略采的数据更新当前策略”。这句话没错,但不够精确。PPO 这类算法之所以能工作,依赖的是一个更细的前提:用于计算重要性采样比(importance sampling ratio)的那份概率,必须和实际产生这批数据的策略概率一致。
PPO 的目标函数里有个关键项:
ratio = exp(logp_new - logp_old)这里的logp_old是采样时记录下来的对数概率,logp_new是更新时重新计算的对数概率。算法假设logp_old忠实反映了“数据是怎么来的”。一旦这个假设被打破,ratio 就不再是真正的重要性权重,而是一个被污染的量。
注意:on-policy 的“on”不是指时间上的同步,而是指分布上的一致。哪怕你是同步采样、采完立刻更新,只要
logp_old和真实采样分布对不上,你就已经在做 off-policy 了。
2.2 偏差从哪几个缝隙钻进来
训推不一致的来源可以归成三类,我按出现频率从高到低排:
第一类:推理引擎与训练引擎的算子差异。采样阶段通常用专门的推理引擎(为了吞吐),更新阶段用训练框架。两套引擎的 attention 实现、归一化层、激活函数精度可能不同。比如推理引擎为了速度用了 fp16 甚至 int8 的 KV cache,而训练时用 bf16 重算 logprob,同一个 token 的概率就能差出百分之几。别小看这个量级,ratio 在训练后期对微小差异极其敏感。
第二类:并行策略导致的计算路径不同。训练时为了塞进显存,可能用了张量并行、序列并行,logprob 的计算经过不同的 all-reduce 和切分路径。浮点运算不满足结合律,不同的归约顺序会带来不同的舍入误差。序列越长、并行度越高,这个误差累积越明显。
第三类:采样与更新之间的策略漂移。这个最直观:你采了一批数据,但在更新之前策略已经因为之前的梯度步发生了变化。如果 batch 内做了多个 epoch 的更新,第一个 epoch 之后logp_old就已经过期了。这是 PPO 设计里本来就容忍的,但如果叠加了前两类的实现偏差,容忍度会被迅速耗尽。
2.3 为什么它表现得像 off-policy 而不是直接报错
关键在于 PPO 的 clip 机制。当 ratio 偏离 1 太多时,clip 会把梯度截断,于是坏数据不会产生大梯度,但也不会被纠正。训练看起来稳定,实际上是在用一批分布不对的数据做有限更新。策略在慢慢偏离真实采样分布,但因为 clip 兜底,loss 不会爆炸,reward 甚至还在涨——涨的是训练环境里的 reward,不是真实推理分布下的 reward。
这就解释了那个经典现象:训练 reward 稳步上升,上线效果却停滞甚至下降。你名义上在做 on-policy,实际上每一批数据都在把策略往一个和推理分布渐行渐远的方向推。
3. 一个能埋进训练循环的诊断指标
3.1 为什么现成的指标不够用
大部分人监控的是clip_fraction(被 clip 的样本比例)和approx_kl。这两个指标有用,但它们是结果,不是原因。clip_fraction 升高说明 ratio 偏了,但你不知道是策略真的更新太多,还是训推实现不一致导致的假偏差。approx_kl 同理,它衡量的是新旧策略的差异,但把实现误差和真实策略变化混在了一起。
你需要一个能把“实现层面的训推不一致”单独拎出来的指标。
3.2 核心思路:同策略重算的 logprob 偏差
做法很直接:在采样阶段,除了记录推理引擎给出的logp_infer,额外用训练引擎对同一批 token 重算一遍logp_train。这两个值理论上应该相等,因为它们描述的是同一个策略在同一个状态动作对上的概率。它们的差,就是纯粹的训推实现偏差,和策略有没有更新无关。
具体计算:
delta = logp_train - logp_infer然后统计这个 delta 的分布。我一般看三个量:
| 指标 | 计算方式 | 健康范围(经验值) | 异常含义 |
|---|---|---|---|
| mean_delta | delta 的均值 | 绝对值 < 1e-3 | 系统性偏移,通常是算子精度问题 |
| std_delta | delta 的标准差 | < 5e-3 | 随机性偏差,并行归约顺序导致 |
| max_abs_delta | delta 绝对值的最大值 | < 5e-2 | 个别 token 严重不一致,查长序列或特殊 token |
这三个阈值不是绝对的,取决于你的精度配置。fp16 采样配 bf16 训练的话,mean_delta 到 1e-2 都算正常。但如果你全用 bf16,mean_delta 还超过 1e-3,那就值得查了。
3.3 怎么把它接进训练循环
伪代码大概长这样:
# 采样阶段 with torch.no_grad(): logp_infer = inference_engine.compute_logprob(states, actions) # 立刻用训练引擎重算,注意要切到 eval 模式 model.eval() logp_train = model.compute_logprob(states, actions) model.train() delta = logp_train - logp_infer metrics["train_infer_delta_mean"] = delta.mean().item() metrics["train_infer_delta_std"] = delta.std().item() metrics["train_infer_delta_max"] = delta.abs().max().item()几个实操要点:
- 重算必须在采样后立刻做,不能等到更新阶段。因为更新阶段模型参数可能已经变了,重算的就不是同一个策略了。
- 重算时模型要切 eval 模式,关掉 dropout 之类的随机性,否则你测的是随机噪声。
- 重算的 batch 可以只取子集,比如每批抽 10% 的样本,降低开销。这个指标不需要每个样本都算。
- 记录到 tensorboard 或 wandb,和 clip_fraction 放一起看。当 delta 指标升高时,clip_fraction 通常会跟着升高,两者能互相印证。
提示:这个指标的开销主要是前向计算,大概占一次完整训练步的 5% 到 15%,取决于你抽样的比例。如果嫌贵,可以每 N 步才测一次。
3.4 怎么读这个指标
拿到 delta 分布后,判断逻辑是这样的:
如果mean_delta显著非零,说明存在系统性偏差。这通常意味着两套引擎在某个算子上有固定的实现差异,比如推理引擎的 softmax 用了不同的数值稳定技巧。这种偏差最危险,因为它会持续地把 ratio 往一个方向推。
如果mean_delta接近零但std_delta很大,说明是随机性偏差,多半来自并行归约顺序或低精度舍入。这种偏差在平均意义上无害,但会增大 ratio 的方差,让 clip 更容易触发。
如果max_abs_delta特别大而其他两个正常,去查那些极端 token。常见原因是序列末尾的 padding token、特殊分隔符,或者超长序列在并行切分边界上的计算错误。
4. 三条修法:从止血到根治
4.1 第一条:统一精度与算子实现
这是最省事、见效最快的一条。核心动作是让采样和更新走同一套数值路径。
具体做法:
- 统一精度。如果训练用 bf16,采样也用 bf16。不要为了推理速度单独降精度。KV cache 的精度尤其要注意,很多推理引擎默认 fp16 cache,而训练时没有 cache 这个概念,重算 logprob 时是全精度。这个差异是 mean_delta 的主要来源之一。
- 对齐归一化层实现。检查两套引擎的 LayerNorm/RMSNorm 是否用了相同的 epsilon 和计算顺序。有些实现先算方差再开方,有些用 rsqrt,数值结果不同。
- 对齐 attention 的 softmax。推理引擎常用 online softmax 来省显存,训练框架可能用标准 softmax。两者在数值上等价但舍入不同。如果差异大,考虑在重算 logprob 时也用 online 版本。
这条修法的局限在于:它只能消除实现层面的偏差,消除不了策略漂移。而且有些差异是引擎架构决定的,强行对齐会牺牲推理吞吐。所以它适合作为第一道防线,把 delta 压到一个可接受的范围,但不能指望它归零。
4.2 第二条:用重算的 logprob 替换采样的 logprob
如果第一条做完 delta 还是偏大,或者你不想动推理引擎,那就换个思路:干脆不用推理引擎给的 logprob,全部用训练引擎重算。
这个做法在工程上很干净。采样阶段只负责生成 token 序列,不记录 logprob。更新阶段用训练引擎对整批序列重算logp_old,然后正常算 ratio。这样logp_old和logp_new都来自同一个引擎,训推不一致被彻底绕开。
代价是计算量。重算整批序列的 logprob 相当于多做一次前向,训练步时间会增加。但好处是:
- 不需要维护两套引擎的数值一致性,省掉大量对齐工作。
- delta 指标直接归零(因为定义上就是同一个引擎)。
- 可以放心用高吞吐的推理引擎,不用为了精度牺牲速度。
注意:这个做法要求你的训练引擎能高效地对给定序列做 teacher-forcing 前向。如果你的训练框架本身支持这个(大部分都支持),实现成本很低。
我个人的经验是,当推理引擎和训练引擎差异较大、对齐成本高时,这条修法比第一条更划算。多出来的前向计算,比起反复调试数值对齐的人力,通常更便宜。
4.3 第三条:控制策略漂移,让 off-policy 程度可控
前两条解决的是实现偏差。但即使实现完全一致,PPO 在 batch 内做多 epoch 更新时,logp_old依然会过期。这是算法层面的 off-policy,需要单独处理。
几个有效的手段:
限制每个 batch 的更新 epoch 数。这是最直接的。epoch 越多,策略漂移越大。很多团队默认跑 4 个 epoch,但在长序列、大模型场景下,2 个甚至 1 个 epoch 可能更稳。你可以用 delta 指标辅助判断:如果第一个 epoch 后 delta 就开始明显上升,说明漂移太快,该减 epoch 了。
用动态 clip 范围。固定 clip 值(比如 0.2)在训练不同阶段的最优值不一样。早期策略变化大,需要宽一点;后期需要紧一点。可以根据实测的 ratio 分布动态调整 clip 范围,让 clip_fraction 维持在一个目标区间。
引入 trust region 的显式约束。除了 PPO 自带的 clip,可以额外加一个 KL 惩罚项,或者用类似 GRPO 里那种组内归一化的方式,减少对绝对 logprob 的依赖。这类方法对训推不一致的鲁棒性更好,因为它们不直接依赖logp_old的绝对值。
分批更新,缩短数据新鲜度窗口。把一个大 batch 拆成几个小 batch,采一批更一批,而不是采一大批反复更新。这样每批数据被使用时离采样时刻更近,漂移更小。代价是采样和训练的交替更频繁,吞吐可能下降。
4.4 三条修法怎么选
给个决策参考:
| 场景 | 推荐修法 | 理由 |
|---|---|---|
| 刚发现训推不一致,想快速验证 | 第一条 | 改动小,能立刻看到 delta 下降 |
| 推理引擎和训练引擎差异大,对齐困难 | 第二条 | 绕开对齐,一劳永逸 |
| 实现已对齐,但训练后期 ratio 仍偏高 | 第三条 | 问题在算法层面,不在实现 |
| 资源充足,追求极致稳定 | 第二 + 第三条 | 实现和算法双管齐下 |
实际项目里,我一般先上第一条把 delta 压下来,同时埋好诊断指标。如果压不到理想范围,再上第二条。第三条是长期调优的事,随着训练规模变大,它的重要性会越来越高。
5. 几个容易踩的坑和实测经验
5.1 别在更新阶段测 delta
我见过有人把 delta 的计算放在更新循环里,结果指标忽高忽低,完全没法用。原因是更新阶段模型参数已经变了,重算的 logprob 对应的是新策略,和采样时的策略不是一回事。delta 必须在采样后、任何参数更新前计算。这个顺序错了,指标就废了。
5.2 长序列的 delta 天然更大
序列长度增加时,logprob 是逐 token 累加的,误差也会累加。所以max_abs_delta在长序列上偏高是正常的。判断时要看单位 token 的 delta,而不是绝对值的总和。我一般会额外记录delta / seq_len,这个量在不同序列长度之间可比。
5.3 特殊 token 是重灾区
padding token、序列结束符、对话模板里的角色标记,这些位置的 logprob 经常出现异常大的 delta。原因是不同引擎对这些 token 的处理逻辑可能不同,比如有的引擎在 padding 位置不算 logprob,有的算了但值不对。建议在计算 delta 时mask 掉 padding 位置,否则 max_abs_delta 会被这些噪声主导。
5.4 诊断指标本身也有开销,要采样
全量计算 delta 会让训练变慢。我的做法是每个 batch 随机抽 5% 到 10% 的序列来算,既能反映整体分布,又不至于拖慢太多。如果发现异常,再临时调高采样比例做详细排查。
5.5 别指望 delta 归零
即使做了所有对齐,delta 也不会精确为零,因为浮点运算本身就有舍入。目标是把它控制在一个不影响 ratio 分布的量级。判断标准是:当你把 delta 纳入考虑后,ratio 的分布和 clip_fraction 没有显著变化。如果 delta 的量级远小于策略更新带来的 logprob 变化,那它就是可接受的。
6. 把诊断和修复串成日常流程
最后说说怎么把这套东西变成日常习惯,而不是出了问题才临时查。
我建议在训练脚本里固定做三件事。第一,每个训练步都记录train_infer_delta的三个统计量,和 reward、clip_fraction 放在同一张图上。第二,设置告警阈值,当mean_delta超过你实测的健康范围时自动打印警告,附上当时的序列长度分布和精度配置。第三,每次调整推理引擎配置或训练并行策略后,重新跑一次基线,确认 delta 没有跳变。
这套流程的价值在于,它把“训推不一致”从一个玄学问题变成了一个可观测、可量化的工程指标。你不再需要等到上线效果打折才回头怀疑,而是在训练过程中就能看到偏差在累积。
我个人踩过最深的坑,是在一个长序列场景里,推理引擎默认开了 fp16 KV cache,训练用 bf16,delta 的 mean 一直在 1e-2 量级。当时没在意,觉得 clip 能兜住。结果训练到后期,clip_fraction 从 0.1 涨到 0.4,reward 还在涨,但上线评测掉了将近一成。后来把 cache 精度统一到 bf16,delta 降到 1e-4 量级,clip_fraction 回落到 0.15,上线效果才恢复。这件事让我意识到,训推不一致不是“有点误差无所谓”的小事,它会随着训练推进被放大。越早把它纳入监控,越省事。
如果你现在正在调 RL 训练,不妨今天就加一行代码,把采样和重算的 logprob 差值打出来看看。很多时候,那个数字会告诉你一些训练曲线永远不会说的事。