Hugging Face 的 Amine Dirhoussi 等人展示了在 HF Jobs 上跑异步 GRPO 的完整工程细节:LoRA 只同步适配器、共享存储桶替代 NCCL、代理做 KV 缓存亲和路由。同一份 recipe 从 3 小时 27 分钟压到 53 分钟,MFU 从 3.9% 提到 23.5%——每一步的诊断路径都写进了指标里。
本文基于 Hugging Face 团队 2026 年 9 月 10 日发表的《Async GRPO with LoRA across HF Jobs: a bucket, a proxy, and no NCCL》(作者 Amine Dirhoussi、Quentin Gallouédec、Kashif Rasul、Sergio Paniego),原文见 huggingface.co/blog/asyncgrpo-lora-hfjobs。我在原文之外接入了 Thinking Machines「LoRA Without Regret」的信息论论证,用来解释为什么整套架构成立的物理前提是 rank-1 就够——不理解这一步,后面的桶和代理都只是奇技淫巧。
强化学习训练最痛的地方在于「训练器和采样器必须紧耦合」。GRPO 每几个优化器步就要把最新权重推给推理端,让 rollout 用得上最新策略。传统做法是训练和 vLLM 共享节点,靠 NCCL 或本地文件系统直接搬 tensor。可一旦搬到「一个容器一台机器」的云环境(比如 Hugging Face Jobs),跨节点没 NCCL、没共享磁盘、连 localhost 都不通,这套逻辑瞬间失效。每次同步搬 3GB 的权重穿越公网,训 500 步花几小时都不够。
HF 团队的解法看起来很朴素:只训 LoRA 适配器,只同步适配器。rank-1 的 LoRA 适配器对于 1.5B 模型只有几 MB,走一个挂在所有 Job 上的存储桶就行——桶就是那个「延迟一点、但够用」的共享文件系统。5 次调参跑下来,同样 500 步的训练从 3 小时 27 分压到 53 分,模型 FLOPs 利用率(MFU)从 3.9% 拉到 23.5%,reward 曲线几乎完全重合。这不是「快了一点」的量级,是 3.9 倍。

架构图。TRL 只跟本机的代理讲话,代理转发到两台 vLLM,适配器目录走桶。图片来源:Hugging Face 官方博客
如果 rank-1 只是「凑合能用」,这套架构根本没意义——每次同步几百 MB 的中等秩适配器一样搬不动。所以 Thinking Machines 那篇《LoRA Without Regret》是这里绕不过去的前提。他们在 MATH、GSM8K、DeepMath 这几个数学推理任务上,用 policy gradient 类算法(就是 GRPO 家族)系统对比了 LoRA 和 full fine-tuning,结论是:即便 rank 低到 1,LoRA 在 RL 上也能追平 full fine-tuning 的表现。
证据里最有说服力的一环是信息论论证:每一个 RL episode 通过 advantage function 大约只提供 O(1) 比特的有效学习信号——不像 SFT 每 episode 提供 O(tokens) 比特。他们估算 MATH 数据集上 rank-1 LoRA 对 1.5B 模型有 300 万参数量,即便按每参数 1 比特信息容量算,整个训练过程的总信号量也远远填不满这些参数。换句话说:瓶颈是信号量,不是容量。你上更高的 rank,多出来的容量只是空转。
理解了这一层,HF 团队接下来的所有工程选择才有依据:能拿 rank-1 打天下,就不用担心桶传得慢、担心适配器多占内存、担心跨机搬运的开销——这些都被压到工程可接受的范围内。
整个系统就四个组件:
训练 Job:跑 AsyncGRPOTrainer(TRL v1.14 起支持 LoRA-only 同步,PR #7017),h200×2 双卡 FSDP。每几个优化器步(默认 4 步)保存一次适配器到 <output_dir>/.vllm_lora/trl-policy-v{N},原子改名发布,然后 POST 到 vLLM 的 /v1/load_lora_adapter 端点。
vLLM 副本:两个(或三个)独立 Job,各占一张 H200,跑 vllm/vllm-openai:v0.27.1 镜像。启动时开三个开关:--enable-lora、--max-lora-rank 1、--max-loras 6。为什么是 6?因为 max_staleness=4,训练器要求 vLLM 同时挂载「当前策略 + 前 4 个版本」共 5 份适配器,加上换版本瞬间需要多一个槽位承接新版,所以是 6。少一个,vLLM 就会在同步瞬间静默驱逐一个仍有 rollout 在跑的策略版本。
存储桶:通过 hf-mount 以 FUSE 文件系统的形式挂在三个 Job 的同一路径 /lora 下。训练器写入,vLLM 只读挂载。TRL 和 vLLM 的代码一个字都没改——它俩都以为在跟本地磁盘打交道。
代理:跑在训练 Job 的 127.0.0.1:8000,TRL 只跟它讲话。
这里有一个非常容易踩的坑,几乎所有想复用 vLLM 推理端点的团队都会独立重新发现它一遍:适配器必须带版本号。vLLM 的前缀缓存(prefix cache)是按适配器名字做键的。如果每次都用同一个名字覆盖,权重变了但同名的旧 KV blocks 还在缓存里匹配得上——一个 rollout 的 prefill 走的是老版本、decode 走的是新版本,训练器完全察觉不到,只会看到 ratio 指标从 1.0 慢慢漂移。带版本号(、)就是让「一个名字永远只对应一份权重」,缓存要么命中要么整段作废,永远不会串。
免费获取企业 AI 成熟度诊断报告,发现转型机会
trl-policy-v1trl-policy-v2如果只是加鉴权头,代理没必要专门写。它真正必要,是因为有两件事只有代理能做:
广播加载:适配器要在两台 vLLM 上同时可用,才能让 TRL 假装自己面对一个逻辑上的推理服务器。代理的 /v1/load_lora_adapter 走的是 all-or-nothing 语义:任何一台加载失败,已经加载成功的那台要马上 unload 回滚,返回 504。原因是 FUSE 挂载偶尔会有传播延迟,第一次没读到很正常,代理里带一个 0.5 秒的重试就能把 252 次加载做到 100% 成功——其中 246 次一遍过,6 次要第二次。
KV 缓存亲和路由:这是全篇最工程化的一段。vLLM 内部把 KV cache 按 16-token 的块(block)粒度缓存,同一个前缀已经算过的块下次可以复用,跳过 prefill。GRPO 训练里同一个 prompt 会被采样 8 次,如果 8 次全都路由到同一台 vLLM,后面 7 次 prefill 就能直接命中。代理的路由算法是这样的:
跑完一轮 64,728 次 rollout 的分布是:亲和命中 84.5%(54,712 次),因为负载不均衡而放弃亲和的溢出 1.3%,全新 prompt 只能均匀分发的 14.2%——理论最小值是 12.5%(每 8 个 rollout 里必然有 1 个是首次出现)。这意味着大约 35,000 次 prefill 被完全跳过。
这一段是这篇博客对做 RL 训练工程的读者最直接的启发。作者没有一开始就给最优参数,而是从一份「能跑但慢」的配置出发,一轮一轮读指标、改一个变量、再读指标。三个指标是他们的诊断三剑客:
sample/rollout_queue_size:训练器和采样器之间的缓冲队列,看它是满的还是空的perf/rollout_wait_s:训练器等采样的时间rollout/backpressure_s:采样器因为队列满而被憋住的时间判断规则:队列满 + 训练器不等 + 采样器被憋 → 训练器慢;队列空 + 训练器在等 + 采样器不憋 → 采样器慢。

Run 2 上 token budget packing 的效果。图片来源:Hugging Face 官方博客
Run 1(基线):3h 27min,MFU 3.9%。每步 22.9 秒,前向+反向 21.9 秒。队列 476/512 常满,采样器空转。判定:训练器是瓶颈。为什么这么慢?batch/samples_per_row=1.0,也就是一行只装一个样本、64 个微批。GPU 大量算力浪费在 padding 上。
Run 2:token_budget=16384、gradient_accumulation_steps=6。让每一行装满 token 而不是每一行一个样本。结果:每行 12.7 个样本、微批 6 个、步时 5.9 秒、MFU 提到 19%。生成端从 4.6k tok/s 涨到 25k tok/s,副本终于不闲。这一步是 4 倍加速,也是全部 5 轮里收益最大的一步。
Run 3:关闭 gradient checkpointing。checkpointing 会在反向传播里重算前向,代价是「反向≈2×前向」。但 LoRA 训练里 base weights 冻结,反向可以只算适配器那部分。关掉后反向少 1 秒,队列从 420 掉到 71——瓶颈第一次翻到生成端。
Run 4:加第三个 vLLM 副本。但奇怪的是加速不明显,rollout/inflight 卡在 128。翻代码发现是客户端的 max_inflight_tasks=128 上限撞到了,不是 vLLM 满载。这里的教训是:加副本前先确认瓶颈是不是 GPU,不然只是给不用的机器付钱。
Run 5:把 max_inflight_tasks 提到 384、queue_maxsize 提到 768。三个副本各拿 128 并发。53 分钟跑完 500 步,MFU 23.5%,生成 27.5k tok/s。队列又满了,瓶颈又回到训练器。这时候再加副本就没意义了,只有换更多训练卡才有效。

同一个 recipe,同样的 reward 终点,run 5 用 52 分钟走完 run 1 需要 3 小时 26 分的路。图片来源:Hugging Face 官方博客
reward 曲线的重合本身是最重要的验证:这不是拿收敛质量换速度,是同一个训练结果、同一个终点、同一个 ratio≈1.000,只是把中间的等待都消灭了。
第一,桶就是一个「延迟一点的共享文件系统」。这个视角本身就是一次心智解绑。绝大多数分布式训练的教程都在教你怎么在 NCCL、Ray、TorchDistributed 之间选,好像跨机通信必须是同步的、必须是 tensor 级的。这篇的思路是:如果我要同步的东西只有几 MB、每几秒同步一次,我完全可以用一个最普通的对象存储加一次原子改名把它变成一个软性的同步机制。它不 real-time、有几百毫秒延迟,但 GRPO 那种「大约每几步同步一次」的场景压根不需要 real-time。
第二,前缀缓存 + 版本号的问题会在任何复用推理服务器的训练场景里出现。不止 vLLM,任何带 KV cache 的推理系统都会碰到「权重更了、cache 没更」的情况。工程惯用的解法是要么显式把 cache 冲掉、要么让新旧策略走不同的命名空间。HF 团队选了后者——这个思路可以直接搬到 SGLang、TensorRT-LLM 上,只要你的推理服务器支持按 adapter/model 名做 cache 隔离。
第三,性能优化必须靠指标而不是靠感觉。5 轮里每一次改动都对应一个可测的现象:队列满不满、wait 长不长、backpressure 高不高。这三个数就是一台 RL 训练机的血压计。如果你的训练系统连这三个数都没有埋点,那么你所有的「跑起来慢」都只是猜测。这一点对国内很多快速搭起 RL pipeline 的团队尤其相关——很多系统跑到「能训出来」就停了,从来没测过 MFU、从来没看过队列长度,「慢」和「快」之间的路径根本无法复现。
HF 团队给了完整可跑的代码:github.com/AmineDiro/hfjobs-lora-buckets。快速验证只要 15 分钟(MAX_STEPS=20 ./run_all.sh --wait),跑完整 run 5 的最优配置 55 分钟左右,按 4×H200 的价位大约 18 美元一轮。
配套是三份文档:TRL 的 AsyncGRPOTrainer 文档写清楚了所有可观察的指标,Storage Buckets 讲桶怎么建,hf-mount 讲挂载的 FUSE 层。
如果你想自己 fork 出一份国产云版本,需要替换的只有两件事:一个能被多机以 FUSE 挂载的对象存储(阿里 OSS、腾讯 COS 都有类似能力,加个 s3fs 或 ossfs 就行),以及一个能像 HF Jobs 那样跨机器分配 GPU 容器的编排层(自建 K8s + gpu-operator 就够)。核心的三个组件——LoRA-only 同步、带版本号的适配器、KV 亲和路由——都是与云平台无关的,全部在 TRL/vLLM 侧。
这篇博客最反常识的一点,其实不是技术。它花了大量篇幅记录了 5 轮跑的每一次弯路:一开始 MFU 只有 3.9%、加副本以为会快但被客户端限流卡住、以为需要更大 batch 但发现是 padding 浪费。大部分工程博客只写「最优解」,这一篇把「怎么找到最优解」摊在桌面上。这种写法比直接给一个最终参数表值钱得多——参数表 3 个月就过期,方法论不会。

关注公众号

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