[强化学习]RL 学习以及开源 RL 框架的一些个人解读
Jasaxion / 2025-11-08
本着想要学习一下 verl,RL 火了这么久了,一直都是零零散散学的东西,于是顺便把 RL 的一些碎片的知识整理了一下。
前置知识
前置知识,主要是一些名词&概念解释
前置知识(I):RL 相关
一些 RL 的经典问题,经常被提及的问题,需要弄清楚。
Value-based & Policy-based
强化学习的目标是选择最优动作从而获得最大累计奖励,实现这一目标主要有两种途径,基于价值的方法Value-based 和基于策略的方法 Policy-based 「结合 Value 和 Policy 的 Actor-Critic 方法在当前是主流」
Value-based 方法,核心目标是学习值函数,学习价值函数(状态价值V(s)或动作价值Q(s,a)),策略为隐式从价值推导(选择当前Q值最大的动作);
- 典型算法:Q-learning,SARSA
- 对离散动作空间适应性更好,训练过程也比较稳定。
Policy-based 方法,合并目标直接优化策略,直接优化策略π(a|s)(可随机/确定性),策略为显式表示,不依赖价值函数;
- 典型算法:Policy Gradient,通过采样轨迹并使用梯度上升方法来最大化累计奖励
- 动作空间对连续/高维动作空间也能具备比较好的适应性。
两类方法各有优劣,将两者结合就是 Actor-Critic 方法,同时学习策略和价值函数实现取长补短,Actor负责更新策略,Critic用价值函数评估策略优劣,为Actor提供改进方向;
Model-based & Model Free
强化学习需要一个智能体在环境中进行交互,基于此就有一些 Model-based 和 Model Free 的方法。「Model Free 方法目前在 LLM 中是主流」
Model-based 方法
- 核心:显式建模环境的状态转移关系 $P(s'|s,a)$,通过学习的模型支持规划(planning)或预演(rollout);
- 典型算法:值迭代(Value Iteration)、策略迭代(Policy Iteration);
- 特点:依赖环境转移模型的建模条件;优势:数据稀缺时能高效决策;
Model Free方法
- 核心:不依赖环境的状态转移模型,直接与环境交互获取采样经验更新学习(如价值函数或策略);
- 典型算法:MC Learning(蒙特卡洛学习)、Policy Gradient(策略梯度);
- 特点:更通用,适用于复杂/未知环境;劣势:需要大量交互样本,学习效率相对低于有模型方法;
两者选择依据:取决于问题特点——有模型方法适合环境可建模或数据稀缺场景;免模型方法适合环境复杂/未知、难以建模的场景。
Online & Offline 算法
根据模型交互池是否是实时产生的,又可以分为 Online 和 Offline 方法
Online 算法「主要优势:适应环境动态变化,可实时调整策略保持最优。」
核心:实时与环境交互,持续更新策略/价值函数,每次交互直接影响学习过程;
典型算法:Q-learning、Actor-Critic;
细分(基于行为策略与目标策略是否一致),也就是当前的行为是否是当前的策略模型产生的。
On-policy(同策略):行为策略=目标策略(用当前策略采样数据并更新策略);
- 典型算法:MC Learning、SARSA、REINFORCE、A3C、PPO;
Off-policy(异策略):行为策略≠目标策略(用探索策略采样数据,目标策略独立更新);
- 典型算法:Q-learning、DQN、DDPG、SAC;
在当前的 verl 框架中,PPO/GRPO 等一定是 Online 的,但具体是不是 On-policy,要看 mini batch 是多少,如果是 1,那么也是 On-policy 的。
Offline 算法(批量强化学习)
- 核心:基于预先收集的经验池(含奖励)学习,训练阶段不直接与环境交互;
- 核心挑战:分布不匹配(经验池数据分布与真实环境分布差异,导致策略泛化差);
- 与模仿学习(如 SFT)的区别:SFT数据无奖励,假设专家数据近似最优;Offline RL数据含奖励,不依赖专家数据最优性;
两者结合:经验回放机制(在线收集数据存入池,离线回放更新策略,如DQN)。
RL 的一些专业词汇。
- Actor: 一般指的就是 policy 模型,我们的目标;
- Reference policy network: 原始的模型,这个模型一般不会被训练,用于计算 KL 散度,限制训练过程中模型变化过大;
- Reward model: 用于计算奖励的模型;
- Critic model: 用于计算价值函数;
- Policy Ratio:策略比率,在 VGP 形式的 RL 算法中常见,用于评判新策略比旧策略的好坏,也有人把这个叫做“重要性比率”
verl 里面的一堆 batch 说明


前置知识(II):训练&并行策略相关
标准的训练流程包含 3 个核心环节:
模型前向传播 forward,计算 loss 并保存中间激活值;
模型后向传播 backward,通过中间激活值来计算梯度 gradient;
模型更新 update,把 gradien 传给优化器 optimizer,更新模型权重
- 在前向传播时激活值迅速增加, 输入数据从网络的第一层流入,经过每一层的计算(线性变换与激活函数),最终在输出层产生预测值 。
- 在反向传播时梯度累积,根据前向传播的预测值与真实标签之间的损失(Loss),利用链式法则从输出层向输入层逐层计算损失函数对每个参数的梯度。
- 随着反向传播的进行,用于计算梯度的存储激活值逐渐被清除,
- 执行优化步骤,在此期间我们需要所有的梯度,并更新优化器状态。
- 迭代循环,开始下一次前向传播
一些并行手段,若把模型训练看成是 Y=X*W 的矩阵乘法
- 对于输入进行切分(X 维度):包含数据并行(Data Parallel)/序列并行(Sequence Parallel);
- 对于权重进行切分(W 维度):包含张量并行(Tensor Parallel)/流水线并行(Pipeline Parallel)/ 专家并行(Expert Parrallel)
FSDP(Fully Sharded Data Parallel):
- 类似于 deepspeed 的 zero 3 阶段;
- 把优化器状态、梯度张量和模型参数都进行分片数据并行;
Deepspeed zero stage 「仍是数据并行」的优化简述
zero1中,每张卡只需要保留1/n的optimizer参数,通信量保持不变
- 针对优化器状态 Optimizer(例如 Adam 的 m、v 向量)将其切分到不同的 GPU 上
zero2在zero1的基础上,每张卡只需要保留1/n的graident,通信量保持不变;
- 进一步,将梯度张量也进行分片,每个 GPU 只包含一部分梯度;
- 梯度同步时采用 reduce-scatter 代替 all-reduce,避免了通信开销;
zero3在zero2的基础上,每张卡只需要保留1/n的model weight,通信量变为1.5倍;
不仅分片优化器状态、梯度,同时还直接把模型的参数量分片;
每张 GPU 仅保存部分参数,在前向与反向传播中按需进行参数的 gather / scatter;
- all-gather获取完整参数→计算loss→释放参数→保存中间激活;
- all-gather获取完整参数→计算梯度→释放参数;
- reduce-scatter获取梯度切片→优化器更新局部参数";
通信开销和复杂度会比较高:不过对于通信开销,可结合 NVLink、InfiniBand、CPU offload、ZeRO-Infinity 等手段缓解;
一些通信缓减通信开销的手段:
- NVLink:NVIDIA 提供的高速 GPU-GPU 互联技术。相比 PCIe 通道,NVLink 具有更高的带宽与更低的延迟,可显著减少多 GPU 训练中的梯度同步时间。「通过 GPU 间直接数据传输,减少对主机 CPU 和系统总线的依赖,从而加速模型参数、梯度交换等操作。」
- InfiniBand:一种用于集群间通信的高性能互联协议。支持远程直接内存访问(RDMA),可绕过 CPU 直接在节点间传输数据,极大减少延迟和 CPU 占用。InfiniBand 通常用于多机多卡分布式训练环境中,与 NCCL 等通信库配合以优化 AllReduce 操作。
- CPU offload:指将部分训练状态(如优化器状态、梯度、激活值)临时转移到 CPU 内存,以减少 GPU 显存占用并缓解通信瓶颈。典型实现为 ZeRO-Offload(DeepSpeed),将优化器状态与梯度从 GPU 移到 CPU 内存中,同时通过异步数据传输与计算重叠来降低开销。「将优化器状态与梯度放到 CPU 内存」
- ZeRO-Infinity:支持 CPU + NVMe 混合分层存储,可在单机训练 1T 参数模型;
前置知识(III):分布式架构相关
SPMD(Single Program Multiple Data)架构
- 所有进程执行相同代码逻辑,通过环境变量差异自主确定行为模式,无需中心调度节点;「当前的主流并行框架(DDP/DeepSpeed/Megatron)均基于 SPMD 范式」
有关分布式架构的相关术语:
在分布式系统中,这些术语描述了数据在多个进程或节点间的通信操作。它们常用于集体通信(collective communication),以优化数据交换、减少延迟和带宽使用。
Reduce: 将多个进程(或节点)的数据通过某种操作(如求和、求最大值、求最小值)合并到一个目标进程中。例如,在分布式训练中,多个GPU的梯度通过Reduce操作合并到主GPU上。
- 在All-Reduce之前,常用Reduce来聚合梯度,操作符可以是SUM、MAX等。
Scatter: 将一个进程的数据均匀分割并分发到多个进程中。每个进程收到数据的一部分。常用于数据并行中,将训练数据分布到不同工作节点。
- 主节点将一个大数组分割成多个小块,然后Scatter到各个工作节点。
Gather: 将多个进程的数据收集到一个目标进程中。与Scatter相反,常用于汇总结果或参数。
- 在训练结束后,各个节点将本地模型参数Gather到主节点进行保存或进一步处理。
All-Reduce: 所有进程都参与Reduce操作,并且所有进程都得到最终结果。这是一种高效的同步操作,避免了单点瓶颈。在分布式训练中广泛使用,例如梯度同步。
- PyTorch的DistributedDataParallel使用All-Reduce来同步所有GPU的梯度。
Broadcast: 将一个进程的数据复制并发送到所有其他进程中。常用于初始化模型参数或分发配置信息。
- 主节点将初始模型权重Broadcast到所有工作节点。
All-Gather: 所有进程收集所有其他进程的数据,每个进程最终拥有完整的数据集。与Gather不同,All-Gather的结果在所有进程上都是相同的。
- 在模型并行中,各个节点交换局部参数,通过All-Gather获取全局参数视图。
Reduce-Scatter: 结合Reduce和Scatter操作:首先对多个进程的数据进行Reduce(如求和),然后将结果分割并Scatter到各个进程中。常用于高效的数据分布和聚合。
- 在ZeRO优化器中,使用Reduce-Scatter来分布梯度的聚合结果。
Barrier: 同步操作,所有进程必须等待直到所有进程都到达Barrier点后才继续执行。用于确保数据一致性或协调并行任务。
- 在分布式训练中,每个epoch结束后使用Barrier来同步所有节点,避免某些节点提前进入下一阶段
Point-to-Point通信(虽非集体操作,但是基础):指两个进程间的直接通信,包括发送(Send)和接收(Receive)。这是分布式通信的基础,但在集体通信中常被封装。
- MPI_Send和MPI_Recv用于节点间传递消息。
All-to-All: 个进程向所有其他进程发送数据,同时从所有其他进程接收数据。常用于完全数据交换场景,但通信开销较大。
- 在矩阵转置或某些图计算中,使用All-to-All来重新分布数据。
一些模型实用公式
批量大小术语
- mbs: micro_batch_size, 每个 GPU 的微批量大小;
- gas:梯度累计步数;
- mseqlen:每个 GPU 的序列长度(在 CP 之后)
- gbs:global_batch_size, 全局批量大小=$mbs*dp*gas*mseqlen$
每个 GPU 在训练步骤中的峰值内存使用量可以近似为:$peak\_memory = model\_bf16+grads\_fp32+optimstats+acivs $
其中$model\_bf16=bf16\_bytes * num\_params = 2 * num\_layers * 16 * hidden\_size^2$
每个 GPU在训练步骤中的计算量可以近似为:$compute=6*model\_bf16*mbs*seq*gas$
相关论文
verl「HybirdFlow」
HybridFlow: A Flexible and Efficient RLHF Framework
创新点:
1)HybridFlow,以混合方式结合单控制器与多控制器范式,实现RLHF数据流的灵活表征与高效执行。2)3D-HybridEngine以实现训练与生成阶段间执行者模型的重分片,在达成零内存冗余的同时显著降低通信开销
一些常见的 RL算法的数据流图:可以划分为生成阶段–>预处理阶段–>训练阶段。

| |
Single-Controller:完成控制流,使用 Ray 实现,作为中央集中节点;
Multi-Controller:完成计算流,Megatron-LM/FSDP/DeepSpeed/vllm;

HybirdFlow给出了一个非常详细的 AI Infra 的各种并行的交替流程(TP,DP,PP 等),值得一提的是,目前verl 似乎没有开源 Hybird3D flow 的过程,不过当前的版本已经有足够多的优化了。

User Input 这三类输入用于系统初始化与资源规划。
- RLHF dataflow graph「verl 把 RL 过程定义为数据流系统」: 定义 RLHF 算法的数据流(如 PPO、ReMax、Safe-RLHF),节点代表各 LLM 模型(Actor、Critic、Reward 等),边表示它们的数据依赖。
- Model Config: 指定每个模型的结构与规模。
- Device Config: 指定可用设备及硬件资源(GPU 数量、内存等)。
ParrallelWorker 该层实现 RLHF 数据流各节点(模型)的高效分布式计算。
负责每个模型的分布式并行执行,封装多控制器(multi-controller)机制。
- Transfer Protocol (§4): 统一不同模型之间的数据重分片(resharding)与传输逻辑,通过 collect/distribute 函数实现 many-to-many 广播与聚合。
- 3D-HybridEngine (§5): 针对 Actor 模型训练与生成的混合 3D 并行引擎,可在两阶段间无冗余地重分片权重。
- LLM Training Engine / Generation Engine: 分别封装大模型训练(前向+反向)和推理生成(多步自回归)的底层执行,与 Megatron-LM、FSDP、ZeRO 等框架兼容。
Auto Mapping 实现模型到设备的自动映射与调度,实现的一种自动映射的算法。
- Model Placement: 确定各模型应部署在哪些 GPU 组上,决定是否 colocate (同机共享)或分布执行。
- Device Allocation: 在已确定的映射中分配具体 GPU ID、并行组(DP/TP/PP)。
自动映射算法搜索多种放置策略(如 Actor/Ref 同机、Critic/RM 同机等),以最小化 RLHF 迭代总时延。
Resource Pool:虚拟化集群资源。维护统一的 GPU 资源池,为不同 ParallelWorker 实例提供分配接口。不同模型若使用相同 ResourcePool 实例则 colocate 在同一组 GPU 上,否则分配到不同设备,实现灵活的资源隔离与共享。
Phsical Devices:底层硬件层,包含实际 GPU 与网络通信资源(NVLink、InfiniBand 等)。系统通过 ResourcePool 虚拟化并统一调度。
原文过多强调如何去优化这个 RL 训练流程的,对 RL 的具体算法过程表述比较少,所以推荐另外一篇OpenRLHF,论文中对如何实现 PPO 的过程讲得比较详细。
拓展阅读框架:OpenRLHF
https://arxiv.org/pdf/2405.11143,几乎跟 verl 同期发布的一个RL 框架
OpenRLHF 也是基于 Ray 来完成分布式训练,这是 AI Infra 架构图
下面这张图展示 PPO 算法的一个完整的代码实现逻辑

RL数据产生的过长,经过左侧一系列算法过程,把数据放在 Replay Buffer 内。
流程,左侧全部用于计算和返回 Advantages
- Prompt $x$ 输入给 Actor Model 完成一个 Response 的输出
- 将 Prompt 与 Response 结合起来输入给 Actor Model 和 Ref Model,分别得到 2 个 logits 值,用于计算 KL散度;
- 将 Prompt 与 Response 结合起来输入Critic Model,用于生成 Value 值;
- 将 Prompt 与 Response 结合起来输入Reward Model,用于生成 Reward 值;
- 优势计算采用 GAE,注意这里 RLHF 的设计方式是把 KL 散度融合到了 Reward 里面,从而影响了优势函数,换言之优势函数已经包含了 KL 惩罚
$\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)$
$A_t = \sum_l (\gamma \lambda)^l \delta_{t+l}$

在完成优势函数的计算之后,分别训练 Actor Model 和 Critic Model
Replay Buffer 前序阶段计算优势函数时的过长,所生成的内容都保存下来的「Rollout 阶段得到样本、旧策略对数概率 ($\log \pi_{\text{old}}$)、回报 ($R_t$)、优势 ($A_t$) 等,全部存入 Replay Buffer。」
对于 Actor Model,首先生成新的动作的概率值,计算相对提升(即 Policy Ratio $\gamma(\theta)=\frac{\pi(a_{t}|s_{t})}{\pi_{old}(a_{t}|s_{t})}$),与优势函数一起,计算 Actor Loss 然后完成反向传播更新 Actor 模型的梯度;
- 用当前 Actor 前向同一批样本,得新对数概率 $(\log \pi_\theta)$,计算
$[ r_t(\theta)=\frac{\pi_\theta(a_t|s_t)}{\pi_{\text{old}}(a_t|s_t)}. ]$ - Actor 损失(剪切代理目标) :
$[ L_{\text{actor}} =\mathbb{E}\left[\min\big(r_t A_t,\ \operatorname{clip}(r_t,1-\epsilon,1+\epsilon),A_t\big)\right]. ]$
- 用当前 Actor 前向同一批样本,得新对数概率 $(\log \pi_\theta)$,计算
对于 Critic Model,值函数生成新的 value
- 前向得到 $(V_\psi(s_t))$。目标回报$(R_t=A_t+V_\psi(s_t))$
- 无剪切时:
$[ L_V=\mathbb{E}\big[(V_\psi(s_t)-R_t)^2\big]. ]$ - 开启 value clipping 时(图中标的 $(v_{\text{clip}}))$:
$[ V_{\text{clip}}=V_{\text{old}}+\operatorname{clip}!\big(V_\psi-V_{\text{old}},-\epsilon_v,\epsilon_v\big),\quad L_V=\mathbb{E}\big[(V_{\text{clip}}-R_t)^2\big]. ]$
总损失
$[ L_{\text{total}}=L_{\text{actor}}+c_1 L_V+c_2,\mathcal{H}[\pi_\theta], ]$
其中 ($\mathcal{H}$) 为熵奖励$,(c_1,c_2)$ 为权重。
OpenRLHF 设计了多个“引擎”分别负责不同任务:
- Rollout Engine:负责生成(推理阶段,用 vLLM 加速)
- Reward Engine:负责计算奖励和 KL
- Critic Engine:计算价值和优势
- ZeRO Engine:负责训练更新(使用 DeepSpeed ZeRO 节省显存)
- 各部分可在不同 GPU 集群并行,通过 Ray 框架 管理调度。
关于 OpenRLHF 的相关代码解读,可以查阅:OpenRLHF-PPO 实践
High-level看verl
RL流程:生成阶段–准备阶段–训练阶段
核心算法魔改版PPO
如何加快训练效率?
- 推理方面如何加速?直接复用vllm,魔改vllm将vllm的ray依赖其变为SPMD模式;
- 相当多并行机制 TP,DP,PP 等,复用 Magetron,FSDP,Deepspeed
RL算法核心PPO
基础的强化学习认识

在一个特定环境下执行动作的智能体
- State $s_t$:状态空间,标志智能体的状态信息;
- Action $a_t$:动作空间,智能体能够与环境进行的操作;
- Reward $r_t$:智能体在某一状态下与环境进行操作所产生的价值;
特定行为在策略下的概率建模为$π_θ(a_t | s_t)$
环境状态的转移函数记作为$P(s_t+1 | a_t, s_t)$ <–在LLM RL 中这一点被重新建模为$s_t = {x, a_1, a_2, …, a_t}$ ,这里的 $x$一般代表提示词
每个动作($a_t$)、奖励($r_t$)和状态($s_t$)都与时间步长t相关联。将这些时间步长组合在一起就形成了一条轨迹:

使用链式法则,可以得到概率轨迹的完整表达式:

强化学习的目标
题外话 1:存在一些变体,例如是否期望尽早获得奖励而非延迟获得,这其实也是一个值得平衡的问题。
探索与利用的平衡是强化学习的核心难题之一。探索(Exploration)指智能体尝试未知的动作,以期发现更优的策略;利用(Exploitation)则指智能体基于现有知识选择最优动作,从而最大化即时奖励。二者看似对立,但必须平衡,才能在有限的尝试次数内获得最大的累计奖励。
我们可以通过 K 臂老虎机问题(K-armed Bandit) 直观理解:在这个理论模型中,智能体面临 K 个选择,每个选择的奖励分布未知,目标是通过策略最大化奖励。
- 如果智能体完全依赖 Exploration(如随机选择每个动作),它可能很好地估计各动作的期望奖励,但无法充分利用最优动作。
- 如果智能体完全依赖 Exploitation(如总是选择当前奖励最大的动作),它可能快速陷入次优解。
为解决这一问题,常用方法例如 ε-贪婪策略(ϵ-greedy),以一定概率 ϵ 随机探索,而以 1−ϵ 的概率选择当前最优动作。
引入一个折现因子$\gamma$
一般情况下,强化学习的目标通常可以表达为期望的累计奖励,其中的期望值是对轨迹进行计算,可以用连续情况和离散情况进行表述。

定义新的函数:状态、价值和优势函数,这些与强化学习目标息息相关
状态价值函数$V(s)$:表示从状态 s 出发,按照当前策略 $π_θ$行动时所能获得的期望累积奖励。
- $V^{\pi}(s) = \mathbb{E}_{a\sim\pi}[Q^{\pi}(s,a)]$
- 只关心状态好不好,用于判断当前局面整体的好坏
- 帮助判断策略是否需要更新;
动作价值函数 $Q(s, a)$:表示从状态 $s$ 出发,采取动作$a$,然后根据策略 $π_θ$行动所能获得的期望累积奖励。
- $Q^{\pi}(s,a) = \mathbb{E}_{\pi}[R_t \mid s_t=s, a_t=a]$
- 既关心状态,也关心动作“好不好”
- 帮助选“动作”
优势函数 $A(s, a)$:表示动作价值函数与状态价值函数之间的差值,即 $A(s, a) = Q(s, a) - V(s)$。
- 表示动作 $a$ 相对平均水平(即当前状态下策略期望收益 $V(s)$)的额外优势。
- 如果动作a带来的奖励高于预期,优势值为正,反之则为负。优势函数在强化学习研究中扮演着重要角色——它们被用来计算我们策略的梯度。
题外话 2:强化学习的两种范式
Value-based 方法(值函数方法):
学习一个 动作价值函数 $Q(s,a)$,用于估计“在状态 $s$ 执行动作 $a$ 后能得到多少未来奖励”。
策略不是显式表示的,而是通过 Q 值间接获得。
- $Q^*(s,a) = \mathbb{E}[r_t + \gamma \max_{a'} Q^*(s', a')]$
- Bellman Optimality Equation(贝尔曼最优方程)
代表算法:Q-Learning,DQN
Policy-based 方法(策略梯度方法):
- 不再学习“值函数”,而是直接学习策略函数 $\pi_\theta(a|s)$。
- 策略可以是确定性的,也可以是随机的(如 softmax over logits)
- $\nabla_\theta J(\theta) = \mathbb{E}*{\pi*\theta} \left[ \nabla_\theta \log \pi_\theta(a_t|s_t) R_t \right]$ 即直接通过梯度上升优化策略,使得高奖励的动作概率更大
- 代表算法:REINFORCE,Actor-Critic,PPO(主流)
LLM 对 RL 的重新定义
一些概念的映射

- Policy 就是 LLM 本身;
- 初始的状态其实就是固定的 Prompt;
- LLM的输出无论是每个 token还是整个完成内容都是一种动作;
- 状态就是,提示+输出的集合体;
- 完整的 LLM 输出结果就是一条轨迹;
- 奖励可以来自验证器或者奖励模型;
题外话 3:关于 LLM 模式中的一些状态定义
- 赌博机 Bandit:将LLM的完整生成内容或响应整体建模为单一动作;
- 蒙特卡洛Markov Decision Process (MDP):将大语言模型输出中的每个词元视为独立动作;「当前主流」
LLM通过下一个词元预测来生成输出;即通过逐个生成输出补全中的每个词元「LLM 的自回归范式」

对于这样一个自回归过程,可以建模为 MDP 过程:

强化学习训练
对于强化学习的训练,我们的目标是最大化目标函数,即累积(可能经过折扣的)奖励,基于 Policy-based 的范式,可以直接采用梯度上升的方法:

备注:
- 在监督学习中,我们想让 loss 越小越好,往负梯度方向走(下降)
- 在强化学习中,我们想让 reward 越大越好,往正梯度方向走(上升)
那如何计算这个梯度?
几乎所有用于大语言模型训练的强化学习优化器(例如PPO 、GRPO和REINFORCE)都属于策略梯度算法,其运作方式为:i)估计策略梯度,ii)使用该估计值执行梯度上升。这些算法采用不同方法估计策略梯度,但后面的思想其实非常相似。
强化学习的目标是最大化累积奖励。如果我们尝试计算该目标相对于策略参数θ的梯度,可以推导出如下结果,最终得到策略梯度的基本表达式。

推导过程,主要是对对数导数的使用技巧以及最后一步引入轨迹概率的定义。对于最后一步,我们可以发现:初始状态概率和状态转移函数关于策略参数的梯度始终为零,因为这两者均不依赖于策略。
因为初始状态分布 $(\rho_0(s_0)$) 和状态转移概率 ($P(s_{t+1}|s_t, a_t)$) 都由环境决定、与策略参数 ($\theta)$ 无关,所以它们关于 ($\theta$) 的梯度为 0。
因此,轨迹对数概率的梯度只依赖于策略本身:
$\nabla_\theta \log P(\tau|\theta) = \sum_t \nabla_\theta \log \pi_\theta(a_t|s_t)$

- 奖励从哪来?验证器或奖励模型
- action 的概率从哪来?LLM 的 logits
伪代码实现策略梯度的基本表达式,我们并非直接计算策略梯度,而是构建一个损失函数,使其梯度等于策略梯度,然后借助PyTorch的自动微分功能来计算策略梯度,这一过程发生在loss.backward()执行时。
| |

以上是基础的策略梯度的表达式和伪代码,简单直接,但存在几个明显的问题:
- 高方差:梯度估计可能具有高方差,导致训练不稳定。
- 策略更新不稳定:缺乏防止对策略进行大规模、可能破坏稳定性的更新的机制。
题外话4:通用策略梯度,通过更通用的策略梯度表达式总结了计算策略梯度的可选方法
PPO 以及绝大多数LLM强化学习优化器,都聚焦于将$Ψ_t$设定为优势函数$A(s_t, a_t)$ 这个称为原始策略梯度 VPG

开始PPO(Proximal Policy Optimization)
前身TRPO
来到开胃菜,先看看PPO的前身TRPO
TRPO的核心动机是创建一种数据效率高且无需过多超参数调整的算法。为实现这一目标,研究者提出了以下约束目标函数,该函数能保证策略的单调改进。该目标通过强制策略更新处于置信域内,从而消除了可能破坏训练稳定性的大幅度策略更新风险。

在强化学习中,我们的目标是最大化累积奖励,但正如我们在VPG的讨论中所见,直接最大化这个强化学习的"真实"目标可能导致训练不稳定。TRPO通过构建替代目标来替代真实目标进行最大化。
与 VPG 的差异:
- 当前策略中的动作概率通过该动作在旧策略(即训练前的策略)中的概率进行归一化——这构成了策略比率(也称为重要性比率)。在此公式中,我们同样使用概率而非对数概率。
- 目标函数上设置了一个约束条件,以确保新旧策略之间的期望KL散度小于阈值δ。
$r_t$ 策略比率:TRPO损失函数的核心是策略比率,其定义如下所示。策略比率告诉我们,在当前策略下执行某个动作的概率相对于训练过程开始前(即"旧"策略)该动作概率的比值。

若新策略对某个动作的赋予概率高于旧策略,则该比率大于1,从而增强该动作优势值在目标函数中的影响力;反之,若新策略赋予的概率较低,比率则小于1,相应动作的影响力便会减弱。策略比率机制确保策略更新过程能够重点强化新策略更倾向于采用的动作——特别是那些具有高优势值的动作——同时抑制新策略中可能性降低的动作。通过这种方式,我们能够根据新旧策略的差异程度进行精确加权更新,从而实现稳定高效的政策优化。
从 TRPO 到 PPO
于是希望开发一种算法,既能保留TRPO的优势——如稳定性、数据效率和可靠性——又能避免其复杂性。理想情况下,该算法应具有广泛适用性,并能通过基本梯度上升方法求解。这些目标促使我们提出了PPO算法,该算法主要受TRPO启发。
PPO的目标函数借鉴了TRPO的代理目标,但通过用剪裁机制替代严格的KL约束,以更简单的方式实现了信任区域的实施。

训练过程与TRPO类似,PPO专注于优化替代目标函数,但PPO中的目标函数没有约束条件且经过轻微修改。如上算法所示,PPO在每一步中执行多次策略更新,而非单次更新,具体交替执行以下操作:
- 从策略中采样新数据或轨迹。
- 对采样数据执行多个周期的优化。
PPO 替代目标
PPO 的替代目标也是基于当前策略与旧模型之间的策略比率,我们将策略比率记为$r_t(θ)$,这与表示时间步t奖励的$r_t$符号相似,但策略比率与奖励无关!为推导PPO目标,我们从TRPO在无KL约束条件下最大化的代理目标开始,具体如下所示:

我们将这一公式称为"无裁剪"目标函数。由于没有约束条件,该目标函数可以轻松计算得出策略梯度,具体通过:i) 估计优势函数,ii) 计算策略比率。然而,如果我们试图最大化这个无约束目标函数,可能会导致巨大且破坏性的策略梯度更新,从而使训练过程变得不稳定。
Clip 裁剪机制「方式 1」
为解决这个问题,PPO在替代目标函数中引入了一种新颖的裁剪机制,该机制有助于维持信任区域,通过这种方式,PPO算法抑制过大的策略比率,从而确保策略在训练更新后不会与旧策略产生过大偏离。

题外话 5:一些对 Clip 函数的理解
- 案例 #1 [ $A > 0 , r_t(θ) ≤ 1 + ε$]:优势值为正——这是我们希望强化的行为。策略比率低于 $1 + ε$,因此我们执行常规策略梯度更新来增加该行为的概率。
- 案例 #2 [ $A > 0 , r_t(θ) > 1 + ε$]:优势函数再次为正,但我们的策略比率大于$1 + ε$。这意味着相较于旧策略,该动作在新策略中已经更可能出现。此时目标函数会被截断,且策略比率进一步增大的梯度为零。这可以防止策略使该动作出现的概率继续增加。
- 案例三 [ $A < 0 , r_t(θ) ≥ 1 - ε$]:优势函数为负——这是我们需要负向强化的动作(即降低概率)。我们的策略比率高于$1 - ε$,因此执行常规策略梯度更新来降低该动作的概率。
- 案例 #4 [ $A < 0 , r_t(θ) < 1 - ε$]:优势函数再次为负,但我们的策略比率小于 $1 - ε$。这意味着相较于旧策略,该动作在新策略中已经更不可能发生。目标函数被截断,且关于策略比率进一步降低的梯度为零。这防止策略使该动作发生的可能性进一步降低。
KL散度「方式 2」
对于TRPO的替代目标的求解,这个求解过程相当复杂,还需要求二阶优化(共轭梯度近似),但是可以转换一下求解目标,例如将 KL 散度作为惩罚项加入到损失函数,这种无约束的损失更为简单,并且可以再次通过基本梯度上升法进行求解。
在使用PPO训练LLM时,我们通常会将当前策略与参考策略(通常是强化学习训练开始前的某个策略,例如SFT模型)之间的KL散度纳入训练过程。这个附加的KL散度项会惩罚策略与参考策略差异过大的情况,从而起到正则化效果。我们通过比较两个LLM对序列中每个标记输出的概率分布来计算逐标记的KL散度。
两种加入方式:
RL的奖励中减去KL散度

作为训练目标的惩罚项「⭐️现在用的比较多,但DAPO直接去掉了」

Critic价值网络

例如,我们可以创建策略的独立副本,或者——为了获得更好的参数效率——添加一个与策略共享权重的专用价值头来预测价值函数。这种习得的价值函数通常被称为价值模型或Critic。Critic以部分响应作为输入,预测序列中每个标记位置的预期最终奖励;详见下文。
在大语言模型的语境中,我们通过奖励模型来预测奖励值。此外,大多数大语言模型采用结果监督进行训练,这意味着只有在模型生成完整回复后(即输出

Critic training,价值函数采用 On-policy 的模式,依赖于我们策略的当前参数。与强化学习训练开始时固定的奖励模型不同,评论者在每次策略更新中与大型语言模型同步训练,以确保其预测保持同策略性,这被称为 actor-critic setup。具体实现方式是在替代目标的损失函数基础上,额外添加评论者预测奖励与实际奖励之间的均方误差损失。
⭐️伪代码实现
| |
求取优势函数-GAE(Generalized Advantage Estimation)
优势函数告诉我们,在特定状态下某个动作比平均动作优越多少:$A(s_t, a_t) = Q(s_t, a_t) - V(s_t)$。该公式中的价值函数由评论家估计,但我们尚未详细讨论如何计算优势函数。在PPO中,优势函数是基于每个标记(或动作)进行估计的。计算优势主要有两种方法,这些方法构成了大多数其他技术的基础。
求解优势函数的一些方法:
蒙特卡洛法(MC)。蒙特卡洛法对优势函数的估计依赖于完整轨迹中观测到的实际奖励。具体而言,优势函数计算为完整轨迹的累积奖励 $R(s_t)$与评论家预测的当前状态价值函数 $V(s_t)$之间的差值。
- 到目前为止,我们对PPO的讨论都假设采用蒙特卡洛方法来估计优势函数。蒙特卡洛估计具有较低的偏差,因为它依赖于轨迹观测到的实际奖励(精确信息),但蒙特卡洛估计也存在高方差的问题。因此,我们需要采集大量样本并进行足够多的观测才能获得准确的优势估计——这可能会带来高昂的计算成本。
- 无偏估计,方差极高
- 等想办法优化一下,于是就有了时序差分
时序差分。TD残差利用评论家对每个令牌的价值预测来形成一步优势估计,如下所示。

N 步估计器,TD残差分析单步实际奖励与预期奖励之间的差异。但我们可以推广这一思路来捕捉任意步数的情况,N步优势估计器与TD残差具有相似结构,但它融合了N个状态的真实奖励,其中N可以大于1。

不同的 N 表示着不同的方差与偏差的权衡。
GAE能更好地平衡偏差-方差权衡。传统的单步优势估计可能引入过多偏差,而使用完整轨迹又往往存在高方差问题。GAE通过结合两种思想——多步预测和加权滑动平均(或仅采用其中一种)来解决这个问题。
广义优势估计(GAE)是PPO中最常用的优势估计方法,它利用了N步优势估计。不过,GAE并非选择单一的N值,而是通过取不同N值的N步优势估计的平均值来使用所有N值。这是通过为GAE引入混合参数λ来实现的。

将λ设为0会产生单步时序差分残差,因为只有求和中的第一项获得非零权重。此外,将λ设为1则恢复了蒙特卡洛估计 MC。
展开求和中每个时序差分残差的定义,可得到累计折扣奖励与当前状态值函数之间的差值,如下:

GAE的优势在于λ∈[0,1]的取值能够控制偏差-方差的权衡。当我们增大λ值时,优势估计中会使用更精确的奖励信息,从而降低偏差(但会增加方差)。同样地,我们可以使用较小的λ值来降低方差,但代价是更高的偏差。
结果奖励。当我们使用LLMs时,通常采用结果奖励设置,这简化了GAE。除非处于轨迹的最后一步,否则奖励始终为零。在这种情况下,我们GAE求和中的大多数TD残差项仅仅是两个时间步之间(折现后)价值函数的差值$γV(s_{t + 1}) - V(s_t)$。求和中的最后一项则包含轨迹实际观察到的结果奖励。
时间步 奖励 (r_t) TD 残差 ( \delta_t ) 含义 中间步骤 0 $( \gamma V(s_{t+1}) - V(s_t) )$ 价值变化(预测更新) 最后一步 结果奖励 (R) $( R - V(s_{T-1}) )$ 真正的反馈信号
GAE 的代码实现:
| |
在LLM中使用PPO
两种常用于训练大语言模型的不同强化学习训练类型,区别主要在于如何获取奖励信息。

- 基于人类反馈的强化学习(RLHF)通过使用源自人类偏好奖励模型的奖励,采用强化学习方法来训练大型语言模型。
- 基于可验证奖励的强化学习(RLVR)通过使用源自基于规则或确定性验证器的奖励,对大型语言模型进行强化学习训练。
PPO的缺点。尽管它迅速成为RLHF的默认强化学习优化器,但PPO是一种复杂的actor-critic算法,具有较高的计算和内存开销,以及许多底层实现复杂性。PPO的内存开销很高,因为我们需要在内存中保存四个LLM副本:
- The policy: 训练的模型 「参数需要更新」
- The reference policy:参考模型,原来的模型 「参数不更新」
- The critic:计算价值函数的模型 「参数需要更新」
- The reward model:可能需要
题外话6:老是容易搞混policy模型和critic模型,他们共用一套架构,但出自不同头,下面这个代码可以清晰展示,一个是输出一个概率,一个是输出一个标量。
1 2 3 4 5 6 7 8 9 10 11 12class ActorCriticModel(nn.Module): def __init__(self, base_model): super().__init__() self.shared = base_model self.policy_head = nn.Linear(hidden_dim, vocab_size) self.value_head = nn.Linear(hidden_dim, 1) def forward(self, input_ids): hidden = self.shared(input_ids) logits = self.policy_head(hidden) values = self.value_head(hidden) return logits, values同时也需要注意,PPO 的开销实在是太大了,PPO是一种敏感的算法,容易产生不稳定性——我们可能会投入大量计算资源和时间训练模型,最终却因超参数设置不当而导致性能不佳。正因如此,像REINFORCE和GRPO这类更简单的强化学习算法——甚至无需强化学习的DPO等技术——已成为PPO的热门替代方案。
LLM 中应用 RLHF 的一些常见思路
| 阶段 | 输入 | 输出 | 目的 |
|---|---|---|---|
| SFT(Supervised Fine-Tuning) | 人类编写的高质量指令与回答 | 初始模型(policy π₀) | 教模型“会说话” |
| 奖励模型训练(Reward Model, RM)** | 同一提示的多种模型回答 + 人类偏好排序 | 奖励模型 r(x, y) | 教模型“分辨好坏” |
| 强化学习优化(RL with PPO/GRPO) | SFT 模型 + 奖励模型 + 参考模型 | 对齐后的最终模型 π* | 教模型“更讨人喜欢” |
REINFORCE&RLOO 算法优化
REINFORCE算法是一种基于蒙特卡洛采样的策略梯度(Policy Gradient)算法,属于原始策略梯度(Vanilla Policy Gradient, VPG)的具体实现形式。它通过直接优化参数化的策略网络,利用完整轨迹的累积回报(Return)估计策略梯度,进而更新策略参数以最大化期望奖励。
该算法具有实现开销低、原理直观的特点,在大语言模型的强化学习训练场景中表现出有效性。其核心优化逻辑与带基线的策略梯度估计一致,特别采用训练过程中观察到的奖励平均值(如滑动奖励平均值或当前批次内奖励的算术平均值)作为基线,在保证梯度估计无偏性的前提下,有效降低了梯度估计的方差。

为了计算批次数据的梯度更新,执行以下的步骤:
- 使用当前的策略$\pi_\theta$为每个提示词生成回答;
- 存储每个回答中token 的对数概率;
- 为每个回答计算奖励(通常使用奖励模型);
- 通过计算奖励的平均值来得到基线值 Baseline;
- 然后从奖励中减去 Basline 来计算优势;
- 计算每个回答的 log 概率与优势值的乘积之和,然后对批次求平均以形成蒙特卡洛估计;

REINFORCE在其表达式中包含了学习率作为一个“非负因子”,因为我们正在进行梯度上升并试图最大化奖励。
偏移强化(Offset Reinforcement):比较容易理解,就是在策略梯度表达式中,基线直接从奖励中减去,换句话说,基线用于偏移奖励——即强化学习中的强化信号(奖励决定了动作的好坏)。因此,基线就是对强化信号的偏移量。
特征资格(Characteristic Eligibility):学习如何按 Token 归因学习的方式,可以是一个常数或者是参数,但是现在一般都是用策略模型的 Logit 概率;
- 强化学习里的“资格(Eligibility)”,就是判断模型之前做的某个具体行动,是不是真的对后来拿到的奖励有贡献。
引入 KL 散度,REINFORCE 算法是直接将 KL 散度融入到奖励函数 「值得一提的是,KL 散度的融合方式多种多样,例如 GRPO 是将 KL 散度整合到损失函数中,目的都是为了确保策略不会显著偏离参考策略」

在效率与开销方面,与PPO等算法相比,REINFORCE的开销更低,因为它不需要使用价值(Critic)模型来计算优势估计,而是直接用奖励的平均值替代了评判模型。

- 这种优势估计的缺点主要就是方差会比较高,但是一些研究表明在 LLM 微调领域,REINFORCE 的高方差并没有造成太大的问题,而是在实践中有奇效,在后来的 GRPO 也进一步验证了这样一点。
最后是,如何聚合 logits 概率、KL 散度和奖励来形成最终的策略梯度更新,这也是 REINFORCE 算法的一个区别了,它采用赌博机问题来建模,使用完整的生成内容(而非单独的 token)为一个动作来进行训练。简单来说,就是REINFORCE把生成一整段话当成一次操作,PPO则把每个字都当成单独操作;奖励通常只在生成结束时才给(很少有),这时候REINFORCE会把奖励算给整个生成过程,PPO却只算给最后那个结束的字[EOS]。

- 注意图中的区别是 Policy Logprob 最终通过累加获得一个最终的序列的 logprob,奖励也是最终也是对生成的这一个整个句子给出一个奖励,这跟 PPO 使用 GAE 等方法去获取reward/value 最终计算advantage 的区别。
简单来实现一下 REINFORCE算法
| |
REINFORCE 留一法 RLOO
REINFORCE 算法中通过为每个提示生成单个on-policy 的结果,然后使用这些完成结果的奖励通过移动平均「之前所有问题回答的打分平均值」或批次内奖励平均值「当前一批问题里所有回答的打分平均值」来构建基线,而RLOO 的主要优化如下:
- 对于每个提示完成 多次(K)采样
- 使用这些多次采样的结果分别计算每个单独的提示的奖励平均值

简单举例:
核心是每个问题生成K个回答,然后用“留一法”算基线:
比如对某个问题生成了3个回答(K=3),打分分别是8分、7分、9分。
- 算第1个回答(8分)的基线:去掉它,取剩下两个回答的平均分 → (7+9)/2=8;
- 算第2个回答(7分)的基线:去掉它,取剩下两个 → (8+9)/2=8.5;
- 算第3个回答(9分)的基线:去掉它,取剩下两个 → (8+7)/2=7.5。
每个回答的基线,都是同问题下其他所有回答的平均分(公式里的1/(K-1)就是除以剩下的K-1个,求和j≠i就是排除自己)。
关键区别分析
- REINFORCE:每个问题1个回答,基线是“跨问题的平均”;
- RLOO:每个问题K个回答,基线是“同问题内排除自己的平均”——更聚焦当前问题的参考值,训练更稳定。
RLOO 的高效实现

a. 算总奖励=8+7+9=24 → 平均奖励=24/3=8
b. 对每个生成i:–>只需要计算一次平均值
优势$A_i$= $(K/(K-1)) × (R_i - 平均奖励)$
A₁=3/(3-1) × (8-8)= 1.5×0=0
A₂=3/2 ×(7-8)= -1.5
A₃=3/2 ×(9-8)=1.5
Putting RL back in RLHF 阐述了一些 RLOO 的一些对比。

可以发现,RLOO比PPO节省50-70%的内存,运行速度快2-3倍。随着模型规模的增大,这些优势会更加明显。除了效率提升之外,RLOO与PPO相比具有相当竞争力,并且持续优于DPO等离线算法。这些结果证明了RLOO(以及REINFORCE)的核心价值主张——这些算法在保持在线强化学习算法性能优势的同时,实现更简单且运行成本更低。
简单来实现一下 RLOO 算法「直接在 REINFORCE 上进行修改」
| |
GRPO/DAPO/GSPO 系列优化器
GRPO 算法
- 成本低:直接删掉了 Critic 模型和奖励模型
原文其实写的很详细了,GRPO 与 PPO 的区别

PPO的Critic 模型其实主要作用就是在计算优势函数$A_t$时是为了降低方差而被当做baseline,但是LLM的奖励模型的性质就决定了它只会为每个回答的最后一个token分配奖励$r$而其余的token的奖励都是0,就是因为这个性质,我们很难在每个token处训练出准确的价值函数。
因此 GRPO 决定去掉了 $V$函数,也就是 Critic 模型,在旧策略$\pi_\theta$中采样多个输出,将输出的奖励平均值作为 baseline 来降低方差。
将 PPO 的优化目标改写为,KL 散度也写在了目标函数里面,这与 REINFORCE 算法也不同。
$$ \begin{align*} \mathcal{J}_{GRPO}(\theta) &= \mathbb{E}[q \sim P(Q), \{o_i\}_{i=1}^G \sim \pi_{\theta_{old}}(O|q)] \\ &\frac{1}{G} \sum_{i=1}^G \frac{1}{|o_i|} \sum_{t=1}^{|o_i|} \left\{ \min \left[ \frac{\pi_{\theta}(o_{i,t}|q, o_{i,<t})}{\pi_{\theta, id}(o_{i,t}|q, o_{i,<t})} \hat{A}_{i,t}, \text{clip} \left( \frac{\pi_{\theta}(o_{i,t}|q, o_{i,<t})}{\pi_{\theta_{old}}(o_{i,t}|q, o_{i,<t})}, 1 - \epsilon, 1 + \epsilon \right) \hat{A}_{i,t} \right] - \beta \mathbb{D}_{KL} \left[ \pi_{\theta} \| \pi_{ref} \right] \right\} \end{align*} $$$1/G ∑_{i=1}^G$ → 把G个回答的损失「平均化」,每个回答的权重一样(不管长短,每个回答占1/G)。
$1/|o_i| ∑_{t=1}^{|o_i|}$→ 对单个回答$o_i$,把里面所有词的损失加起来,再除以词数,得到「这个回答的平均词损失」。
与此同时,在计算 KL 散度时,也有一点区别,使用的是无偏估计
$$ \mathbb{D}_{KL}[\pi_{\theta}||\pi_{ref}]=\frac{\pi_{ref}(o_{i,t}|q,o_{i,<t})}{\pi_{\theta}(o_{i,t}|q,o_{i,<t})}-\log\frac{\pi_{ref}(o_{i,t}|q,o_{i,<t})}{\pi_{\theta}(o_{i,t}|q,o_{i,<t})}-1 $$优势函数计算:
$$ \hat{A}_{i,t} = r_i - \frac{r_i - \text{mean}(r)}{\text{std}(r)} $$
GRPO 的伪代码实现「跟之前一样,实现一轮的策略更新过程」
| |
DAPO 算法
在 GRPO 的基础上,主要修改:
- 裁剪偏移(Clip-Shifting),增大了 clip 上限,促进系统多样性并允许自适应采样;
- 动态采样(Dynamic Sampling),过滤到全对的回答,提高训练效率和稳定性;
- Token级策略梯度损失(Token-Level Policy Gradient Loss),避免长回答不公平的损失,在长思维链 RL 场景中至关重要;
- 溢出奖励塑造(Overflowing Reward Shaping),避免训练过程中因为超过输出最大长度而给予惩罚,减少奖励噪声并稳定训练;
- DAPO 认为 KL 散度限制了模型的训练,完全移除 KL 散度;
DAPO的目标函数

- 提高上限:更高裁剪(Clip-Higher)
遵循更高裁剪策略,DAPO将下裁剪和上裁剪范围解耦为 $\epsilon_{\text{low}}$ 和 $\epsilon_{\text{high}}$,DAPO增加 $\epsilon_{high}$ 的值,为低概率 Token 的增加留出更多空间。在论文实验中,这种调整有效地提高了策略的熵,促进了更多样化样本的生成。与此同时DAPO选择保持 $\epsilon_{low}$ 相对较小,因为增加它会最终抑制这些 Token 的概率,导致采样空间的崩塌。
具体来说,当 $\epsilon = 0.2$(大多数算法的默认值)时,考虑两个动作,其概率分别为 $\pi_{\text{data}}(o_i | q) = 0.01$ 和 $0.9$。更新后的最大可能概率分别为 $\pi(o_i | q) = 0.012$ 和 $1.08$。这意味着对于概率较高的 token(如 $0.9$),受到的约束较少。相反,对于低概率 token,要实现概率的显著增加要困难得多。
动态采样:当某个提示的所有输出都完全正确时,模型就没法从这些样本里进步了。比如GRPO算法,要是某个提示下所有结果都对,奖励都是1,模型会觉得“我已经完美了,不用改”,所以不会更新(梯度为零)。训练越久,这种“完美样本”越多,每次训练用的批次里,能让模型学到东西的样本就越少,导致模型更新的方向忽左忽右(梯度方差大),学起来更费劲。
DAPO 的具体措施:选训练样本时,只留那些有对有错的(准确率既不是0也不是1),去掉全对或全错的,直到凑够一批。这样每批样本都能帮模型进步,保持更新效果。
$$ s.t\ \ \ 0<|\{o_i|is\_equivalent(a,o_i)\}|<G $$Token级策略梯度损失
原始方法按样本算损失(每个样本权重一样),但长文本里的每个词,比短文本里的词影响小(比如100词的长文本,每个词占1/100;10词的短文本,每个词占1/10)。
这样导致两个问题:
- 高质量长文本的好推理模式(比如一步步解题的逻辑),因每个词贡献小,模型学不到;
- 低质量长文本的坏毛病(比如重复、胡说),惩罚力度不够,输出越来越长、越来越乱;
原始 GRPO 存在的问题,案例举例:
短回答(10个词):每个词的损失占这个回答的1/10 → 对总损失的贡献是(1/10 × 词损失和)× 1/G;
长回答(100个词):每个词的损失占这个回答的1/100 → 对总损失的贡献是(1/100 × 词损失和)×1/G;
DAPO 修改每个词算的损失,不管来自长/短文本,每个词权重相同,实际做法就是直接除以全部词的数量,1/(总词数) × 所有词的损失总和 → 对所有词做平均:
$$ \frac{1}{\sum_{i=1}^G|o_i|} \sum_{i=1}^G \sum_{t=1}^{|o_i|} $$优化截断奖励:原来训练时,回答超过最大长度会被截断,然后直接给惩罚。但有些截断的回答推理是对的,只是太长,这种惩罚会让模型懵(不知道错在推理还是长度);
DAPO 的解决方法,对超长回答,分梯度惩罚(不冤枉合理稍长的好推理),过滤无效截断样本,让模型既控制长度又不丢逻辑,训练更稳更好。
$$ R_{\text{length}}(y) = \begin{cases} 0, & |y| \leq L_{\text{max}} - L_{\text{cache}} \\ \frac{(L_{\text{max}} - L_{\text{cache}}) - |y|}{L_{\text{cache}}}, & L_{\text{max}} - L_{\text{cache}} < |y| \leq L_{\text{max}} \\ -1, & L_{\text{max}} < |y| \end{cases} $$
DAPO 的伪代码实现

GSPO 算法
相较于 GRPO,GSPO 的修改在于直接将奖励计算从 token 级别转换到了 sequence 级别,解决了 LLM 过多关注 token 导致训练不稳定的问题。
GSPO 的优化目标,「可以发现,主要改动是在策略比率(也有人叫做重要性采样) 」
知乎上有句话说的好:原文
GSPO本质上是把GRPO优化目标里的T个$ratio_t * a$从算术平均改为了几何平均
$$ > \text{GRPO} = \frac{1}{n} \sum_{t=1}^{n} \text{ratio}_t > $$$$ > \text{GSPO} = \left( \prod_{t=1}^{n} \text{ratio}_t \right)^{\frac{1}{n}} = \sqrt[n]{\prod_{t=1}^{n} \text{ratio}_t} > $$

GSPO 论文中还提到一个变体,GSPO-token,主要是为了能够兼顾 token 级别的细粒度
GSPO-token:
- 在保持句子整体比值 $s_i(\theta)$ 不变的前提下,
允许每个 token 有不同的优势 $A_{i,t}$。- 通过引入一个 token-级比值 $s_{i,t}(\theta)$,
但它的定义方式确保数值不变、梯度一致。
这里的 sg 是 stop-gradient(detach)操作,为了在梯度上结合 token-level 的优势
换言之:
- 取数值,但不反向传播梯度。
- 数学上相当于「只把外层梯度传给 $s_i(\theta)$,而不让内层重复梯度」。
因此:
$$ > \text{数值上}:\quad s_{i,t}(\theta) = s_i(\theta) \times 1 = s_i(\theta) > $$但在梯度传播时,只让整体句子比值的梯度 $s_i(\theta)$ 发挥作用。
理解这里可能涉及到 Pytorch(或其他自动微分系统中的训练流的分配),例如在 Pytorch 中:
- 数值流(forward):负责计算损失的具体数值。
- 梯度流(backward):负责反向传播更新参数。
.detach() 会保留数值,但切断梯度。
ratio_token = seq_ratio_detach * (torch.exp(logps) / torch.exp(logps.detach()))其中:
seq_ratio_detach数值上是句子的比值$s_i(\theta)$,但梯度会被切断,不会反传;
torch.exp(logps) / torch.exp(logps.detach(),数值上横为 1,但梯度能够沿着 token 的 log 概率方向传于是就实现了:
- 模型“看到”的比值保持整句统一(数值一致)
- 但每个 token 都能被独立更新(梯度分开走)。
GSPO 相较于 GRPO 改动并不大,可以从伪代码层面进行理解
| |
verl代码解析
(外层库结构)代码架构
| |
(内层主要结构)代码结构
| |
verl 框架的训练流程
由于 verl 的框架代码infra 含量过高,我主要针对其 PPO 的流程进行梳理和分析,主要流程以及涉及代码。

一些关键参数
| 参数 | 含义 |
|---|---|
data.train_batch_size | 用于生成一组采样轨迹/推演的全局提示批次大小。响应/轨迹数量为 data.train_batch_size * actor_rollout.ref.rollout.n |
actor_rollout_ref.actor.ppo_mini_batch_size | 采样轨迹集被分割为多个小批次,其批次大小=ppo_mini_batch_size,用于PPO的 actor 模型的更新。该ppo_mini_batch_size是所有工作节点间统一的全局尺寸。 |
critic.ppo_mini_batch_size | 采样轨迹集被分割成多个小批次,批次大小=ppo_mini_batch_size,用于PPO critic 模型的更新。ppo_mini_batch_size是所有工作进程间的全局统一尺寸。 |
actor_rollout_ref.actor.clip_ratio | PPO裁剪比率。默认值为0.2 |
actor_rollout_ref.actor.ppo_epochs | actor model 在一组采样生成中多久更新一次 |
critic.ppo_epochs | critic model 在一组采样生成中多久更新一次,默认为actor_rollout_ref.actor.ppo_epochs。 |
algorithm.gemma | 折扣因子 |
algorithm.lam | 计算 GAE 时平衡偏差和方差的 lambda 项 |
algorithm.adv_estimator | 优势估计器(计算优势函数的方式),目前verl 支持gae、grpo、reinforce_plus_plus、reinforce_plus_plus_baseline、rloo |
| KL 散度控制 | |
actor_rollout_ref.actor.use_kl_loss | 在 actor 模型损失函数中加入 kl 散度 |
actor_rollout_ref.actor.kl_loss_coef | kl损失的系数。默认值为0.001 |
actor_rollout_ref.actor.kl_loss_type | 支持 kl(k1)、abs、mse(k2)、low_var_kl(k3) 和 full。在末尾添加“+”(例如“k1+”和“k3+”)将应用直通估计器,无论 kl 值估计如何都使用 k2 进行无偏梯度估计 |
| 也可以在奖励中加入 kl 散度 | |
algorithm.use_kl_in_reward | 是否在奖励中启用KL惩罚。默认为False |
algorithm.kl_penalty | 支持kl(k1)、abs、mse(k2)、low_var_kl(k3)和full。这定义了计算演员策略与参考策略之间kl散度的方法。具体选项请参考core_algos.py中的kl_penalty。详细分析请参阅此博客文章:http://joschu.net/blog/kl-approx.html |
algorithm.kl_ctrl.kl_coef | (初始)奖励内kl惩罚系数。默认值为0.001。 |
algorithm.kl_ctrl.type | ‘fixed’ 对应 FixedKLController,‘adaptive’ 对应 AdaptiveKLController |
algorithm.kl_ctrl.horizon | 详见AdaptiveKLController源代码 |
algorithm.kl_ctrl.target_kl | 详见AdaptiveKLController源代码 |
用户配置文件
./examples目录中提供了许多 PPO/GRPO等优化算法的配置文件的实现。
run_gemma.sh为例:
| |
入口函数 main_ppo.py
verl 把 RL 训练过程看成是一个数据流的过程,一个完整的训练需要完成这些步骤:
- 定义数据,这个由
RLHFDataset定义,至少包含一个字段prompt,定义数据的教程- 为数据集绑定奖励函数,规则类奖励模式还是说独立的奖励模型,verl 提供了两种定义示例,规则类方法(GSM8k),包含了一个
_select_rm_score_fn方法,奖励模型类型full_hh_rlhf- 定义工作类,worker 节点定义;
- 构建数据流/训练流,也就是定义角色与 worker 类之间的映射关系,以及角色与worker 类(这个 verl 预先实现了)
- 定义资源池 ID 和资源池规格;
- 完成奖励函数/模型,以及初始化 PPO 训练器
我们对 main_ppo.py进行分析,完整代码
- Hydra 入口,VERL 使用 Hydra 管理配置。执行
python main_ppo.py 时,Hydra 会自动加载config/ppo_trainer.yaml,并注入配置对象config。
| |
Hydra 的好处是:模块化配置、多层合并、命令行覆盖参数,可适配复杂分布式训练场景。
verl 数据加载与采样
-
create_rl_dataset数据集的构建 - 自定义数据集加载,
load_extern_type:通过模块路径字符串加载外部 Python 类,这个在配置文件中定义 data: path_cls 和 name - 动态生成数据模式,当训练阶段配置了
datagen,会启用 DynamicGenDataset,这类数据集会在每个 epoch 动态生成样本(例如模型自生成 Prompt-Response 对) - 都没配置,那就使用默认的 RLHF 模式
- 最后不管使用哪种模式,都会被写入为,tokenizer 将文本转化为 token,如果是多模态数据还会使用到 processor,config 用于调控采样、清洗、最大长度等
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30def create_rl_dataset(data_paths, data_config, tokenizer, processor, is_train=True, max_samples: int = -1): from torch.utils.data import Dataset from verl.utils.dataset.rl_dataset import RLHFDataset #默认加载RLHF模式的数据集 # 检查数据配置中是否指定了自定义数据集类,以及是否提供了自定义类的路径 if "custom_cls" in data_config and data_config.custom_cls.get("path", None) is not None: # 动态加载自定义数据集类 dataset_cls = load_extern_type(data_config.custom_cls.path, data_config.custom_cls.name) if not issubclass(dataset_cls, Dataset): raise TypeError( f"The custom dataset class '{data_config.custom_cls.name}' from " f"'{data_config.custom_cls.path}' must inherit from torch.utils.data.Dataset" ) elif "datagen" in data_config and data_config.datagen.get("path", None) is not None and is_train: from verl.utils.dataset.dynamicgen_dataset import DynamicGenDataset dataset_cls = DynamicGenDataset print("Using DynamicGenDataset for data generation.") else: dataset_cls = RLHFDataset print(f"Using dataset class: {dataset_cls.__name__}") dataset = dataset_cls( data_files=data_paths, tokenizer=tokenizer, processor=processor, config=data_config, max_samples=max_samples, ) return dataset
create_rl_sampler:采样器与课程学习,主要支持三种采样方式策略 条件 实现类 说明 Curriculum Sampler 指定 data_config.sampler.class_path用户自定义类(继承 AbstractSampler)可动态调整样本顺序,如从简单到复杂 RandomSampler data_config.shuffle=TruePyTorch 内置 随机打乱样本顺序 SequentialSampler 否 PyTorch 内置 按原始顺序迭代样本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32def create_rl_sampler(data_config, dataset): import torch from torch.utils.data import RandomSampler, SequentialSampler if data_config.sampler is not None and data_config.sampler.get("class_path", None) is not None: #课程学习采样,难度递进(easy->hard),要求 dataloader_num_workers == 0,防止缓存导致样本顺序混乱,通过 AbstractSampler 抽象类,将 curriculum learning 机制内置为一等功能,不再依赖外部脚本实现。 curriculum_class = load_extern_type( data_config.sampler.class_path, data_config.sampler.class_name, ) sampler = curriculum_class( data_source=dataset, data_config=data_config, ) assert isinstance(sampler, AbstractSampler) assert data_config.get("dataloader_num_workers", 8) == 0, ( "If using curriculum, num_workers must be 0 to prevent data caching. " "If the dataloader caches data before the batch is done the " "curriculum sampler won't have the opportunity to reorder it. " ) # 使用采样器以方便检查点的恢复。 # 若数据配置中启用了随机打乱功能,则创建一个随机采样器。 elif data_config.shuffle: #随机采样 train_dataloader_generator = torch.Generator() seed = data_config.get("seed") if seed is not None: train_dataloader_generator.manual_seed(seed) sampler = RandomSampler(data_source=dataset, generator=train_dataloader_generator) else: #原始顺序 # 如果禁用随机打乱功能,则使用顺序采样器按顺序遍历数据集。 sampler = SequentialSampler(data_source=dataset) return sampler-
训练主流程入口
run_ppo(config)主要完成的工作有;初始化 Ray 分布式集群(单控制器架构)、构建 TaskRunner、执行远程训练任务以及完成性能日志追踪等
| |
相比 OpenRLHF 的单机单脚本实现,VERL 在主函数层面已经显式切入 分布式多角色训练 模式。
核心类:
TaskRunner
TaskRunner 是 VERL 中的核心执行类,用于在 Ray 集群上分配各类 Worker(Actor、Critic、Reward、RefPolicy) 并调度训练任务。TaskRunner 通过一系列
add_*_worker方法动态注册角色。
| |
定义工作类,PPO 的四大组成成分
注册 Actor,支持 fsdp 和 megatron 并行策略加载,使用 ray 远程加载,由
Role枚举管理角色类型。1 2 3 4 5 6def add_actor_rollout_worker(self, config): if config.actor_rollout_ref.actor.strategy in {"fsdp", "fsdp2"}: from verl.workers.fsdp_workers import ActorRolloutRefWorker elif config.actor_rollout_ref.actor.strategy == "megatron": from verl.workers.megatron_workers import ActorRolloutRefWorker self.role_worker_mapping[Role.ActorRollout] = ray.remote(ActorRolloutRefWorker)注册 Critic
1 2 3 4 5 6def add_critic_worker(self, config): if config.critic.strategy in {"fsdp", "fsdp2"}: from verl.workers.fsdp_workers import CriticWorker elif config.critic.strategy == "megatron": from verl.workers.megatron_workers import CriticWorker self.role_worker_mapping[Role.Critic] = ray.remote(CriticWorker)注册 Reward Model
1 2 3 4def add_reward_model_worker(self, config): if config.reward_model.enable: from verl.workers.fsdp_workers import RewardModelWorker self.role_worker_mapping[Role.RewardModel] = ray.remote(RewardModelWorker)注册 Reference Policy
1 2 3def add_ref_policy_worker(self, config, ref_policy_cls): if config.algorithm.use_kl_in_reward or config.actor_rollout_ref.actor.use_kl_loss: self.role_worker_mapping[Role.RefPolicy] = ray.remote(ref_policy_cls)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15role_worker_mapping = { Role.ActorRollout: ActorRolloutRefWorker, Role.Critic: CriticWorker, Role.RefPolicy: ActorRolloutRefWorker } global_pool_id = 'global_pool' resource_pool_spec = { global_pool_id: [config.trainer.n_gpus_per_node] * config.trainer.nnodes, } mapping = { Role.ActorRollout: global_pool_id, Role.Critic: global_pool_id, Role.RefPolicy: global_pool_id, }资源池管理与初始化
verl 不直接绑定 GPU 设备,而是通过资源池抽象:
这样支持多节点、多池的结构,Reward Model 可使用独立 GPU 池,
ResourcePoolManager动态分配 GPU,统一调度。1 2 3 4def init_resource_pool_mgr(self, config): global_pool_id = "global_pool" resource_pool_spec = {global_pool_id: [config.trainer.n_gpus_per_node] * config.trainer.nnodes} resource_pool_manager = ResourcePoolManager(resource_pool_spec, mapping=self.mapping)训练主流程 run(config)
打印配置与环境信息
1pprint(OmegaConf.to_container(config, resolve=True))注册全部角色
1 2 3 4actor_rollout_cls, ray_worker_group_cls = self.add_actor_rollout_worker(config) self.add_critic_worker(config) self.add_reward_model_worker(config) self.add_ref_policy_worker(config, actor_rollout_cls)配置验证
1validate_config(config, use_reference_policy=need_reference_policy(...), use_critic=need_critic(config))加载模型与 tokenizer
1 2 3local_path = copy_to_local(config.actor_rollout_ref.model.path) tokenizer = hf_tokenizer(local_path) processor = hf_processor(local_path)加载奖励函数
1 2reward_fn = load_reward_manager(config, tokenizer) val_reward_fn = load_reward_manager(config, tokenizer, num_examine=1)创建数据集与采样器
1 2train_dataset = create_rl_dataset(config.data.train_files, ...) train_sampler = create_rl_sampler(config.data, train_dataset)构建 PPO Trainer
verl 的 Trainer 被抽象为 远程多进程任务协调器,PPO 的核心逻辑(如策略损失、价值函数、优势估计)则封装在
RayPPOTrainer中。1 2 3 4 5 6 7 8 9 10 11trainer = RayPPOTrainer( config=config, tokenizer=tokenizer, processor=processor, role_worker_mapping=self.role_worker_mapping, resource_pool_manager=resource_pool_manager, reward_fn=reward_fn, ... ) trainer.init_workers() trainer.fit()
训练器 ray_trainer.py
verl 的 RayPPOTrainer 是一个 分布式 PPO 训练器, 囊括了PPO 训练的各个步骤,主要职责包括:
初始化 Ray 集群与资源池(ResourcePoolManager);
管理分布式 Worker(ActorRollout、Critic、RewardModel、RefPolicy);
实现 主训练循环(fit) ,协调生成、奖励计算、优势估计与梯度更新;
管理检查点(Checkpoint)保存与加载;
提供验证与日志输出机制。
首先先看看,主要的类和函数
| 模块 / 函数 | 作用 |
|---|---|
ResourcePoolManager | 管理 GPU 资源池(节点 → GPU 数)映射与调度 |
apply_kl_penalty() | 计算 KL 散度惩罚,用于在奖励层面控制策略更新幅度 |
compute_response_mask() | 生成响应部分的 attention mask |
compute_advantage() | 根据不同算法计算优势函数(GAE、GRPO、REINFORCE 等) |
RayPPOTrainer | 核心类,负责分布式 PPO 训练的完整生命周期 |
ResourcePoolManager:GPU 资源管理器
参数名 类型 说明 resource_pool_specdict[str, list[int]] 每个节点的 GPU 分布(如 { "global_pool": [8, 8] }) mappingdict[Role, str] 角色与资源池名称的映射(如 ActorRollout → global_pool) resource_pool_dictdict[str, RayResourcePool] 具体的 Ray GPU 资源池对象 核心代码
1 2 3 4 5 6 7 8 9 10@dataclass class ResourcePoolManager: def create_resource_pool(self): for pool_name, process_on_nodes in self.resource_pool_spec.items(): resource_pool = RayResourcePool( process_on_nodes=process_on_nodes, use_gpu=True, max_colocate_count=1, name_prefix=pool_name ) self.resource_pool_dict[pool_name] = resource_pool self._check_resource_available()
RayResourcePool 是 VERL 封装的 GPU 资源抽象,每个 pool 对应一组节点和 GPU。其中FSDP 模式下max_colocate_count=1表示所有 worker 合并为一个进程,而Megatron 模式可设置更高以支持 pipeline 并行。
apply_kl_penaltyKL 惩罚项主要参数:
参数名 类型 说明 data DataProto包含生成序列的 logits、奖励、掩码等数据结构 kl_ctrl AdaptiveKLController动态 KL 系数控制器 kl_penaltystr KL 惩罚类型(默认 "kl")核心代码
1 2 3 4 5 6kld = core_algos.kl_penalty( data.batch["old_log_probs"], data.batch["ref_log_prob"], kl_penalty=kl_penalty ) token_level_rewards = token_level_scores - beta * kld # 这部分的代码参考自 trl: https://github.com/huggingface/trl/blob/951ca1841f29114b969b57b26c7d3e80a39f75a0/trl/trainer/ppo_trainer.py#L837 kl_ctrl.update(current_kl=current_kl, n_steps=batch_size)计算新旧策略之间的 token-level KL 散度;根据动态系数 β(由
AdaptiveKLController控制)惩罚奖励;
compute_response_mask:响应掩码生成在 RLHF 中,输入序列通常为
[prompt + response],该函数的主要目的是提取生成部分的attention_mask,用于区分哪些 token 属于动作(需优化),哪些是 prompt(仅上下文),这样后续的 reward、advantage、loss 都只作用于 response 区域1 2 3 4 5def compute_response_mask(data): responses = data.batch["responses"] response_length = responses.size(1) attention_mask = data.batch["attention_mask"] return attention_mask[:, -response_length:]
compute_advantage:优势函数估计模块GAE 模式:标准 PPO 方式,通过时间差(TD-error)累计获得平滑优势:
$$ A_t = \delta_t + (\gamma \lambda) \delta_{t+1} + ... $$GRPO 模式(Group Relative Policy Optimization):基于多样本比较的相对优势:「GRPO, DAPO, GSPO, REINFORCE/RLOO 都移除了 Critic 模型」
- 对同一 Prompt 的多个 Response 比较相对得分;
- 不依赖 Critic;
- 常用于无 Value Model 的偏好学习。
其他模式,
core_algos.get_adv_estimator_fn(adv_estimator)来自定义优势函数
主要参数:
参数 类型 说明 dataDataProto 训练样本数据 adv_estimatorAdvantageEstimator 优势估计方法(GAE、GRPO、REINFORCE 等) gammafloat 折扣因子 lamfloat GAE 中的 λ 参数 num_repeatint 每个 prompt 生成样本数 norm_adv_by_std_in_grpobool 是否在 GRPO 中标准化优势 configAlgoConfig 算法配置 核心代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28if adv_estimator == AdvantageEstimator.GAE: advantages, returns = core_algos.compute_gae_advantage_return( token_level_rewards=data.batch["token_level_rewards"], values=data.batch["values"], response_mask=data.batch["response_mask"], gamma=gamma, lam=lam ) elif adv_estimator == AdvantageEstimator.GRPO: advantages, returns = core_algos.compute_grpo_outcome_advantage( token_level_rewards=data.batch["token_level_rewards"], response_mask=data.batch["response_mask"], index=data.non_tensor_batch["uid"], norm_adv_by_std_in_grpo=norm_adv_by_std_in_grpo, ) else: adv_estimator_fn = core_algos.get_adv_estimator_fn(adv_estimator) adv_kwargs = { "token_level_rewards": data.batch["token_level_rewards"], "response_mask": data.batch["response_mask"], "config": config, } if "uid" in data.non_tensor_batch: # 可选 adv_kwargs["index"] = data.non_tensor_batch["uid"] if "reward_baselines" in data.batch: # 可选 adv_kwargs["reward_baselines"] = data.batch["reward_baselines"] advantages, returns = adv_estimator_fn(**adv_kwargs) data.batch["advantages"] = advantages data.batch["returns"] = returns
RayPPOTrainer:主训练类参数表(包含构造函数)
参数名 类型 说明 configOmegaConf 配置文件(Hydra 格式) tokenizerHF Tokenizer 用于文本编码与解码 role_worker_mappingdict[Role, WorkerType] 各角色对应的 Ray Worker 类 resource_pool_managerResourcePoolManager GPU 资源池管理器 ray_worker_group_clsRayWorkerGroup WorkerGroup 类(默认) processoroptional 处理多模态数据的 Processor reward_fncallable 训练阶段奖励函数 val_reward_fncallable 验证阶段奖励函数 train_dataset,val_datasetDataset 数据集 collate_fncallable Batch 拼接函数 train_samplerSampler 数据采样器 device_namestr 设备名(cuda / cpu) 初始化逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37def __init__(...): self.tokenizer = tokenizer self.processor = processor self.config = config self.reward_fn = reward_fn self.val_reward_fn = val_reward_fn self.hybrid_engine = config.actor_rollout_ref.hybrid_engine assert self.hybrid_engine, "Currently, only support hybrid engine" if self.hybrid_engine: assert Role.ActorRollout in role_worker_mapping, f"{role_worker_mapping.keys()=}" self.role_worker_mapping = role_worker_mapping self.resource_pool_manager = resource_pool_manager self.use_reference_policy = need_reference_policy(self.role_worker_mapping) self.use_rm = need_reward_model(self.role_worker_mapping) self.use_critic = need_critic(self.config) self.ray_worker_group_cls = ray_worker_group_cls self.device_name = device_name if device_name else self.config.trainer.device self.validation_generations_logger = ValidationGenerationsLogger( project_name=self.config.trainer.project_name, experiment_name=self.config.trainer.experiment_name, ) # 若ref_in_actor为True,则参考策略将采用未应用LoRA的原来的 actor 模型。 self.ref_in_actor = ( config.actor_rollout_ref.model.get("lora_rank", 0) > 0 or config.actor_rollout_ref.model.get("lora_adapter_path") is not None ) # 在奖励中控制 KL 散度 # 目前不支持在损失中控制 KL 散度 if self.config.algorithm.use_kl_in_reward: self.kl_ctrl_in_reward = core_algos.get_kl_controller(self.config.algorithm.kl_ctrl) self._create_dataloader(train_dataset, val_dataset, collate_fn, train_sampler)核心要点:
- 目前只支持 Hybrid Engine 模式,当前还没有实现论文中的 Hybrid3D flow
- 加载日志工具,以及完成动态确定是否加载模型,例如 Reference Model、Reward Model 和 Critic Model
数据加载与分布式 DataLoader
该部分完成数据集创建、采样器创建、DataLoader 实例化以及计算总训练步数等
核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20def _create_dataloader(self, train_dataset, val_dataset, collate_fn, train_sampler): #调用前面的 create_rl_dataset()自动选择合适的数据集类,包含 RLHF 类型数据集以及 DynamicGenDataset 动态生成的数据集 train_dataset = create_rl_dataset(self.config.data.train_files, ...) val_dataset = create_rl_dataset(self.config.data.val_files, ...) #创建采样器 train_sampler = create_rl_sampler(self.config.data, self.train_dataset) #Dataloader 实例化 self.train_dataloader = StatefulDataLoader(dataset=self.train_dataset, batch_size=self.config.data.get("gen_batch_size", self.config.data.train_batch_size), num_workers=num_workers, drop_last=True, collate_fn=collate_fn, sampler=train_sampler) #StatefulDataLoader 是 VERL 自定义版本,支持: # - 保存/加载迭代状态(断点恢复); # - 动态调整采样; #计算总训练步数,并写回配置供优化器调度。 total_training_steps = len(self.train_dataloader) * self.config.trainer.total_epochsWorker 初始化
负责用 Ray 初始化所有分布式工作进程(Worker Groups)。为不同角色(Actor、Critic、Reference、Reward Model)分配资源池并远程启动模型实例,实现多 GPU 并行执行。
核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55def init_workers(self): self.resource_pool_manager.create_resource_pool() #按配置在各节点组装 Ray 资源配额(GPU 数、放置策略) # 把每个角色(ActorRollout/Critic/Ref/RM)绑定到其进程入口类和初始化参数。Ray 会在远端据此构造对象。 self.resource_pool_to_cls = {pool: {} for pool in self.resource_pool_manager.resource_pool_dict.values()} # 注册各角色到资源池 rp = self.resource_pool_manager.get_resource_pool(Role.ActorRollout) self.resource_pool_to_cls[rp][str(Role.ActorRollout)] = RayClassWithInitArgs( cls=self.role_worker_mapping[Role.ActorRollout], config=self.config.actor_rollout_ref, role=str(Role.ActorRollout), ) if self.use_critic: rp = self.resource_pool_manager.get_resource_pool(Role.Critic) self.resource_pool_to_cls[rp][str(Role.Critic)] = RayClassWithInitArgs( cls=self.role_worker_mapping[Role.Critic], config=omega_conf_to_dataclass(self.config.critic), ) if self.use_reference_policy: rp = self.resource_pool_manager.get_resource_pool(Role.RefPolicy) self.resource_pool_to_cls[rp][str(Role.RefPolicy)] = RayClassWithInitArgs( self.role_worker_mapping[Role.RefPolicy], config=self.config.actor_rollout_ref, role=str(Role.RefPolicy), ) if self.use_rm: rp = self.resource_pool_manager.get_resource_pool(Role.RewardModel) self.resource_pool_to_cls[rp][str(Role.RewardModel)] = RayClassWithInitArgs( self.role_worker_mapping[Role.RewardModel], config=self.config.reward_model, ) # spawn WorkerGroup wg_kwargs = {"device_name": self.device_name} all_wg = {} for rp, class_dict in self.resource_pool_to_cls.items(): worker_dict_cls = create_colocated_worker_cls(class_dict=class_dict) #把同一进程内的多个角色封装到一个“共址容器类”,减少重复 CUDA/通信上下文。Colocate 就是放在一起共享 GPU wg_dict = self.ray_worker_group_cls(resource_pool=rp, ray_cls_with_init=worker_dict_cls, **wg_kwargs) all_wg.update(wg_dict.spawn(prefix_set=class_dict.keys())) # 依次 init_model;Actor 模型是肯定有的 # 让每个远端角色加载权重与优化器状态。Actor 放最后,同时给 vLLM 预估 KV cache 留空间。 if self.use_critic: self.critic_wg = all_wg[str(Role.Critic)]; self.critic_wg.init_model() if self.use_reference_policy and not self.ref_in_actor: self.ref_policy_wg = all_wg[str(Role.RefPolicy)]; self.ref_policy_wg.init_model() if self.use_rm: self.rm_wg = all_wg[str(Role.RewardModel)]; self.rm_wg.init_model() self.actor_rollout_wg = all_wg[str(Role.ActorRollout)]; self.actor_rollout_wg.init_model() # 异步 rollout self.async_rollout_mode = (self.config.actor_rollout_ref.rollout.mode == "async") if self.async_rollout_mode: from verl.experimental.agent_loop import AgentLoopManager #异步,统筹 rollout 与 RM 调度,提升吞吐。 self.async_rollout_manager = AgentLoopManager(config=self.config, worker_group=self.actor_rollout_wg, rm_wg=self.rm_wg)保存模型检查点
_save_checkpoint()1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20def _save_checkpoint(self): root = os.path.join(self.config.trainer.default_local_dir, f"global_step_{self.global_steps}") #以 global_step_X 为目录组织快照。 actor_local = os.path.join(root, "actor") actor_remote = (None if self.config.trainer.default_hdfs_dir is None else os.path.join(self.config.trainer.default_hdfs_dir, f"global_step_{self.global_steps}", "actor")) #调用远端 save_checkpoint,由各 Worker 在其进程内落盘(可选同时写远端存储)。 self.actor_rollout_wg.save_checkpoint(actor_local, actor_remote, self.global_steps, max_ckpt_to_keep=self.config.trainer.get("max_actor_ckpt_to_keep")) if self.use_critic: critic_local = os.path.join(root, str(Role.Critic)) critic_remote = (None if self.config.trainer.default_hdfs_dir is None else os.path.join(self.config.trainer.default_hdfs_dir, f"global_step_{self.global_steps}", str(Role.Critic))) self.critic_wg.save_checkpoint(critic_local, critic_remote, self.global_steps, max_ckpt_to_keep=self.config.trainer.get("max_critic_ckpt_to_keep")) torch.save(self.train_dataloader.state_dict(), os.path.join(root, "data.pt")) #保存 dataloader.state_dict(),以便断点续训恢复迭代位置。 with open(os.path.join(self.config.trainer.default_local_dir, "latest_checkpointed_iteration.txt"), "w") as f: f.write(str(self.global_steps)) #写 latest_checkpointed_iteration.txt 便于“自动恢复最新”读取模型检查点
_load_checkpoint三种模式:
disable(不恢复)、auto(找最新)、resume_path(指定路径)。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23def _load_checkpoint(self): if self.config.trainer.resume_mode == "disable": self.actor_rollout_wg.load_checkpoint(None); return 0 base = self.config.trainer.default_local_dir if not os.path.isabs(base): base = os.path.join(os.getcwd(), base) #找最新的模型检查点 latest = (find_latest_ckpt_path(base) if self.config.trainer.resume_mode == "auto" else os.path.join(os.getcwd(), self.config.trainer.resume_from_path)) #解析 step 编号,设置 self.global_steps,分别让 Actor/Critic 远端加载。 self.global_steps = int(str(latest).split("global_step_")[-1]) actor_path = os.path.join(latest, "actor") critic_path = os.path.join(latest, str(Role.Critic)) self.actor_rollout_wg.load_checkpoint(actor_path, del_local_after_load=self.config.trainer.del_local_ckpt_after_load) if self.use_critic: self.critic_wg.load_checkpoint(critic_path, del_local_after_load=self.config.trainer.del_local_ckpt_after_load) #本地恢复 dataloader 状态 data_path = os.path.join(latest, "data.pt") if os.path.exists(data_path): self.train_dataloader.load_state_dict(torch.load(data_path, weights_only=False))
_start_profiling/_stop_profiling统一向各远端 Worker 发送“开始/结束”性能采集命令,保证时间窗一致,便于跨进程关联分析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21def _start_profiling(self, do_profile: bool) -> None: """Start profiling for all worker groups if profiling is enabled.""" if do_profile: self.actor_rollout_wg.start_profile(role="e2e", profile_step=self.global_steps) if self.use_reference_policy: self.ref_policy_wg.start_profile(profile_step=self.global_steps) if self.use_critic: self.critic_wg.start_profile(profile_step=self.global_steps) if self.use_rm: self.rm_wg.start_profile(profile_step=self.global_steps) def _stop_profiling(self, do_profile: bool) -> None: """Stop profiling for all worker groups if profiling is enabled.""" if do_profile: self.actor_rollout_wg.stop_profile() if self.use_reference_policy: self.ref_policy_wg.stop_profile() if self.use_critic: self.critic_wg.stop_profile() if self.use_rm: self.rm_wg.stop_profile()
_balance_batch对 batch 进行优化操作对单个控制器上的数据进行重新排序,使得每个数据并行层级获得相近的总token数。
这个主要是加快流水线并行的效率,普通的 batch 拆分是“按样本数量”平均分,但语言模型的样本长度差异极大。→ 结果:某些 GPU 处理很长序列,其他 GPU 很快就闲置,导致 pipeline bubble(GPU 等待)。
所以为了减少 GPU 的并行等待(DP)呢,就需要重新调整一下 batch,让每张卡处理的 token 数尽可能相等,从而提升吞吐率,减少因序列长度差异带来的等待与 GPU 空转。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16def _balance_batch(self, batch: DataProto, metrics, logging_prefix="global_seqlen", keep_minibatch=False): attn = batch.batch["attention_mask"] seqlen = attn.view(attn.shape[0], -1).sum(-1) #以 attention_mask 统计每条样本 token 数 seqlen = calculate_workload(seqlen) # 统计量或平滑 ws = self.actor_rollout_wg.world_size parts = ( # 可选“按小批保持”或整体划分 _decoupled_parts(seqlen, ws, self.config.actor_rollout_ref.actor.get("ppo_mini_batch_size")) if keep_minibatch else get_seqlen_balanced_partitions(seqlen, k_partitions=ws, equal_size=True) #get_seqlen_balanced_partitions 把样本划分到各 DP rank,使总 token接近一致 )#keep_minibatch, 按小批次单独平衡,而不是对整个 batch 平衡 for i, p in enumerate(parts): # 小的在两端,减少流水线气泡 p.sort(key=lambda x: (seqlen[x], x)) parts[i] = p[::2] + p[1::2][::-1] # 按长度从短到长排序,然后两端交错排列(短、长、短、长…) # 这主要是流水线并行的优化,流水线并行时,让长序列与短序列交替,有助于保持稳定的 GPU 利用率(减少 bubble) idx = torch.tensor([j for p in parts for j in p]) batch.reorder(idx) #根据索引重新排序。数据将通过调度函数自动进行均等分区。 metrics.update(log_seqlen_unbalance(seqlen_list=seqlen, partitions=parts, prefix=logging_prefix))
compute_rollout_importance_weights_and_add_to_batch计算重要性采样(IS)权重,并应用拒绝采样以解决推演与训练不匹配的问题。前提:有
rollout_log_probs,并设置了阈值。该函数主要是修正策略比率(重要性比率),在优势估计前完成这个步骤,包含 1.计算
IS weights = π_new(a|s) / π_rollout(a|s)的近似;2.拒绝极端情况(mask|veto);3.可选加入权重来修正期望;
compute_rollout_importance_weights(...)通常$r=exp(log\pi_{rollout}-log\pi_{old})$计算比值作为重要性权重,随后按level/mode/threshold 做裁剪、平滑或掩码1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21def compute_rollout_importance_weights_and_add_to_batch(self, batch: DataProto) -> tuple[DataProto, dict]: thr = self.config.algorithm.get("rollout_is_threshold", None) if thr is not None and thr > 0 and "rollout_log_probs" in batch.batch: #产生重要性权重 w,更新后的 response_mask,以及 mismatch 统计 w, new_mask, m = compute_rollout_importance_weights( old_log_prob=batch.batch["old_log_probs"], #旧策略 prob rollout_log_prob=batch.batch["rollout_log_probs"], #新策略prob response_mask=batch.batch["response_mask"], #掩码去掉prompt 和 padding rollout_is_level=self.config.algorithm.rollout_is_level, # token级还是序列级? rollout_is_mode=self.config.algorithm.rollout_is_mode, # 仅掩码、上下截断 rollout_is_threshold=self.config.algorithm.rollout_is_threshold, #clip 阈值 rollout_is_threshold_lower=self.config.algorithm.get("rollout_is_threshold_lower", None), #clip 阈值 rollout_is_veto_threshold=self.config.algorithm.get("rollout_is_veto_threshold", None), #极端值的处理 ) batch.batch["response_mask"] = new_mask # 无论是否真正用权重,一律更新掩码。这保证被拒绝的 token 不参与损失与归一化,防止分母泄漏。 #只有 rollout_is=True 才把 w并入 batch 用于损失加权;否则仅做拒绝与监控。 if self.config.algorithm.get("rollout_is", False): # 仅在开启时并入权重 batch = batch.union(w) return batch, m #返回 m(metrics):用于监控 rollout 与 old 的失配程度,便于先“只看指标不施加权重”的安全过渡 return batch, {}训练循环
fit()-PPO 的训练循环其流程可以转化与 PPO 理论截断进行匹配
PPO 理论阶段 代码对应实现 作用 ① 收集样本(Rollout) generate_sequences()用当前 Actor 策略生成回复 ② 计算奖励(Reward) compute_rm_score()+reward_fn()使用奖励模型与规则综合奖励 ③ KL 惩罚 apply_kl_penalty()对策略偏离程度施加惩罚项 ④ 优势估计 compute_advantage()用 GAE 或其他方法计算 Advantage ⑤ 更新 Critic update_critic()拟合价值函数 (V_\phi(s)) ⑥ 更新 Actor update_actor()用 PPO 目标更新策略参数 ⑦ 验证与日志 _validate()+logger.log()在周期性迭代后评估模型 PPO 中需要训练的模型是 Critic 模型和 Actor 模型
KL 惩罚项的引入 $r_t = r_t - \beta , KL[\pi_t||\pi_{ref}]$
GAE 计算优势:$A_t = \delta_t + (\gamma \lambda)\delta_{t+1} + \dots$
系数 含义 控制效果 $\gamma$ (gamma) 折扣未来奖励 控制长期性:未来奖励对当前决策的影响程度,回到了经典的问题,这里再重申一下 exploration(探索) 「只探索->行为随机无法收敛」和 exploitation(利用) 「只利用,陷入局部最优解,无法改进」 $\lambda$ (lambda) GAE 平滑参数 控制偏差-方差权衡:λ→1 近似 MC,λ→0 近似 TD(0)
对于 Critic 模型的更新目标是:最小化 时序差分TD 误差,$L_V = \frac{1}{2} (V_\phi(s_t) - \hat{R}_t)^2$
之前也讲过,Critic 的存在是为了给 Actor 提供一个基线 (baseline),从而让策略梯度更稳定、方差更低。
在强化学习中,我们希望 Vϕ(s) 尽可能接近真实的回报:$R_t = r_t + \gamma r_{t+1} + \gamma^2 r_{t+2} + \dots$,由于未来回报难以直接计算,我们用“估计的目标回报” $(\hat{R}_t)$代替,例如用 GAE (Generalized Advantage Estimation) 或 n-step TD 计算得到:
$$ \hat{R}_t = r_t + \gamma (1 - \lambda) V*\phi(s_{t+1}) + (\gamma \lambda) (r_{t+1} + \gamma V_\phi(s_{t+2})) + \dots $$这个 $(\hat{R}_t)$相当于 Critic 应该输出的“真值”,而 $Vϕ(s_t)$是 Critic 当前的预测。
推导一下啊哈这个损失函数,我们希望让 $Vϕ$预测的值尽可能接近 $(\hat{R}_t)$,那么最直接的方式就是最小化两者的均方误差MSE。
Crtic 在给定状态输出更准确的价值估计对于计算 Advantage 至关重要,$A_t = \hat{R}_t - V_\phi(s_t)$
Actor 模型的更新目标,已经提到很多次啦~
$$ L^{CLIP} = -\mathbb{E}_t [\min(r_t A_t, \text{clip}(r_t, 1-\epsilon, 1+\epsilon)A_t)] $$其中 ($r_t = \frac{\pi*\theta(a_t|s_t)}{\pi_{old}(a_t|s_t)}$)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134def fit(self): """ PPO 主训练循环:驱动器节点负责调度数据流,WorkerGroup 在 GPU 上执行计算。 """ from verl.utils.tracking import Tracking from omegaconf import OmegaConf logger = Tracking(project_name=self.config.trainer.project_name, experiment_name=self.config.trainer.experiment_name, default_backend=self.config.trainer.logger, config=OmegaConf.to_container(self.config, resolve=True)) global_steps = 0 # perform validation before training # currently, we only support validation using the reward_function. # 训练前先测试一下模型的性能(按照奖励函数) if self.val_reward_fn is not None: val_metrics = self._validate() # 若配置了验证奖励函数,则先在验证集上测试当前模型性能(baseline) pprint(f'Initial validation metrics: {val_metrics}') for epoch in range(self.config.trainer.total_epochs): for batch_dict in self.train_dataloader: #每个 batch_dict 是从强化学习数据集中加载的一批 prompt metrics = {} batch: DataProto = DataProto.from_single_dict(batch_dict) #封装为一个 DataProto(verl 的数据集格式) # batch = batch.to('cuda') # pop those keys for generation gen_batch = batch.pop(batch_keys=['input_ids', 'attention_mask', 'position_ids']) #提取用于生成的输入字段,形成新的 `batch gen_batch` # 生成样本(rollout) with Timer(name='gen', logger=None) as timer: gen_batch_output = self.actor_rollout_wg.generate_sequences(gen_batch) #调用远程 worker group(actor model) 的 generate_sequences()。 metrics['timing/gen'] = timer.last batch = batch.union(gen_batch_output) #合并生成结果,这里 actor 在GPU 上生成了一批新的回复,生成完后我们将其合并起来 if self.use_reference_policy: #如果有使用参考模型的话,那么就需要开始计算参考模型的 log_prob,这一步的主要目的是为了计算 PPO 中的 KL 散度 # compute reference log_prob with Timer(name='ref', logger=None) as timer: ref_log_prob = self.ref_policy_wg.compute_ref_log_prob(batch) #调用远程 worker(reference model) 的 compute_ref_log_prob 完成 ref模型的 log 计算 batch = batch.union(ref_log_prob) #合并各个 batch 结果 metrics['timing/ref'] = timer.last # compute values # Critic模型来计算value with Timer(name='values', logger=None) as timer: values = self.critic_wg.compute_values(batch) #调用远程 worker(critic model) 的 compute_values 来计算 value batch = batch.union(values) #同样合并结果 metrics['timing/values'] = timer.last #=====这上面这部分,其实都是准备阶段,为了计算优势,我们需要先准备好这些结果======= #因此可以并行进行计算,verl 使用 ray 来调控各个节点,各个节点内部是并行的 #====================================================================== #下面我们需要开始整合结果,包括计算奖励、KL 惩罚的加入还有进行优势估计了 with Timer(name='adv', logger=None) as timer: # 计算分数。支持基于模型和基于函数的。我们首先使用奖励模型计算分数。然后,我们调用reward_fn来结合奖励模型和基于规则的结果。 if self.use_rm: # 若有奖励模型(RM),先获得基于模型的奖励; reward_tensor = self.rm_wg.compute_rm_score(batch) batch = batch.union(reward_tensor) # reward_fn 再融合规则式奖励; reward_tensor = self.reward_fn(batch) batch.batch['token_level_scores'] = reward_tensor # 应用 KL 惩罚 r_t = r_t - βKL[π_t || π_ref] batch, kl_metrics = apply_kl_penalty(batch, kl_ctrl=self.kl_ctrl_in_reward, kl_penalty=self.config.algorithm.kl_penalty) metrics.update(kl_metrics) # 调用 compute_advantage() 计算 PPO 的优势,algorithm.adv_estimator可以用为 GAE。 batch = compute_advantage(batch, self.config.algorithm.gamma, #<---- self.config.algorithm.lam, #<---- adv_estimator=self.config.algorithm.adv_estimator) metrics['timing/adv'] = timer.last # 更新模型(更新 Critic) # Critic 更新目标:最小化 TD 误差, if self.use_critic: with Timer(name='update_critic', logger=None) as timer: critic_output = self.critic_wg.update_critic(batch) metrics['timing/update_critic'] = timer.last critic_output_metrics = reduce_metrics(critic_output.meta_info['metrics']) metrics.update(critic_output_metrics) # 当 Critic 预热完毕后才更新 Actor; # 如果 Critic 不准的话,会严重影响到 Adavantage 的计算,在训练的前若干步,只更新 Critic,不更新 Actor,确保 Critic的稳定 if self.config.trainer.critic_warmup <= global_steps: #只有当步数 > critic_warmup 后,Actor 才开始参与训练 # 更新 Actor(策略优化) with Timer(name='update_actor', logger=None) as timer: actor_output = self.actor_rollout_wg.update_actor(batch) metrics['timing/update_actor'] = timer.last actor_output_metrics = reduce_metrics(actor_output.meta_info['metrics']) metrics.update(actor_output_metrics) # 下面主要是完成验证和日志记录等操作; if self.val_reward_fn is not None and (global_steps + 1) % self.config.trainer.test_freq == 0: with Timer(name='testing', logger=None) as timer: val_metrics: dict = self._validate() #定期调用 _validate();来看看模型在测试集上的性能怎么样 val_metrics = {f'val/{key}': val for key, val in val_metrics.items()} metrics['timing/testing'] = timer.last metrics.update(val_metrics) # collect metrics data_metrics = compute_data_metrics(batch=batch) metrics.update(data_metrics) # TODO: make a canonical logger that supports various backend logger.log(data=metrics, step=global_steps) #计算训练统计量并写入日志系统。 # 这里就是保存模型检查点了 if self.config.trainer.save_freq > 0 and (global_steps + 1) % self.config.trainer.save_freq == 0: actor_local_path = os.path.join(self.config.trainer.default_local_dir, 'actor', f'global_step_{global_steps}') actor_remote_path = os.path.join(self.config.trainer.default_hdfs_dir, 'actor') self.actor_rollout_wg.save_checkpoint(actor_local_path, actor_remote_path) if self.use_critic: critic_local_path = os.path.join(self.config.trainer.default_local_dir, 'critic', f'global_step_{global_steps}') critic_remote_path = os.path.join(self.config.trainer.default_hdfs_dir, 'critic') self.critic_wg.save_checkpoint(critic_local_path, critic_remote_path) global_steps += 1 # perform validation after training if self.val_reward_fn is not None: val_metrics = self._validate() pprint(f'Final validation metrics: {val_metrics}')
总结 verl 实现的 PPO 的逻辑流程
阶段 主要函数 关键数据 数学意义 生成 generate_sequencesresponses, old_log_probs 从 $pi_{old}$采样 打分 compute_rm_score+compute_rewardtoken_level_scores 获得 $r_t$ 奖励惩罚 apply_kl_penaltytoken_level_rewards 加入 $−β·KL$ 优势 compute_advantageadvantages, returns 计算 $A\_t 、 R\_t$ Critic 更新 update_criticvalues, returns 最小化 MSE Actor 更新 update_actorold_log_probs, advantages 最大化 clip 目标
RL的一些 tricks
经常会问到 RL 和 SFT 有啥区别,RL 那么强,SFT 是不是没啥用了这里的,可以做一个简单的分析,首先捋清RL/SFT的 loss 逻辑:
RL 的单个 step 的 loss 是$l_t(\theta)=-A_t·log\pi_\theta(a_t)$
LLM 中的每个 step 输出一个 logits 向量,对这个 logits 做一次 softmax 函数就得到了每个 token 被选中的概率。
若记$z$为logits 向量,那么$\pi$=softmax(z),令 loss 对 logits 向量 $z$ 求导得: $\nabla_{z_{t,k}} \ell_t = A_t \cdot (\pi_\theta(k) - \mathbf{1}[k=a_t])$
- 当 $k=a_t$(被选中的 token) $\pi(k) - 1 < 0$ 所以如果 $A_t>0$,这个 logit 被推高
- 当 $k \neq a_t$(未选中的 token) $\pi(k) - 0 > 0$ 所以如果 $A_t>0$,这个 logit 被推低
换言之,RL 的优化动力可以概括为:如果 Advantage 大于零,logits 向量在当前 token 维度的梯度是负数,这个 logit 会朝增大的方向优化,logits 向量在其他所有维度的梯度都是正数,剩余的 vocab_size - 1 个 logit 都会朝着减小的方向优化;反之亦然。
那么对 SFT是怎么样的呢?SFT 的 loss 是目标分布和模型分布的交叉熵函数(极大似然估计/交叉熵)。
给定目标分布 $q$(通常是 one-hot),模型预测分布 $\pi_\theta$,
交叉熵定义为: $\ell^{\text{CE}}(q, \pi_\theta) = - \sum_{k} q(k) \, \log \pi_\theta(k)$
在 SFT 中,我们有一个 参考答案 $a_t$,所以目标分布是: $q(k) = \mathbf{1}[k=a_t]$
综上, SFT 的单个 token 的loss 是:
$\ell_t^{\text{SFT}}(\theta) = - \sum_k \mathbf{1}[k=a_t] \log \pi_\theta(k) = - \log \pi_\theta(a_t)$
令 loss 对 logits 向量 $z$ 求导得: $\nabla_{z_{t,k}} \ell_t^{\text{SFT}} = \pi_\theta(k) - \mathbf{1}[k=a_t]$
SFT loss 和 RL loss 在形式上没有区别,SFT 仅仅是 RL advantage 全为 1 时的一个特例罢了。更准确的说法是:SFT 是一种全部样本都为 off_policy,只计算正例 loss,且 advantage 均为 1 的 RL。
RL 中的 $A_t$ 并不是一个加权常数,$A_t$的“完整”写法是 $A_t(\pi)$,这是一个随着$\pi$的变化而发生变化的函数,更麻烦的是,这是一个黑箱函数,无法对 $A_t(\pi)$求导,而只能通过采样来评估,采样必然存在误差,进而导致训练不稳定。与之相对的,SFT 的 $A_t$ 实打实就是常数 1。
–> loss 形式一致但训练差异性较大,说明我们应该聚焦在两种训练方式的数据分布究竟有何差异。
那为什么 RL 训练那么不稳定?不如 SFT 稳定呢?
AI Infra 方面:这个涉及到 RL 训练框架的底层了,系统级的 BUG 来自RL 框架的各个方面,TP,DP,PP 确定鲁棒吗?推理和训练过程的 temperature/top_p/top_k 一致吗?
数据方面,SFT 的数据一般会经过一个复杂的流程,比如说多轮过滤,以及可能存在的人工 review;RL 的往往只是一个 reward model 去打分之类的,RL 的每条数据都很重要,如果这些样本没有探索到有效的信息,这条数据就成为了“毒数据”,模型便向崩溃进了一步;
那如何来提高 RL 训练过程的稳定性呢?
entropy collapse:训练的时候加不加 entropy loss,至今仍未达成共识;
CILP 裁剪,至少一半的强化工作都围绕 clip 做文章,这些工作分析的非常有道理,实际用起来却乏善可陈;
Token Mask:对高熵 / 低熵 token 做特殊的逻辑,或鼓励某些 token 的学习,或阻止某些 token 的更新,也是重点雕花区域;
Reward Shape:
- 控制 reward 样本中 0 / 1 的分布在一个区间范围内;
- 用 pass@K 代替 pass@1 作为优化目标;
- 用 test case 的通过率作为 reward;
- 长度惩罚;
- 大致思路是想稳定奖励,减少奖励之间的方差;
训推一致性:目前比较热的话题,以 tis、icepop 最为火热,但可以说和算法没啥关系,全看 infra 功底,比如之前有一篇就是把 verl 里面的 vllm 推理部分和模型部分拎出来了,效果就好了很多;
- 推荐阅读:Your Efficient RL Framework Secretly Brings You Off-Policy RL Training
- 梯度上升计算时:$\theta \gets \theta + \mu \cdot \mathbb{E}_{\underbrace{a \sim{\pi}(\theta)}_{rollout}} [R(a)\cdot \underbrace{\nabla_\theta \log {\pi}(a, \theta)}_{\tiny{training}}].$
- 但是这里 rollout 和 training 的模型都不一样「训练和采样的模型不一样」:$\theta \gets \theta + \mu \cdot \mathbb{E}_{a \sim \textcolor{red}{\pi_{\text{sampler}}}(\theta)} [R(a)\cdot \nabla_\theta \log \textcolor{blue}{\pi_{\text{learner}}}(a, \theta)].$
- 导致了分布的不一样,那么怎么解决?
- 把$\mathbb{E}_{a \sim \textcolor{red}{\pi_{\text{sampler}}}(\theta)} [R(a)\cdot \nabla_\theta \log \textcolor{blue}{\pi_{\text{learner}}}(a, \theta)]$转换为$\mathbb{E}_{a \sim \textcolor{red}{\pi_{\text{sampler}}}(\theta)} \Bigl[\frac{\textcolor{blue}{\pi_{\text{learner}}}(a, \theta)}{\textcolor{red}{\pi_{\text{sampler}}}(a, \theta)} \cdot R(a)\cdot \nabla_\theta \log \textcolor{blue}{\pi_{\text{learner}}}(a, \theta)\Bigr].$
- $\mathbb{E}_{a \sim \textcolor{red}{\pi_{\text{sampler}}}(\theta)} \Bigl[\underbrace{\min\Bigl(\frac{\textcolor{blue}{\pi_{\text{learner}}}(a, \theta)}{\textcolor{red}{\pi_{\text{sampler}}}(a, \theta)}, C\Bigr)}_{\text{truncated importance ratio}} \cdot R(a) \cdot \nabla_\theta \log \textcolor{blue}{\pi_{\text{learner}}}(a, \theta)\Bigr]$ 增加一个这样的截断性重要性比例
为什么会熵增?按理说训练是一个确定性增加的过程应该熵减,但是模型确实在增,那就说明训练的过程中:高概率 token 常被当作负例,或者是低概率 token 被常当作正例;
为什么会熵减过快?rollout 多样性差呗。是不是调整下 rollout temperature 和 rollout prompt 要比加一个 entropy loss 更合理些;
总而言之,各种技巧的本质其实都是为了帮助模型寻找一个适合它的训练数据的分布,因此,分析 rollout 数据分布的变化,优先级要始终领先于尝试引入某个稳定训练的技巧。 这些技巧在稳定某次训练的同时,也会掩盖训练崩溃的原因。但同时,若某个技巧确实有用,也可以反过来推哪种数据分布“有利于/有损于”模型的学习:例如, off_policy 和训推不一致会引起崩溃,是不是在间接说明“一个整体上与模型分布很接近,但却在个别 token 上和模型分布差异很大的样本”可能是一种不太适合模型的数据。
引入训练技巧必然会引起训练数据分布的变化,有些分布的变化是在我们预期之内的,有些分布的变化则是我们预期之外且不知情的。 CISPO 的作者就曾分享过:在 off_policy 的时候, 被 clip 掉的 token 是具有多重分布的,概率值与当前模型的分布不一致只是其所具有的一个分布,“概率低但影响 long cot涌现”则是这些 token 的另外一个分布。作为训练者,我们往往不能留意到所有分布的变化,从而总结出一些错误的结论。
那么,RL 的没有任何副作用的 trick 是什么呢?——洗数据,高质量的数据获取很重要
先说数据吧,今年大家普遍进入了 post train 深水区之后(从 math、gsm8k 进阶到 aime、imo),一个很严重的问题就是:训模型者看不懂数据了,没有办法通过肉眼看解题过程来判断数据质量了。而训模型者日常批量清洗数据的手段,往往都存在一个问题:“无法区分难题和错题”。
- 难题有什么特点?模型多次采样后,屡屡犯错,偶尔灵机一动,做对了。
- 错题有什么特点?模型多次采样后,基本都做对,但是因为和 ground_truth 不一致屡屡被判错,偶尔脑子抽风做错了,好巧不巧和 ground_truth 一致了。
对于错题,不要以为错题的答案是离谱到一眼就能看出来的那种,事实上,错题往往是十分接近 ground_truth 且非常具有迷惑性的。这里举几个例子:
- 一张票 2 块钱,9 块钱能买几张票?我们以为错题的答案会是 356 张这种离谱的数字,其实是 4.5 张;
- 一个一元七次方程,错题的答案给了 3 个实数解,我们 review 的时候,反代入进去发现是对的,留下了这道题目。但在训练的时候,模型拿着 3 个实数解和 4 个复数解,高高兴兴的去找 reward_model 要奖励的时候,反手被打了 0 分,这对模型是多大的心理阴影呀。
- ……
目前的开源 RL 数据的质量真的是一言难尽。要么请专业的硕博理科生去标注,要么用启发式的规则去清洗,在不够干净的数据上只能得到错误的实验结论。
对于reward model,千万不要以为所谓的 rule_based reward model 真的就是靠 rule 来打分的,或者是靠 math_verify 这种规则库 。有很多情况下,靠 rule 几乎无解:
- 问题是盈利__%?标准答案是 96,而模型输出了“盈利96%”,模型活该拿 0 分吗?
- 标准答案是 3.14,模型输出了 $\pi$、圆周率、3.1415926,模型活该拿 0 分吗?
- ……
reward 要准,有研究者建议使用 generate reward(LLM),而且得是能力巨强的那种。这个模型需要读的懂题目要求的输出格式、 ground_truth 的等价变换,以及各种复杂的高阶公式。除了较强的知识能力外,模型还要具备很强的指令遵循能力,否则它容易自己亲自下场解题。
参考资料
[1] PPO for LLMs: A Guide for Normal People: Cameron R. Wolfe, Ph.D. https://cameronrwolfe.substack.com/p/ppo-llm
[2] RL 杂谈,作者: ybq, https://zhuanlan.zhihu.com/p/1966609550032475890
[3] Your Efficient RL Framework Secretly Brings You Off-Policy RL Training, fengyao et al. https://fengyao.notion.site/off-policy-rl
[4] 如何理解verl框架中那些Batch Size,https://zhuanlan.zhihu.com/p/1944151286984471847
[5] RL 学习笔记 #01 基本概念 https://hwcoder.top/RL-Note-1
[6] Putting RL back in RLHF:https://huggingface.co/blog/putting_rl_back_in_rlhf_with_rloo
[7] REINFORCE: Easy Online RL for LLMs: https://cameronrwolfe.substack.com/p/reinforce




