先给结论:RL 的真正瓶颈是推理,不是训练
这次我们聊一个偏工程架构,而不是具体工具的主题:强化学习(RL)在 LLM 上训练时,最容易被忽略的性能瓶颈,是推理(Inference)侧。
很多团队在扩展 RL 训练时,第一反应是加训练 GPU、调并行策略、换更大的模型。但实际跑下来会发现,训练卡的利用率上去了,整体吞吐却上不去。问题往往不在训练本身,而在推理。
原因也很直接:RL 训练循环不是普通的“一次前向 + 一次反向”。策略模型要反复生成样本,奖励模型要给每个样本打分,每一步都依赖推理结果。推理一旦变慢,训练就只能等着。
传统做法是把推理塞在训练进程里跑,省事,但代价很大。推理和训练的算力需求不一样、显存需求不一样、并行方式也不一样,硬塞在一起,训练被推理拖住,推理又因为训练调度而无法做动态批次优化。
更好的思路是:把推理从 RL 训练循环里拆出来,作为一个独立服务集群,独立扩缩容。这就是标题那句话的意思——RL Is Bottlenecked by Inference. Scale It Independently。
这篇文章会展开四件事:推理为什么成为 RL 瓶颈;独立扩展推理集群的架构思路;接口和调度怎么设计;以及实际落地时要观察哪些指标、避哪些坑。适合正在做 RL 训练、LLM 推理服务化,或者准备上大规模 RL 的团队阅读。
1. 核心观点速览
| 维度 | 说明 |
|---|---|
| 问题 | RL 训练循环反复依赖推理生成样本,推理速度直接决定训练上限 |
| 核心瓶颈 | 推理与训练混合部署时互相抢占资源,无法独立扩展 |
| 典型表现 | 训练卡利用率低、RL 冷启动阶段更明显、样本生成效率低 |
| 解决思路 | 推理服务独立部署、独立扩缩容、接口化访问 |
| 关键技术 | 异步采样、动态批次、前缀缓存、优先级队列、可观测监控 |
| 收益 | 训练吞吐更高、推理成本更可控、系统可扩展性更好 |
| 代价 | 系统复杂度增加、需要额外维护推理服务、网络通信开销 |
| 适用场景 | 大规模 RL 训练、RLHF/RLVR、在线策略迭代、多模型并发训练 |
| 不适合场景 | 小型实验、玩具项目、单机调试阶段 |
一句话总结:训练和推理要解耦,才能各自优化。把推理当作独立服务,而不是训练进程里的一个函数调用,这一步是 RL 工程化的关键。
2. 为什么推理是 RL 训练的瓶颈
2.1 RL 训练循环对推理的依赖
先看一个典型的 RL 训练循环。
策略模型根据当前状态生成动作或文本,环境返回奖励,然后基于奖励更新策略。在 LLM 场景下,策略模型的“动作生成”就是一次完整的推理过程。这意味着每个训练 step,策略模型要先跑一批生成,拿到结果之后才能计算 loss 和梯度。
伪代码示意如下:
# 典型 RL 训练循环(同步阻塞式) for iteration in range(max_iterations): # 推理:策略模型生成一批样本 samples = policy_model.generate(prompts) # 这里是推理,很慢 # 奖励:奖励模型打分 rewards = reward_model.score(samples) # 训练:基于样本计算策略梯度 loss = compute_policy_loss(samples, rewards) loss.backward() optimizer.step()问题很明显:generate()是同步调用,训练进程必须等全部样本生成完才能继续。如果生成一个 batch 需要 30 秒,那不管训练卡多强,每个 iteration 至少 30 秒起步。
2.2 推理与训练的资源冲突
推理和训练对资源的需求逻辑完全不同。
训练阶段是“算力密集”,大量矩阵乘法,需要高吞吐计算,通常 batch size 可以拉得很大。推理阶段是“延迟敏感 + 吞吐敏感混合”,既要结果,又要吞吐,而且生成是自回归的,无法像训练那样通过大 batch 完全掩盖延迟。
如果推理和训练共用一块卡,问题更复杂。训练要显存放梯度、优化器状态、中间激活值;推理要显存放 KV Cache。两者同时跑,显存很快会爆,最后只能互相挤压 batch size。
更麻烦的是,推理的负载是不均匀的。比如 RL 刚启动时,策略模型还没有形成稳定输出,生成质量波动大,集束搜索和采样次数也会增加,导致推理负载瞬间升高。这个阶段可以称为 RL 冷启动,推理压力反而是最大的。
2.3 冷启动问题
RL 冷启动的“冷”,不只是模型权重没训练好,还包括缓存系统冷。
RL 刚开始时,策略模型每轮生成的 prompt 和动作都比较随机,分布不稳定。如果你用前缀缓存(Prefix Cache)来加速推理,冷启动阶段缓存基本不命中,每条请求都要重新计算完整 KV Cache,推理服务响应时间会明显偏高。
而且冷启动阶段模型输出质量不稳定,如果还加了 reject sampling 之类的过滤机制,大部分生成样本可能被丢弃,有效样本率很低。这时候推理资源花了很多,但有效训练样本很少,整体效率很受影响。
冷启动问题在混合部署时更严重,因为推理服务没有独立的扩展能力,负载高了也只能硬扛。独立部署之后,至少可以让推理集群在冷启动阶段临时扩容,等策略稳定后再缩容。
2.4 混合部署为什么难优化
混合部署还有一个根本问题:两种负载的最优并发策略不同。
训练任务讲究稳定性,希望 batch 尽量固定,算力均匀分配。推理任务是离散请求,延迟波动大,需要动态批次(Continuous Batching)把不同请求拼到同一个 batch 里。
如果推理和训练共用一批计算资源,两个调度器互不认识,最后要么训练优先导致推理排队,要么推理优先打乱训练节奏。无论哪种情况,总吞吐都很难做到最优。
独立扩展推理集群之后,训练集群和推理集群各用各的资源池,互相不干扰。训练集群追求高吞吐,推理集群追求低延迟和高并发,各自可以独立调优。
3. 独立扩展推理集群的架构思路
3.1 目标架构
把推理从 RL 训练循环里抽出来,不是简单换个端口,而是要在训练进程和推理服务之间加一层异步接口。
建议架构如下:
训练进程/采样器 ↓ 异步提交生成请求 任务队列 / 消息总线 ↓ 调度 推理服务集群(独立 GPU 池) ↓ 返回生成结果 奖励模型/环境评分 ↓ 训练进程计算 loss核心变化是:训练进程不再直接调用推理函数,而是把推理请求发给任务队列,推理服务集群消费队列,完成后异步回传结果。这样训练和推理之间是松耦合的,两边可以独立扩缩容。
3.2 为什么用异步而非同步
同步调用虽然简单,但训练和推理之间是强绑定,推理慢一点,训练就等。异步之后,训练可以在等待推理结果时做别的计算,比如处理上一个 batch 的奖励计算、更新参数、准备下一批 prompt。
异步也会引入复杂度:你要管理任务队列、处理超时、重试、重复结果。但在大规模场景下,这些复杂度是值得的。推理服务本身有自己的批处理逻辑,如果训练侧永远同步等待,推理服务的动态批次优化就发挥不出来。
建议第一版先做同步接口,把流程跑通;等确认推理确实是瓶颈后,再改成异步队列。不要一开始就上复杂队列。
3.3 推理服务独立扩展的三个层次
所谓独立扩展,至少包含三个层次:
第一是资源池独立。推理服务要有独立的 GPU 资源池,不能和训练共用显卡。否则“独立扩展”就只是逻辑上独立,物理上还是互相抢资源。
第二是调度独立。推理服务用自己的调度器,支持动态批次、优先级队列、缓存复用,不受训练调度影响。
第三是容量独立。推理服务可以单独扩容和缩容,根据队列长度、请求延迟等指标自动伸缩。RL 训练早期和后期,推理负载差异很大,能独立扩缩容的价值很直接。
4. 推理服务接口与调度设计
4.1 接口协议
第一版建议走 gRPC 或 HTTP,不要自己造二进制协议。训练侧和推理服务之间至少需要三个接口:
- 生成:提交 prompt,返回文本和 token 数量。
- 分数:可选,奖励模型打分可以直接做成另一个推理服务。
- 取消:如果训练提前终止,能取消未完成请求。
下面是 gRPC 风格的接口示意:如果想要避免作者个性尾端,可能用更直接的方式收尾,但为了符合CSDN博客习惯和套路,还是需要有结尾。既然要求“避免AI套路化结尾”,最好用真实经验收窄,比如“先把推理独立出去,再想扩展的事”这种可操作收尾。
service RLInferenceService { rpc Generate(GenerateRequest) returns (GenerateResponse); rpc Cancel(CancelRequest) returns (CancelResponse); } message GenerateRequest { string prompt = 1; int32 max_tokens = 2; float temperature = 3; int32 num_return_sequences = 4; } message GenerateResponse { repeated string texts = 1; int64 num_tokens_generated = 2; float latency_ms = 3; }HTTP 的话类似,POST /generate,JSON 请求体,返回文本和性能统计。第一版不需要太复杂的 schema,重点是让训练侧能拿到生成结果和 token 统计,方便算吞吐。
4.2 动态批次和优先级队列
推理服务内部要支持动态批次(Continuous Batching),目前主流推理框架基本都有,不需要自己写。但要注意给 RL 场景配优先级队列。
RL 里的推理请求并不是同等重要的。有些请求是探索用的,用来扩展样本空间,重要性不高;有些请求是 exploit 用的,直接决定当前策略质量,需要低延迟返回。
建议在请求里加一个 priority 字段,推理服务根据优先级决定调度顺序:
class Request: def __init__(self, prompt, priority=5): self.prompt = prompt self.priority = priority self.create_time = time.time() # 调度时按优先级排序,优先级相同则按先来后到 import heapq queue = [] heapq.heappush(queue, (-req.priority, req.create_time, req))这只是示意代码,实际生产环境建议直接用消息队列的优先级能力,比如 RabbitMQ 的 priority queue,或者 Kafka 里按优先级分 topic 消费。
4.3 缓存复用
RL 场景里,prompt 的复用率其实很高。大量 prompt 是同一个任务模板,只有一小部分变化。如果推理服务支持前缀缓存,可以直接跳过重复部分的 KV 计算,大幅降低推理成本。
实践上要注意几点:缓存 key 要设计好,建议用 prompt 的 token 哈希作为 key,而不是 prompt 字符串;缓存的淘汰策略要配合 RL 训练节奏,频繁更新策略时缓存命中率会下降;冷启动阶段缓存基本空,这是正常的,不需要提前预热。
5. 性能观测与资源监控
5.1 必须盯的指标
独立部署推理服务之后,一定要有指标监控,否则“独立扩展”就是盲人摸象。建议至少盯下面这些:
| 指标 | 说明 | 健康范围建议 |
|---|---|---|
| 推理服务吞吐 | tokens/s,整体生成能力 | 需按模型规模和 GPU 规格测试 |
| 首 token 延迟 | 从请求到首个 token 返回的时间 | 越低越好,需持续观察 |
| 端到端延迟 | 一次请求从提交到完全返回 | 需按 max_tokens 设置判断 |
| 队列长度 | 当前待处理请求数 | 持续增长说明推理服务容量不足 |
| GPU 利用率 | 推理集群平均利用率 | 低于 50% 要考虑缩容或调整批次 |
| KV Cache 命中率 | 前缀缓存命中比例 | 冷启动阶段低,稳定后应提升 |
| 训练 waiting 时间 | 训练等待推理结果的比例 | 高说明推理仍是瓶颈 |
5.2 如何判断推理是不是瓶颈
一个判断方法:训练进程每个 iteration 的时间里,真正在等推理结果的时间占比。如果超过 40%,说明推理已经在拖后腿。
可以做一个简单的估算:
iteration_wait_time = total_generation_time iteration_total_time = total_forward_time + total_backward_time + total_generation_time bottleneck_ratio = iteration_wait_time / iteration_total_time if bottleneck_ratio > 0.4: print("推理是主要瓶颈,建议独立扩展推理集群") else: print("当前训练和推理相对平衡,可以继续观察")这个比例不需要很精确,主要是判断趋势。随着训练规模扩大,如果等待比例持续上升,说明推理越来越跟不上。
5.3 成本估算
独立扩展推理集群会带来新的成本。最直接的是资源成本:推理服务需要独立的 GPU 池,不能白嫖训练集群的剩余算力。这部分成本要单独核算,不要混在训练成本里。
从算力角度看,RL 训练里推理占比很高。一个典型任务是:训练参数量=1B 的模型,RL 训练中生成样本的推理 token 数是训练 token 数的几倍到几十倍。如果推理不优化,整体算力成本很难降下来。
6. 独立扩展落地步骤
6.1 第一阶段:同步接口打通
先把推理逻辑包装成独立服务,提供 HTTP 或 gRPC 接口,训练侧改成调用远程服务。这一步不改架构,只是把“内存中函数调用”改成“网络服务调用”。
训练进程 -> HTTP 调用 -> 推理服务(独立 GPU)-> 返回结果 -> 训练进程这个阶段会发现一些额外开销:网络传输 prompt 和生成结果、序列化反序列化、并发连接管理。如果这些开销可以接受,就继续;如果延迟太高,再考虑异步方案。
6.2 第二阶段:异步队列和优先级
推理服务的请求改成投递到队列,训练侧不用等单个请求返回,而是批量提交、批量拉取结果。配合优先级队列,把探索和利用的请求分开调度。
这个阶段的效果:训练侧等待时间下降,推理服务吞吐提升。代价是代码复杂度明显增加,需要处理超时、重试、重复结果、日志追踪。
6.3 第三阶段:自动扩缩容
推理服务集群根据队列长度、请求延迟、GPU 利用率等指标自动扩容缩容。
| 触发条件 | 动作 |
|---|---|
| 队列长度持续 30s 超过阈值 | 推理服务扩容 1 个副本 |
| GPU 利用率持续 10 分钟低于 30% | 推理服务缩容 1 个副本 |
| 首 token 延迟超过目标 | 优先加副本或开启前缀缓存 |
| 推理集群内存不足 | 降低动态批次最大值或换更高配置实例 |
自动扩缩容不要一开始就做,先把监控做出来,手动扩容跑一两周,等确定了阈值之后再加自动策略。
7. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 训练进程等待时间很长 | 推理服务容量不足 | 看队列长度和 GPU 利用率 | 扩容推理集群或降低单请求 max_tokens |
| 推理服务 GPU 利用率高但吞吐低 | 动态批次没生效 | 检查推理框架配置 | 开启 Continuous Batching 或增大 max_batch_size |
| 冷启动阶段推理特别慢 | 前缀缓存未命中 | 看缓存命中率指标 | 预填充固定 prompt 模板,或暂时提高延迟容忍度 |
| HTTP 调用超时 | 推理服务过载 | 看服务端日志和排队时间 | 前端加超时重试,后端扩容或限流 |
| 训练和推理两边延迟都高 | 网络传输开销 | 对比本地调用和远程调用延迟 | 推理集群尽量和训练集群同一机房 |
| 奖励模型打分不一致 | 奖励模型和策略模型部署混用 | 检查服务路由 | 奖励模型单独部署,避免被生成任务挤占 |
| 请求取消无效 | 取消接口没有真正中断生成 | 检查推理服务取消逻辑 | 服务端生成循环里增加取消信号检查 |
| 显存不足 | 动态批次拉得太大 | 查看显存使用率 | 降低 batch 大小或开启 PagedAttention |
8. 最佳实践与使用建议
第一个建议:先把推理独立出去,再想扩展的事。很多团队在推理还没拆出来的时候就开始调训练并行策略,方向错了。推理不独立,训练再怎么调都受限于推理速度。
第二个建议:RL 训练里推理服务的质量目标要单独定。训练和推理服务的 SLA 不一样。训练可以容忍较高的延迟,但不能容忍低吞吐;RL 推理服务既要延迟又要吞吐,但不同任务侧重点不同。探索类任务可以放宽延迟,利用类任务要保证延迟。
第三个建议:缓存不是免费的。前缀缓存会占用额外显存,在 RL 冷启动阶段命中率低,反而浪费显存。建议先关掉缓存跑一轮,确认命中率确实成为瓶颈后再开。
第四个建议:异步队列引进来之后,一定要做幂等和去重。推理服务超时重试可能导致重复生成结果,最后污染训练样本。建议在结果里加 request_id,训练侧用这个字段去重。
第五个建议:涉及数据和模型合规时,推理服务如果是跨机器部署,要确认数据传输链路符合内部安全规范。RL 训练涉及大量 prompt 和模型输出,日志要做好脱敏,尤其是涉及用户数据或隐私字段时。
第六个建议:监控先行。没有指标,所有优化都是碰运气。先把 wait time、队列长度、缓存命中率这几个指标收集起来,再决定是否扩展推理集群。
9. 总结与下一步
这个思路最值得尝试的点,是把推理从训练循环里拿出来单独看、单独优化。它不是某个具体框架提供的功能,而是一种架构选择。对于正在被 RL 训练效率问题困扰的团队,先做一个诊断:训练进程的 iteration 时间,有多少是在等推理?如果这个比例高,那独立扩展推理集群就是性价比最高的下一步。
最先应该验证的功能是生成接口的异步改造,先用同步接口跑通,再切异步队列,最后再上自动扩缩容。最容易踩的坑是过早优化:一上来就搞复杂队列和自动扩缩容,结果监控没跟上,出了问题反而定位不了。
后续可以继续扩展的方向包括:推理服务的优先级调度优化、KV Cache 命中率调优、多模型共享推理集群的资源隔离策略、以及 RL 训练框架和推理框架的标准化接口对接。
先把推理独立出去,再想扩展的事。