前四篇文章已经梳理了RLHF训练的核心脉络:RLHF不仅仅是一个简单的训练脚本,而是一条完整的训练推理闭环;HybridFlow 将这个闭环拆解为高层的数据流(dataflow);Single Controller 负责保持阶段的有序执行;ResourcePool 和 WorkerGroup 则将各个角色分配到 GPU 集群上执行。今天我们要深入一个更具体的问题:这些角色之间到底传输了什么数据?
许多人会本能地回答“tensor batch”。这个答案只说对了一半。后训练阶段中的样本远不止 input_ids、attention_mask——它还会产生 response、reward、old logprob、ref logprob、value、advantage、return、uid、timing 以及各种运行元信息。verl 项目采用 DataProto 将这批不断“变胖”的训练证据打包成一个统一的协议。
核心洞察在于:DataProto 并不是一个普通的字典(dict),而是 controller、worker、rollout、reward、actor update 之间的数据契约。它使得 PPO 主循环能够清晰地组织成 repeat → union → dispatch → collect → update 模式,但同时也把字段增长、序列化、对象列以及样本对齐的风险集中到了这个协议边界上。
先看一张图,展示 DataProto 的三层结构。观察时请重点关注 batch 维度:tensor、object array 和 meta 信息都必须围绕同一批样本对齐。

DataProto 的三层结构
在源码中,DataProto 是一个 dataclass,核心字段包括 batch: TensorDict、non_tensor_batch: dict 和 meta_info: dict(定义在 verl/protocol.py:317-328)。check_consistency() 方法要求 batch 只有一个 batch 维度,且 non_tensor_batch 中的每个 np.ndarray 的第 0 维必须与 batch size 对齐(verl/protocol.py:454-478)。这正是 DataProto 最重要的隐含契约:无论字段来自 rollout、reward 还是 trainer,它们都必须描述同一批样本。
1. 三层结构分别应对三类数据
第一层 batch 存放 TensorDict,适合存储已 tensor 化且按样本对齐的数据:input_ids、attention_mask、responses、response_mask、old_log_probs、ref_log_prob、values、advantages、returns 等。from_dict() 会检查 tensor 的 batch 维度是否一致,然后再构造 TensorDict(verl/protocol.py:496-543)。
第二层 non_tensor_batch 存储按样本对齐但不适合 tensor 化的对象列。典型例子包括 uid、raw prompt、data source、多模态对象、reward 额外信息、工具或环境相关字段。from_single_dict() 会将 torch tensor 放入 batch,将 np.ndarray 放入 non_tensor_batch(verl/protocol.py:479-493)。
第三层 meta_info 存放整批数据的控制信息和运行信息,例如 temperature、global step、global token number、timing、metrics、auto padding 标记等。这些信息不一定逐样本变化,但会影响后续阶段如何解释这批数据。
to_tensordict() 进一步说明了这三层如何接回 worker 侧执行:tensor batch 先转换成普通 dict,non-tensor 列会包装成 NonTensorStack,meta_info 会作为 non-tensor dict 合并进 TensorDict(verl/protocol.py:1102-1126)。因此,DataProto 是 controller 侧的协议,而 TensorDict 更接近于 worker 执行前的操作形态。
2. 一个 PPO batch 会在主循环中逐步“长大”
DataProto 只有放入 RayPPOTrainer.fit() 才真正发挥价值。主循环开始时,dataloader 产出的 batch_dict 被转为 DataProto.from_single_dict(batch_dict),然后写入 temperature,并为每条样本生成 uid(verl/trainer/ppo/ray_trainer.py:1330-1349)。
接下来 rollout 过程让样本数量变多。trainer 先拿到 gen_batch,写入 global_steps,再根据 rollout.n 执行 repeat();如果是 REMAX 策略,还会将 sampled rollout 和 greedy baseline 拼接成一个 combined batch(verl/trainer/ppo/ray_trainer.py:1351-1370)。生成完成后,主 batch 也会按照 rollout.n 复制,再与 gen_batch_output 做 union(),补上 responses 等新字段(verl/trainer/ppo/ray_trainer.py:1386-1407)。
下面这张图展示的是“batch 如何逐步扩展”,而不是单个字段的来源。重点在于:每个阶段都不是替换整批数据,而是在同一个 DataProto 语义空间里追加字段或更新 meta 信息。

一个 DataProto batch 在 PPO/GRPO 主循环中逐步长大
生成之后,DataProto 继续“变胖”:reward 阶段可能通过 union() 合并 reward model 输出,old logprob 阶段 union() old_log_probs,reference 阶段 union() ref_log_prob,critic 阶段 union() values,advantage 阶段写入 token_level_scores、token_level_rewards、advantages、returns 等字段(verl/trainer/ppo/ray_trainer.py:1426-1541)。最终 actor/critic update 所消费的已经不再是原始 batch,而是一批携带完整训练证据的 DataProto。
这也解释了 uid 为何重要:一个 prompt 可能生成多条 response,样本顺序还可能被 balance 或 dispatch 改写。uid 是后续 advantage 计算、prefix grouping、诊断以及样本追踪能够继续识别“同一个原始 prompt”的关键列。
3. DataProto 的 API 是流水线操作,而非便利函数
union() 是 PPO 主循环中最常见的合箱动作。它会分别合并 tensor batch、non_tensor_batch 和 meta_info;如果已有同名字段但内容不一致,就会触发一致性检查(verl/protocol.py:109-122,verl/protocol.py:188-199,verl/protocol.py:781-798)。这能防止不同阶段将同一个字段写成语义不一致的数据。
repeat()、slice()、select_idxs()、reorder() 负责样本级变换,确保 tensor 列和 non-tensor 列同步变换(verl/protocol.py:635-719,verl/protocol.py:963-1013)。这一点在 RLHF 中非常实用:rollout.n 会扩展样本,batch balance 会重排样本,REMAX 会切出 baseline 区段,如果只处理 tensor 而不处理对象列,样本语义就会错位。
chunk() 和 concat() 则直接服务于分布式边界。chunk() 按 batch 维度将 DataProto 切成 worker shard,并将 meta_info 传递给每个 shard;concat() 将多个 DataProto 沿 batch 维度拼接回来,并对 metrics 做合并处理(verl/protocol.py:864-961)。这是 DataProto 能够穿过 WorkerGroup 的基础。
4. 穿过 WorkerGroup 时,DataProto 必须可切、可合、可对齐
第三篇文章提到,WorkerGroup 的调用会经过 dispatch 和 collect。对于 DataProto 而言,dispatch/collect 不仅仅是传递对象引用,而是要在 controller 和 workers 之间保持 batch 语义。
decorator.py 中的 _split_args_kwargs_data_proto() 会通过 BatchData(arg).chunk(chunks) 切分输入;带有 auto padding 的版本会在 batch size 不能整除 worker 数时补齐样本,并将 padding size 放入 kwargs(verl/single_controller/base/decorator.py:71-117)。dispatch_dp_compute_data_proto() 按 WorkerGroup world size 切分,collect_dp_compute_data_proto() 再通过 BatchData(output).concat() 合并输出(verl/single_controller/base/decorator.py:167-199)。
下面这张图要展示的就是这个协议边界:controller 不仅将单个对象发送给 worker,而是先切成 shard;worker 返回后,collect 再合成一个 DataProto,以便 PPO 主循环能够继续按完整 batch 推进。

DataProto 如何在 controller 和 workers 之间切分与合并
当前训练 worker 常用的 ND dispatch 还会先查询 mesh 的 DP rank mapping,再按 DP 维度进行分发和收集(verl/single_controller/base/decorator.py:202-304)。这说明 DataProto 的“集装箱”属性并非比喻:它必须能够按 rank 拆箱、发货、收货、合箱,并且在合并后仍然保持样本对齐。
5. DataProto 的代价源于它日益像系统总线
DataProto 让代码更加清晰,但它并非免费的抽象。
第一类成本是字段增长。一个 batch 从 prompt 出发,经历 rollout、reward、logprob、value、advantage 之后,tensor 字段会越来越多,response length 也可能变得很长。print_size() 专门统计 TensorDict 和 non_tensor_batch 的大小,说明这不仅仅是语义问题(verl/protocol.py:436-452)。
第二类成本是序列化。__getstate__() 默认会将 TensorDict 合并后通过 torch.save 写入 buffer,也支持通过环境变量切换到 numpy 序列化(verl/protocol.py:377-424)。当 DataProto 频繁穿越 Ray object store 或 controller/worker 边界时,序列化和反序列化会转化为真实的系统成本。
第三类成本是对象列和对齐风险。non_tensor_batch 中可能包含 raw prompt、多模态对象、工具参数、reward 诊断信息等。这些字段很难像 tensor 那样被高效移动,但又必须与样本的第 0 维严格对齐。DataProto 的一致性检查能够捕获部分错误,但系统设计仍需避免将过重的对象长期挂在主 batch 上。
下面这张图汇总了这些代价。它补充说明:DataProto 解决了“数据语义统一”的问题,但也可能成为 controller 内存、Ray object store 以及字段对齐的压力点。

DataProto 的主要系统压力点
因此,优化 RLHF 数据流不能只问“这批 tensor 有多大”。更完整的追问应当是:哪些字段必须留在主 DataProto 中,哪些可以只在某个阶段临时存在,哪些对象列应该提前压缩或延迟加载,哪些 metric 应该只回传摘要。DataProto 让这些问题有了统一的落脚点。
小结:DataProto 是后训练系统的数据契约
至此,第一组的第 2-5 篇文章可以连贯起来看:
HybridFlow 解释阶段 → Single Controller 保留阶段顺序 → ResourcePool / WorkerGroup 放置执行角色 → DataProto 在角色之间搬运训练证据
DataProto 的价值在于将不断变化的 RL batch 转化为统一协议:tensor 字段、对象列和 meta 信息都围绕同一批样本流动。其代价也源于此:字段会增长,对象列会变重,dispatch/collect 必须保持可切分和可合并,controller 边界会承担序列化和聚合压力。
下一篇可以回到 PPO/GRPO step 本身:现在我们已经知道控制流在哪里、worker 放在哪里、数据如何流动,接下来就可以按照 fit() 逐段解释一轮 step 中每个阶段到底消费和产出什么。
本文源码索引
verl/protocol.py:317-328:DataProto 的三层字段定义。verl/protocol.py:454-478:batch 与 non-tensor 列的一致性检查。verl/protocol.py:479-543:from_single_dict() 和 from_dict() 如何构造 DataProto。verl/protocol.py:781-798:union() 如何合并不同阶段产出的字段。verl/protocol.py:864-961:chunk() 和 concat() 如何支撑分布式切分与合并。verl/protocol.py:971-1013:repeat() 如何复制 batch 和 non-tensor 列。verl/protocol.py:1102-1126:to_tensordict() 如何把 DataProto 转成 worker 可执行形态。verl/trainer/ppo/ray_trainer.py:1330-1407:PPO 主循环如何从 dataloader batch 变成 rollout 后的 DataProto。verl/trainer/ppo/ray_trainer.py:1426-1541:reward、logprob、value、advantage 如何继续给 batch 追加字段。verl/single_controller/base/decorator.py:71-117:DataProto dispatch 前的切分和 auto padding。verl/single_controller/base/decorator.py:167-199:DP DataProto dispatch/collect。verl/single_controller/base/decorator.py:202-304:ND mesh 下的 DataProto dispatch/collect。
