你的 RL 训练日志,可能正在对你撒谎。
这是一次真实的翻车复盘,附赠一个开源工具PTH(Probe The Harness):
pip install probe-the-harness,一条命令扫描你的 verl 日志,把藏在"正常日志"背后的 bug 直接点名。
一个好得不像话的结果
做大模型强化学习的朋友们,涉及到大规模后训练的应该都知道一种常规的异步训练的方式:为了提速,采样器往往要隔几十步才同步一次权重,学习器只能拿着"过期"的数据硬训。但是数据越陈旧,训练越容易崩。
我起初把这当成切入点,我设计了一个新的方法专门解决易崩这个问题,然后拿它去和业界常用的重要性校正基线(TIS、PPO clip)正面做对比。
最开始的时候结果确实相当的好:
- 两套框架、所有设置,全部领先:verl 和我手写的单卡 trainer,无一例外。
- 刷新间隔 64 时,我的方法连同两个变体共9 个 run,全部高于基线的 6 个 run,而且一个反例都没有。
- 刷新间隔 96 时,PPO-clip 基线先涨到 74%,随后在三个种子里全线崩盘,最低跌到 9.9%;而我的方法稳稳守在 75% 以上。同一个种子上,差距超过 60 个点。
当时论文初稿都写好了。我想着我设计出了一种特别强的方法,那录取顶会不是纯纯易如反掌。
但是我同时也很好奇,我想这结果怎么会这么好?一个结果好到这种程度,是不是本身就是一种危险信号?
于是我做了一件很麻烦的事:我把训练框架(后文叫harness,就是负责延迟采样器、组装每组配置、挑选每步数据、计算每个 loss 的那些代码),连同启动脚本和日志,一行一行对着我们的论文草稿和代码过了一遍。
重点来了,我揪出了几个核心 bug。
问题是最可怕的不是 bug 本身,而是每一个 bug,都藏在一份看起来完全正常的日志后面。
四个 bug,日志里看都一切正常
为了方便描述,我给它们各起了一个名字。
Bug 1:Idle Clip
伪装:PPO-clip 那组,clip fraction 每一步都是 0.000。光看日志,这是一次再平稳不过的训练。
真相:事实是verl 里 PPO ratio 的分母有两种取法:采样器记录的概率,或者学习器在更新开始时重新算一遍的概率。我选了后者。每个 batch 只做一次优化时,重算用的就是当前参数,ratio 恒等于 1,clip 一次都不会触发。
与此同时,采样器和学习器之间的 KL 一路飙升,整整涨了四个数量级(一万倍),刷新前高达每 token 7 到 9 nats。
杀伤力:这组"基线"本质上其实没有做任何离策略校正。我的方法赢的只是一个本来就该崩的方法对手。
Bug 2:Lost Seed
伪装:三个 TIS run 标着三个不同的种子,结果也确实各不相同:.763、.776、.770。
真相:启动脚本先把参数拼进一个变量,再追加数据种子,问题是一旦开启重要性采样,脚本就给同一个变量重新赋了值,种子凭空蒸发。整个实验里,偏偏只有 TIS 这一组没拿到种子,三个 run 其实是同一个数据顺序跑了三遍。
杀伤力:我的方法跑了三种数据顺序,基线只跑了一种。三个看似独立的重复,其实只差在采样的随机性上。
Bug 3:Stuck Batch
伪装:日志里每一步的"数据年龄"(训练数据是几步之前生成的)都和设计完全一致。
真相:为了模拟陈旧数据,单卡 trainer 每步生成一批新数据放进队列,但训练时取 32 步之前的那批。但没人知道问题出在开头:队列攒满之前,代码一直取最老的第 0 批。于是前 33 步训的都是同一批 48 条回复,100 步里只用到了 68 个不同的 batch。而第 0 批确实一步步变"老",数据年龄从 0 涨到 32,和设计一模一样,日志看不出任何异常。
杀伤力:在这个队列下,TIS 的四次实验奖励全部归零;修好之后,TIS 正常学到 0.70。而且崩溃发生在第 50 步之后,开头埋下的问题,过了很久才显现。
Bug 4:Rogue Normaliser
伪装:两个变体都能正常训练、正常打日志,短跑看不出任何问题。
真相:我的方法会参照一个"锚点"来决定每个 token 的权重。原版的锚点是这条回复自身的平均惊讶度,两个变体则把一个冻结的参考模型也引入了锚点。论文里写,变体"只换了锚点,其他不变",但其实代码里两个变体的归一化方式也和原版不同。
杀伤力:实验数字本身没错,错误的地方的是解读。我以为变体之间的差别来自锚点,实际上锚点和归一化同时在变,没法单独归因给任何一个。这类 bug 靠重跑实验发现不了,只能把实现和公式逐项对照。
四个 bug 的共同点
clip fraction 记了,ratio 是对着谁算的没记;种子标签记了,种子有没有真正生效没记;数据年龄记了,用的是哪一批数据没记;loss 能训也能打日志,但没人拿它对过公式。
Bug | 日志里看到的 | 真正该看的 |
|---|---|---|
Idle Clip | clip fraction,始终为 0 | 相对采样器记录概率的 ratio |
Lost Seed | run 标签,三个不同结果 | 每组实际收到的数据种子 |
Stuck Batch | 数据年龄,完全符合设计 | 训练 batch 的身份 |
Rogue Normaliser | 短跑能训、能打日志 | loss 是否等于写下的公式 |
修好之后:verl 上打平了
四个 bug 里,前三个改了数字,第四个改了数字的含义。全部修好后重跑,结论完全反过来:
- verl 上,碾压变平局。刷新间隔 96 时,TIS 落在 76% 到 79%,和我的方法打成平手。刷新间隔 64 时,TIS 拿回种子后差距随之消失:按种子配对,平均只差 0.9 个点,而且在其中一个种子上 TIS 反而赢了。
- 单卡 trainer 上,TIS直接原地复活。换成每步都用新 batch 的队列后,TIS 从起初的 0.00 回到 0.70,留出集准确率 68.4% 和 67.2%。我的方法是 75.4% 和 77.0%,仍然领先 7 到 10 个点,这部分差距从哪来,还要靠析因实验回答。
所谓的"全面领先",有一大半是 harness 造出来的。如果当初没查,这篇论文现在大概已经躺在顶会审稿人的待审列表里了(笑哭)。
顺带,我得到了一张配置正确的基线参考表。设置是 Qwen2.5-Math-1.5B 在 GSM8K 上训练,verl 加 vLLM 0.11,每步 16 个 prompt、每个 8 条回复,学习率 2e-6,训练 100 步,学习器权重每 N 步才同步给采样器。下表是 1319 道测试题上的最终贪心准确率:
方法 | N=64 默认 | 43 | 44 | N=96 默认 | 43 | 44 |
|---|---|---|---|---|---|---|
无校正 GRPO | .743 | .778 | .777 | .114 | .280 | .099 |
TIS | .763 | .785 | .808 | .764 | .781 | .785 |
配置正确的 TIS,在两个刷新间隔下都稳稳跑完 100 步。所以,如果你的 TIS 基线在类似设置下崩了,在得出"TIS 不行"的结论之前,最好先查一遍 harness。
为什么没被发现
这四个坑,没有一个是什么高深错误:
- Idle Clip:verl 官方文档把"对着重算的概率取 ratio"列为常见的实现方式,也指出它是不稳定的常见来源。用 verl 做异步或滞后训练、每个 batch 只更新一步的研究者都有可能中招。
- Lost Seed:一个 shell 变量被后面的赋值覆盖了。只要启动脚本里有按条件拼接参数的逻辑,就有可能出现。
- Stuck Batch:一个队列在启动阶段的边界条件没处理好。自己实现 replay 队列、或者用其他方式模拟陈旧数据的代码,都可能有类似问题。
- Rogue Normaliser:代码和论文里的公式对不上。实验迭代过几轮之后,这种不一致也很常见。
这么简单的错误,却一直留到论文快成稿的时候才被发现,总体原因是因为常规日志里根本没有记录能暴露它们的量。
PTH:用一条命令检查这四个问题
我把这次排查的过程做成了一个开源工具 PTH。四项核心检查里,两项直接读取 verl 的控制台日志,只需在训练时打开两个日志选项;另外两项只要在训练循环里加几行记录,或者写一个小测试。
30 秒上手
pip install probe-the-harness # 需要检查 4(依赖 PyTorch)或读取 YAML 配置时 pip install "probe-the-harness[all]"核心只依赖 numpy,Python 3.9 及以上即可。然后,可以对着最近一次训练的日志:
pth verl path/to/run.log操作上整体很简单。要比较多组实验,只需要告诉 pth 哪些日志属于哪一组:
pth verl --arm "grpo=logs/grpo-*.txt" --arm "tis=logs/tis-*.txt" \ --vary algorithm.rollout_correction.rollout_is -q拿我论文里那 6 份 verl 日志跑这条命令,PTH 直接报出了四个 bug 中的两个:所有 GRPO run 里的 Idle Clip,以及 TIS 组的 Lost Seed。这两个问题是我当初是逐行翻日志和脚本才找到的,现在利用PTH只需要一条命令就够了。
而且只要有任何一项检查失败,pth就会以状态码 1 退出。所以可以把它加进生成结果表的 CI 流程里,检查不通过,流程就会直接报错。
读取 verl 日志需要两个配置:trainer.logger里包含console,并设置actor_rollout_ref.rollout.calculate_log_probs=True。
Python API:嵌进你自己的训练代码
每项检查返回一个Report,分fail、warn、info三级,附带背后的统计量。命中这四个 bug 之一时,结果会带上对应的名字:pth.IDLE_CLIP、pth.LOST_SEED、pth.STUCK_BATCH、pth.ROGUE_NORMALISER。
抓 Idle Clip,在更新函数里看 ratio 到底对着谁算:
import pth # 每个 micro-batch 调一次;verl 中在 verl/workers/actor/dp_actor.py rep = pth.ratio_report(log_prob, rollout_log_probs, response_mask, logp_old=old_log_prob) rep.stats["kl_sampler_to_old"] # KL(采样器 || pi_old),每 token 的 nat 数 rep.stats["ratio_to_sampler"]["p99"] # pi_theta / q 的 99 分位数抓 Stuck Batch,给每个训练 batch 记个指纹:
ledger = pth.BatchLedger() for update in range(num_updates): batch, age = queue.next() ledger.record(update, pth.batch_fingerprint(batch["input_ids"]), age=age) print(ledger.report(expected_age=lambda u: min(u, k)))输出可以直接点名:
[FAIL] check 3 (Stuck Batch): 32 of 100 updates reuse an earlier batch 100 updates trained on 68 distinct batches. Updates 0 to 32 all trained on one batch (33 updates in a row). The logged data age does not reveal this, since a repeated batch can carry any age. [INFO] check 3: data age follows the design on every update注意最后一行:数据年龄的检查是通过的。如果只记录年龄,这个问题永远不会暴露。
一条原则,七项检查
PTH 背后其实只有一条原则:
对 harness 的每一层,都去测量真正决定比较结果的那个量,而不只是测量方便记录的量。
所以本质落到实处,就是七项检查:
- 记录 ratio 相对于采样器的分布(对应 Idle Clip)。记录 clip fraction,以及 πθ/q 的分布,q 是采样器记录的概率。采样器滞后时,如果 clip fraction 恰好为 0,就要去查 πold 是从哪来的。
- 核对每组实际生效的配置(对应 Lost Seed)。该变的字段确实变了,该固定的字段(包括数据种子)确实一致。前几个 batch 的 prompt ID 顺序,则能直接验证数据顺序。
- 记录数据年龄的同时,记录 batch 身份(对应 Stuck Batch)。统计实际用到了多少个不同的 batch,确认和设计一致。
- 拿每个 loss 去对公式(对应 Rogue Normaliser)。在固定输入上比较实现的 loss 和梯度。能正常训练,不代表它就是论文里写的那个公式。
- 先摸清基线的稳定范围。正式比较前,先找出基线在预期配置下从哪里开始变差。
- 按种子报告配对差值。"9 个 run 全部高于 6 个 run"这种合并排名,会把种子效应和方法效应混在一起。我最初的"全面领先",就是这样得出来的。
- 逐句对照代码和日志检查每个结论。这次的四个 bug,都是这样查出来的。
写在最后
这四个 bug 是我在投稿前自己查出来的。如果是被审稿人指出来,结果会难看得多。
所以我的建议是:在结果表定稿之前,对着日志跑一遍pth verl。没有报错,再写进论文;如果报出 Idle Clip 或 Lost Seed,先修 harness,再比较方法。
如果这个工具对各位有用,欢迎在 GitHub 上点个 Star,也欢迎提 issue 交流你遇到的类似问题。
- 代码:github.com/pth2002/probe-the-harness
- 论文:arXiv:2610.02911
- PyPI:probe-the-harness
引用:
@misc{pan2026probe, title = {Probe the Harness: Setup Checks for Stale-Data {RL} Comparisons in Language Models}, author = {Pan, Taiheng}, year = {2026}, eprint = {2610.02911}, archivePrefix = {arXiv}, primaryClass = {cs.LG}, doi = {10.48550/arXiv.2610.02911} }