Hugging Face 用 TRL v1.14 的 AsyncGRPOTrainer 只同步 rank-1 LoRA 适配器,靠一个挂载进每个 Job 的存储桶和一个按 KV 前缀路由的代理,在没有 NCCL 的 HF Jobs 上跨机跑通异步 GRPO。架构只决定能不能跑:500 步从 3 小时 27 分缩到 53 分钟,靠的是打包、关梯度检查点、放开在途上限这三处设置。
本文基于 Hugging Face 的 Amine Dirhoussi、Quentin Gallouédec、Kashif Rasul、Sergio Paniego 于 2026 年 9 月 10 日发表在 Hugging Face Blog 的《Async GRPO with LoRA across HF Jobs: a bucket, a proxy, and no NCCL》,原文链接:https://huggingface.co/blog/asyncgrpo-lora-hfjobs。复现代码在 https://github.com/AmineDiro/hfjobs-lora-buckets。对照部分引用了同一团队 5 月的《Delta Weight Sync》:https://huggingface.co/blog/delta-weight-sync。
TRL 的 AsyncGRPOTrainer 把训练和生成拆开:训练器和 vLLM 推理服务可以放在不同机器上,各跑各的速度。这在单机或集群里很好办,两边要么共享文件系统,要么能组一个 NCCL 通信组。
Hugging Face Jobs 不一样。一个 Job 本质上是一台虚拟机上的一个容器,单节点最多 8 张 H200,而且一个 Job 没法再拉起多个节点。Job 之间不能跨节点通信,没有共享本地盘,更没有共享的 localhost。团队给自己出的题目是:如果不再要求训练器和推理服务在同一个节点上,能走多远?
如果每次同步都要搬整套权重,答案是"走不远"——每次更新都是几个 GB 的数据跨机器搬运,这本来就是密集集群里 NCCL 的活。转机来自 LoRA。随着 PR #7017 合入、TRL v1.14 发布,AsyncGRPOTrainer 可以只训练一个 LoRA 适配器,并且只把适配器同步给 vLLM。一个 1.5B 模型的 rank-1 适配器只有几 MB,而完整模型约 3 GB。
rank-1 够不够用?原文引用了 Thinking Machines 的《LoRA Without Regret》:在策略梯度类 RL 里,LoRA 能追平全参微调,rank 1 也可以。理由是优势函数每个 episode 只提供大约 O(1) 比特的信息,从信息总量看,每一步本来就没多少东西可学,rank-1 的容量足够装下。
另一个系统层面的好处是:vLLM 能同时挂多个适配器。旧的 rollout 用它开始时的策略跑完,新的 rollout 用最新策略,两者互不干扰。
最后搭出来的系统很小:
AsyncGRPOTrainer(外加 FSDP);新的"只同步适配器"路径是这样工作的:训练器不向 vLLM 发送任何张量。每隔几个优化器步,它把适配器存到 <output_dir>/.vllm_lora/trl-policy-v{N},用一次原子重命名把目录发布出去,再把这个路径 POST 给 vLLM 的 /v1/load_lora_adapter。vLLM 从磁盘读文件,之后 rollout 就可以请求 model="trl-policy-v{N}"。
这其实就是 vLLM 运行时加载适配器的原生方式:接口收的是路径,不是张量,所以本来就默认训练器和推理服务共享文件系统。在 Slurm 集群上,那是网络文件系统;在 Jobs 上,团队用 hf-mount 把 Storage Bucket 以 POSIX 文件系统的形式挂进每个容器,而且挂在相同的绝对路径:
hf jobs run ... -v hf://buckets/aminediroHF/asyncgrpo-lora-buckets:/lora ...
TRL 和 vLLM 一行都没改。训练器往 /lora/<run>/.vllm_lora/ 写,推理端从同一路径读,POST 请求里的路径在每个容器里都有效。检查点和最终适配器也存在桶里,Job 是一次性的,但被抢占的训练器可以从桶里恢复。
为什么每个版本单独起名。 另一种设计是只保留最新适配器、永远用同一个名字发布。团队没这么做,因为 vLLM 的前缀缓存是按适配器名做键的。名字不变,旧权重算出来的 KV 块在换权重之后仍然能命中,prefill 不会重算,一条 rollout 可能前缀来自上一个策略、解码来自下一个。训练器察觉不到,唯一的症状是 ratio 慢慢偏离 1。带版本号的名字从根上排除了这种情况:一个名字永远只对应一套权重。
适配器槽位怎么算。 槽位数由 max_staleness 决定。每次权重同步,策略版本号加一;max_staleness 是一个样本最多能落后当前策略几个版本还不被丢弃。设为 4 时,训练器在 v7 仍会用 v3 生成的样本,而在 v3 下开始的 rollout 也必须能在 v3 下跑完。所以 vLLM 任何时刻都要同时服务当前版本加前面四个,共 5 个。每次同步先加载新版本、再卸载最旧的,交接瞬间还要多一个槽,于是 。 换成一般规律:。
免费获取企业 AI 成熟度诊断报告,发现转型机会
--max-loras 6max-loras = max_staleness + 2推理端用的是原版 vllm/vllm-openai 镜像,版本钉在 v0.27.1,需要打开两个环境变量:VLLM_ALLOW_RUNTIME_LORA_UPDATING=1 启用运行时加载接口,VLLM_SERVER_DEV_MODE=1 启用 TRL 需要的 /pause、/resume、/server_info。原文特意提醒,vLLM 迭代很快,版本号应当视为配方的一部分。
还有一个容易漏的检查点:TRL 初始化时会调 /server_info,看到 lora_config 才走只同步适配器的路径。DoRA、modules_to_save、rank 超过 --max-lora-rank 这类 vLLM 不能直接服务的配置,会带着一条警告退回到合并权重同步。日志里应该能看到 Adapter-only vLLM sync enabled——没有这行,后面所有"几 MB"的前提都不成立。
训练器和 vLLM Job 之间需要一个代理,原因有两个。
第一个很朴素:Job 暴露的端口要求每个请求带 Authorization: Bearer <HF token>,代理负责加上,TRL 不用知道。
第二个更关键。想让多张 GPU 一起生成,单个 vLLM 的常规做法是 --data-parallel-size > 1,但 TRL 在这种模式下拒绝只同步适配器,而且理由充分:一次 /v1/load_lora_adapter 调用只会到达应答它的那个 DP rank,其他 rank 会继续拿基座模型冒充新策略。所以数据并行只能挪到上一层,由一个能把适配器加载"扇出"到每个副本的组件来做。
团队在训练器 Job 里起了一个跑在 127.0.0.1:8000 的代理,让 TRL 把它当成一台 vLLM。从 TRL 看过去,这是一台 data_parallel_size=1 的服务器,所以它会选择只同步适配器。
广播是全有或全无。 每个副本有自己的桶挂载,看到新目录的时间不一定相同。No adapter found for <path> 通常意味着某个副本的挂载还没跟上,只重试这一个副本;遇到其他错误,就把已经接受适配器的副本全部卸载回滚,保证一个策略名不会只存在于部分副本上。/pause、/resume 和卸载请求同样广播;/health 要所有副本都健康才返回 200。
按 KV 前缀路由。 GRPO 对同一个 prompt 会发 G 个请求,这里 G=8。如果它们落在同一副本,第一个请求算完 prefill,后面七个直接复用;轮询分发的话,一半请求会落到没缓存的副本上,白白重算。
代理的路由器模仿 vLLM 的做法,把 token 切成 16 个一块,只对完整块做链式 blake2b 哈希:第 3 块的哈希代表第 1 到 3 块的整体,这正对应因果注意力下 KV 的依赖关系。哈希链的种子是适配器名,所以同一个 prompt 在 v4 下永远匹配不到 v3 留下的缓存。
难点在"公共前缀"。所有请求都以相同的聊天模板和系统提示开头,这个数据集里是前 23 个 token,1,460 道题都一样。原文提到,换到 Agent 场景,公共前缀是工具描述;多轮环境里则是共享的对话历史。这些块几秒内就会进入每个副本的缓存,拿它们做匹配,每个新 prompt 看起来都像命中。路由器的办法是看"扇出":一个块如果被所有副本服务过,或者后面接过不止一种后继块,它就是公共的,不参与路由判断。去掉公共前缀之后:
跑完 64,728 次 rollout,代理计数是 affinity 54,712、spill 820、unmatched 9,196。每个 prompt 8 次 rollout,至少有 1 次必然是冷的,理论下限 12.5%;实测 unmatched 14.2%,affinity 84.5%,spill 1.3%,已经很接近天花板。
团队原本担心 Python asyncio 写的代理会成为瓶颈,实际没有:在途请求最多 128 个,每次路由只算几个哈希,一个线程绰绰有余。
全量跑的同步耗时,原文给了一张拆分表:整段同步中位数 8.5 秒(早先的版本是 30.8 秒),其中暂停两个副本 0.3 秒,适配器 all-gather 加写入桶 1.1 秒,两个副本接受适配器约 7 秒。126 次同步乘以 2 个副本,252 次加载全部成功——其中 6 次在第二次尝试时成功,246 次在第三次。
这组数字原文没有展开,但我认为它是整篇最值得多看一眼的地方:**没有一次加载是第一次就成功的。**适配器只有几 MB,写入桶只要 1 秒出头;真正的时间花在等各副本的挂载"看见"新目录上。换句话说,用存储桶当传输通道,贵的不是带宽,而是可见性延迟。
旁证来自第四轮实验:代理的重试间隔从 2 秒降到 0.5 秒之后,同步从 7.6 秒降到 5.8 秒,这和"时间主要耗在等待重试"的解释吻合。但要说清楚,第四轮同时改了三处——加了第三个副本、缩短重试间隔、关掉 FSDP 的 reshard——并不是一次单变量对照。把同步提速归到重试间隔上,是原作者的判断,也是合理的判断,但不是被隔离验证过的结论。
把它和同一团队 5 月的 Delta Weight Sync 放在一起看会更清楚。那条路线不用 LoRA,同步的是全参数:相邻两步之间约 99% 的 bf16 权重逐位不变,于是只把变化的元素编码成稀疏 safetensors 上传到 Bucket,在 Qwen3-0.6B 上把每步负载从 1.2 GB 压到 20 到 35 MB。那篇的日志显示,推理只暂停了 1.1 秒,剩下的上传时间在后台完成、不阻塞生成。
两条路线的模型、同步方式都不同,秒数不能直接比。但它们指向同一件事:一旦每次同步的数据量压到 MB 级,"跨机通信"就退化成"文件出现在存储里",瓶颈从带宽转移到存储一致性。 选哪条路线取决于训练方式:LoRA 路线负载最小,但被锁在 vLLM 能直接服务的适配器配置里,DoRA 或 modules_to_save 都会退回合并权重;Delta 路线对全参微调都适用,代价是要维护锚点和 CPU 侧的 bf16 快照。我的判断是,规模再往上走,决定同步耗时的会是挂载层多快能让所有副本看到新文件,而不是文件本身多大。
架构证明了"能跑":第一轮 500 步里奖励从前 20 步均值 0.145 升到后 20 步 0.438,ratio 每一步都保持在 1.000,126 次同步中 vLLM 服务的策略始终和训练器打分用的策略一致,平均陈旧度 1.5 个版本(上限 4)。

第一轮 r1-dp2 的奖励与 ratio 曲线。图片来源:Hugging Face / Hugging Face Blog
但这一轮跑了 3 小时 27 分钟,而且问题很明显。原文后半篇是一次"瓶颈乒乓":每轮只改一处,看仪表盘把瓶颈推到哪一侧。异步 RL 是训练和生成之间的一条流水线,一侧提速,另一侧跟不上就白费。判断方法只需要盯四组指标:
perf/step_s 与 perf/fwd_bwd_s:一步里前向加反向占多少,两者接近说明训练器算力受限;perf/rollout_wait_s:训练器等样本等了多久,接近 0 说明生成跑在前面;sample/rollout_queue_size 与 queue_maxsize:两侧之间的缓冲区,满了说明生成被节流,空了说明训练器在挨饿;rollout/backpressure_s 与 rollout/score_block_s:缓冲区满时 rollout worker 被卡住的时长,先卡在打分阶段,再传回生成阶段。诊断规则一句话:队列满、等待为零、背压高,是训练器慢;队列空、等待上升、没有背压,是生成慢。
**第一轮 r1-dp2:训练器跟不上。**一步 22.9 秒,前向加反向 21.9 秒,占 96%;训练器等样本只等 0.02 秒,队列停在 512 容量中的 476,rollout worker 每组被背压卡 15 秒。MFU 只有 3.9%。原因在批次指标里:每步 64 个 micro-batch,每行只有 1 条样本,每个 rank 一次只处理一条约 1.2k token 的序列,重复 64 次。根源是参考配方里的 per_device_train_batch_size=1,对 H200 上的 1.5B 模型来说完全是延迟受限。第二个推理副本在这一轮基本是闲置的。
**第二轮 r1-dp2-tb16k:把 micro-batch 塞满。**不改批大小,每个优化器步仍是 128 条补全,只改它们在 GPU 上的排布:开启 token 预算打包,token_budget=16384、gradient_accumulation_steps=6,把多条样本无填充地塞进同一行。每行样本数从 1 升到约 12.7,micro-batch 从 64 降到 6,行填充率 95%。前向加反向从 21.9 秒降到 5.6 秒,MFU 从 3.9% 升到 19%。

开启 token 预算打包前后的六项指标对比。图片来源:Hugging Face / Hugging Face Blog
更有意思的是,生成吞吐也从 4.6k 跳到 25k token/s,而 vLLM 那边什么都没改。队列不再一直满,副本终于能放开跑。原文由此强调不要孤立地优化流水线的某一段:慢的一段会把它前面所有环节的真实性能都掩盖掉。
**第三轮 r1-dp2-tb16k-nockpt:别再重算前向。**前向 1.34 秒,前向加反向 5.6 秒。正常反向约是前向的两倍,基座权重冻结时应该更接近一倍,3.2 倍的比例可疑。原因是 AsyncGRPOConfig 默认 gradient_checkpointing=True,每个 micro-batch 在反向时都要重算一遍前向——这也解释了为什么一行 16k token 在 141 GB 的 H200 上只用了 25 GB。模型够小,激活值完全放得下。关掉后前向加反向降到 4.6 秒,几乎正好少了一次前向,MFU 到 23%。这时队列跌到 71,训练器等样本的时间从 0.04 秒升到 0.6 秒:瓶颈被成功推到了生成侧。
这也暴露出两个新成本。每四步一次、每次 7.6 秒的权重同步,现在占了墙钟时间的 25%,而在每步 23 秒的时候只占 8%。另外反向仍比前向慢 2.5 倍,每步约有 2 秒不像正常的模型计算——这一点原文到最后也没有找到答案。
**第四轮 r1-dp3-tb16k-nockpt:加了第三张卡,结果白加。**既然生成慢,就加第三个副本。同步从 7.6 秒降到 5.8 秒;前向加反向纹丝不动,排除了 FSDP reshard 的嫌疑;生成吞吐只从 25k 升到 26k token/s,第三个副本几乎没干活。

第三轮与第四轮的同步耗时、生成吞吐、在途请求与前向加反向对比。图片来源:Hugging Face / Hugging Face Blog
答案在 rollout/inflight 里:每一轮都是 128。max_inflight_tasks 限制的是整个 rollout worker 的并发,不是每个副本的。代理显示这 128 个请求被分成 44、43、41 摊给三个副本,而 H200 上的 1.5B 模型处理 43 条和 130 条并发序列,每 token 成本几乎一样。128 个请求分给三张卡和分给两张卡,吞吐差不多。卡住系统的不是 vLLM,是自己客户端里的一个常数。团队当初设得保守,是因为不确定几百条长 HTTPS 请求穿过 Jobs 的公网代理会怎样——而到这时,已经有 13 万次 rollout 穿过去,没有一次传输错误。
**第五轮 r1-dp3-inflight384:放开在途上限。**只改两个值:max_inflight_tasks=384、queue_maxsize=768。每个副本分到 128 个请求,队列很快填到 768 中的约 690,背压回到 5 秒,等样本降到 0.03 秒——瓶颈又回到训练器。步时中位数 4.8 秒,其中前向加反向 4.6 秒,权重同步摊到每步 1.5 秒。队列变大,样本等得更久,平均陈旧度从 1.5 升到 2.0,仍在上限 4 以内,ratio 依然贴着 1.000。
| 500 步 | 第一轮 r1-dp2 | 第五轮 r1-dp3-inflight384 |
|---|---|---|
| 墙钟时间 | 3 小时 27 分 | 53 分钟 |
| 步时中位数 | 22.9 秒 | 4.8 秒 |
| 前向加反向中位数 | 21.9 秒 | 4.6 秒 |
| MFU(前向加反向) | 3.9% | 23.5% |
| 每步样本数 | 128 | 168 |
| 训练样本总数 | 64,000 | 84,078 |
| 权重同步中位数 | 8.5 秒 | 6.2 秒 |
| 平均陈旧度 | 1.5 | 2.0 |
| 奖励(前 20 步 → 后 20 步) | 0.145 → 0.438 | 0.145 → 0.416 |

第一轮与第五轮按墙钟时间对比奖励曲线。图片来源:Hugging Face / Hugging Face Blog
最终快了 3.9 倍,多训了 31% 的样本,奖励曲线基本重合。回头看这三处改动:per_device_train_batch_size=1 是照抄参考配方带来的,gradient_checkpointing=True 是训练器的默认值,max_inflight_tasks=128 是团队自己保守设的。**没有一处是架构问题。**桶和代理决定了这套系统能不能跑,这三处设置决定了它跑多快。
国内团队多数不会把训练放在 HF Jobs 上,但这篇的做法并不绑定 HF。按可迁移程度排序:
**第一,指标诊断法可以原样照搬。**无论用的是哪个异步 RL 框架,只要能拿到"训练器等样本的时间""缓冲队列占用"和"生成端被背压卡住的时间"这三类数,就能用上面那条规则判断瓶颈在哪一侧。加 GPU 之前,先看在途请求有没有被一个全局常数卡死——第四轮就是反例:多花一张 H200,换来 4% 的吞吐。
**第二,三处默认值值得逐条对一遍。**参考配方里的 per_device_train_batch_size=1 照搬到大显存卡上跑小模型,GPU 大部分时间在空等;梯度检查点是显存优化,模型放得下时它就是一笔白付的前向;客户端并发上限是全局还是按副本计算,决定了加卡有没有用。
**第三,用对象存储当通道时,先确认 rename 语义。**这套方案依赖"原子重命名发布目录",而 hf-mount 提供的是 POSIX 文件系统语义。如果换成阿里云 OSS 的 ossfs、腾讯云 COS 的 COSFS 这类把对象存储挂成本地目录的 FUSE 工具,目录重命名通常是逐个对象复制再删除,不是原子操作,推理端可能读到写了一半的适配器。搬之前要先测清楚自己挂载工具的行为;用 NAS 这类真正的网络文件系统则没有这个问题。无论哪种,都要保留"No adapter found 只重试该副本、其他错误整体回滚"的逻辑——252 次加载没有一次首发成功,说明可见性延迟是常态而不是意外。
第四,槽位和命名两条规则通用。--max-loras 至少设为 max_staleness + 2,少一个就会在每次同步时悄悄驱逐在用的策略;每个版本用独立的适配器名,否则前缀缓存会把旧权重的 KV 拼到新权重的解码上,唯一的症状是 ratio 缓慢漂移。
想先摸一遍流程,作者给了一条约 15 分钟、20 步的冒烟测试命令:MAX_STEPS=20 RUN_TAG=smoke ./run_all.sh --wait;第五轮的完整配方约 55 分钟。按原文的报价,三个 Job 同时运行大约每小时 20 美元。
关注公众号

扫码关注,获取最新 AI 资讯
3 步完成企业诊断,获取专属转型建议
已有 200+ 企业完成诊断