深入verl源码:分布式大模型RLHF训练框架核心机制解析

发布时间:2026/10/18 16:25:57
深入verl源码:分布式大模型RLHF训练框架核心机制解析 简介面向希望七天掌握 verlVolcano Engine Reinforcement Learning for LLMs框架的开发者这份源码包聚焦从单机部署到千万级参数模型训练的完整链路覆盖环境准备、首个强化学习实验、性能优化与调参、分布式训练部署以及生产级最佳实践。全套资源共 1058 个文件以 Python 源码578 个 .py、Shell 脚本212 个 .sh、YAML 配置72 个 .yaml为主体辅以 Markdown 与 RST 文档、Dockerfile 及工具配置可支撑从阅读、动手实验到集群部署的各个环节压缩包仅 2.64MB轻量易用。目前已有 142 人学习。内容包含多种框架组合的 DockerfilevLLM、SGLang、Megatron-Core 等、可复用的训练脚本与调参清单帮助读者避开部署雷区快速跑通 PPO 训练并逐步走向大规模分布式实践。1. verl 这个框架到底解决什么问题7天掌握源码的阅读路线大模型强化学习训练跑通不难难的是训练跑崩之后你只能对着报错日志猜。verl 是一个面向大规模 RLHF/RLVR 训练的开源分布式框架它把 rollout 生成、奖励计算、优势估计、策略更新拆成一组可替换的模块化流水线底层用 vLLM 承接文本生成、用 Ray 做跨节点资源调度所以你在单卡上调试的逻辑和千卡集群上跑的是同一套代码。本文预设你手里已经有 verl 源码目标是 7 天内从「能跑通示例」推进到「敢改源码」知道一条数据从 prompt 变成 loss 的完整流向清楚每个关键张量在哪产生、在哪被消费并且不推翻框架结构就能替换自己的奖励逻辑。适合已经会做大模型 SFT、对 PPO 或 GRPO 有基础概念、想深入读框架源码的工程师。2. 先读结构再读实现verl 源码的目录导航与核心抽象读源码最忌讳按字母序翻文件夹verl 这种框架级仓库必须先建立「训练流程地图」再往代码里填地图坐标。我见过太多人前三天把所有文件打开一遍最后什么也没记住。换一种方式先回答「一条 prompt 进来到 loss 出去经历哪几个阶段」再回答「每个阶段对应源码里哪几个文件」两天之后你就能在框架里自由指路。2.1 训练周期拆解从 prompt 到 loss 的四个阶段verl 的训练周期本质上是强化学习经典闭环但它的实现把不同阶段分给了不同类型的 worker。第一阶段是 rollout策略模型拿到一批 prompt 生成 responseverl 默认不自己写自回归采样循环而是把生成任务交给 vLLM 这类高性能推理引擎因为纯 PyTorch 逐 token 生成在大规模 RL 训练里慢到不可接受。第二阶段是奖励计算响应生成后需要给每条 response 打分可以调用规则奖励函数也可以加载一个独立的奖励模型甚至两者混合。第三阶段是优势估计把奖励序列折算成每个 token 的优势值PPO 走 GAEGRPO 走组内归一化这决定了后面的 loss 方向是否正确。第四阶段是策略更新用优势值作为权重更新策略网络同时用参考模型约束策略偏移防止奖励作弊。表面上这是四个阶段实际源码里至少有七到八个执行点因为 rollout 需要把最新策略权重先同步给生成引擎更新完成之后又要让生成引擎拿到新权重这套同步在朴素实现里是串行瓶颈。verl 的架构和朴素闭环的差别就在这它把 actor 的 FSDP 训练流、rollout worker 的 vLLM 生成流安排成可重叠的异步管线计算和通信并行推进而不是训一步等一步。读源码一旦抓住「权重从 actor 流向 rollout、token 和 logprob 从 rollout 流回 actor」这条主线剩下的模块都是在主线周围长出来的辅助设施。2.2 WorkerGroup 与 DataProtoverl 里必须搞懂的两个抽象我第一次翻开 verl 源码时最先注意到的不是训练循环而是一个叫 WorkerGroup 的封装。它的作用很直接把分布在一组机器、一组 GPU 上的 worker 打包成一个类似单机对象的调用入口trainer 侧根本不关心底层有几台机器只需要对 WorkerGroup 说「去执行 compute_advantage」这个调用就会被广播到所有 worker 上。从代码形态上看它长得像一个简化版 Ray ActorPool 的封装# 简化自 verl/workers/worker_group.py 的核心调用形态 class WorkerGroup: def __init__(self, world_size, worker_cls, args): # 使用 Ray 拉起 world_size 个相同类型的 worker self._worker_group ray.util.ActorPool([ self._create_actor(worker_cls, args) for _ in range(world_size) ]) def call(self, func_name, *args, **kwargs): # 在所有 worker 上并行执行同名方法并收集返回结果 return self._worker_group.map( lambda worker, ffunc_name: getattr(worker, f)(*args, **kwargs), list(range(self._worker_group._num_actors)) ) def call_all(self, func_name, *args, **kwargs): # 用于只需在全局协调点上执行一次的方法 return getattr(self._worker_group, func_name)(*args, **kwargs)注意这里的call和call_all是两种语义。call广播给每个 worker让它们各算各的分片常用于前向、反向这类天然可并行的计算call_all只在某一个协调点执行常用于日志、保存 checkpoint、或者需要全局归约结果再分发的逻辑。读代码时看到 trainer 主循环里大量call不要急着进函数体先看它调用的是哪个 worker 类型actor、critic、rollout、reward 是四组完全不同的 worker职责边界非常清晰。第二个必须搞懂的是张量协议层 DataProto。verl 在阶段之间传递的不是裸 tensor而是一个既带模型输入字段、又带元信息的封装对象。比如一份 DataProto 里同时有prompts、responses、old_log_prob、advantages等字段每个字段是什么形状、来自哪个阶段在协议定义里写得明明白白。我在读代码时有个习惯每到一个阶段衔接处先打印list(data_proto.batch.keys())清楚了有哪些字段再去找字段的 producer 和 consumer比从头读函数快得多。2.3 7 天里的前 2 天只读这 7 个文件目录再大新手期需要精读的文件其实有限。按我自己的阅读路径前两天下班后各花一小时只围绕下面这张清单走就足够支撑后续几天的深挖文件按源码根目录相对路径在训练周期中的角色精读重点verl/trainer/main.py入口解析配置并组装训练器配置项与模块的绑定关系verl/trainer/core_alg_trainer.pyPPO/GRPO 训练主循环四个阶段如何串行/并行verl/workers/fsdp_workers.pyactor/critic/ref 模型 worker前向、反向、参数同步的切入位置verl/workers/rollout/vllm_rollout.pyvLLM 生成引擎封装prompt 变 response 的字段映射verl/protocols/data_proto.py跨阶段张量协议batch 字段与张量形状verl/utils/checkpoint/checkpoint 读写权重保存和加载的入口主循环里三个阶段命中同一份 DataProtorollout 产出的数据、reward 阶段补充的奖励字段、advantage 阶段补上的优势值最后带着这些字段流进更新阶段计算 loss。理解了这个数据流你甚至不需要完整读完 trainer就能回答「某个张量从哪来、到哪去」这类面试里最常见的源码问题。2.4 从 config 反推模块组合verl 的配置体系是 dataclass 风格分三块WorkerConfig 决定每种 worker 的资源数量和进程拓扑AlgorithmConfig 决定算法族和超参TaskConfig 决定任务类型、奖励函数和环境。我读源码前会先看一个最小配置然后顺着字段去找它在 trainer 里的消费点。下面是我在本地做实验时的配置雏形字段名以你手里的源码为准# 最小训练配置雏形字段含义为重数值按环境调整 worker: actor: num_gpus_per_worker: 1 num_cpus_per_worker: 8 rollout: name: vllm num_gpus_per_worker: 1 num_cpus_per_worker: 4 algorithm: algo: ppo # 可选 ppo 或 grpo gamma: 1.0 lam: 1.0 kl_coef: 0.001 max_response_length: 512 task: reward_func: my_reward # 奖励函数入口 use_rm: false # false 表示不加载独立奖励模型读配置时重点看name: vllm这个字段它说明 rollout worker 的类型是由配置驱动的。框架内部大概率有一张「字符串到 worker 类」的注册表trainer 拿着配置去查表、实例化对应 worker。你之后想替换生成引擎或者自定义 reward改动点就在这张注册表附近而不是在主循环里加 if 分支。3. 用最小命令打穿 verl 主循环单机跑通、日志追踪与断点策略到了第 3 天理论地图已经画完这一步的关键是让代码真的跑起来。我建议不要一上来就复现大模型 RLHF那是给自己找罪受。先找一个最小的任务配置把训练步数设到个位数目标是看清主循环日志里出现的阶段顺序并在关键衔接点打印 DataProto 字段。3.1 环境准备与最小命令单机跑通源码阅读需要可执行环境否则 grep 一辈子也理解不了上下文。常见做法是在项目根目录下先安装依赖然后用源码方式把包装进当前环境这样改代码不需要重装# 在 verl 源码根目录下执行 pip install -e . # 验证安装是否成功能正常输出版本号即通过 python -c import verl; print(verl.__file__)接着准备一份非常小的数据。verl 的常见示例是数学类的可验证奖励任务每条数据至少包含 prompt 和标准答案。我习惯只留 8 条样本把重复数设成 2确保单卡几分钟内能跑完一个 step[ {prompt: Calculate: 123 456. Answer:, answer: 579}, {prompt: Calculate: 987 - 654. Answer:, answer: 333} ]启动训练用模块入口不要用 IDE 里直接右键运行某段代码因为 verl 依赖 Ray 的进程模型必须让框架自己拉起 actor 组python -m verl.trainer.main --config_path examples/grpo_example.yaml这一行命令背后发生的事情包括读取配置、初始化 Ray、按 WorkerConfig 拉起 actor worker 和 rollout worker、加载数据、进入训练循环。如果examples/grpo_example.yaml在你手里的源码里路径不同先find . -name *.yaml | head找一下。头一回能把这一行跑通源码阅读的地基就算打好了。3.2 在主循环里插断点用 batch.keys() 当导航仪跑通一次之后寻址问题就变成了「如何把代码行映射到我看到的日志上」。我的土办法是直接改源码加临时打印跑完再撤。重点在主循环里每个阶段入口打印 DataProto 包含哪些字段# 临时插入到 core_alg_trainer.py 的训练循环仅用于阅读定位 def _trace_stage(self, tag, data_proto): # 打印当前阶段名称与 batch 内的字段清单 print(f[trace][{tag}] keys{list(data_proto.batch.keys())}) # 顺手打印第一组数据的 response 形状确认生成是否成功 if responses in data_proto.batch: print(f[trace][{tag}] responses shape{data_proto.batch[responses].shape})这个函数的意图很简单rl 训练跑起来之后你不确定当前代码执行到哪个阶段时看日志前缀就能定位。trace字段的作用是过滤噪声正常训练日志很多加了统一前缀之后用grep trace就能只看你想看的信息。字段清单的变化趋势也很有用rollout 阶段之后多出responses和old_log_probreward 阶段之后多出rewardsadvantage 阶段之后多出advantages。看到这些字段按顺序出现说明数据流是通的某个阶段的字段没出现问题就锁死在那个阶段的实现里。3.3 用日志反推流程一条样本的完整旅程训练日志里通常已经自带阶段标识关键是别只盯着 loss。我按时间顺序记录下每个事件对应的模块路径大致是数据加载进 DataProtoactor worker 做一次前向得到旧 logprob随后 vLLM rollout 生成 responsereward worker 打分trainer 在协调点归并优势值最后 actor 做 PPO 更新并保存 checkpoint。这套顺序看起来平淡但不少人在读源码时犯的错是直接钻进compute_advantage的实现里出不来忘了它输入输出的前后置关系。建议第三天结束时做一个练习把上面顺序中的每一步对应到你从命令行日志里看到的具体行。如果某一步日志里没有明显标志就看print或loguru的输出在哪一行然后回溯该行所属文件。把「日志行 → 文件路径 → 函数名」三者的对应关系写在一张纸上这张纸就是你自己的 verl 源码地图。4. verl 主链路源码拆解rollout 生成、奖励与优势估计、策略更新从第 4 天开始进入正题。前面两天建立地图第三天跑通流程接下来就是要能跟别人讲清楚verl 在训练一个 step 时内部到底怎么协作。我会按数据流向拆成三个子链路。4.1 rollout 生成链路vLLM 如何与策略模型握手rollout 阶段的核心矛盾是「生成速度」与「新旧权重一致性」之间的权衡。verl 的做法是让 vLLM 引擎独立运行actor worker 在需要时把当前策略权重同步给它。读源码时你会看到一个生成调用参数里最值得注意的是温度、top_p、max_tokens 和是否返回 logprob# 简化自 verl/workers/rollout/vllm_rollout.py 的生成调用形态 from vllm import SamplingParams sampling_params SamplingParams( temperature1.0, top_p1.0, max_tokens512, logprobs1, # 让 vLLM 返回每个 token 的 logprob prompt_logprobs0 ) outputs self.vllm_engine.generate( prompts, sampling_params ) # 将 vLLM 输出转换为 DataProto字段名与 actor 侧对齐 data_proto self._process_outputs(outputs, prompts)这里有一个关键点logprobs1。PPO 更新策略时需要旧策略在每个 token 上的对数概率如果生成阶段不返回 logprob更新之前就得用 actor 再前向一次开销直接翻倍。verl 利用 vLLM 生成时附带的 logprob 来充当旧策略概率省掉一步额外前向。读代码时如果看到旧 logprob 不是通过模型 forward 算出来的不要惊讶这是性能优化的必然选择。同步权重的路径也值得看actor 更新完之后rollout worker 手里的策略还是旧的所以主循环里一定有一个权重广播动作。常见做法是调用 worker group 的方法把当前模型 state_dict 的分片推给 vLLM worker这个过程在异构 worker 之间走的是 Ray 对象存储权重大、频率高最容易出现性能瓶颈。你在源码里看到类似update_weights或sync_weights的调用就是这条握手链路。4.2 奖励与优势估计PPO 与 GRPO 的分岔点奖励阶段结束之后是一个算法分岔点。PPO 需要额外的 critic 网络估计状态价值然后走 GAEGRPO 不训练 critic改成在组内对奖励做归一化省掉一整套值函数网络。这个分岔在源码里通常体现为一个算法选择分支# advantage 计算的概念形态不同版本实现位置和接口不同 if config.algorithm.algo ppo: # 需要 critic 的 value 输出和 GAE 递推 advantages gae( rewards, values, gammaconfig.algorithm.gamma, lamconfig.algorithm.lam ) elif config.algorithm.algo grpo: # 组内归一化同一组内的奖励减均值、除标准差 group_mean rewards.mean(dim-1, keepdimTrue) group_std rewards.std(dim-1, keepdimTrue) 1e-6 advantages (rewards - group_mean) / group_stdGRPO 组内归一化有个隐藏逻辑数据必须按「同一个 prompt 生成的多条 response」分在同一个组里。这个分组在数据加载或 rollout 后处理阶段完成组大小通常等于 2 的幂以便开方求均值。读到这里值得停下来确认手里的示例配置里n次采样是不是正确传给了 rollout worker。很多人改配置只改采样次数没注意组划分的实现是按 batch 内顺序连续切分数据 shap 不对时 advantage 计算出来的结果就是乱的。两种算法的另一个差别是 PPO 需要同时维护 actor 和 critic 两套模型所以 WorkerConfig 里 actor 的参数量要按两套网络算显存GRPO 不需要 critic多出来的显存可以加大 batch 或采样数。选算法时这也是是否切换的硬性约束。4.3 策略更新旧概率、裁剪与 KL 控制策略更新阶段的源码读起来最有公式感。手写 PPO 时常见的 loss 形态是三个部分相乘相加策略比例、裁剪后的优势项、KL 惩罚项在 verl 里只是把这三个部分的数据来源接上 DataProto 而已# 简化自 update 阶段的 loss 计算形态 ratio torch.exp(new_log_prob - old_log_prob) surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * advantages policy_loss -torch.min(surr1, surr2) # KL 惩罚用参考模型输出限制新策略不要偏离太远 kl_penalty kl_coef * (ref_log_prob - new_log_prob).detach() loss (policy_loss kl_penalty).mean()注意kl_coef前面是否 detach不同版本处理方式不完全一样。有的版本对 KL 项整体 detach 只当正则用有的版本保留梯度参与更新读代码时多问一句「这个张量要不要梯度」能帮你理解作者的设计意图。更新阶段还有一个容易忽略的细节这次前向计算的new_log_prob是一次全新的 actor forward和 rollout 阶段返回的old_log_prob不是同一时刻算的中间隔了权重同步。如果权重同步没有及时把最新权重同步给 rollout worker那么old_log_prob其实对应的是旧权重PPO 算出来 ratio 的分布会发生偏移。所以源码里 update 完成之后一定紧跟一次权重广播读代码时可以顺带确认它的触发条件。4.4 分布式边界哪些代码只在 driver 上跑哪些在每张卡上跑verl 用 Ray driver 进程做协调真正的计算都在 worker actor 里。这导致同一个文件里有些代码在所有进程中执行有些逻辑只执行一次。我的排查技巧是搜三个特征torch.distributed的 group 定义、torch.cuda.set_device的设备选择、以及带is_worker之类的条件分支。看到一个函数先问自己这个函数是在 driver 进程上做的调度决策还是在具体 GPU 上做的前向计算两者混着读容易把控制流理解错。这条边界搞清楚了后面读 checkpoint 保存、日志聚合、指标统计都会顺畅很多。5. verl 源码阅读与训练排查五个经典坑的现象、原因、解法跑到这里你的源码阅读能力已经能支撑改代码了。第 5 天开始故意「踩坑」比刻意「避坑」更有价值因为每个坑都会逼你重新回到源码里找答案。下面五个问题是我见过的出镜率最高的覆盖环境、生成链路、数据协议三个层面。坑 1单机能跑多机一启动就卡死在 Ray 初始化现象是命令发出去之后终端没有任何日志更新进程像睡着了一样。你以为程序在加载模型其实它卡在 Ray 的 actor 创建上。常见原因是多机环境里 Ray head 的地址配置不对或者各节点的共享存储路径不一致导致 worker 拉不起来。解决方法是先把ray start --head单独跑一遍确认集群健康再检查每台机器上的shared_storage配置路径是否指向同一个挂载点。如果本地只是双机调试可以先降低num_gpus_per_worker把每台机器分到的 actor 数量调小排除资源不足导致的假死。坑 2vLLM 与 FSDP 之间的权重同步超时现象是训练跑一会就报一个和权重传输相关的超时错误尤其在模型达到几十亿参数之后。原因是权重同步走 Ray 对象存储大权重全部序列化传输耗时太长而默认超时阈值没接住这种情况。解决方式有两个方向一是调大调度超时参数给权重同步留足时间二是降低同步频率比如开启do_writes延迟合并、累积多步更新后再做权重广播。这两种改法都会影响训练的实时性调完要多观察一步 rollout 阶段所用的策略是否陈旧找到平衡点。坑 3rollout 字段和 actor 侧 batch 对不上new_log_prob 出来全是默认值现象是训练日志里 loss 正常下降但看过 GAE 的中间输出发现优势值全是同一个常数策略几乎没有更新方向。原因是 vLLM 的输出在转换成 DataProto 时字段名没有对齐 actor 消费的数据字段比如token_ids没写进 batchactor 侧拿到的是空张量。解决方法是回到_process_outputs这类转换函数里逐个字段核对 vLLM output 到 DataProto 的映射token 序列、logprob、prompt 长度、response 长度都必须一一对应。我排查这种问题时的动作是打印 batch 里每个字段的 shape和 rollout 输出逐一对齐五分钟就能定位。坑 4GRPO 组内归一化失效奖励很高但 advantage 全部趋于 0现象是 reward 均值持续上升但 advantage 的绝对值小到影响权重更新。原因是同一组里混入了难度差异极大的样本简单任务全对、困难任务全错归一化之后所有值都被压平了信号消失。解决方法是先确认组内数据的划分逻辑同一个 prompt 的多次采样必须在同一组里不同 prompt 之间不要混组。如果已经确认分组无误尝试提高组内采样数量n让单组方差更稳定。GRPO 的效果对组大小非常敏感这是这类算法里最像「玄学」的参数。坑 5本地想用 gloo 后端调试FSDP 直接报后端不支持现象是代码里指定了gloo作为分布式后端一启动就抛出带NCCL字样的错误。原因是 FSDP 的浮点通信对后端有硬性要求gloo 在某些硬件或模型尺寸下无法满足。解决方式是把分布式后端换成nccl代价是本地单机多卡必须满足 NCCL 的运行条件。如果只是想做逻辑验证另一个低成本方案是用极小模型跑 CPU 模式绕开 GPU 通信实在需要调试 GPU 行为就老老实实配置好 NCCL 环境变量再跑。这个坑提醒我读源码改配置前先确认自己手里的硬件条件能不能支撑框架默认选型。6. 进阶给 verl 换一个自定义奖励函数并验证你的改动没有破坏数据流源码读到能改的程度最有性价比的动手点就是奖励函数。verl 的奖励入口通常以函数形式暴露输入一份 DataProto 和 tokenizer输出每条 response 的奖励张量。一个典型的规则奖励函数会从 response 尾部提取答案文本和标准答案比较后给出 0/1 奖励# 自定义奖励函数入口函数签名以你手里版本为准 def my_reward(data_proto, tokenizer): responses data_proto.batch[responses] answers data_proto.batch[answers] # 逐个样本提取模型输出中的答案片段 rewards [] for resp, ans in zip(responses, answers): text tokenizer.decode(resp, skip_special_tokensTrue) rewards.append(float(ans.strip() in text)) return torch.tensor(rewards, dtypetorch.float32)写完后先做两件事别急着启动大训练。第一单独用几行 code 跑一次这个函数确认返回张量的 shape 与 batch 大小一致第二把训练步数设成 1在 advantage 计算处打印 rewards 的统计量确认进入优势估计的奖励值确实来自你的函数。我最初改奖励时没注意 rollout 阶段返回的old_log_prob会比 reward 计算更早被消费导致我自作聪明修改了数据字段顺序后优势方向直接反了后来养成了「先打印 advantage 信号再相信它」的习惯。换奖励函数是理解 verl 数据流最好的练习也是日后把框架用到自己任务上的必由之路希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询