news 2026/10/7 8:35:16

开源 Probe the Harness: 论文初稿都写好了,我才发现“全面碾压”是 4 个 bug 堆出来的

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
开源 Probe the Harness: 论文初稿都写好了,我才发现“全面碾压”是 4 个 bug 堆出来的

你的 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 的每一层,都去测量真正决定比较结果的那个量,而不只是测量方便记录的量。

所以本质落到实处,就是七项检查:

  1. 记录 ratio 相对于采样器的分布(对应 Idle Clip)。记录 clip fraction,以及 πθ/q 的分布,q 是采样器记录的概率。采样器滞后时,如果 clip fraction 恰好为 0,就要去查 πold 是从哪来的。
  2. 核对每组实际生效的配置(对应 Lost Seed)。该变的字段确实变了,该固定的字段(包括数据种子)确实一致。前几个 batch 的 prompt ID 顺序,则能直接验证数据顺序。
  3. 记录数据年龄的同时,记录 batch 身份(对应 Stuck Batch)。统计实际用到了多少个不同的 batch,确认和设计一致。
  4. 拿每个 loss 去对公式(对应 Rogue Normaliser)。在固定输入上比较实现的 loss 和梯度。能正常训练,不代表它就是论文里写的那个公式。
  5. 先摸清基线的稳定范围。正式比较前,先找出基线在预期配置下从哪里开始变差。
  6. 按种子报告配对差值。"9 个 run 全部高于 6 个 run"这种合并排名,会把种子效应和方法效应混在一起。我最初的"全面领先",就是这样得出来的。
  7. 逐句对照代码和日志检查每个结论。这次的四个 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} }
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 8:35:15

千问无门槛券申领通道,限新人,【新人5047】,日常消费都能抵

​ 先把千问这个APP弄在手机里,然后在对话框里输入10月专属口令内容(新人5047)后,会看到"待领取"按钮,按照页面指引完成账号绑定,成功后8面额的券就会自动发放到你的卡包中,整个流程也就完成了。快去试一试吧…

作者头像 李华
网站建设 2026/10/7 8:35:03

unet_DeepLabV3+模型训练道路车辆行人 目标分割检测数据集 训练识别道路目标分割检测数据集json 道路车辆行人公交车自行车路标等

使用unet/DeepLabV3对汽车道路分割检测数据集 道路分割 9000张 json coco 道路语义分割数据集 道路分割数据集 道路语义分割数据集 语义分割检测数据集 26类9000张 文章目录使用unet/DeepLabV3对汽车道路分割检测数据集 道路分割 9000张 json coco 道路语义分割数据集 道路分割…

作者头像 李华
网站建设 2026/10/7 8:34:01

免注册调用大漠插件:NetCore5.0 WinForm将DLL转为COM对象实践

简介:这是一份面向C# WinForm开发者的免注册调用大漠插件(dm.dll)资源包,基于.NET Core 5.0框架,适用于Windows 10环境。大漠插件提供找字、找图、截图、打字等图像识别与自动化能力,本资源可为自动化测试、…

作者头像 李华
网站建设 2026/10/7 8:33:50

投研AI判断一致性评估:LLM框架效应与基准率忽视的工程实践

1. 从一个反直觉的信贷场景说起1.1 为什么87%和13%这两个数字值得单独拎出来聊先把这个标题拆开看。87%未违约、13%违约,这是一个典型的信贷资产组合的标签分布。做过评分卡或者投研模型的人对这个比例不会陌生——绝大多数样本是“好人”,少数是“坏人”…

作者头像 李华
网站建设 2026/10/7 8:31:56

Qt/C++对接量子通信骨干网:架构、线程与性能优化实战

接到“用Qt和C开发一个对接中国电信量子通信骨干网的应用”这个题目时,我的第一反应不是仰视这个高大上的技术名词,而是快速梳理了三件事:密钥或者说信令到底怎么从骨干网那边拿下来、桌面端扛不扛得住长时间运行、界面在数据量上来之后会不会…

作者头像 李华