这篇文章对应视频:【[Agentic RL] 12 verl infra AgentLoop 基本概念及流程,AgentLoopManager,hybrid训练与推理】(BV135zrBaEEU)。

如果你已经看完我在上一篇里写的 “Agent Loop 为什么需要 async rollout” 与 “response_mask 基本概念”,那么这篇就是 infra 深挖版:把 verl 的 AgentLoop 体系从“能用”讲到“你能改、能调、能排障”。

你看完应该能回答这些工程问题:

  1. AgentLoopManager / Worker / AgentLoop / AsyncLLMServerManager 各自负责什么,边界怎么划?
  2. 为什么 async rollout 不是优化项,而是 multi-turn tool use 的必要条件?它和 vLLM 的 continuous batching 怎么配合?
  3. sticky session 为什么必须有?它和 prefix cache、load balancing 是什么关系?
  4. “hybrid 推训”到底在复用什么资源?wake_up / sleep 在做什么?什么时候应该直接用 standalone 部署?

系列导航:

关联阅读(建议顺序):

  1. 先建立 AgentLoop 的基本直觉(async + 状态机 + mask)
  2. token-in-token-out:为什么多轮对话里 encode/decode 不一致会直接把 RL 训崩
  3. 理解 KV cache 与吞吐瓶颈:你才知道 hybrid 在省什么
  4. 下一篇:把 AgentLoop 和 Ray trainer 的代码路径串起来

配套仓库(你本地已下载)里,本文主要对齐这些笔记(按推荐顺序):

  • 架构鸟瞰(四层抽象 + hybrid wake_up/sleep):
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_arch.ipynb
  • 细节拆解(async vs sync、状态机、sticky session、output 结构、reward 前置):
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_details.ipynb
  • 代码入口(指向 verl 源码的 agent_loop.py):
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_code.ipynb
  • GPU/Replica 配置(num_replicas vs engine internal DP):
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_config.ipynb
  • 推理模式(inference-only agent loop):
    • /Users/wangpeng/Downloads/modern_genai_bilibili-main/agentic_rl/verl/agent/agent_loop_inference.ipynb

0. 一句话定位:AgentLoop 是“可训练的 LangChain”,区别在于你必须保留 token 级轨迹

很多人第一次看 verl 的 agent loop,会觉得:

这不就是把 LangChain/LangGraph 的 ReAct 流程编排搬进来了吗?

某种意义上没错(agent_loop_inference.ipynb 也明确说推理模式下就是 react)。但 RL 训练让它变得更“硬核”:

  • 你不仅要“跑通流程”,还要产出可用于优化的轨迹数据
    • token ids
    • logp 对齐所需的信息(以及至少能重算)
    • response_mask(区分 action token vs observation token)
    • turn/reward 的挂载位置与口径
  • 你必须在 infra 层面解决:
    • 工具 IO 的长尾延迟(straggler)
    • 多轮请求的路由一致性(sticky session)
    • 推理与训练对同一组 GPU 显存的争抢(hybrid)

所以这篇文章会更像“系统设计与排障手册”,而不是算法推导。


1. 四层抽象:谁负责“怎么聊”,谁负责“怎么调度”,谁负责“怎么生成”

verl 的一个很工程化的拆分是:把 “Agent” 这件事拆成 4 层,每一层只负责一件事(agent_loop_arch.ipynb):

  • AgentLoop:只关心 逻辑(怎么多轮对话、怎么决定调用工具、怎么终止)。
  • AgentLoopWorker:只关心 并发与编排(asyncio 并发跑很多条 loop,把结果合并)。
  • AgentLoopManager:只关心 分发与资源切换(Ray 分发 chunk、hybrid wake_up/sleep、汇总)。
  • AsyncLLMServerManager:只关心 推理服务网关(负载均衡、sticky session、RPC)。

一张图就够(来自 notebook,我稍微改了命名):

1
2
3
4
5
6
graph TD
A[AgentLoopManager] -->|1. 切分 Batch & 分发| B(AgentLoopWorker)
B -->|2. 实例化 & 调用 run| C{AgentLoop <br> e.g. ToolAgentLoop}
C -->|3. 请求生成| D[AsyncLLMServerManager]
D -->|4. RPC 调用| E[Remote vLLM / SGLang Engine]
C -->|5. 执行工具| F[Tools / Environment]

这个拆分真正的价值在于:你改动某个维度时,不会“牵一发动全身”。

  • 想换工具协议/状态机:改 AgentLoop
  • 想调并发粒度:改 Worker(或 worker 数)。
  • 想换推理引擎:改 ServerManager(vLLM / SGLang / OpenAI compatible server)。
  • 想做推训资源复用:改 Manager 的 wake_up/sleep 策略。

2. AgentLoopManager:从 Trainer 接单,到 hybrid wake_up/sleep 的总控

agent_loop_code.ipynb 给出的入口看,训练主控(例如 RayPPOTrainer)在需要新 rollout 时会调用:

AgentLoopManager.generate_sequences(prompts=batch)

它的核心职责可以总结为 3 件事:

  1. 拆 batch:把一个大 batch 切成多个 chunk。
  2. 分发到 Worker:Ray remote 调 worker.generate_sequences.remote(chunk)
  3. 资源切换(可选):如果启用了 hybrid/free_cache_engine,就在生成前后做:
    • wake_up():让推理引擎准备好(权重同步/显存准备/prefix cache 重置等)
    • sleep():让推理引擎释放 KV cache,给训练阶段让路

一个你在工程里一定会遇到的点:

  • hybrid 不是“永远更快”,它是显存复用的节省方案
  • 你的瓶颈如果在网络 IO、tool latency、或者 reward 计算,那 hybrid 可能不会带来收益,反而增加 wake_up/sleep 的切换开销。

我的建议是:

  • 小规模先跑 standalone(推理和训练分不同 GPU),把正确性与指标闭环打通。
  • 再考虑 hybrid,把 GPU 利用率榨到极限。

3. AgentLoopWorker:CPU 节点上的并发器,避免 straggler 拖垮整批

agent_loop_details.ipynb 一开始就强调:

  • Worker 被调度到 CPU 节点上运行;
  • 大量的工具调用与环境交互在 CPU 上异步并发;
  • GPU 侧只负责“生成”,通过 server manager 统一调用。

核心实现范式是:

  • 每个 sample 一个 async task
  • await asyncio.gather(*tasks) 等全部完成后在 Worker 内做一次同步(merge)

这给了你一个很清晰的“同步边界”:

rollout 阶段尽可能异步;update 阶段必须同步。

这个同步边界一旦画清楚,你很多设计会自然变对:

  • 工具输出永远是 observation(mask=0)。
  • 只有 LLM 生成 token 是 action(mask=1,参与梯度)。
  • reward 如果要前置,应该在 “merge 前”完成,把分数挂载到轨迹里。

4. AgentLoop:状态机是 tool-use agent 的通用接口

对 tool-use agent,verl 的实现思路是“显式状态机”(在 agent_loop_details.ipynb 给了一个很标准的 5 状态版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
stateDiagram-v2
[*] --> PENDING: Start

state "PENDING (初始化)" as PENDING
state "GENERATING (模型生成)" as GENERATING
state "PROCESSING_TOOLS (执行工具)" as PROCESSING_TOOLS
state "INTERACTING (环境/用户交互)" as INTERACTING
state "TERMINATED (结束)" as TERMINATED

PENDING --> GENERATING : 准备好 Prompt

GENERATING --> PROCESSING_TOOLS : 模型决定调用工具
GENERATING --> INTERACTING : 无工具调用 & 配置了交互环境
GENERATING --> TERMINATED : 完成任务 / 达到最大轮数 / 达到长度限制

PROCESSING_TOOLS --> GENERATING : 工具执行完毕 (更新上下文)
PROCESSING_TOOLS --> TERMINATED : 上下文超长

INTERACTING --> GENERATING : 收到外部反馈 (更新上下文)
INTERACTING --> TERMINATED : 交互结束 (should_terminate=True)

TERMINATED --> [*]: End

这套状态机的工程意义是:

  • 你后续想加 “planner / memory / tool router / user simulator”,都能自然插到对应状态里。
  • 你想做并发限制(比如 max_parallel_calls),也能在 PROCESSING_TOOLS 里做得很干净。

同时它强迫你把 “action vs observation” 分清楚:

  • GENERATING 输出的是 action token(参与梯度)。
  • PROCESSING_TOOLS / INTERACTING 输出的是 observation(不参与梯度)。

5. async 为什么不是优化项:Sync 训练会把 agent 绑成“旅行团”

如果你用 batch 同步推进 multi-turn,会发生一种非常典型的“木桶效应”(notebook 把它讲得很形象):

  • 同一个 batch 里,只要有一个 agent 慢(生成长 / tool 慢 / 网络慢),整个 batch 就卡住;
  • 更糟的是:LLM 生成与 tool 执行是交替的,sync 模式会让快 agent 也必须等慢 agent 才能进入下一轮生成。

对比一下伪代码(摘自 agent_loop_details.ipynb):

1
2
3
4
5
# Sync: 强制绑定的“旅行团”
outputs_round_1 = llm.generate(prompts) # 卡点 A:等所有人生成完
observations = run_tools(outputs_round_1) # 卡点 B:等所有人跑完工具
prompts_round_2 = update_history(...)
outputs_round_2 = llm.generate(prompts_round_2) # 卡点 C:下一轮也得等
1
2
3
4
5
6
# Async: 各跑各的“自由行”
async def run_agent_loop(agent_id, prompt):
while not done:
response = await llm_server.generate(prompt)
observation = await run_tool(response)
prompt = update_history(prompt, response, observation)

真正关键的一句是:

await 只挂起当前 agent,不挂起别人。

于是 “快任务” 可以不断发起下一轮生成请求,vLLM/SGLang 的 continuous batching 就能持续吃满 GPU。


6. AsyncLLMServerManager:负载均衡 + Sticky Session(多轮必需)

6.1 为什么必须 sticky:多轮对话需要 prefix cache 命中

如果多轮请求被随机路由到不同 server:

  • prefix cache 命不中(每轮都当新 prompt),吞吐会掉;
  • 更糟的是:不同 server 的 tokenizer/chat template/状态如果不完全一致,你会把 token-in-token-out 彻底破坏。

所以 verl 用了一个很直接的设计:

  • 用 LRU 维护 request_id -> server 映射(同一 trajectory 固定路由)
  • 用最小堆维护 server 的“当前负载”(粗粒度的请求计数)

agent_loop_details.ipynb 给出了一个非常直观的代码骨架(我原样贴出来,便于你对齐实现思路):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class AsyncLLMServerManager:
def __init__(self, config, server_handles, max_cache_size=10000):
self.server_handles = server_handles # Ray actor handles

# min-heap for load balancing
self.weighted_serveres = [[0, (hash(server), server)] for server in server_handles]
heapq.heapify(self.weighted_serveres)

# sticky routing: request_id -> server
self.request_id_to_server = LRUCache(maxsize=max_cache_size)

def _choose_server(self, request_id: str):
if request_id in self.request_id_to_server:
return self.request_id_to_server[request_id]

server = self.weighted_serveres[0][1][1]
self.weighted_serveres[0][0] += 1
heapq.heapreplace(self.weighted_serveres, self.weighted_serveres[0])

self.request_id_to_server[request_id] = server
return server

6.2 你一定要想清楚:request_id 的定义与生命周期

sticky 的关键在 request_id

  • 必须是 “同一 trajectory 跨轮一致” 的 id;
  • 且要避免不同样本冲突(否则会错误粘连);
  • 还要考虑 LRU 淘汰后继续请求会被重新分配 server(notebook 也提示了这一点)。

工程上我更建议你:

  • trajectory_id(而不是 “当前 turn id”)作为 sticky key;
  • 在日志里把 trajectory_id -> server_id 打出来(排障时非常关键)。

7. AgentLoopOutput:交错轨迹(interleaved trajectory)必须显式带 mask

agent loop 的输出不是“纯 LLM token 序列”,而是交错的:

  • LLM 生成 token(action)
  • tool 返回 token(observation)
  • LLM 继续生成 token(action)

所以输出结构里至少要包含:

  • response_token_ids:包含 LLM 与 tool 的所有 token
  • response_mask:LLM token=1,tool token=0
  • num_turns:多少轮 user/assistant/tool(用于 shaping 或统计)

这件事在 PPO/GRPO 里会直接影响梯度:

  • 你只能对 mask=1 的 token 计算 logp、ratio、KL、entropy;
  • tool 输出如果混进去,会污染分布,导致训练发散(这也是 token-in-token-out 那篇强调的点)。

8. Reward 前置:async rollout 下把 RM 打分放进 rollout 阶段

agent_loop_details.ipynb 给了一个非常重要的时序图:在 async 模式下,reward 计算被前置到了 rollout 阶段内部完成,Trainer 在 training phase 里可以直接跳过 compute_rm_score。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant Trainer as RayPPOTrainer
participant Manager as AgentLoopManager
participant Worker as AgentLoopWorker
participant RM as RewardManagerWorker

Trainer->>Manager: generate_sequences()
Manager->>Worker: distribute tasks

rect rgb(240, 248, 255)
Note over Worker: Async Rollout Phase
Worker->>Worker: agent_loop.run()
Note over Worker: Reward Calculation Trigger
Worker->>RM: compute_score(Full Trajectory)
RM-->>Worker: return reward_score
Worker->>Worker: _postprocess() (Attach score to last token)
end

Worker-->>Manager: Return Batch (with rm_scores)
Manager-->>Trainer: Return Batch

这一步的好处是:

  • rollout 阶段就能把 “轨迹 + reward” 封装完整;
  • training phase 可以更纯粹地做 advantage / loss / update;
  • 并且在多 worker 并发下,reward 的吞吐也更容易 scale。

但它也带来一个新挑战:你要更清楚 reward 的挂载口径(挂最后 token?按 turn 分配?是否加 tool shaping?)。


9. Hybrid 推训:wake_up / sleep 到底在干嘛

agent_loop_arch.ipynb 把 hybrid 的动机说得很直白:

  • 显存宝贵;
  • 训练(FSDP/ZeRO)和推理(vLLM/TP)对显存的占用形态完全不同;
  • 如果推理引擎一直常驻,会吃掉大量 KV cache,训练可能直接 OOM;
  • 所以需要在 “rollout 阶段” 与 “update 阶段” 之间切换推理引擎状态。

在 verl 里,这个机制通常通过:

  • wake_up():推理引擎准备好(可能包含权重同步 / cache reset / 显存准备)
  • sleep():推理引擎释放 KV cache 等显存占用

并且在 standalone 模式下可以跳过(推理和训练不在同一组 GPU)。

工程建议(经验,不是教条):

  • 你在单机/小集群:先 standalone(降低复杂度)。
  • 你在多机大集群:hybrid 才值得(否则 GPU 利用率很难拉满)。

10. GPU/Replica 配置:num_replicas vs 引擎内部 DP

agent_loop_config.ipynb 给了一个很实用的公式:

num_replicas = total_gpus / (TP × DP × PP)

这里有一个很容易混的点(notebook 也专门对比了):

  • num_replicas:服务级 replica(多个完全独立的推理服务),由 AsyncLLMServerManager 做负载均衡,松耦合。
  • data_parallel_size:引擎内部 DP(一个推理服务实例内部的并行),由 vLLM/SGLang 自己处理。

你在集群扩展与容错上,往往更依赖 num_replicas
你在单实例吞吐上,才更依赖引擎内部并行(TP/PP/DP)。

一个典型例子(notebook 的例子):

  • 16 GPU 集群,模型推理需要 4 卡 TP:
    • rollout_world_size=4
    • num_replicas=16/4=4(4 个独立 server)

11. 排障 Checklist:你要记录哪些日志,才能真的 debug AgentLoop

我建议最少记录这几类日志(否则你会在“看起来卡住了”时毫无头绪):

  1. 轨迹级路由
    • trajectory_id
    • server_id(sticky 选择结果)
    • LRU 是否命中/被淘汰
  2. 异步并发
    • 每个 sample 的 llm_latency / tool_latency / turns / token_count
    • max_parallel_calls 触发次数
  3. 输出口径
    • response_mask 中 1/0 的比例
    • tool 输出 token 长度分布(防 OOM)
  4. hybrid 切换
    • wake_up/sleep 耗时
    • 权重同步耗时(如果有)
    • KV cache 释放前后显存占用(对齐你 vLLM 那篇的显存分析)

12. 小结

  1. AgentLoop 是 agentic RL 的“数据生成器”:它要产出可训练轨迹,而不是只跑通流程。
  2. async rollout 是 multi-turn tool use 的必要条件:它把 straggler 从系统瓶颈里移走,并让 continuous batching 真正发挥作用。
  3. sticky session 是多轮必需品:它连接 prefix cache、token-in-token-out 与推理吞吐。
  4. hybrid(wake_up/sleep)是显存复用策略:先把 correctness 与指标闭环跑通,再考虑复杂度更高的 hybrid。