Agentic RL 后训练,很多团队跑起来之后第一个问题不是模型不收敛,而是 GPU 看起来都在用,训练却一直很慢。问题往往出在资源分配:采样要算力,训练要显存,奖励模型还要输出一个分数,三类任务抢同一批卡,抢得不好就是互相等。港中文和恒生大学近期提出的 Libra,目标就是解决这个资源分配问题。从公开标题看,它在“吞吐”这个指标上给出的结果是最高提升约 3 倍。这篇文章不讨论“有没有用”,直接拆开它解决的是哪个环节、你要怎么在自己的训练环境里复现类似的效果。
先说清楚这个东西到底是什么、适合谁。Libra 不是一个新的基座模型,也不是一个直接拿来生成内容的推理工具,它属于 Agentic RL 后训练的工程层面研究。Agentic RL 后训练需要把采样、策略更新、奖励评估串成一条流水线,流水线里的任意一个阶段拖慢,整体训练时间都会被拉长。Libra 的关注点就是把这条流水线的资源调度理顺,让同一批 GPU 发挥更高的有效吞吐。
文章后面会按下面几条线展开:第一,Agentic RL 后训练里的资源竞争到底发生在哪里;第二,Libra 这类资源分配方案的核心思路是什么;第三,在没有拿到官方源码之前,你能用哪些通用方法验证“资源分配改一改,吞吐会不会涨”;第四,给出调度策略实验的环境准备、配置模板、批量跑实验和问题排查方法。适合读者是正在做 LLM Agent 训练、强化学习后训练、或者是分布式训练平台维护的同学。下面直接进入正文。
1. 核心能力速览
先把 Libra 的定位和关键信息放在一张表里。需要注意,当前输入材料只包含公开标题层面的信息,所以表格里凡是涉及“是否开源”“启动方式”的地方,都按“以项目官方发布为准”处理,不替官方提前下结论。
| 能力项 | 说明 |
|---|---|
| 项目名称 | Libra |
| 提出方 | 香港中文大学、恒生大学(以论文/公开资料为准) |
| 所属方向 | Agentic RL 后训练、资源调度、分布式训练 |
| 核心问题 | Agentic RL 后训练中采样、训练、评估等任务对 GPU 资源竞争激烈,固定分配导致吞吐受限 |
| 关键收益 | 对外公开信息中,吞吐最高提升约 3 倍 |
| 主要对象 | 策略模型、奖励模型、rollout 采样任务 |
| 适用硬件 | GPU 集群,多卡环境;具体显存要求需按模型规模和论文设置 |
| 启动方式 | 需以项目官方发布为准,本文给出通用训练资源分配实验脚手架 |
| 是否支持 API | 论文研究阶段通常以训练日志和实验脚本为主,接口能力待定 |
| 是否支持批量任务 | 训练实验天然支持多配置批量跑,本文会给出批量实验脚本模板 |
| 适合场景 | Agent 后训练、RLHF/RLVR 类训练、多阶段流水线训练性能调优 |
一句话总结:Libra 解决的是“GPU 资源在 Agentic RL 后训练多个阶段之间怎么分”的问题,它的价值点不是单卡训练更快,而是让整个训练流水线的有效产出更高。
2. 问题:Agentic RL 后训练的资源分配瓶颈
Agentic RL 后训练和普通 SFT 不一样,它不是一个“读数据 -> 算损失 -> 更新权重”的简单循环,而是一个多智能体交互式的闭环。一次完整的训练循环通常包含下面几个环节:策略模型根据当前任务输入生成多步推理或动作;奖励模型或规则评估器对生成结果打分;策略模型利用得分做策略梯度更新;为了稳定训练,还可能要维护参考模型和价值模型。这些环节分别对 GPU 资源提出了不同的需求。
这里的关键冲突点在于,各环节最贪心的硬件资源不一样。采样阶段需要高吞吐的文本生成,它吃的是 GPU 算力、显存带宽和并发能力,batch size 可以开得很大,但不需要特别深的训练反向传播。策略训练阶段则需要大显存来放模型、优化器状态和梯度,而且 forward 和 backward 都要占用算力。奖励评估和 Critic 模型虽然单次计算量不一定大,但在多轮 Agent 任务里,每一条轨迹都要被反复评估,数量堆上去之后同样会成为瓶颈。
如果这三类任务各自独占固定的 GPU 数量,问题就会立刻暴露出来。假设你分配 4 张卡给 rollout 采样、4 张卡给策略训练、2 张卡给奖励评估,只要采样速度跟不上训练速度,训练卡就会进入等待状态,GPU 利用率看起来不算低,但有效产出并没有增加。反过来,如果训练速度跟不上采样速度,采样卡就会一直生成大量结果,排队缓存越堆越高,等到需要更新策略时,这批缓存里的旧数据可能已经要作废了。这种“某一阶段等待、另一阶段超产”的情况在训练日志里非常难发现,因为每张卡都在运行,只不过很多卡在跑空循环或者等待队列。
还有一个容易被忽略的问题:Agentic RL 的轨迹长度不是固定的。智能体可能要执行 5 步,也可能执行 50 步,每一步都可能调用工具、读取环境反馈,导致单个样本的耗时方差非常大。固定分配模式下,采样端的并发度是死的,遇到长轨迹就会整体拖慢,而训练端根本不知道采样端发生了什么。反过来,训练端的 batch 大小和更新频率也是死的,采样结果多了也无法临时多分配一点计算资源来“加速消化”。这种动态不匹配,正是 Libra 这类资源分配系统想解决的核心问题。
衡量这个问题严重程度,不要只看“GPU 利用率”,要看“端到端吞吐”。端到端吞吐通常可以用单位时间内完成的策略更新次数、单位时间内处理完的有效轨迹数,或者跑完固定数量训练 step 的总耗时长来表示。固定分配下,任何一个阶段成为短板,整体吞吐就由短板决定。效率高的时候是 70% 到 80%,效率低的时候可能只有 30% 到 40%,剩下的时间都消耗在跨阶段等待上。这也是为什么“吞吐最高提升 3 倍”这个数字有工程意义,它说明原先的分配方案可能产生了大量的调度气泡。
3. Libra 的思路:资源怎么分才能提吞吐
由于当前输入材料没有给出 Libra 的具体论文细节,下面的内容会区分“从公开标题能确定的信息”和“结合同类系统设计做的合理推断”。先说能确定的信息:Libra 的重点在“资源分配”,而且优化的指标是“吞吐”。也就是说,它大概率不是去改某个 loss 函数,也不是去换一个更快的注意力实现,而是把采样、训练、评估任务的资源配额放到一个更智能的调控框架里。
再往下说合理推断。Agentic RL 后训练里的资源分配,通常会有几个设计维度。第一,按阶段划分资源池,比如 rollout 池、训练池、评估池,各池之间可以动态借用;第二,按流水线积压情况调整实例数,比如训练端 backlog 大,就把多余的采样卡让渡给训练;第三,设置任务的优先级和抢占策略,防止某一个长尾任务卡住整条流水线;第四,结合可观测数据做预测,提前调整资源配比,而不是等队列堆起来再处理。Libra 大概率是在这些维度里找了一个比较好的动态调节策略。
从“吞吐提升 3 倍”这个结果看,可以进一步推测它重点解决的应该是动态资源借用。比如某一段时间内任务以长轨迹为主,采样端的吞吐下降,系统检测到训练端喂料不足,就把一部分训练卡临时转换成采样任务;等短轨迹样本多了,训练端积压,再把这部分卡调回来。这类“临时转换”不是只能靠物理换卡,还可以通过同一批 GPU 上调度不同容器或不同进程来实现,核心是避免固定分配。
如果你要在自己的环境里验证这类思路,最直接的方法就是写一个资源调度逻辑,或者用框架自带的动态资源分配能力。以 Ray 为例,你可以给 rollout 任务和训练任务各声明不同的 GPU 资源数量,并在每个训练 step 结束后读取队列长度,重新调整后续任务的数量。这个过程不需要立刻实现 Libra 的全部细节,先把“固定分配”和“动态调整”两个版本的吞吐测出来,就能有一个直观判断。
当然也要说明,Libra 的完整实现一定比通用脚本复杂得多。它可能还要考虑到模型权重如何在不同任务间切换、显存如何重新划分、通信开销会不会抵消收益、调度器本身会不会成为新的瓶颈。参考层面看,动态调度是一个收益上限很高、但工程复杂度也不低的方案。验证时不要只看第一轮实验,要多跑几轮,取稳定的中间值,避免被调度器抖动影响判断。
4. 前置条件与环境准备
在复现或验证 Agentic RL 后训练资源分配方案之前,环境准备可以按下面的通用清单来做。具体版本要以你使用的训练框架和模型代码为准,不需要照抄。
硬件方面,建议至少准备多卡 GPU 环境。单卡也可以验证思想和观察指标,但无法模拟“资源分配”带来的真实收益,因为资源分配的核心是把多张卡在不同任务之间重新划分。显存大小取决于你的策略模型和奖励模型规模,如果做 7B 到 13B 级别的实验,单卡至少需要 24G 以上,并且训练时建议开启 ZeRO 或梯度检查点来降低显存压力。CPU 内存建议不低于 128G,因为 rollout 阶段会缓存大量生成结果。磁盘方面要预留模型权重、数据集、日志和临时缓存的空间,根据实际模型大小评估。
操作系统建议使用 Linux,常见发行版都可以。CUDA 驱动和 PyTorch 版本需要匹配你使用的 GPU。如果你用 Ray 做资源调度,需要安装 Ray 客户端和对应版本的依赖。如果你用 DeepSpeed 做训练,还需要安装 DeepSpeed 并确认它与 PyTorch 版本兼容。rollout 采样如果用 vLLM 或 SGLang 这类推理引擎,要单独确认它们支持的模型格式和量化方式。
不要忽视监控工具。资源分配实验没有监控就等于没有眼睛。推荐使用 nvidia-smi 做即时查看,用 Ray Dashboard 查看任务和资源分配情况,用 Prometheus + Grafana 做长时间记录。下面写一段最简单的即时监控命令,方便你在实验时随时看显存和算力占用:
# 每 2 秒刷新一次 GPU 状态 watch -n 2 nvidia-smi # 查看详细利用率、显存、温度、功耗 nvidia-smi dmon -s pucmet -d 2如果希望记录训练进程每个阶段的等待时间,比较简单的做法是在脚本里打印带时间戳的日志,再用 grep 过滤关键事件。下面是一个日志标记示例:
# 训练脚本内部打印的关键事件 # [2025-06-01 10:00:00.123] [trainer] step 10 start # [2025-06-01 10:00:00.456] [trainer] step 10 wait_for_rollout # [2025-06-01 10:00:05.789] [trainer] step 10 finish环境准备完成后,先跑一个最简单的“单阶段冒烟测试”,确认模型可以正常加载、数据可以正常读取,再进入资源分配实验。不要一上来就起整个分布式集群,否则问题很难定位。
5. 搭建最小实验环境:部署与配置示例
在 Libra 官方仓库还没有公开的部署命令之前,这里给出一套通用的 Agentic RL 后训练资源分配实验脚手架。你拿到官方代码后,把其中“模型路径”“GPU 配额”“训练参数”替换成实际值即可。先看目录结构:
agentic_rl_exp/ ├── configs/ │ ├── fixed.yaml │ └── dynamic.yaml ├── scripts/ │ ├── start_fixed.sh │ ├── start_dynamic.sh │ └── run_batch.sh ├── src/ │ ├── rollout.py │ ├── trainer.py │ ├── reward.py │ └── scheduler.py ├── logs/ ├── outputs/ └── README.md这里的关键不是目录有多复杂,而是要把“固定分配”和“动态分配”两种策略隔离成两套配置,方便对比。下面给一个配置文件模板,里面用gpu_quota表示不同阶段的 GPU 数量:
# configs/fixed.yaml model: policy: "/path/to/policy_model" reward: "/path/to/reward_model" resources: rollout_gpus: 4 train_gpus: 4 reward_gpus: 2 train: batch_size: 8 grad_accum_steps: 2 max_steps: 1000 rollout: max_turns: 20 temperature: 0.8动态配置则可以在resources里加入一个调度策略描述,比如允许rollout_pool和train_pool之间按队列长度互借:
# configs/dynamic.yaml scheduler: enabled: true adjust_interval_steps: 5 queue_weight: 0.6 min_rollout_gpus: 2 min_train_gpus: 2 max_rollout_gpus: 8启动脚本按照配置读取参数,然后用分布式训练框架拉起服务。下面给一个 shell 脚本模板,注意路径需要按实际项目替换:
# scripts/start_fixed.sh export DATA_DIR="./data" export CONFIG_PATH="./configs/fixed.yaml" python -m torch.distributed.run \ --nproc_per_node=8 \ src/trainer.py \ --config "$CONFIG_PATH" \ --output_dir "./outputs/fixed" \ --log_file "./logs/fixed.log"如果你使用 Ray 而不是纯 torch.distributed,脚本可以改成:
# scripts/start_dynamic.sh export DATA_DIR="./data" export CONFIG_PATH="./configs/dynamic.yaml" ray start --head --port=6379 python src/scheduler.py \ --config "$CONFIG_PATH" \ --output_dir "./outputs/dynamic" \ --log_file "./logs/dynamic.log"需要说明,这套代码不是 Libra 的实现,只是验证“资源分配策略是否有影响”的最小骨架。核心思想是:固定策略下所有进程的资源配额保持不动;动态策略下,调度器每隔 N 步读取一次队列长度,决定是否把某类任务缩容,把资源让给当前积压最多的阶段。
6. 功能测试与吞吐验证
在跑真实训练之前,先用一个小规模任务验证整体流程能走通。选择一个较小的策略模型或一个缩短的评测任务,确认采样、训练、奖励评估三个阶段都能正常执行并产出日志。冒烟测试的判断标准是:日志中出现完整的 step 更新记录;采样进程能持续生成 rollout;奖励评估进程能返回分数;模型权重能正常保存。
冒烟测试通过后,再进入吞吐对比实验。建议按下面的流程操作:
先跑固定分配策略。使用fixed.yaml配置,启动训练脚本,记录三个数字:总耗时、每秒完成的有效样本数、GPU 空闲时间占比。这里的“有效样本数”要过滤掉因等待或重试产生的无效样本。然后把日志归档,比如命名为baseline_run_1.log。重复至少 3 次,取中位数,避免单次抖动影响判断。
再跑动态分配策略。使用dynamic.yaml配置,启动动态调度脚本,记录同样的指标。关键观察点是调度器是否真的发生了资源调整。一个简单的日志输出示例如下:
[2025-06-01 10:01:00] step=10 rollout_queue=15 train_backlog=3 => keep rollout=4 train=4 [2025-06-01 10:02:00] step=15 rollout_queue=6 train_backlog=9 => adjust rollout=3 train=5如果日志里没有任何调整动作,说明队列长度没有触发阈值,或者调度逻辑没有生效。这时要先检查队列长度指标是否被正确统计。
除了真实训练,也可以用一个极简的模拟脚本验证“固定分配 vs 动态调整”对端到端耗时的影响。下面这段 Python 代码模拟了两个阶段互相等待的情况,动态策略在检测到队列积压后会将资源转移:
import time def simulate(fixed: bool, total_steps: int = 100): rollout_gpu = 4 train_gpu = 4 queue = 0 elapsed = 0 for step in range(total_steps): # rollout 阶段:每个 GPU 每秒产生 10 个样本,动态模式下部分资源可以转移 produce_speed = 10 * rollout_gpu queue += produce_speed # train 阶段:每个 GPU 每秒消耗 8 个样本 consume_speed = 8 * train_gpu consumed = min(queue, consume_speed) queue -= consumed # 动态模式:每 5 步根据队列长度调整一次资源配额 if not fixed and step % 5 == 0 and step > 0: if queue > 30 and rollout_gpu > 2: rollout_gpu -= 1 train_gpu += 1 elif queue < 5 and train_gpu > 2: train_gpu -= 1 rollout_gpu += 1 time.sleep(0.01) elapsed += 1 return elapsed fixed_time = simulate(fixed=True) dynamic_time = simulate(fixed=False) print(f"fixed total_steps: {fixed_time}") print(f"dynamic total_steps: {dynamic_time}")这个模拟非常简化,只说明一个问题:当采样和训练速度不匹配时,固定配额会产生累积队列或饥饿,动态调整则能让两端尽量平衡。真实训练中还要考虑通信开销、显存分配、模型切换成本,所以模拟结果不能直接等同于真实收益。建议以真实训练日志为准。
判断实验是否成功,重点比较三个指标。总耗时有明显下降,说明调度策略起了作用。有效吞吐提升,说明单位时间内完成的有效工作变多。GPU 空闲等待占比下降,说明资源气泡被压缩。如果三项指标都没有变化,就要检查是不是存在其他瓶颈,比如单卡算力已经跑满、通信带宽受限、数据加载过慢。
7. 批量实验与接口对接
资源分配实验通常要跑多组配置,而不是只跑两个配置文件。比如你希望对比“固定 4+4+2”“动态最小 2+2+8”“动态最小 3+3+6”等不同配置,就需要一个批量实验脚本,把不同配置依次跑完并保存日志。下面给一个 Python 批量启动脚本模板:
import subprocess import time configs = [ "configs/fixed.yaml", "configs/dynamic_min2.yaml", "configs/dynamic_min3.yaml", ] for cfg in configs: print(f"run with {cfg}") proc = subprocess.run( ["bash", "scripts/start_dynamic.sh", "--config", cfg], capture_output=True, text=True, ) time.sleep(5) with open(f"logs/{cfg.split('/')[-1]}.log", "w") as f: f.write(proc.stdout) f.write(proc.stderr)批量跑实验时,要注意日志命名不能冲突,建议把配置名、时间戳和重复次数都拼进文件名。每次实验之间清理残留进程,避免上一轮的采样进程还在占用显存,影响下一轮结果。如果你用 Ray,每次实验结束后用ray stop --force清理集群。
如果你的训练系统需要和监控平台对接,一个轻量做法是让训练脚本在关键节点通过 HTTP 回调上报指标。下面给一个简单的 HTTP 上报脚本,你只需要在训练的每个 step 结束时调用它:
import requests METRICS_SERVER = "http://127.0.0.1:8000/metrics" def report_metrics(step, rollout_queue, train_backlog, gpu_util): payload = { "step": step, "rollout_queue": rollout_queue, "train_backlog": train_backlog, "gpu_util": gpu_util, } requests.post(METRICS_SERVER, json=payload, timeout=5)这个接口不是给 Libra 用的,它是一个通用的验证脚手架。如果你已经有了 Prometheus 采集体系,也可以直接把指标暴露成/metrics格式,让采集端自动拉取。重点是让资源分配过程中的队列长度、等待时间、资源配额可以被动记录,这样后续优化才能有数据支撑。