Skip to content

第 16 章:verl 与 TRL 训练框架


16.1 verl 架构概念与设计哲学

verl 的设计哲学:单控制器调度 + 多控制器执行的混合架构,让用户只关注数据流图。

为什么需要专用的 RL 训练框架

RLHF/RLVR 训练与标准 SFT 存在本质区别:

  • 多模型协同:Actor、Critic、Reference Policy、Reward Model 需要同时在线,在有限 GPU 资源上合理共享显存。
  • 推理-训练交替:Rollout(推理生成)和 Training(梯度更新)对 GPU 使用模式完全不同——前者需要 KV Cache,后者需要优化器状态和激活值。
  • 数据流复杂:生成的轨迹需要携带 log prob、reward、advantage 等元信息在多个组件间流转。

verl(由字节跳动开源)通过"单控制器调度 + 多控制器执行"的混合架构解决了这些挑战。其核心设计哲学是:让用户只关注数据流图(Dataflow Graph),不需要操心分布式实现细节

Single-Controller vs. Multi-Controller

verl 单控制器架构

verl 的架构分为两层:

Single-Controller(RayPPOTrainer)——控制层:

  • 运行在普通 Python 进程中,不占用 GPU
  • 负责训练循环的编排:生成 -> 奖励计算 -> 优势计算 -> 更新
  • 不断地更新(维护)DataProto 数据容器
  • Worker Procedure 之间按序执行,用户只需关注数据流依赖

Multi-Controller(WorkerGroup)——执行层:

  • 每个 Worker Group 内部以 SPMD(Single Program, Multiple Data)方式运行
  • 所有 GPU/进程跑同一段程序,只是各自处理不同的数据分片
  • SPMD 是 DP(DDP/ZeRO/FSDP)、TP、PP、SP 等分布式方法的编程模型
python
# 概念上等价于:
out = wg.generate_sequences(batch)

# 底层实际执行:
shards = split(batch, world_size)
refs = [worker_i.generate_sequences.remote(shards[i]) for i in range(world_size)]
out = concat(ray.get(refs))

Worker Group 与角色分工

verl 中的核心 Worker Group 及其方法映射:

Worker Group关键方法职责
actor_rollout_wggenerate_sequences, compute_log_prob, update_actor推理生成 + Actor 训练
ref_policy_wgcompute_ref_log_prob参考策略 KL 计算
critic_wgcompute_values, update_critic价值估计 + Critic 训练

Hybrid Engine:训练与推理的融合

verl 将传统上分离的 Actor(策略网络训练)和 Rollout(推理生成)融合到同一个 Worker 中,即同一 GPU 既可训练也可推理。这是通过 ActorRolloutRefWorker 实现的:

python
class ActorRolloutRefWorker:
    # 可以被实例化为 standalone actor / standalone rollout /
    # standalone reference policy / hybrid engine

通过 rollout_mode()trainer_mode() 在两种模式间切换。

EntryPoint:训练入口

python
# main_ppo.TaskRunner.run
resource_pool_spec = {
    global_pool_id: [config.trainer.n_gpus_per_node] * config.trainer.nnodes,
}

# 所有角色映射到同一个 GPU 资源池(Colocation)
self.mapping[Role.ActorRollout] = global_pool_id
self.mapping[Role.Critic] = global_pool_id
self.mapping[Role.RefPolicy] = global_pool_id

# 创建 Worker Group
resource_pool_manager = ResourcePoolManager(
    resource_pool_spec=resource_pool_spec, mapping=self.mapping
)

trainer = RayPPOTrainer(config, ...)
trainer.init_workers()
trainer.fit()

Colocated Worker 机制

每个 Worker Group 对应一个 resource_pool(一组 GPU 资源)。多个角色共享同一个 pool 时,verl 使用 create_colocated_worker_cls 将它们合并:

python
resource_pool_to_cls = {
    global_pool: {
        "actor_rollout": RayClassWithInitArgs(ActorRolloutRefWorker, ...),
        "critic": RayClassWithInitArgs(CriticWorker, ...),
        "ref": RayClassWithInitArgs(RefPolicyWorker, ...),
    }
}
worker_dict_cls = create_colocated_worker_cls(class_dict=...)

RayWorkerGroup 在初始化时会扫描 Worker 类中所有带 @register 装饰器的方法,并动态通过 setattr 将它们挂载到自己身上。


16.2 verl 核心架构详解

DataProto 数据容器、WorkerGroup 执行层与参数同步机制的实现细节。

Training Step 同步序列

一个完整的训练 step 按以下顺序依次执行:

  1. generate_sequences() — 所有 DP rank 生成完成
  2. compute_rm_score() — Reward Model 计算
  3. compute_log_prob() — Actor 重新计算 old log prob
  4. compute_ref_log_prob() — Reference Policy 计算
  5. compute_values() — Critic 计算 Values(PPO 模式)
  6. update_critic() — Critic 梯度更新(NCCL AllReduce)
  7. update_actor() — Actor 梯度更新(NCCL AllReduce)

Hybrid Colocate 模式下的显存分析

关于 FSDP/ZeRO 分片策略的原理与配置,参见第 14 章"分布式训练"。

核心挑战:Actor 需要同时承担 Rollout(vLLM 推理)和 Training(FSDP 训练)两个角色,但两种状态的显存需求完全不同,无法同时存放在 GPU 上。

verl 的解决方案是分时复用——通过 sleepwake_up 在两个阶段间动态切换:

Rollout 阶段(vLLM 是主占用方):

  • vLLM weights + KV cache + decode/prefill buffers
  • Trainer 仍保留底座:optimizer state + grad buffer + CUDA/NCCL context
  • 模型参数 shard 已经被 offload 到 CPU(self.actor.engine.to("cpu", model=True, optimizer=False, grad=False)

Training 阶段(Trainer 是主占用方):

  • 训练开始前先让 rollout sleep_replicas(),释放 weights 和 KV cache
  • vLLM 在 hybrid 模式下的 sleep(level=2) 会释放 weights 和 kv_cache
  • 主占用方变成 actor model shard + optimizer states + grad buffers + activations
  • vLLM 侧只剩进程本身、CUDA context 等少量 runtime 状态

瞬时显存峰值:在 update_weights() 阶段存在短暂的叠加:

  1. resume(weights),触发 vLLM wake_up,权重显存重新映射回 GPU
  2. 然后才从 actor 取参数并同步到 rollout
  3. 最后才把 actor offload 到 CPU
python
# engine_workers.py 中的关键顺序
rollout.resume(tags=["weights"])    # vLLM 唤醒,权重回 GPU
# 此时 actor 训练态显存还在 -> 瞬时叠加峰值
actor.engine.to("cpu", model=True, optimizer=False, grad=False)  # actor offload
rollout.resume(tags=["kv_cache"])   # KV cache 恢复

wake_up / sleep 实现

python
# vllm_async_server.py
async def wake_up(self):
    if self.rollout_mode == RolloutMode.HYBRID:
        # 调用所有 worker 进行权重同步和显存准备
        await asyncio.gather(*[worker.wake_up.remote() for worker in self.workers])
    elif self.rollout_mode == RolloutMode.STANDALONE:
        logger.info("skip wake_up in standalone mode")

async def sleep(self):
    if self.rollout_mode == RolloutMode.HYBRID:
        if self.node_rank == 0:
            await self.engine.reset_prefix_cache()  # 重置 Prefix Cache
        await asyncio.gather(*[worker.sleep.remote() for worker in self.workers])

在 Standalone 模式(推理和训练在不同 GPU 上)下,wake_up/sleep 不需要执行任何操作。

πθπoldπref 的部署

Policy部署位置角色更新时机
πθGPU 常驻当前正在优化的策略每个 PPO mini-batch 后
πoldGPU 重算IS Ratio 分母每个 Global Step 前
πrefCPU 常驻,FSDP OffloadKL 惩罚基准不更新

Actor(常驻 GPU):需要频繁 Forward/Backward/Optimizer Step,频繁搬运不可接受。

Ref Model(常驻 CPU)ref.fsdp_config.param_offload=True,且 forward_only=True。调用 compute_ref_log_prob 时,FSDP 会流式加载——算到第 i 层时将参数从 CPU 拷到 GPU,算完立即释放,同一时刻 GPU 上只有 Actor 全部参数 + Ref 一层参数。

Metrics

关键时间指标:timing_s/gen|old_log_prob|ref|values|update_critic|update_actor|save_checkpoint


16.3 Rollout Engine

Rollout Engine 的配置与调优:vLLM/SGLang 后端选择、采样参数与显存管理。

Rollout 引擎注册表

verl 通过注册表支持多种推理后端:

python
_ROLLOUT_REGISTRY = {
    ("vllm", "sync"):   "verl.workers.rollout.vllm_rollout.vLLMRollout",
    ("vllm", "async"):  "verl.workers.rollout.vllm_rollout.vLLMAsyncRollout",
    ("sglang", "sync"): "verl.workers.rollout.sglang_rollout.SGLangRollout",
    ("sglang", "async"):"verl.workers.rollout.sglang_rollout.SGLangRollout",
}
  • Sync 模式:同步批量推理,适合 GRPO 等单轮对话场景
  • Async 模式:异步推理,适合多轮 Agent 任务(见 16.7 节)

vLLM Rollout 关键配置

yaml
actor_rollout_ref:
  rollout:
    name: vllm
    mode: sync               # sync / async
    temperature: 1.0
    n: 4                      # 每个 prompt 采样 4 条轨迹
    max_model_len: 8192
    gpu_memory_utilization: 0.85
    free_cache_engine: true   # 启用 sleep/wake_up

vLLM 调度参数

  • max_model_len:最大生成长度(含 prompt + generated)
  • max_num_batched_tokens:一次推理中处理的最大 token 数,把所有活跃序列的 prefill + decode token 合计
  • max_num_seqs:同时处理的最大序列数

Prefill 分块调度示例:对于长 prompt(如 6000 token),vLLM 按 long_prefill_token_threshold=1024 分块,每步与短请求的 decode 混合执行,最大化 GPU 利用率。

@register Dispatch 模式

模式含义典型用途
Dispatch.DP_COMPUTE_PROTO数据按 DP 维度切分generate_sequences
Dispatch.ONE_TO_ALL广播到所有 Workerinit_model

16.4 目标函数与 Loss:GRPO、GSPO、DR-GRPO

本节涉及的 RL 基础概念(策略梯度、优势函数、GAE、PPO-Clip)在第 8 章有系统讲解。

带 KL 惩罚的 RL 目标

J(πθ)=EqpQ[Eoπθ[R(q,o)]βDKL[πθ(|q)πref(|q)]]

训练时关注的是 rewards curve,而不是 loss curve。PPO-Clip 的 loss 是一种保守的、低方差的代理目标(surrogate),其正负反映的是"当前 batch 中好动作占优还是坏动作占优"。

PPO-Clip 目标函数

LPPO=Et[max(rt(θ)A^t,clip(rt(θ),1ϵ,1+ϵ)A^t)]

其中 rt(θ)=πθ(ot|q,o<t)πθold(ot|q,o<t)

Clip 机制的直觉

  • A^t>0(好动作):rt 被上限 1+ϵ,防止过度增强
  • A^t<0(坏动作):rt 被下限 1ϵ,防止过度惩罚
  • A^t 不贡献梯度——它是一个不在计算图中的标量,来源于 πθold

Policy Loss 曲线的解读

  • policy_loss < 0:好动作的正面影响超过坏动作,策略在改善
  • policy_loss > 0:坏动作占优,策略在试错修正
  • 理想趋势:在 0 附近震荡,Actor 和 Critic 步调一致

Clip Fraction 监控

  • 理想范围:0.1~0.4
  • 高 clip fraction + 高 KL:步子过猛(LR 大、epoch 多)
  • 高 clip fraction 但 KL 不高:同一批数据被反复训练(epoch 太多/复用过度)
  • 持续为 0:步子太小,学习停滞

Dual-Clip PPO

Dual-Clip PPO

A^t<0 时,标准 PPO Clip 可能让 rt 远低于 1ϵ 而不受约束。Dual-Clip 增加了下界:

Ldual-clip=min(LPPO,cA^t)

GRPO(Group Relative Policy Optimization)

GRPO 核心要点

GRPO 是 DeepSeek 提出的无 Critic 算法。πθ(yi,t|x,yi,<t) 是单个训练样本 (x,y) 上的 token 级似然,与预训练 loss 的形式一致,区别在于 GRPO 引入了两个额外的缩放因子:1πθold(yi,t|x,yi,<t)(IS Ratio)和 A^i,t(优势估计)。

GRPO token-level IS Ratio

GRPO 序列级聚合

对每个 prompt q,采样 G 条轨迹,利用组内相对奖励替代价值函数:

A^i,t=Rimean(R)std(R)

GRPO 的目标函数与 PPO-Clip 结构相同,区别仅在于优势估计从 Critic 的 token 级 GAE 替换为组内相对奖励(序列级常数)。其 token 级损失为:

LtGRPO=max(rt(θ)A^i,t,clip(rt(θ),1ϵ,1+ϵ)A^i,t)

其中 rt(θ)=πθ(yi,t|x,yi,<t)πθold(yi,t|x,yi,<t) 是 token 级 IS Ratio,与 PPO 相同。GRPO 的核心创新不在损失函数形式,而在于用组内相对奖励消除了 Critic 网络(参见第 8 章 RL 基础中关于 Advantage 估计的讨论)。

PPO vs. GRPO:PPO 比 GRPO 更精细,优势定义在 single step 上(基于每步的 Critic)。但 LLM 经过大规模 pretraining 后已足够稳定,初始策略本身就很好,GRPO 的序列级优势估计已经足够。在传统 RL 任务中,PPO 仍优于 GRPO。

GRPO 的 token-wise issue(x,yi)πθold 采样但用于更新 πθ1πθold(yi,t|x,yi,<t) 的值域为 [1,+)。低概率 token 的梯度可能比高概率 token 大几个数量级,"淹没"高概率 token 的梯度信号。

DeepSeek v3.2 改进

  • 去掉 σA^i,t=Rimean(R)
  • 无偏 K3 估计的 KL 散度
  • Off-policy 序列掩码:对 KL 散度过大的负样本进行掩码

GSPO(Geometric-mean Surrogate Policy Optimization)

GRPO 与 GSPO 的核心区别在于如何将 token 级 IS Ratio 聚合到序列级。GRPO 对 token 级 ratio 取算术平均,GSPO 改为几何平均

r¯iGRPO=1|yi|t=1|yi|πθ(yi,t|x,yi,<t)πθold(yi,t|x,yi,<t)(算术平均 IS Ratio)si(θ)=(πθ(yi|x)πθold(yi|x))1|yi|=exp(1|yi|tlogπθπθold)(几何平均 IS Ratio)

注意:r¯iGRPOsi(θ) 都是序列级的标量缩放因子,它们分别被代入 PPO-Clip 风格的损失函数中。算术平均对离群 token ratio 敏感(一个极大的 rt 会主导整个序列),而几何平均在 log 空间取均值,对离群值更鲁棒。

GSPO Loss 对比

GRPO 与 GSPO 梯度对比

核心优势:在 GSPO 中,序列中所有 token 共享同一个序列级缩放因子 si(θ),损失的数值大小由 si(θ) 决定,但梯度的方向仍由单个 token 的策略梯度 θlogπθ(yi,t|x,yi,<t) 决定——"用整个团队的整体表现决定奖励大小,根据每个队员自己的行为决定奖励方向"。

Stop-Gradient 实现技巧

python
# verl/trainer/ppo/core_algos.py: compute_policy_loss_gspo

# 序列级几何平均 IS ratio(log 空间)
negative_approx_kl = log_prob - old_log_prob
seq_lengths = torch.sum(response_mask, dim=-1).clamp(min=1)
negative_approx_kl_seq = torch.sum(negative_approx_kl * response_mask, dim=-1) / seq_lengths

# 关键:stop-gradient 分离
# log(s_{i,t}(θ)) = sg[log(s_i(θ))] + log_prob - sg[log_prob]
log_seq_importance_ratio = (log_prob - log_prob.detach()
                           + negative_approx_kl_seq.detach().unsqueeze(-1))
  • negative_approx_kl_seq.detach():提供更新的"大小/幅度",不影响梯度方向
  • log_prob - log_prob.detach():数值为 0(不影响前向),但保留梯度,提供更新的"方向"

为什么需要 Stop-Gradient:直接实现 GSPO 目标时,si(θ) 依赖于整个序列的 log prob,自动微分时某个 token yi,t 的梯度会受到序列中所有其他 token 的影响。通过 stop-gradient 分离,构造 token 级代理变量:

si,t(θ)=sg[si(θ)]πθ(yi,t|x,yi,<t)sg[πθ(yi,t|x,yi,<t)]

sg[] 部分视为常数 C1,C2,则 si,t(θ)=C1C2πθ(yi,t|x,yi,<t),对其求导得到 θsi,t(θ)=C1C2θπθ(yi,t|x,yi,<t)——每个 token 的梯度方向仅由自身的策略梯度决定。

verl 中已注册的损失函数

python
@register_policy_loss("vanilla")    # 标准 PPO
@register_policy_loss("gpg")        # Group Policy Gradient
@register_policy_loss("kl_cov")     # KL 协方差
@register_policy_loss("clip_cov")   # Clip 协方差
@register_policy_loss("geo_mean")   # 几何平均变体
@register_policy_loss("gspo")       # GSPO

通过配置 actor.policy_loss.loss_mode 切换。自定义 Loss 只需在 core_algos.py 中用 @register_policy_loss 注册。

PPO 中 Actor 与 Critic 的关系

Actor-Critic 架构

  • Critic 网络提供 token 级别的价值 V(st)
  • Reward Model 通常只在序列最后一个 token 处有非零值 rRM
  • Token 级奖励(含 KL 惩罚):rt=rRMβlogπθ(at|st)πSFT(at|st)
  • TD errorδt=rt+γV(st+1)V(st)
  • GAEA^t=δt+(γλ)δt+1+(γλ)2δt+2+

πθold 的重算时机

πθold 在每个 Global Step 的 Training 阶段重新计算,而非使用 Rollout 阶段的 log prob(因为 vLLM 与 Actor 的数值精度不完全一致,参见 16.6 节):

python
# ray_trainer.py, fit
with marked_timer("old_log_prob", timing_raw, color="blue"):
    old_log_prob = self.actor_rollout_wg.compute_log_prob(batch)

熵与 KL Loss

Entropy 计算

H=vp(v)logp(v)=logZvp(v)logitsv
python
def entropy_from_logits(logits: torch.Tensor):
    pd = torch.nn.functional.softmax(logits, dim=-1)
    entropy = torch.logsumexp(logits, dim=-1) - torch.sum(pd * logits, dim=-1)
    return entropy  # [batch, seq_len],token 粒度

Entropy 也需要经过 loss agg。actor.entropy_coeff 默认为 0.0。

KL Loss:使用 K3 近似估计(参考 http://joschu.net/blog/kl-approx.html),`policy_loss = policy_loss + kl_loss * kl_loss_coef`。


16.5 Aggregated Loss 与 Tinker

Aggregated Loss 的设计动机与 Tinker 框架对 token-level 梯度的精细控制。

损失聚合模式

设损失矩阵 LRB×T,掩码 M{0,1}B×T,verl 支持四种聚合方式:

模式公式推荐场景
token-meani,jLijMiji,jMijDAPO(推荐)
seq-mean-token-mean1BijLijMijjMij原始 GRPO 论文
seq-mean-token-sum1BijLijMij长序列时不稳定
seq-mean-token-sum-normi,jLijMijTDrGRPO

序列长度偏差问题

长度偏差

序列级与 Token 级 PG

Implementation + data > algorithm

Policy Gradient 的标准推导要求对所有 token 的 log prob 求和(来自概率的链式分解 πθ(o|q)=tπθ(ot|q,o<t)),然后最大化整个序列的期望总奖励。

Vanilla GRPO 的问题:除以序列长度 |oi| 后,优化目标从"最大化回报"变成了"最大化每个词的平均奖励",会系统性地偏向短输出。

一个案例说明:

  • 序列 A:长度 20,总奖励 RA=10,平均 10/20=0.5
  • 序列 B:长度 2,总奖励 RB=4,平均 4/2=2
  • 正确逻辑下 A 远优于 B;但按平均奖励来看,模型会偏好 B

1Gi=1G 是对 batch 中 G 个样本取平均,这是标准的蒙特卡洛估计,不改变最优点,只让梯度尺度与 G 无关。

Tinker

Tinker 框架在 Loss 设计上提供了更多实验选项,可参考 https://tinker-docs.thinkingmachines.ai/losses。


16.6 Off-policy Mismatch 与缓解策略

Off-policy Mismatch 的根因分析与 Importance Sampling 修正策略。

重要性采样的本质

Objective (loss) vs. Gradient:理解 IS Ratio 需要区分目标函数和梯度。

Policy Gradient 的原始目标是最大化期望回报:

J(θ)=Eτπθ[R(τ)]=τP(τ|θ)R(τ)

对其求梯度后才出现 log(这是求导结果,不是目标函数的一部分):

J(θ)=Eτπθ[logP(τ|θ)R(τ)]

当用 πold 的数据更新 πθ 时,引入重要性采样修正:

L(θ)=πθ(a|s)πold(a|s)IS RatioA^logπθ(a|s)

IS Ratio 的三重身份:

  1. 修正分布差异:保证用旧数据估计新策略梯度的无偏性
  2. 梯度缩放系数:决定梯度下降时在某方向上推多大劲
  3. 信任区域边界rt>1 表示新策略更倾向该动作,rt<1 反之

Mismatch 问题

在 vLLM 与 FSDP 混合部署时,真正的 Behavior Policy 是 vLLM(πrollout),而我们通常假设的 πold 是 Actor(πactor)。由于底层实现差异(PagedAttention vs. 标准 Attention 的浮点累积顺序、KV Cache 精度等),两者不完全一致:

πvLLM(o|q)πactor(o|q)

一个直观案例

  • vLLM 认为"好"的概率是 90%(0.9),因此容易采样到
  • Actor 重算时得到 10%(0.1)
  • 错误做法:Ratio=πnew(0.1)0.11.0
  • 正确做法:Ratio=πnew(0.1)0.90.11

verl 的缓解方案

verl 通过 RolloutCorrectionConfig 提供两种模式:

Bypass Mode(旁路模式)——直接将 πold 设为 πrollout

rtbypass(θ)=πθ(at|st)πrollout(at|st)

优点:简单直接,理论上无偏。缺点:信任域约束变成了针对 πrollout 的约束。

Decoupled Mode(解耦模式)——保留 πold 作为信任域锚点,引入 IS 权重修正:

wt=πold(at|st)πrollout(at|st)LDecoupled(θ)=Eπrollout[wtmin(rtstdAt,clip(rtstd,1ϵ,1+ϵ)At)]

展开后约化为 Eπrollout[πθπrolloutA]——修正后的 IS 估计是无偏的。

批内陈旧(Batch-internal Staleness)

同一 batch 多轮 PPO 更新还会导致另一种 off-policy:πold 在 batch 开头算一次,但 πθ 会随 ppo_epochs / mini-batch 迭代偏移,后续更新天然更 off-policy。


16.7 verl Agent Loop

verl 对 Tool-augmented RL 的支持:Agent Loop 中工具调用与奖励计算的集成。

问题背景

单轮 RL 中,一个 Rollout 就是一次"问-答"。Agentic RL 中,Agent 需要:生成思考 -> 调用工具 -> 接收反馈 -> 继续推理,直到任务完成。这种多轮交互使得不同样本的轨迹长度差异巨大(Straggler 问题),同步 Batching 效率极低。

架构全景

Agent Loop 架构

四层架构

RayPPOTrainer
└── AgentLoopManager          (控制层:任务分发 + vLLM 服务管理)
    ├── AsyncLLMServerManager (服务层:负载均衡 + Sticky Session)
    │   ├── vLLMReplica #0   (GPU 推理服务 replica)
    │   ├── vLLMReplica #1
    │   └── ...
    └── AgentLoopWorker[i]    (逻辑层:CPU 上的 Agent 状态机)
        └── ToolAgentLoop     (状态机:PENDING → GENERATING → PROCESSING_TOOLS → TERMINATED)

AgentLoop 只关注"怎么聊"/调用工具(逻辑),Worker 只关注"怎么调度"(并发),ServerManager 只关注"怎么生成"(算力),三者互不干扰。

GPU 配置与 Replica 数

num_replicas=total_gpusTP×DP×PP

例如 16 GPU 集群,TP=4, DP=1, PP=1 -> 4 个独立的 LLM Server replica。

AgentData 状态容器

AgentData 在整个状态机生命周期中维护轨迹的完整状态:

python
@dataclass
class AgentData:
    prompt_ids: list[int] = field(default_factory=list)        # 不断追加的 token 序列
    response_mask: list[int] = field(default_factory=list)     # 标记 LLM 生成(1) vs 工具返回(0)
    response_logprobs: list[float] = field(default_factory=list)

    # 中间奖励
    turn_scores: list[float] = field(default_factory=list)     # 每轮 interaction 的 reward
    tool_rewards: list[float] = field(default_factory=list)    # 每次工具调用的 reward

    # 计数器
    user_turns: int = 0
    assistant_turns: int = 0

AgentLoopOutput

状态机运行结束后返回 AgentLoopOutput,包含完整轨迹信息:

python
class AgentLoopOutput(BaseModel):
    prompt_ids: list[int]              # Prompt token IDs
    response_ids: list[int]            # Response token IDs(含 LLM 生成 + 工具返回)
    response_mask: list[int]           # 1=LLM 生成, 0=工具返回
    response_logprobs: Optional[list[float]] = None
    reward_score: Optional[float] = None  # 轨迹奖励
    num_turns: int = 0                 # 对话轮数(user + assistant + tool)
    metrics: AgentLoopMetrics          # 性能指标

Agent 状态机

python
state = AgentState.PENDING
while state != AgentState.TERMINATED:
    if state == AgentState.PENDING:
        state = await self._handle_pending_state(...)      # 初始化 prompt_ids
    elif state == AgentState.GENERATING:
        state = await self._handle_generating_state(...)   # 调用 LLM,解析 tool_calls
    elif state == AgentState.PROCESSING_TOOLS:
        state = await self._handle_processing_tools_state(...)  # 并行执行工具
    elif state == AgentState.INTERACTING:
        state = await self._handle_interacting_state(...)  # 环境/用户反馈

状态转换规则

状态处理函数输出状态
PENDING_handle_pending_state-> GENERATING
GENERATING_handle_generating_state-> PROCESSING_TOOLS / INTERACTING / TERMINATED
PROCESSING_TOOLS_handle_processing_tools_state-> GENERATING / TERMINATED(上下文超长)
INTERACTING_handle_interacting_state-> GENERATING / TERMINATED(should_terminate=True

终止条件:完成任务、达到最大轮数(max_turns)、达到长度限制。

INTERACTING 状态:当模型未调用工具且配置了交互环境(interaction 模拟器)时进入此状态。环境/用户模拟器根据当前轨迹返回反馈:

python
async def generate_response(self, instance_id, messages, **kwargs):
    reward = await self.calculate_score(instance_id)
    if reward == 1.0:  # 答对了
        response = "Your response is correct!"
        should_terminate_sequence = True   # 终止
    else:
        response = "Your response is incorrect! You need to reflect..."
        should_terminate_sequence = False  # 继续
    return should_terminate_sequence, response, reward, {}

AsyncLLMServerManager 负载均衡

python
class AsyncLLMServerManager:
    def _choose_server(self, request_id: str):
        # 1. Sticky Session:同一 trajectory 路由到同一 server
        if request_id in self.request_id_to_server:
            return self.request_id_to_server[request_id]
        # 2. 最少请求数负载均衡(Min-Heap)
        server = self.weighted_serveres[0][1][1]
        heapq.heapreplace(...)
        self.request_id_to_server[request_id] = server  # LRU cache
        return server

Sticky Session 保证同一 trajectory 的多轮对话路由到同一 vLLM replica,复用 KV Cache。request_id_to_server 使用 LRUCache(maxsize=10000) 实现,当活跃 trajectory 数量超过上限时,最早的映射被淘汰;被淘汰的 trajectory 如果继续请求,会重新分配 server(但推理引擎内部的 KV Cache 也是 LRU 淘汰,影响不大)。

异步并发的三个层次

python
# 层次 1:Worker 内部,batch 中每个样本并发
tasks = [asyncio.create_task(self._run_agent_loop(...)) for i in range(len(batch))]
outputs = await asyncio.gather(*tasks)   # 64 个 trajectory 并发

# 层次 2:每轮工具调用并发
responses = await asyncio.gather(*[self._call_tool(tc)
                                   for tc in tool_calls[:max_parallel_calls]])

# 层次 3:vLLM Continuous Batching
# 快 trajectory 立即发起下一轮请求,无需等慢 trajectory

Sync vs. Async 的本质区别:Sync 模式把"一批人"绑在一起——llm.generate(batch) 必须等所有样本生成完,工具执行也必须全部完成才能进入下一轮。Async 模式把"每个人"拆开——每个 Agent 在自己的独立世界里跑循环,互不阻塞。

Async Rollout 的双层协作

  • 客户端层面(asyncio):CPU 上非阻塞地发射请求,并发处理工具调用、Prompt 拼接等业务逻辑,快速产生新的 Request
  • 服务端层面(Continuous Batching):GPU 上利用 vLLM 的 Continuous Batching,见缝插针地执行请求——快 trajectory 完成后立即腾出位置给新请求,消灭同步等待带来的算力空窗期
传统 Batching:
Batch 1: [req1, req2, req3, req4] -> 全部完成 -> 输出
Batch 2: [req5, req6, req7, req8] -> 全部完成 -> 输出

Continuous Batching:
t0: [req1, req2, req3, req4] 开始
t1: req1 完成 -> [req2, req3, req4, req5] <- req5 立即插入
t2: req3 完成 -> [req2, req4, req5, req6] <- req6 立即插入

Response Mask

工具返回的 Observation token 不参与 policy gradient 计算:

python
# tool_agent_loop.py
agent_data.response_mask += [1] * len(llm_response_ids)    # LLM 生成: mask=1
agent_data.response_mask += [0] * len(tool_response_ids)    # Tool 返回: mask=0

Token-in Token-out 原则

几乎所有 Agent 框架(LangGraph、CrewAI、LlamaIndex)都使用 Chat Completion API,以 messages 维护对话历史。但 verl 发现:

encode(messages)prompt_idsresponse_ids

这种不一致对 serving 影响不大,但对 RL 训练是致命的——它导致轨迹偏离策略模型分布。verl 实测发现对最终 messages 做 apply_chat_template 会导致 PPO 训练不收敛。因此 verl 采用 token in / token out 方式,始终维护 token ID 序列。

RolloutReplica 注册与实例化

AsyncLLMServerManager 的 server 通过注册表模式选择后端:

python
# verl/workers/rollout/replica.py
RolloutReplicaRegistry.register("vllm", _load_vllm)
RolloutReplicaRegistry.register("sglang", _load_sglang)

实例化流程:AgentLoopManager._initialize_llm_servers 根据 rollout.name 配置选择 Replica 类,创建 num_replicas 个实例,将 server_handles 注入 AsyncLLMServerManager,再传递给各 AgentLoopWorker。Worker 在运行 AgentLoop 时注入 server_managertokenizer 等基础设施。

数据流汇总:DataProto(Batch) -> Manager 切分 -> Worker 并发 -> AgentLoop 多轮交互 -> AgentLoopOutput(含完整轨迹) -> Worker Pad 成 Tensor -> Manager 拼合 -> 返回 DataProto。

奖励计算与时机

在 Async 模式下,Reward 的计算被前置到 Rollout 阶段内部完成(而非由 Trainer 单独计算),减少数据传递开销。


16.8 Fully Async 训练

Fully Async 训练模式下 Rollout 与 Training 的并行化架构。

动机

在 16.7 节的 Agent Loop 中,虽然 Rollout 阶段内部已经实现了异步并发,但 Rollout 与 Training 之间仍然是严格串行的:Rollout 完成全部轨迹生成后,才启动 Training 进行梯度更新;Training 完成后,才启动下一轮 Rollout。在大规模 Agent 任务中,Rollout 的耗时往往远长于 Training(工具调用、多轮交互导致延迟不可控),这意味着 Training 所在的 GPU 在 Rollout 阶段处于空闲状态,资源利用率低下。

与同步训练的架构差异

同步模式(标准 PPO):

Rollout_k -> Training_k -> Rollout_{k+1} -> Training_{k+1} -> ...
[GPU: 推理]  [GPU: 训练]   [GPU: 推理]       [GPU: 训练]

Training 必须等 Rollout 生成完整 batch 的轨迹后才能启动,GPU 在两个阶段之间存在"气泡"。

Fully Async 模式recipe/fully_async_policy):

Rollout:   |---R_k---|---R_{k+1}---|---R_{k+2}---|
Training:       |--T_k--|---T_{k+1}--|---T_{k+2}--|

Rollout 和 Training 在不同的 GPU 资源池上并行执行。当 Rollout_k 生成的轨迹就绪后,Training_k 立即开始;与此同时 Rollout_{k+1} 已经在用当前最新的策略参数启动下一批生成。两者流水线化,消除了等待气泡。

实现要点

  • 资源池分离:Rollout Worker 和 Training Worker 分配到不同的 GPU 资源池,不再共享 GPU(与 Hybrid Colocate 不同)。这牺牲了 GPU 复用率,但换来了流水线并行。
  • 参数同步:Training 每完成一步,将更新后的参数推送给 Rollout 引擎。Rollout 使用最新可用的参数启动下一批生成,而非严格等待当前 Training 完成。
  • Off-policy 程度加深:由于 Rollout 使用的参数可能落后于 Training 正在优化的参数(间隔 1 步或更多),Fully Async 天然引入了更强的 off-policy 特性。需要更依赖 Clip 机制和 KL 惩罚来约束策略偏离(参见第 14 章关于分布式训练中参数同步延迟的讨论)。

适用场景

  • 长 Rollout 任务:Agentic RL 中工具调用链路长、环境交互耗时大,Rollout 耗时远超 Training,Fully Async 可显著提升吞吐。
  • 大规模集群:GPU 资源充足时,用额外 GPU 跑 Rollout 来换取整体训练速度的提升是合理的 trade-off。
  • 对 off-policy 容忍度高的算法:GRPO/DAPO 等序列级优势估计算法对轻微的参数延迟较为鲁棒。

详见 https://verl.readthedocs.io/en/latest/advance/fully_async.html


16.9 verl 中的 Ray 使用

verl 中 Ray 的使用模式:资源分配、Worker 管理与常见问题排查。

Ray 基础

python
# @ray.remote 修饰函数 -> Ray Task(无状态,并行)
@ray.remote
def slow_square(x):
    return x * x, os.getpid()

# @ray.remote 修饰类 -> Ray Actor(有状态,长活服务)
@ray.remote
class Worker:
    ...

核心概念:

  • ref = f.remote(x):异步提交任务,返回 ObjectRef(Future)
  • ray.get(ref):取回结果
  • ObjectRef 作为参数传给下游任务,Ray 自动识别拓扑顺序,构建 DAG

Ray 在 verl 中的角色

  • WorkerGroup:每个 Worker 是一个 Ray Actor,拥有独立 GPU
  • AgentLoopWorker:调度到 CPU 节点的 Ray Actor
  • AsyncLLMServerManager:持有一组 server handles(Ray Actor 句柄)
  • TokenBucketWorker:全局限流器,Ray Named Actor 单例
  • ExecutionWorker pool:工具执行池,Ray Actor with max_concurrency

ray.wait 完成即消费

python
refs = [preprocess.remote(bid, arr) for bid, arr in batches.items()]
remaining = set(refs)
while remaining:
    done, pending = ray.wait(list(remaining), num_returns=1)
    remaining -= set(done)
    out = ray.get(done[0])
    # 处理结果

Map-Reduce 示例

python
# Map 阶段(并行)
parts = [map_count.remote(t) for t in texts]

# Reduce 阶段(树形合并,依赖于前序结果的 ObjectRef)
while len(parts) > 1:
    nxt = []
    for i in range(0, len(parts), 2):
        if i+1 < len(parts):
            nxt.append(reduce_merge.remote(parts[i], parts[i+1]))
        else:
            nxt.append(parts[i])
    parts = nxt

16.10 Tokenizer 处理

Tokenizer 在 RL 训练中的特殊处理:Chat Template、特殊 token 与 padding 策略。

Encode-Decode 不可逆性

Encode-Decode 不对称

关键事实:D(E(text)) = text,但 E(D(token_ids)) != token_ids

python
T = AutoTokenizer.from_pretrained('Qwen/Qwen2.5-7B-Instruct')

# <think> 有两种 tokenization:
T.decode([13708, 766, 29])       # ['<th', 'ink', '>'] -> '<think>'
T.decode([27, 26865, 29])        # ['<', 'think', '>'] -> '<think>'

# 但 encode 只会产生一种(贪心最长匹配):
T.encode('<think>')              # [13708, 766, 29]
T.encode(T.decode([27, 26865, 29]))  # [13708, 766, 29]  != [27, 26865, 29]

生成时模型每步从整个词表里挑一个 token,可能依次挑出 <think> 这条"非规范"路径。但 encode 会按贪心/最长匹配走另一条路径。这对 RL 训练的影响:在计算 IS Ratio 时,如果 token_ids 不一致,logπθ 的计算就是错的。

Qwen3 的 Thinking Token

  • <think> / </think>:special tokens
  • /no_think / /think:软开关,且非 special tokens(语义指令,普通文本)

Vocabulary Size 差异

python
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
cfg = AutoConfig.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
print(tok.vocab_size)   # 151643
print(cfg.vocab_size)   # 152064 (= 297 * 512,对齐到 512 倍数)

填充区间 [tok.vocab_size, cfg.vocab_size) 中的 token 在 embedding 表中有权重但不会被 tokenizer 产生。


16.11 ReTool:工具增强 RL

ReTool 在 verl 中的实现:工具调用的 Reward 设计与多轮交互训练。

总览

ReTool 是 verl 上实现的工具增强 RL 训练 recipe,核心是"冷启动 SFT + RL with Tool Integration"两阶段流水线。目前主要支持 Code Interpreter(代码执行器)。

交错生成格式

o=[t1c1f1tn]
  • ti:自然语言推理步骤(Thought)
  • ci:代码片段(Action,<code>...</code>
  • fi:执行器反馈(Observation,<interpreter>...</interpreter>

冷启动 SFT

将普通数学推理数据 Dinit 自动升级为代码增强数据 DCI

  1. 收集 OpenThoughts 等高质量 Text-based CoT 数据
  2. 使用模板自动将手动计算步骤替换为 Python 代码块
  3. 双重验证:格式验证(语法正确、标签闭合)+ 答案验证(与 Ground Truth 一致)

2000 条高质量 SFT 数据就足够(Qwen2.5-32B-Instruct)。

MultiTurnSFT Loss Mask:只对 Assistant 的输出计算 Loss(loss_mask=1),System/User/Tool 消息被掩码。Assistant 的 reasoning、tool_call(name + params)、<|im_end|> 都计算 loss。

RL 阶段

python
# Algorithm: ReTool Training Pipeline
for epoch in range(MAX_EPOCHS):
    prompts = sample(Data)
    trajectories = []
    while not done:
        tokens = Policy.generate()
        if "</code>" in tokens:
            result = Sandbox.exec(tokens.code)
            tokens += f"<interpreter>{result}</interpreter>"
    rewards = compute_outcome_reward(trajectories)
    Policy = PPO_Update(Policy, rewards)

奖励函数

R={1,if correctmin(0.6,1+N220.1),if incorrect

对于错误答案,更多工具调用尝试(更大的 N)获得轻微正向修正,鼓励探索性工具使用。

奖励放置

Reward 放在 response 的最后一个有效 token 位置,整个 trajectory 前面的 token 位置 reward 均为 0:

python
# naive.py
reward_tensor[i, valid_response_length - 1] = reward

运行配置

yaml
actor_rollout_ref.rollout.mode: async
actor_rollout_ref.rollout.multi_turn.format: hermes
actor_rollout_ref.rollout.multi_turn.tool_config_path: recipe/retool/sandbox_fusion_tool_config.yaml

sandbox_fusion_tool_config.yaml 中指定沙箱 URL(sandbox_fusion_url: "http://localhost:8080/run_code")及 num_workersrate_limit 参数。

CustomSandboxFusionTool 实现

CustomSandboxFusionTool 继承自 SandboxFusionTool(BaseTool),重写了 execute 方法,在调用沙箱前对代码做两步预处理:

预处理 1:Markdown 代码块提取——模型生成的代码常包裹在 Markdown fenced block 中(如 ```python ... ```),CustomSandboxFusionTool 会自动提取其中的纯代码内容,确保发送到沙箱的是可直接执行的 Python 代码。

预处理 2:自动补 print(...)——沙箱执行服务依赖 stdout 返回结果。如果模型生成的代码只做了计算而没有 print,沙箱将返回空结果。因此 execute 方法逆序遍历代码行,找到最后一行非空行,如果该行不以 print 开头,则强制将其包裹为 print(...)

预处理后的代码通过 ExecutionWorker 池(Ray Actor with max_concurrency)投递到沙箱执行。

SandboxFusion 执行环境

bash
# 部署
docker run -it -p 8080:8080 volcengine/sandbox-fusion:server-20250609

# 测试
curl 'http://localhost:8080/run_code' \
  -H 'Content-Type: application/json' \
  -d '{"code": "print(42)", "language": "python"}'

SandboxFusion 充当 Code Interpreter 后端,接收 HTTP POST 请求并返回 run_result(含 stdout/stderr/return_code)。

并发限流架构

PPO step -> rollout.n=16 条轨迹
  -> AgentLoopWorker(num_workers=8)
    -> ToolAgentLoop(per-sample 实例)
      -> CustomSandboxFusionTool
        -> ExecutionWorker pool(num_workers=128)
          -> 全局 TokenBucketWorker(rate_limit=128)  <- 保护沙箱
            -> HTTP POST to /run_code

各层的并发控制职责:

参数作用
AgentLoop 分片agent.num_workers(默认 8)把轨迹切块并发执行
单轨迹工具并发max_parallel_calls(默认 1)每条轨迹每轮最多并行几个工具
工具执行池num_workers(如 128)ExecutionWorker Actor 的 max_concurrency
全局限流rate_limit(如 128)集群内同时执行的 tool 请求硬上限

TokenBucketWorker 是 Ray Named Actor 单例,使用 threading.Semaphore(rate_limit) 实现全局并发上限控制。其命名虽含"Token Bucket",但实际实现的是并发上限控制器(Max Concurrency Limiter),确保同一时刻正在沙箱中执行的代码任务不超过 rate_limit 个:

python
@ray.remote(concurrency_groups={"acquire": 1, "release": 10})
class TokenBucketWorker:
    def __init__(self, rate_limit):
        self._semaphore = threading.Semaphore(rate_limit)
    def acquire(self):
        self._semaphore.acquire()   # 名额为 0 时阻塞等待
    def release(self):
        self._semaphore.release()   # 归还名额

concurrency_groups 的设计确保 acquire(可能阻塞)不会占满 Ray Actor 的并发槽位而导致 release 饥饿。

ReTool 端到端执行流程

完整的一个 PPO Step 中 ReTool 的执行路径:

  1. Trainer 将 batch 扩展为 rollout.n
  2. 调用 AgentLoopManager.generate_sequences
  3. Manager wake_up rollout replicas,按 worker 数切块分发
  4. 每个 Worker 对 chunk 内每个样本 asyncio.create_task 并发运行
  5. 进入 ToolAgentLoop 状态机:PENDING -> GENERATING -> PROCESSING_TOOLS -> ...
  6. GENERATING 调 server_manager,从某个 vLLM replica 取 token 输出
  7. format=hermes 时,parser 从 <tool_call>...</tool_call> 提取函数调用
  8. 进入 PROCESSING_TOOLS,每轮最多 max_parallel_calls 个 tool call 并发执行
  9. _call_tooltool.create -> tool.execute -> tool.release
  10. CustomSandboxFusionTool.execute 做代码清洗(提取 fenced python、补最后一行 print),投递到执行池
  11. 执行池内部先 acquire 全局 token(Semaphore 许可),再 requests.post(/run_code)
  12. 工具返回文本追加到消息,observation token 的 response_mask=0
  13. 满足终止条件后回传 Trainer(长度/轮数上限)
  14. 奖励函数 compute_score 读取 num_turns,错误答案时给多轮调工具正向修正

16.12 相关工作:DAPO

DAPO 算法的核心改进:动态采样、token-level 策略梯度与 overlong 过滤。

Dynamic Sampling(动态采样)

DAPO 过滤全奖励相同的 prompt:如果一个 prompt 的所有 G 条采样轨迹奖励完全相同(全对或全错),则不提供有效的梯度信号,应被丢弃。

python
# recipe/dapo/dapo_ray_trainer.py
kept_prompt_uids = [
    uid for uid, std in prompt_uid2metric_std.items()
    if std > 0 or len(prompt_uid2metric_vals[uid]) == 1
]

实现上,RayDAPOTrainer(RayPPOTrainer) 只覆盖了 fit 函数。

Overlong Penalty

P=min(Lresp(LmaxLbuf)Lbufα,0)

Lresp<LmaxLbuf 时惩罚为 0;超出缓冲区后线性增加。最终奖励 Rdapo=S+Poverlong

DAPO 推荐配置

  • Loss 聚合:token-mean(对 batch 中所有 token 平等对待)
  • Loss mode:与 GRPO 兼容,可直接在 verl 中配置

16.13 vLLM Rollout 与 CLI

vLLM Rollout 后端的配置参数与 verl CLI 命令行使用方法。

verl CLI

bash
python3 -m verl.trainer.main_ppo \
    --config-path=xx/verl/verl/trainer/config \
    --config-name xx.yaml

vLLM Rollout 调度细节

假设 5 个请求同时到来(max_num_batched_tokens=4096long_prefill_token_threshold=1024):

Step操作Token 计数
1R1 prefill 1024 + R2/R3/R4 各 200 + R5 prefill 10242648
2R1 prefill 1024 + R5 余 476 + R2/R3/R4 decode 31503
3R1 prefill 1024 + 4 个 decode1028
...继续混跑直到所有 prefill 完成...

vLLM 把 prefill 与 decode 放在迭代级混合批里(Chunked Prefill),最大化利用率。

FSDP Offload 配置

yaml
# Actor 训练阶段:不开 offload(GPU 够用时)
actor_rollout_ref.actor.fsdp_config.param_offload: False
actor_rollout_ref.actor.fsdp_config.optimizer_offload: False

# Ref Policy:常驻 CPU
actor_rollout_ref.ref.fsdp_config.param_offload: True

注意区分:FSDP 内部的 offload(训练阶段每层动态搬运)与 Hybrid Colocate 的 sleep/wake_up(跨阶段资源调度)是两个不同层次的控制。


16.14 训练调参经验

RL 训练调参的实战经验:学习率、batch size、KL 系数与 reward 工程。

Batch Size 配置层次

Global Batch Size (data.train_batch_size=128)
  x rollout.n=4
  = 512 条 Responses(本 Global Step 的总轨迹数)

/ DP Size (e.g. 8)
  = 每 DP Rank 64 条

/ actor.ppo_mini_batch_size=32 (prompts 级,实际 32*4=128 responses)
  -> 4 次 Optimizer Step per Global Step

/ actor.ppo_micro_batch_size_per_gpu=2 (response 级)
  -> Global micro-batch = 8*2=16
  -> Gradient Accumulation Steps = 128/16 = 8

Dynamic Batch Size

yaml
actor_rollout_ref.actor.use_dynamic_bsz: True
actor_rollout_ref.actor.ppo_max_token_len_per_gpu: 8192

按 Token 总量而非序列数量切分 micro-batch。Loss 缩放因子动态调整:

scalej=|bj|N

其中 |bj| 为当前 micro-batch 的样本数,N 为 mini-batch 的总样本数。

序列并行

序列并行(SP)与张量并行(TP)等分布式策略的原理与对比,参见第 14 章。

长序列训练时启用 SP(序列并行)主要用于节省显存,不加速训练:

yaml
data.max_length: 16384
ulysses_sequence_parallel_size: 4
use_remove_padding: true

Logits 是显存瓶颈:micro_bsz×LSP×V×bytes。以 Qwen2.5-7B(V=152064)为例:

4×163842×152064×29.3 GB

显存分析(7B 模型)

组件大小备注
模型权重(BF16)7B * 2 = 14 GBFSDP 4卡分片 -> 3.5 GB/卡
AdamW Optimizer(FP32)7B * 4 * 2 / 4 = 14 GB/卡一阶矩 + 二阶矩
梯度7B * 4 / 4 = 7 GB/卡
激活值动态取决于 micro_batch_size + gradient checkpointing

精度注意事项

bash
# 危险配置(不要加):
actor_rollout_ref.actor.fsdp_config.model_dtype=bfloat16
# 原因:会导致优化器也变成 BF16,损失 FP32 精度

CPU vs. GPU OOM

  • GPU OOM:torch.cuda.OutOfMemoryError
  • CPU OOM:exit code -9(SIGKILL),由内核 OOM killer 触发
  • Checkpoint 保存时有峰值 CPU 内存占用:params(14GB) + AdamW(56GB) 需要序列化

Grad Norm 监控

grad_norm=g2=igi2
yaml
actor_rollout_ref.actor.grad_clip: 1.0

Grad Norm 突然爆炸(NaN)通常由 IS Ratio 爆炸引起:r=exp(logπθlogπold),当新旧策略偏离过大时 r 可能爆炸。Clamp 不能根治,需要从根源缓解(减小 LR、增大 KL 惩罚、减少 ppo_epochs、解决 encode-decode 不一致)。

RL Collapse

训练崩溃的常见征兆与根因可参考 https://yingru.notion.site/When-Speed-Kills-Stability-271211a558b7808d8b12d403fd15edda ——核心论点是训练-推理 mismatch 是 RL collapse 的重要诱因。

常用监控指标

指标含义理想状态
reward平均奖励稳定上升
kl_div与 ref policy 的 KL 散度保持合理范围
clip_fractionPPO Clip 触发比例0.1~0.4
actor_entropy策略熵(生成多样性)不要过快下降
grad_norm梯度 L2 范数无剧烈波动或 NaN
response_len生成长度不要崩塌到极短
timing_s/*各阶段耗时关注瓶颈

SFT 训练经验

  • data.micro_batch_size_per_gpu 真正影响显存
  • 4卡 7B,2000/32 * 6 epochs,约 1 小时可完成
  • 每个 epoch 结束时 loss 会有一个陡降(新 epoch 开始数据重排)
  • 每个 epoch 保存一个 checkpoint

Checkpoint 转换

bash
# 新版 API
python -m verl.model_merger merge \
    --backend fsdp \
    --local_dir checkpoints/.../global_step_X/actor \
    --target_dir /path/to/merged_hf_model

# 旧版 API
python scripts/legacy_model_merger.py merge \
    --backend fsdp \
    --local_dir /xxx/global_step_372 \
    --target_dir /xxx/weights

16.15 TRL 训练示例

TRL 框架的 GRPO/DPO/SFT 训练示例与关键配置说明。

TRL 与 verl 的定位对比

维度TRL(HuggingFace)verl(ByteDance)
目标用户学术研究、快速原型生产级、大规模训练
部署复杂度低(pip install trl中等(Ray + FSDP)
扩展性单机/小集群大规模多机
推理引擎集成有限深度集成 vLLM/SGLang
算法支持SFT/DPO/PPO/GRPOPPO/GRPO/GSPO/DAPO

官方示例

TRL 提供了丰富的 Notebook 示例:

SFT 微调(支持 QLoRA):

  • Qwen3-VL-4B-Instruct 视觉语言模型微调:使用 SFTTrainer 对 Qwen3 VL 多模态模型进行 SFT,演示了图像-文本对数据的处理流程,支持在 Colab 环境运行。关键配置包括 max_seq_length 和多模态 tokenizer 的初始化。
  • Qwen3-14B with Unsloth 4-bit 量化:通过 unsloth/qwen3-14b-unsloth-bnb-4bit 预量化模型,结合 LoRA/QLoRA 在单卡上完成 14B 模型的 SFT 微调。Unsloth 的 Triton kernel 加速使训练吞吐量提升约 2x,显存占用降低约 60%。

GRPO 强化学习

  • Qwen3-VL-4B-Instruct GRPO 训练:端到端演示视觉语言模型的 GRPO 训练,使用 GRPOTrainer 替代 PPO,无需 Critic 网络。数据格式为 prompt + 多条采样轨迹 + 组内相对奖励,适合在有限 GPU 资源上验证 RLVR 流程。

Agent Training(工具调用):

  • Tool Use Agent 微调:使用工具调用格式数据(function calling)进行 SFT,训练模型学习何时调用工具、如何构造工具参数。数据包含 tool_calls 字段和 tool_response 反馈,适用于构建可调用外部 API 的 Agent。

代码示例参考:https://github.com/huggingface/trl/tree/main/examples/notebooks

适用场景

  1. 快速实验验证:单卡或少量 GPU,验证算法思路
  2. 视觉语言模型微调:原生支持多模态
  3. QLoRA + GRPO:在有限资源上进行 RL 训练
  4. 学术竞赛:结合 Unsloth 加速,适合 Kaggle/Colab 环境

16.16 Unsloth 资源

Unsloth 框架的资源汇总与高效训练方案。

Unsloth 通过手写 Triton kernel 实现训练/推理加速,与 bitsandbytes(QLoRA)深度集成:

  • 将 Flash Attention Backward 融合进 Triton 内核,减少 HBM 读写
  • 优化 Cross-Entropy Loss 计算,支持超大词表
  • 典型使用:在 RTX GPU 或 Colab 上用 4-bit 量化模型做 RLVR
python
from unsloth import FastLanguageModel

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/qwen3-14b-unsloth-bnb-4bit",
    max_seq_length=2048,
    load_in_4bit=True,
)
# 配合 TRL 的 GRPOTrainer 使用

相关资源:


本章小结

本章系统覆盖了 verl 框架的核心设计、算法实现与工程实践:

  1. verl 架构采用"单控制器(RayPPOTrainer)+ 多控制器(SPMD WorkerGroup)"模式,通过 DataProto 统一数据协议,Hybrid Engine 融合训练与推理。

  2. Hybrid Colocate 通过 sleep/wake_up 在 Rollout(vLLM)和 Training(FSDP)之间分时复用 GPU 显存,存在短暂的切换峰值。

  3. 目标函数谱系:PPO-Clip -> Dual-Clip PPO -> GRPO(无 Critic,组内相对奖励)-> GSPO(几何平均 IS Ratio,stop-gradient 技巧)。损失聚合模式(token-mean/seq-mean-token-mean 等)对长 CoT 影响显著,seq-mean-token-mean 会引入长度偏差。

  4. Off-policy Mismatch 来源于 vLLM 与 FSDP 的数值不一致以及批内多轮更新的陈旧性,可通过 Bypass/Decoupled 模式缓解。

  5. AgentLoop 四层架构(Manager -> AsyncLLMServerManager -> Worker -> 状态机)支持多轮 Agentic RL;asyncio + Continuous Batching 解决 Straggler 问题;Token-in Token-out 原则保证训练-推理的 token 级一致性。

  6. ReTool 展示了完整的工具增强训练流水线:冷启动 SFT(2000 条数据)-> RL with Code Interpreter;Response Mask 确保工具 Observation 不参与策略梯度;TokenBucketWorker 实现全局限流。

  7. DAPO 通过动态采样和 Overlong 惩罚进一步稳定训练。

  8. 训练调参:理解 batch size 多层嵌套关系、Dynamic Batch Size、序列并行、显存分析、精度陷阱、Grad Norm 监控是稳定训练的基础。

  9. TRL + Unsloth 提供轻量级替代方案,适合学术研究与快速原型,与 verl 的大规模生产定位互补。


延伸阅读