跳到主要内容

数据截至 (上游 commit cfacd76a0bdd)

03 · 生成侧:推理副本与 AgentLoop

这一章讲什么: RL 的「采样」这一半。推理引擎怎么被部署成服务、请求怎么路由、一条轨迹的生成逻辑写在哪、多轮工具调用怎么做、生成到一半被打断怎么办。


1. 分三层看生成侧

生成这件事在 verl 里被切成三层,各管各的:

┌─────────────────────────────────────────────────────────┐
│ 第三层:AgentLoop —— 一条轨迹的业务逻辑 │
│ 单轮:发一次请求就完事 │
│ 多轮:生成→解析工具调用→执行→拼回上下文→再生成 │
└──────────────────────┬──────────────────────────────────┘
│ server_manager.generate(prompt_ids, ...)
┌──────────────────────▼──────────────────────────────────┐
│ 第二层:LLMServerClient —— 路由与容错 │
│ 粘性会话、最少在途负载均衡、被打断后自动续跑 │
└──────────────────────┬──────────────────────────────────┘

┌──────────────────────▼──────────────────────────────────┐
│ 第一层:RolloutReplica —— 部署形态 │
│ 一个副本 = 一组 GPU 进程 + 一个 OpenAI 兼容 HTTP 服务 │
│ 三种模式:hybrid / colocated / standalone │
└─────────────────────────────────────────────────────────┘

这个分层带来的直接好处: 写一个新 agent(比如「会用搜索引擎的数学 agent」)只需要动第三层,完全不用关心 vLLM 怎么部署、请求打到哪台机器。


2. 第一层:RolloutReplica 与三种部署模式

2.1 什么是一个「副本」

RolloutReplicaverl/workers/rollout/replica.py:70)的 docstring 说得很直白:它等价于你在每个节点上手敲 python -m sglang.launch_server --node-rank i --nnode N

副本大小由并行度算出(verl/workers/rollout/replica.py:107):

world_size = TP × DP × PP
nnodes = world_size / min(每节点GPU数, world_size)

所以一个 8 卡节点、TP=2 的配置会起 4 个副本,每个副本 2 张卡。副本是负载均衡的最小单位。

2.2 三种模式

RolloutModeverl/workers/rollout/replica.py:54)的三个成员,注释直接写明了使用场景:

模式推理与训练的关系何时用初始化方法
HYBRID同一进程内融合,共享 GPU,切换要同步权重on-policy 训练(默认)init_hybrid:131
COLOCATED同一 placement group,不同进程,共享 GPU,切换不需要同步权重GRM(LLM 当裁判)init_colocated:160
STANDALONE独立 GPU 资源,训推分离off-policy 训练init_standalone:189

三者的区别用一张图说清:

HYBRID COLOCATED STANDALONE
┌──────────┐ ┌──────────┐ ┌──────┐ ┌──────┐
│ GPU 0-7 │ │ GPU 0-7 │ │GPU0-7│ │GPU8-15│
│┌────────┐│ │┌───┐┌───┐│ │┌────┐│ │┌────┐│
││进程: ││ ││训练││推理││ ││训练││ ││推理││
││训练+推理││ ││进程││进程││ ││进程││ ││进程││
│└────────┘│ │└───┘└───┘│ │└────┘│ │└────┘│
└──────────┘ └──────────┘ └──────┘ └──────┘
权重同步=进程内 权重不需同步 权重跨机同步
(推理模型是别的模型) (NCCL/NIXL/Mooncake)

注意 HYBRID 模式下 init_hybrid 只是从已有的训练 WorkerGroup 里切出一段 handleverl/workers/rollout/replica.py:138),不新起进程——推理引擎就长在训练进程里。

2.3 具体后端的选择

RolloutReplicaRegistryverl/workers/rollout/replica.py:302)用懒加载函数注册后端,而不是直接 import:

# 示意,非源码:注册的是加载器而非类本身
RolloutReplicaRegistry.register("vllm", _load_vllm)
RolloutReplicaRegistry.register("sglang", _load_sglang)

这样没装 vLLM 的环境也能正常 import verl —— 只有真用到时才触发导入。verl 里这个套路到处都是(引擎、检查点后端也一样)。


3. 第二层:路由

3.1 为什么需要粘性会话

多轮对话的第 2 轮请求,前缀和第 1 轮几乎完全一样。如果打到另一台服务器,prefix cache 全部作废,要重新 prefill 几千个 token。

所以 GlobalRequestLoadBalancerverl/workers/rollout/llm_server.py:46)的 acquire_server 做了两级决策:

acquire_server(request_id)

├─ LRU 缓存里有 request_id?
│ ├─ 有,且那台服务器还在池子里 → 直接用它(粘性命中)
│ └─ 有,但服务器已被移除 → 清掉缓存条目,继续往下

└─ 挑「在途请求最少」的服务器

├─ 只有一个候选 → 用它
├─ 多个候选 + full_determinism → hash(request_id) % len 决定
└─ 多个候选 + 普通模式 → 取第一个

三个细节值得单独点出来:

  1. acquire 是原子的——一次调用同时返回 (server_id, handle) 并把在途计数 +1,避免「查完再选」的竞态。这个 balancer 本身是个 Ray actor,天然串行。
  2. 缓存失效自动处理——服务器被摘掉时不需要遍历清理缓存,取的时候发现失效再清(惰性失效)。
  3. 确定性模式——full_determinism=True 时用 hash(request_id) 打破平局,保证同一个请求在不同次运行里落到同一台机器。这是复现实验的必要条件。

3.2 动态增删服务器

add_servers / remove_serversverl/workers/rollout/llm_server.py:123:134)是批量原子操作。它服务于第 4 章要讲的 separate_async 模式:训练侧空闲时,把 hybrid 引擎临时加进推理池子帮忙生成;要训练了再摘掉(verl/trainer/ppo/v1/trainer_separate_async.py:194-203)。


4. 第三层:AgentLoop

4.1 三个角色

角色是什么位置
AgentLoopBase一条轨迹的生成逻辑,抽象基类,实现 run()verl/experimental/agent_loop/agent_loop.py:204
AgentLoopWorkerRay actor,把一批 prompt 拆成很多 asyncio 任务并发跑:481
AgentLoopManager管一组 worker,按节点轮询分配:1141

注册用一个小装饰器(:470):

@register("single_turn_agent")
class SingleTurnAgentLoop(AgentLoopBase): ...

@register("tool_agent")
class ToolAgentLoop(AgentLoopBase): ...

数据集里每条样本可以带 agent_name 字段选择用哪个 loop;没带就用配置里的 rollout.agent.default_agent_loopverl/trainer/ppo/v1/agent_loop_tq.py:79-81)。

4.2 统一的输出契约

所有 agent loop 都返回 AgentLoopOutputverl/experimental/agent_loop/agent_loop.py:88)。这个 schema 是训练侧的契约,最关键的是这三个字段:

字段含义为什么重要
prompt_ids提示词 token训练时不算 loss
response_ids响应 token,包括工具返回的内容要参与 forward
response_mask1 = 模型生成的,0 = 工具塞进来的只对 1 的位置算策略梯度

response_mask 是多轮 agent RL 的命门:工具返回的文本必须进上下文(模型下一轮要看到),但绝不能当成「模型的动作」去优化——否则模型会学着去「预测工具输出」。单轮场景下这个 mask 全是 1(verl/experimental/agent_loop/single_turn_agent_loop.py:92)。

as_dict():116)负责把它翻译成训练侧字段名,其中有个小细节:reward 是标量,但训练需要 token 级张量,于是它造一个全零向量、只在最后一个位置放上分数:135-137)。

4.3 单轮:最小实现

SingleTurnAgentLoop.runverl/experimental/agent_loop/single_turn_agent_loop.py:38)就四步:

① 从 messages 里抽出多模态数据(图/视频/音频)
② apply_chat_template + tokenize → prompt_ids
③ server_manager.generate(prompt_ids, sampling_params) → token 出来
④ 包成 AgentLoopOutput,response_mask 全 1,num_turns=2

注意第 ③ 步是 token-in-token-out,不走文本。这避免了「文本 → token → 文本 → token」的往返里可能出现的不一致(尤其是特殊 token 和空格处理)。

4.4 多轮工具调用:一个状态机

ToolAgentLoopverl/experimental/agent_loop/tool_agent_loop.py:98)把多轮建模成四态状态机:

PENDING ──► GENERATING ──┬──► PROCESSING_TOOLS ──┐
(拼提示词) (调模型生成) │ (并发执行工具) │
│ │
└──► TERMINATED ◄────────┘
(没有工具调用 / 超轮数 / 超长)

主循环干净得像教科书(:165-174):

state = AgentState.PENDING
while state != AgentState.TERMINATED:
if state == AgentState.PENDING:
state = await self._handle_pending_state(agent_data, sampling_params)
elif state == AgentState.GENERATING:
state = await self._handle_generating_state(agent_data, sampling_params)
elif state == AgentState.PROCESSING_TOOLS:
state = await self._handle_processing_tools_state(agent_data)

所有跨状态的可变量装在 AgentData:56)里一起传——包括正在累积的 prompt_ids / response_mask、轮数计数、以及给工具用的 extra_fields

一个实用技巧: 生成时会把工具解析器的 stop token 注入 sampling_params(:230-232),让模型一吐出工具调用标记就停下来,不浪费算力继续生成。

工具选择还支持按样本过滤:数据集里可以写 extra_info.tool_selection 指定这道题能用哪些工具(:150-161)。这对「训练模型学会挑工具」的实验很有用。

4.5 并发模型

AgentLoopWorkerTQ.generate_sequences(batch)

│ for 每道题:

asyncio.create_task(_run_prompt(...)) ← fire-and-forget,不等结果

│ 在 _run_prompt 里 for i in range(n):

asyncio.create_task(_run_agent_loop(..., session_id=i))

└─ await asyncio.gather(*tasks) → 全组完成 → 标记 finished

verl/trainer/ppo/v1/agent_loop_tq.py:81-124

两级并发:题与题之间并发、同一题的 n 个采样之间并发。所有请求最终汇入负载均衡器,由它摊到各副本。

_run_prompt 里留了个扩展点(:107 的注释):n 可以从 __rollout_n__ 字段按样本读取——允许「难题多采几个」这类动态采样策略


5. partial rollout:生成到一半被打断怎么办

5.1 问题

异步模式下,训练侧到点就要抢 GPU,会调用 abort_all_requests() 打断所有在途生成。一条生成到 800 token 的轨迹被砍掉——直接丢弃就太浪费了。

5.2 解法:客户端层做续跑

FullyAsyncLLMServerClient.generateverl/workers/rollout/llm_server.py:292)在 LLMServerClient 外面套了个 while 循环:

while True:
output = super().generate(prompt_ids + 已生成的部分, ...)
把新 token 追加到 final_output
max_tokens 扣掉已生成的长度
if 已达长度上限: stop_reason="length"; break
if stop_reason 不是 "aborted"/"abort": break ← 正常结束
await asyncio.sleep(1) ← 被打断了,等一秒重试

关键在「对 AgentLoop 完全透明」(类的 docstring 原话:making rollout interruption invisible to the AgentLoop)。上层 agent 代码一行都不用改,它只知道自己 await 了一次 generate,不知道底下重试了 5 次、跨越了 3 个模型版本。

5.3 记录跨越了哪些版本

续跑意味着一条轨迹可能横跨多个权重版本。所以每轮都记:

final_output.extra_fields["min_global_steps"] = min_global_steps # 起始版本
final_output.extra_fields["max_global_steps"] = max_global_steps # 结束版本

verl/workers/rollout/llm_server.py:459-460

这两个值会写进 TransferQueue 的 tag(verl/trainer/ppo/v1/agent_loop_tq.py:216-218),供 ReplayBuffer 做 staleness 判断,也供 rollout correction 评估分布偏移。

还有个容易忽略的正确性处理:MoE 的 routed_experts 在续跑时只追加新生成 token 的路由,不覆盖旧的——因为旧 token 是用旧权重路由的(verl/workers/rollout/llm_server.py:421-429)。


6. 打分:流式还是集中

奖励计算有两条路,由 reward_loop_worker_handles 是否为空决定(verl/trainer/ppo/v1/trainer_base.py:551):

路径何时在哪算优点
流式有 RewardLoopWorker轨迹一生成完就在 agent loop 里打分和生成重叠,不占 step 时间
集中共置需要共置 GPU 上的 RMstep() 第 ③ 步统一算RM 要显存,只能等推理睡了再跑

RewardLoopWorkerverl/experimental/reward_loop/reward_loop.py:93)的决策树 docstring 写得很清楚:

用户提供了自定义 reward 函数?
├─ 是 → 用它(可以自己去 POST 请求 reward_model_router)
└─ 否 ├─ 没开 RM → 用默认规则函数(gsm8k / math / ...)
├─ 开了判别式 RM → compute_score_disrm
└─ 开了生成式 RM → 报错(必须自己写 reward 函数)

规则函数都很朴素,比如 gsm8k 就是正则抓 #### 数字verl/utils/reward_score/gsm8k.py:20)。里面有个性能优化值得一提:只在最后 300 个字符上跑正则(_SOLUTION_CLIP_CHARS),因为答案总在末尾,而长文本上的正则回溯很慢。


7. 代码地图

主题文件路径符号名
副本抽象与三种模式verl/workers/rollout/replica.pyRolloutReplicaRolloutModeinit_hybridinit_colocatedinit_standalone
后端懒注册verl/workers/rollout/replica.pyRolloutReplicaRegistryget_rollout_replica_class
token 级输出契约verl/workers/rollout/replica.pyTokenOutput
负载均衡verl/workers/rollout/llm_server.pyGlobalRequestLoadBalancer.acquire_serveradd_serversremove_servers
客户端 / 断点续跑verl/workers/rollout/llm_server.pyLLMServerClientFullyAsyncLLMServerClient.generate
副本编排verl/workers/rollout/llm_server.pyLLMServerManager
agent loop 基类与注册verl/experimental/agent_loop/agent_loop.pyAgentLoopBaseregisterAgentLoopOutput
并发调度verl/experimental/agent_loop/agent_loop.pyAgentLoopWorkerAgentLoopManager
TQ 版调度(V1 主线)verl/trainer/ppo/v1/agent_loop_tq.pyAgentLoopWorkerTQ._run_prompt_agent_loop_postprocessAgentLoopManagerTQ
单轮实现verl/experimental/agent_loop/single_turn_agent_loop.pySingleTurnAgentLoop.run
多轮工具状态机verl/experimental/agent_loop/tool_agent_loop.pyToolAgentLoopAgentStateAgentData_handle_generating_state
工具调用解析verl/experimental/agent_loop/tool_parser.pyToolParser.get_tool_parser
奖励调度verl/experimental/reward_loop/reward_loop.pyRewardLoopWorkerRewardLoopManager
规则奖励示例verl/utils/reward_score/gsm8k.pycompute_scoreextract_solution