TL;DR:AsyncGRPOTrainer 现在可以训练 LoRA 适配器,并将适配器同步到 vLLM(TRL v1.14)。rank-1 适配器只有几兆字节,因此同步可以通过每个 Job 挂载的存储桶完成,而不需要 NCCL。训练器与 vLLM 副本作为独立的 Hugging Face Job 运行在不同机器上。副本前端的一个小代理负责添加鉴权头、将每个 rollout 路由到已缓存其 KV 前缀的副本,并向所有副本广播适配器加载请求。AsyncGRPO 的指标可以显示瓶颈位置。在相同配方下,五个运行的 500 步训练耗时从 3 小时 27 分钟压缩到 53 分钟。
LoRA 支持已随 PR #7017 进入 TRL 的 AsyncGRPOTrainer,并随 TRL v1.14 发布。异步训练器现在可以训练适配器而非完整模型,并且只需将 LoRA 适配器同步到 vLLM。本文介绍一个建立在该能力之上的实际项目:训练与推理不再共享机器。
正如 Thinking Machines 的博客《LoRA Without Regret》所示,LoRA 训练特别适合 RL。他们的研究表明,即使 rank 为 1,LoRA 也能匹敌策略梯度 RL 的完整微调。原因在于优势函数每次只提供约 O(1) bit 的信息,从总信息量的角度看,每一步可学习的内容本来就很有限,rank-1 适配器足以吸收这些信息。

LoRA 训练还带来系统层面的收益。1.5B 模型的 rank-1 适配器只有几兆字节,而完整模型约为 3 GB。每次更新后只需传输适配器即可。vLLM 还能同时加载多个适配器:旧的 rollout 以启动时的策略完成,新的 rollout 则使用最新策略。
训练与推理分离之后,训练器和 vLLM 可以按各自的节奏运行在不同机器上。在单节点或集群环境中这很容易实现,因为两个进程可以共享文件系统,或者组成一个 NCCL 组。
我们的目标是让 Hugging Face Jobs 实现同样的架构。本质上,HF Job 是一个运行在单台虚拟机上的容器,这意味着一个 Job 目前无法产生多个节点来同时容纳训练器和 vLLM 服务器集群(每个节点最多限制为 8xH200)。那么问题来了:如果放弃训练器与推理服务器共享节点的要求,这套架构能走多远?
答案是"相当远"。问题在于每次更新都需要在机器之间传输数 GB 数据——这正是 NCCL 在密集集群中的用途。Job 之间既没有共享本地磁盘,也没有共享本地主机。而换成 LoRA 后,同步只需要几兆字节。文件系统方面,HF Jobs 提供由存储桶支持的 Volume。这些存储桶可以作为 FUSE 文件系统挂载到每个 Job 中,足以充当节点间的共享文件系统,Job 之间完全不需要网络连通。
最终的配置非常精简:
- 一个运行 AsyncGRPOTrainer 的训练 Job,使用 LoRA(稍后详述 FSDP 的部分)。
- 两个 vLLM Job,各自服务基础模型加训练器最新发布的适配器。
- 一个存储桶,以相同路径挂载到三个 Job 中——这是适配器从训练器传递到服务器的通道。
- 一个代理服务器。为什么需要代理会在下文展开,高层原因是训练器需要一个统一的入口。
架构:基于 Hugging Face Jobs 与存储桶
AsyncGRPOTrainer 新增的适配器专用同步路径工作方式如下:训练器不再向 vLLM 发送张量。每隔几个优化步,它将适配器保存到 <output_dir>/.vllm_lora/trl-policy-v{N},通过原子重命名发布目录,然后把路径发送到 vLLM 的 /v1/load_lora_adapter 端点。vLLM 从磁盘加载这些文件,rollout worker 随后可以用 model="trl-policy-v{N}" 发起请求。
这就是在 vLLM 上运行时适配器的加载方式,因此训练器与服务器需要共享文件系统。在 Slurm 集群上这是网络文件系统,而在 Jobs 中,做法是将一个存储桶作为 Volume 挂载到每个 Job 的相同路径下。底层由 hf-mount 实现,它在容器中暴露出一个 POSIX 文件系统:
hf jobs run ... -v hf://buckets/aminediroHF/asyncgrpo-lora-buckets:/lora ...
TRL 和 vLLM 本身不需要任何改动。训练器写入 /lora/<run>/.vllm_lora/,服务器从同一路径读取。在每个容器内,POST 请求中携带的路径天然有效。
检查点和最新适配器也保存在存储桶中。HF Job 是短暂的,但训练可以恢复:最新适配器始终持久化在桶里,Job 停止时不会丢失。
三个 Job
vLLM 副本
每个副本占用一个 GPU,使用原生 vllm/vllm-openai 镜像。只需启用运行时的 LoRA 加载,并保留足够的适配器槽位。
适配器槽位数量由 max_staleness 决定。在 AsyncGRPOTrainer 中,每次权重同步会将策略版本提升 1,而 max_staleness 表示一个 rollout 样本在被训练器丢弃之前,允许落后于当前策略多少个版本。以 max_staleness=4 为例,当训练器处于 v7 时,在 trl-policy-v3 下生成的样本仍会用于训练;在 v3 下启动的 rollout 也必须在 v3 下完成。因此在任意时刻,vLLM 需要同时服务当前策略加上之前的四个策略。训练器保持 max_staleness+1 个适配器版本的注册,并卸载更旧的版本。每次同步都会先加载新版本再卸载旧版本,交换期间还需要一个额外槽位,这就得到 --max-loras 6。如果只设 5 个,vLLM 会在每次同步时静默驱逐一个仍在服务飞行中 rollout 的策略。
for replica in 1 2; do
hf jobs run --detach --flavor h200 --timeout 8h --secrets HF_TOKEN \
--expose 8000 \
-v hf://buckets/aminediroHF/asyncgrpo-lora-buckets:/lora \
-e VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 \
-e VLLM_SERVER_DEV_MODE=1 \
-- vllm/vllm-openai:v0.27.1 \
vllm serve Qwen/Qwen2.5-Math-1.5B --host 0.0.0.0 --port 8000 \
--max-model-len 4096 --logprobs-mode processed_logprobs --generation-config vllm \
--enable-lora --max-lora-rank 1 --max-loras 6
done
vLLM 固定使用 v0.27.1。运行时的 LoRA 端点由该版本暴露,因此版本号应视为配方的一部分。
另一种可能的设计是让训练器只保留最新适配器,并始终以同一名称发布。我们没有采用这个方向,因为 vLLM 按适配器名称键控其前缀缓存。使用单一名称时,旧权重下计算出的 KV 块在交换后仍会命中,导致预填充不会重新执行,rollout 的前缀来自一个策略版本、解码却来自下一个策略版本,表现为比率偏离 1。而版本化命名使这种情况不可能发生:一个名称永远对应一组确定的权重,旧缓存前缀永远无法匹配新版本。
数据集选择:Sail 数据集
我们选择了 Sail/Sanity-Test-R1D-1.5B,这是为通过 FP16 克服训练-推理不匹配而构建的数据集(Qi 等人,2025)。复现代码来自 sail-sg/Precision-RL。
作者使用 DeepSeek-R1-Distill-Qwen-1.5B 为每道 MATH 题生成了 40 个答案,仅保留成功率在 20% 到 80% 之间的问题,最终得到 1,460 道题。该数据集非常适合 RL 验证:这些问题既非模型已经完全会做,也非完全无解,模型能在训练早期获得良好的学习信号并持续提升。
这也是一次强有力的端到端测试:如果某个 vLLM 副本在适配器名称下静默地服务基础模型,训练曲线上几十个 step 内就会出现异常。此外,这个数据集规模足够小,整个实验不到两个小时即可完成。
超参数沿用论文 oat/scripts/lora 下的 LoRA 脚本:Qwen/Qwen2.5-Math-1.5B,LoRA rank 为 1,alpha 为 2,学习率 4e-5,每个提示 8 个样本,每步 128 个补全,最大生成 3,000 个 token,上下文长度 4,096 个 token。
训练器
训练器使用相同的 vllm/vllm-openai:v0.27.1 镜像,在其上安装 TRL。当时 PR 还在评审中,我们直接基于 PR 分支运行;同样的代码现已在 TRL v1.14 中发布。训练脚本就是一个常规的 AsyncGRPOTrainer 脚本,唯一与 Job 相关的值是输出目录和服务器 URL。
from peft import LoraConfig
from trl.experimental.async_grpo import AsyncGRPOConfig, AsyncGRPOTrainer
config = AsyncGRPOConfig(
output_dir=<output_dir>,
vllm_server_base_url=<proxy_url>,
max_staleness=4,
weight_sync_steps=4,
save_strategy="steps", save_steps=50,
...
)
trainer = AsyncGRPOTrainer(
model="Qwen/Qwen2.5-Math-1.5B",
args=config,
peft_config=LoraConfig(r=1, lora_alpha=2, target_modules="all-linear"),
...
)
初始化过程中,TRL 会调用 /server_info。如果发现 lora_config,就启用适配器专用同步。无法由 vLLM 直接支持的配置——如 DoRA、modules_to_save,或超出 --max-lora-rank 的 rank——会回退到合并权重同步并输出警告。日志中应包含 "adapter-only vLLM sync enabled"。
代理
接下来是关键部分。训练器 Job 与 vLLM Job 之间需要一个代理,原因有二:
- Job 暴露的端口需要鉴权:每个请求都必须携带
Authorization: Bearer <HF_TOKEN>头。这层逻辑不应让 TRL 感知。 - 生成需要不止一块 GPU。在单个 vLLM 服务器上,常规做法是
--data-parallel-size > 1,但 TRL 会拒绝在这种模式下进行适配器专用同步,理由很充分:对/v1/load_lora_adapter的调用只会到达响应它的那一个 DP rank,其余 rank 会在新策略名称下继续服务基础模型。在 Jobs 上这个问题天然不存在,因为每个副本就是独立的一台机器。因此数据并行性被上移了一层,由代理将适配器加载请求扇出到每个副本。
具体做法是在训练 Job 上运行一个小型代理,监听 127.0.0.1:8000,让 TRL 把它当作一台独立的 vLLM 服务器。除了补充请求头之外,代理在功能上还承担两件事:
- 将每个补全请求路由到合适的副本,原则是让同一提示的多个 rollout 发往已缓存其前缀的副本(详见下文)。
- 将所有改变状态的请求——如适配器加载、暂停与恢复——广播给全部副本。
基于 KV 前缀的 rollout 路由
先回顾一下为什么这很重要。生成一个补全包含两个阶段,两者的计算特征截然不同:
- 预填充(prefill)阶段一次性处理整个提示,计算每个 token 的注意力键和值。
- 解码(decode)阶段逐个生成 token,每生成一个新 token 都要处理此前全部 token 的键和值。
这些键和值就是 KV 缓存。由于注意力是因果的,一个 token 的 KV 只取决于它之前的 token,与之后的无关。因此,两个共享前缀的请求也共享该前缀的 KV,已缓存这部分内容的副本可以完全跳过对应的预填充工作,请求自然应该发往见过其前缀的副本。
vLLM 将前缀 KV 缓存按 16 个 token 的块存储。GRPO 的特性是 rollout worker 会用相同的提示函数发送 G 个请求(本文中 G=8)。如果全部落到同一副本,第一个请求完成预填充,其余七个直接复用;如果分散到八个副本,每个副本都要重做一遍预填充,浪费宝贵的 GPU 算力。
代理路由器的职责是追踪哪个副本服务过哪个块哈希。第 3 个块的哈希代表第 1、2、3 块,而不只是第 3 块——这体现了因果性:只有第 1、2 块也相同时,第 3 块的 KV 才有效。同时 KV 缓存还取决于生成它的适配器:v3 策略下缓存的前缀对 v4 策略毫无用处。
下面通过一个来自 Sanity 数据集的真实 135 token 补全请求,完整走一遍副本选择的决策过程:
1. 将提示切成块。代理收到 token ID 后,按与 vLLM 相同的方式切成 16 token 的块。最后一个不满 16 token 的块被忽略。
2. 从前缀链式计算哈希。哈希以适配器名称为种子,每个块依次与前一个哈希串联后计算。因此 h3 依次标识第 1、2、3 块:前 k 个块完全相同的两个提示会得到相同的 hk;一旦某个块不同,其后所有哈希也随之不同。用适配器名称做种子正是为了让 trl-policy-v4 下相同的提示从不同的种子出发,无法匹配 v3 的条目——因为旧的 KV 块是用不同的权重算出来的。
3. 比较两个提示。问题 1 有 103 个 token。两个提示以相同的 23 token 聊天模板开头,第一块相同,但第二块已包含题目文本,哈希自此分道扬镳。
4. 记录块归属。对每个哈希,路由器记录哪个副本服务过它,以及它之后出现了哪些后继哈希(后继集合限制为两个,因为只需知道一个块是否有延续)。运行若干提示后,模板块 h1 由两个副本共同服务且已有多个后继;h2 到 h8 仅由副本 A 服务,各有一个后继;





