QwenGyre 负责在线采样、训练侧只消费轨迹

文章导读
遇到“这段逻辑到底该写在采样侧还是训练侧”的犹豫时,可以先看一个东西:这段代码需不需要碰外部环境或奖励。需要碰的归采样侧,只吃数值做梯度更新的归训练侧。QwenGyre 这类分工默认就是这样:在线采样产出轨迹,训练侧拿到轨迹文件后才开始算 loss 和更新参数。判断标准不清,两侧就会开始互相 import、互相调函数,改采样频率顺手改了优化器行为,改 batch 大小又把环境 reset 次数带偏
📋 目录
  1. A 在现有代码里标出采样调用与参数更新的位置
  2. B 把环境交互限定在采样侧
  3. C 把梯度与优化器调用限定在训练侧
  4. D 给两侧各写一个独立运行入口
A A

遇到“这段逻辑到底该写在采样侧还是训练侧”的犹豫时,可以先看一个东西:这段代码需不需要碰外部环境或奖励。需要碰的归采样侧,只吃数值做梯度更新的归训练侧。QwenGyre 这类分工默认就是这样:在线采样产出轨迹,训练侧拿到轨迹文件后才开始算 loss 和更新参数。判断标准不清,两侧就会开始互相 import、互相调函数,改采样频率顺手改了优化器行为,改 batch 大小又把环境 reset 次数带偏,最后只能靠一次跑完整流程才能发现问题。

把边界落在“是否接触外部环境与奖励”这条线上:环境 reset/step、奖励计算、动作采样归采样侧,梯度反传、优化器 step、学习率调度归训练侧。落地动作是先标记当前代码里两处调用路径并打印调用次数,再按对照表清理越界写法,最后给两侧各写一个独立入口——采样侧只写轨迹文件,训练侧只读该文件,各自单独运行不报错即说明边界已经拉开。边界之外仍需留意的,是轨迹里必须带策略版本号或权重标识,否则两侧独立跑通也可能对不上策略。

在现有代码里标出采样调用与参数更新的位置

先不要急着改结构,第一步是把现状看清楚:当前代码里,采样入口和参数更新入口分别在哪、被调用了多少次。做法很土但有效——在两个入口各包一层,打日志并计数,然后照常跑一小段流程,看两份计数怎么长。

import itertools, logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
_calls = {'sample': itertools.count(), 'update': itertools.count()}

def sample_entry(*a, **kw):
    n = next(_calls['sample'])
    logging.info('sample_call n=%d args=%s', n, kw.get('tag', ''))
    # breakpoint()  # 需要逐帧看栈时再打开
    return _sample(*a, **kw)

def update_entry(*a, **kw):
    n = next(_calls['update'])
    logging.info('update_call n=%d', n)
    return _update(*a, **kw)

跑完后用命令数一下:grep -c sample_call run.log 和 grep -c update_call run.log。正常形态是采样调用次数远多于参数更新次数,因为一条轨迹里有多步动作、多个样本才会攒成一次更新。如果两份计数几乎同步增长,通常说明训练侧被采样循环直接驱动了,也就是每采一步就更新一次,这种耦合值得优先拆掉。断点的用途是看调用栈:在 update_entry 里打断点,观察栈里有没有环境相关的帧;在 sample_entry 里打断点,观察栈里有没有优化器相关的帧。

拿到落点后,按下面这张表给现有代码归位,归不进去的单独列出来再判断。

两侧职责与越界写法对照表

QwenGyre 负责在线采样、训练侧只消费轨迹
职责项采样侧写法典型越界写法可观察迹象
环境 reset / step只在采样循环内调用训练侧 import 环境模块,或在数据加载函数里 reset训练进程日志出现 reset / step 字样
奖励计算在线算出并写进轨迹字段训练侧调 reward 函数实时打分loss 计算前后出现奖励函数的调用日志
模型前向前向并记录 logp,不建计算图采样侧对 logp 做 backward采样侧日志出现 loss 或 backward 关键字
参数更新不碰优化器采样侧热改权重或置 eval/train 标志相邻轨迹的策略版本号突然变化
轨迹落盘写文件并带版本标识训练侧直接从采样进程内存队列取训练启动依赖采样进程存活

把环境交互限定在采样侧

判定规则可以写得很窄:凡是需要读外部状态、推动状态变化、或依赖奖励函数打分的调用,都归采样侧。环境 reset、step、终止判断、奖励计算、以及任何会改变环境内部状态的随机源,都落在这一侧。训练侧拿到的应该只是一份静态记录,里面已经包含观测、动作、奖励和当时的策略标识。

越界写法有几个常见形态,识别它们比记规则更快:训练脚本顶部出现 from env import ... 或 import gym 这类导入;数据集的 __getitem__ 里调用了 reset,理由是“每个 batch 重新开局比较干净”;在 loss 函数里调奖励模型做在线打分;训练过程中穿插一段 rollout 用于“顺便看看效果”。最后一种最难察觉,因为它在日志上和采样侧长得几乎一样,只有看进程和调用栈才能分开。

清理方式是一次性把训练侧的环境导入摘掉,让训练脚本只依赖轨迹文件路径。摘掉后立刻能暴露真问题:某个字段轨迹里没有写过,训练侧一读就 KeyError。这类报错是好信号,说明边界开始起作用了,补字段就补在采样侧的写入逻辑里,而不是在训练侧临时造一个默认值糊过去。

把梯度与优化器调用限定在训练侧

这一侧规则同样简单:梯度反传、优化器 step、学习率调度、梯度裁剪,全部只出现在训练侧。采样侧做前向时建议显式关掉梯度记录,避免为了拿 logp 顺手建了一张计算图,占显存还可能被误用。

常见越界写法包括:采样函数里在算完 logp 后调用 backward;为了“让策略尽快跟上”在采样循环里直接调 optimizer.step();采样和训练共用一个全局模型对象且不加锁,采样读到一半权重被改;把学习率调度器挂在采样步数上。最后一种在配置里往往看不出来,只在日志里表现为学习率随采样步数变化。

QwenGyre 负责在线采样、训练侧只消费轨迹

越界后日志上通常留下这些痕迹:update_call 的计数增速和采样步数几乎一比一;同一段日志里连续出现两次 optimizer.step 中间没有 zero_grad;采样进程的日志里出现 loss 数值或学习率数值;轨迹里同一批次内 logp 与记录的策略版本对不上,回放时算出的比率偏离正常范围。发现这些迹象时,先把采样侧对优化器的引用删掉、重新跑一遍计数,比继续在两侧加锁更省事。

给两侧各写一个独立运行入口

边界拉开的最终形态是两个可以单独启动的脚本,接口只有文件。采样侧不认识优化器,训练侧不认识环境,任何一侧都能在另一侧不启动的情况下跑完并正常退出。

# sampler_main.py —— 只负责与环境交互,产出轨迹
import json, logging
log = logging.getLogger('sampler')

def main():
    env, policy = build_env(), load_policy()   # 替换为各自项目的构造方式
    out = 'traj/part-000.jsonl'
    with open(out, 'a') as f:
        for episode in range(EPISODES):
            traj = run_episode(env, policy)    # 内部含 reset/step/reward
            traj['policy_ver'] = policy.version
            f.write(json.dumps(traj) + '\n')
            log.info('traj_written ep=%d steps=%d', episode, len(traj['act']))

# trainer_main.py —— 只读轨迹,不碰环境
import json, logging
log = logging.getLogger('trainer')

def main():
    policy, opt = load_policy(), build_optimizer()
    for batch in iter_batches('traj/part-000.jsonl'):
        loss = policy.compute_loss(batch)
        opt.zero_grad(); loss.backward(); opt.step()
        log.info('update_done loss=%s ver=%s', float(loss), policy.version)

验证方式就是分别跑:先只启动 sampler_main.py,确认轨迹文件增长、每行 JSON 可解析、关键字段齐全;再关掉采样侧,单独启动 trainer_main.py 读已有文件,确认能连续完成若干次更新且不报错。两侧都能单独跑通,之后才把两侧并行启动,这时再观察 sample_call 与 update_call 的计数关系是否符合预期。若并行后又出现互相阻塞,问题通常不在边界本身,而在文件读写并发或轨迹积压策略,需要结合环境单独确认。

还有一点容易被忽略:轨迹里一定要带策略版本号或权重标识。两侧独立运行后,训练侧读到的可能是几小时前采样侧写下的旧轨迹,没有版本标识就无法判断这份数据和新权重差了几轮。加上标识后,训练侧可以在日志里打印版本差异,判断是否需要丢弃过旧文件,这是边界之外仍需补的一小块。