作者:水木SH
https://zhuanlan.zhihu.com/p/2062647902879667967
前言:前段时间支持了团队的Agentic RL训练工作,在框架开发过程中碰到诸多问题,有了一些新的思考,在此进行记录。
一、从 LLM Rollout 到 Agentic Rollout
强化学习(RL)训练通常在数据生成和模型更新两个阶段交替进行。首先,Rollout模块按照当前策略执行任务,生成输出或交互轨迹,并对结果进行奖励评估。
随后,训练模块利用生成的数据更新模型参数,并将更新后的参数同步回Rollout,用于生成下一轮训练数据,从而形成完整的训练闭环。

对Reasoning时代的 LLM RL来说,一次 Rollout 是单轮的:系统向模型发送 prompt,获得 response输出,计算奖励。

对Agentic RL来说,情况则更加复杂。以Codex代码修复为例,Agent需要读取仓库文件、分析错误、修改代码、测试验证。为了完成这些步骤,它需要多次调用模型,并通过工具调用执行文件操作、运行命令和依赖安装等。

因此,Agent的执行过程需要进行多轮模型调用和工具交互。这要求 Rollout 模块不仅能够提供模型推理,还需要管理 Agent 及其运行环境,并采集执行过程中产生的多轮模型轨迹。
二、耦合式架构
1. 原理
早期实现 Agentic RL 最直接的方式,是在 Rollout 模块中显式实现 Agent Loop,以模拟真实 Agent 框架的执行过程。
如下伪代码所示,Rollout 首先根据当前输入调用模型,随后解析模型输出,执行相应的工具,再将执行结果加入上下文,进入下一轮交互,直到任务完成或满足终止条件。
messages = [task_prompt]
while True:
# 调用模型并记录本轮交互
response = llm_server.generate(messages)
trajectory.record(messages, response)
# 模型给出最终结果,任务结束
if response.is_final_answer():
final_answer = response.content
break
# 执行工具,并将结果加入下一轮上下文
tool_result = env.execute(response.tool_call)
由于整个 Agent Loop 都由 Rollout 模块驱动,模型输入、模型输出、工具调用可以在执行过程中被直接记录。任务结束后,系统再按顺序组织为 Trajectory,并结合评测结果生成训练样本。
因此,在耦合式实现中,Agent 执行、模型推理和轨迹采集位于同一个控制流程中。
2. 优点
Agent 行为容易控制和调试
由于 Agent Loop 由 Rollout 实现,Agent 的执行细节对开发者是完全透明的。Rollout 可以统一控制模型调用、工具执行和任务终止,也可以根据训练需求加入压缩策略、subagent调用、轮数限制等干预逻辑。
训练轨迹易于采集
模型和工具调用都发生在 Rollout 内部,因此每轮交互可以在执行时直接记录。这种方式实现简单,也更容易保证轨迹与实际执行过程一致。
3. 缺点
难以扩展
不同 Agent 可能使用不同的harness工程,如系统提示词、压缩策略、上下文管理方法等存在差异。为了接入RL 训练,Rollout 必须模拟每种 Agent 的逻辑,每增加一种 Agent,通常都需要重新适配。
黑盒 Agent 不适配
一些黑盒Agent 类似 claude code是不开源的,只能从外部提交任务并获得最终结果,无法了解其内部执行逻辑。因此无法完全模拟复刻,导致训练模拟的逻辑与真实使用存在差异,影响最终效果。
三、解耦式架构
1. 从耦合到解耦
耦合式架构的问题在于轨迹采集依赖 Rollout 对 Agent 具体逻辑的模拟实现,从而限制了扩展性和黑盒agent的接入。
解耦式架构则采用另一种思路:让 Agent 保持原生运行,Rollout 系统不再实现具体的 Agent Loop逻辑,而是从外部管理Agent的生命周期,并在 Agent 与模型之间的通信链路上采集交互轨迹。
这样,Rollout 不需要理解 Agent 内部如何维护状态、调用工具,只需要关心 Agent 如何启动、模型交互如何记录,以及任务结束后如何生成训练样本。
然而,解耦式架构要真正落地,需要解决以下三个问题:
如何运行和管理 Agent
虽然Rollout模块不需要实现具体的Agent 逻辑,但仍然需要为每次 Agent 任务准备环境,管理 Agent 的启动、运行、终止和资源回收。
如何采集轨迹
既然 Rollout 不再进入 Agent 内部,那么从 Agent 内部无法再获取到执行轨迹,就需要从 Agent 与模型之间的通信链路中观测并记录这些交互。
如何协调一次完整的 Rollout
Agent 运行、模型交互采集、结果回收、资源清理等发生在不同环节。系统需要统一编排这些操作,跟踪任务状态,并在正常完成、异常退出或超时等情况下生成完整的训练样本。
围绕这三个问题,我们的实现引入三个核心组件进行解决:Runtime Manager、Gateway、Controller。
2. 总体架构

Controller:负责 Rollout 任务的统一编排。它接收任务,协调 Runtime Manager 与 Gateway 的执行流程,跟踪任务状态,并在任务结束后汇总 Agent 执行结果、模型轨迹和评测信息。
Runtime Manager:负责 Agent 的运行时管理,包括创建隔离环境、准备任务文件与依赖、启动和停止 Agent,以及回收运行日志和输出产物。
Gateway:负责轨迹采集,位于 Agent 和 LLM Server 之间,充当 LLM Server 的代理服务。在转发模型请求的同时,记录请求、响应等信息,从中记录多轮交互轨迹。
Agent:在隔离环境中运行的Agent框架,根据任务向Gateway发送推理请求。
LLM Server:负责模型推理,接收来自 Gateway 的模型请求,并生成相应回复。
整体来看,Controller 接收任务后,通过 Runtime Manager 创建运行环境并启动 Agent,同时在 Gateway 中建立模型交互会话;Agent 运行期间通过 Gateway 访问 LLM Server,Gateway 负责记录完整的模型交互轨迹;任务结束后,Controller 汇总 Agent 执行结果、模型轨迹和评测信息,最终生成训练样本。
3. Agentic Rollout 完整流程
下面通过一次完整的任务执行,详细说明系统各个组件是如何协同工作的:

结合时序图来看,一次 Agentic Rollout 可以分为三个阶段:资源创建与初始化、Agent 执行与轨迹采集、结果汇总与资源释放。
资源创建与初始化
Controller 接收上游训练系统下发的 Task,Task 描述了本次任务的 Prompt、Agent 配置、评测规则等信息。
Session 创建。Controller 请求 Gateway 创建独立 Session,Session 可以理解为 Gateway 中用于关联 Task 的数据结构,记录该次执行产生的运行轨迹和状态。
不同 Task 使用独立的 Session,通过唯一的 Session ID 进行标识。Gateway 会返回 Session ID 和模型接入地址。Agent 后续发出的模型请求,都通过 Session ID 关联到具体 Task。
沙盒准备。Controller 请求 Runtime Manager 为本次执行创建独立沙盒,为 Agent 提供隔离的文件系统和执行环境。随后,Runtime Manager 根据 Task 配置初始化环境,包括写入任务文件、准备代码仓库、安装依赖和执行初始化命令,并向沙盒环境注入任务 Prompt、Session ID、Gateway 地址等信息。
Agent 执行与轨迹采集
环境准备完成后,Agent 进程在沙盒环境中启动。
当 Agent 调用模型时,请求会先发送到 Gateway。Gateway 根据 Session ID 将请求归入对应 Session,记录模型输入,再将其转发至 LLM Server。
模型返回结果后,Gateway 保存响应内容、工具调用和 Token 等轨迹信息,并将响应返回给 Agent。这一过程通常会循环多次,同一 Session 下连续产生的请求和响应共同构成本次执行的 Trajectory。
由于轨迹采集发生在模型通信链路上,Gateway 不需要理解 Agent 如何维护内部状态,也不依赖 Agent 的具体实现。
结果汇总与资源释放
Agent 结束后,Runtime Manager 根据进程状态回收执行结果,包括错误信息、运行日志、修改后的环境信息等。
随后,Controller 从 Gateway 获取对应 Session 下的完整 Trajectory,检查轨迹信息是否完整,并过滤失败轨迹。
在执行结果和模型轨迹汇总完成后,系统按照 Task 中定义的规则进行评测,生成 Reward 等训练信号(时序图中省略)。
最终,Controller 将 Task 输入、Trajectory、Agent 执行结果和评测结果对齐,转换为上游训练模块所需的训练样本。完成数据回收后,系统关闭本次执行对应的 Session,并释放 Agent 沙盒资源。
至此,Agent 执行、模型推理和轨迹采集被解耦到不同的模块,相关数据通过统一的Session ID关联到同一个Task,从而完成一次完整的 Agentic Rollout 闭环。
四、Gateway 设计细节
1. 协议归一化
Agent 框架通常通过模型 API 向 Gateway 发起请求。由于不同框架所适配的模型 API 协议可能不一致,Gateway 需要兼容多种请求与响应格式。
目前常见的协议格式主要包括 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,三者的主要区别如下:
| OpenAI Chat Completions | OpenAI Responses | Anthropic Messages |
|---|---|---|
| 框架示例 | LangChain 的 ChatOpenAI | Codex CLI |
| 上下文组织 | 使用 messages 数组组织对话 | 使用 instructions 和 input;输入、消息和工具调用统一表示为 Item |
| System Prompt | 放在 messages 中,使用 developer/ system | 通常放在顶层 instructions |
| 工具调用 | Assistant Message 中的 tool_calls[] | output 中独立的 function_call Item |
| 工具结果 | 使用 role: “tool” 消息,通过 tool_call_id 关联 | 使用 function_call_output Item,通过 call_id 关联 |
为了复用同一套请求处理、模型调用逻辑,我们在 Gateway 入口处增加协议适配器(Adapter),将不同协议格式的请求统一转换为同一格式。我们采用 OpenAI Chat Completions 格式作为标准格式,后续模块只需面向这一种格式进行处理,从而将具体处理逻辑和协议进行解耦。
除请求协议转换外,Adapter 还负责响应阶段的流式协议封装。
为了简化请求转发逻辑,Gateway 向 LLM Server 转发请求时统一采用非流式请求。然而,部分 Agent 框架仅支持流式响应。如果直接返回完整结果,可能导致客户端解析失败。
针对这类场景,Adapter 会将 LLM Server 返回的完整结果重新封装为对应协议可消费的 SSE 事件流进行返回,包括文本增量、工具调用增量和结束事件等。
2. 请求场景分类
在实际任务执行过程中,Gateway 接收到的模型请求并不一定都来自主 Agent Loop,还可能来自旁路任务,比如上下文压缩、SubAgent 调用、会话摘要、心跳检测等不同场景。如下表所示:
| 请求场景 | 典型输入 | 典型输出 | 主要用途 | 请求地址示例 |
|---|---|---|---|---|
| 主 Agent Loop | 用户请求、System Prompt、历史消息、工具定义 | 任务回复,或包含 tool_calls 的 assistant message | 负责任务的理解、规划与执行 | POST /v1/chat/completions |
| 上下文压缩 | 已有执行轨迹、当前任务状态及上下文长度限制 | 压缩后的消息列表、任务状态描述 | 保留关键信息的前提下缩短上下文,供后续主Agent Loop 继续执行 | POST /v1/chat/completions/context-compression |
| SubAgent 调用 | 主 Agent 分配的子任务、局部上下文 | 子任务结论、分析结果或结构化中间产物 | 将复杂任务拆分给不同角色或能力的 SubAgent 独立处理 | POST /v1/chat/completions/sub-agent |
| 会话摘要 | 完整或阶段性的会话消息、任务结果、关键事件 | 面向用户查看或检索的一小句会话摘要 | 总结会话主题,方便用户后续检索 | POST /v1/chat/completions/session-summary |
| 心跳 | 用于探测Provider(推理服务)的最小化消息 | Provider 回复一小段信息 | 探测 Provider 的连通性和健康状态 | POST /v1/chat/completions/heartbeat |
由于 Gateway 会统一记录请求的调用轨迹,但不同类型轨迹的训练价值存在明显差异。例如,主 Agent Loop 和 SubAgent 调用通常包含较完整的推理决策和执行过程,具有较高的训练价值;而会话摘要、心跳检测等调用不是训练数据关注的重点,还可能会对训练造成干扰。
因此,训练框架需要具备调用场景识别与轨迹分类能力,从而进行差异化处理。在某些Agent 框架中(如OpenClaw、Hermes),可以为不同调用场景配置独立的请求 URL,由 Gateway 根据请求入口识别请求类型。
对于 Claude Code、Codex CLI 等Agent ,则需要结合 API 路径、请求头、模型标识及请求内容特征识别调用类型。例如,Codex 的普通模型调用和上下文压缩分别使用 /responses 与 /responses/compact,部分内部或子代理请求会携带 x-openai-subagent、x-openai-memgen-request 等内部标识,Gateway 可以根据这些信息进行场景分类。
3. 轨迹重建
对于主Agent loop来说,在执行过程中,需要向 Gateway发送多次请求。但由于每次请求都是独立的,为了获得完整的执行轨迹,Gateway 需要识别请求之间的延续关系,把整个执行过程进行记录和重建。
在理想情况下,每次请求携带的消息历史会随着执行过程持续增长。主 Agent 发起每一轮请求时,都会携带此前完整的消息记录,包括 System Prompt、User Message,以及已经产生的 Tool Call 和 Tool Result。
在openai chat 协议下,这些消息记录称为message数组。后一轮请求的message数组始终以前一轮请求的完整message数组为前缀,整个 Agent 执行过程可以自然地归并为一条完整轨迹,如下图所示:

然而,在实际执行中,请求历史并不一定单调增长。当上下文长度接近模型窗口上限时,Agent 通常会对历史消息进行压缩,并使用压缩后的摘要消息继续执行。压缩之后,新请求中不再保留此前的完整消息序列,原有的前缀关系也随之被打断。

以上图为例,一次完整的 Agent 执行被拆分为两段:
- 轨迹 1 保留了压缩前的完整执行细节,例如早期的 Tool Call 和 Tool Result;
- 轨迹 2 从压缩后的消息开始,继续记录后续工具调用,并最终产生 Final Answer。
虽然两条轨迹在消息序列上并不连续,但它们实际上属于同一次 Agent 执行过程。若只保留轨迹 1,将缺失压缩后的执行结果和最终答案;若只保留轨迹 2,则会丢失压缩前的原始调用过程和关键细节。
因此,框架需要具备同时保存多段轨迹的能力,并识别它们之间的关联关系。
我们的做法是:保存每个 Session 历史请求对应的完整轨迹,新请求到达时,通过前缀匹配判断是否为已有轨迹的延续:如果当前消息序列以前一轮完整消息序列为前缀,则将新增消息追加到原轨迹;否则,将其保存为一条新轨迹。
不过,在实际运行过程中,严格的字符串前缀匹配可能不是合理的。部分Agent框架(如Hermes)在向 Gateway 发送请求之前,可能会对消息进行脱敏或者注入额外内容,从而导致语义相同的内容在文本字符层面产生差异。
例如,在代码生成场景中,Session 中已保存的 Tool Call 中可能包含变量定义:
sk_test = 123
某些 Agent 框架可能会将变量名 sk_test 误判为 API Key 等敏感信息,并在后续请求中将其替换为:
sk_test = ***
如果采用严格的字符串匹配,处理前后的两条消息将无法建立前缀关系,从而被错误地拆分为两条轨迹。显然,这种拆分并不符合真实的执行过程,针对这些情况,需要 case-by-case 处理,放宽匹配条件。
4. Token 一致性
前文所述的“轨迹”,通常是指以明文形式存储的message数组,Agent与Gateway 交互的API协议也是以明文字符串的形式。但对于模型推理和训练而言,仅保存message数组还不够,还需要同步保存对应的 token id 序列。
这是因为 token 序列解码为文本后,再通过 tokenizer 重新编码,并不一定能够还原出原始的 token 序列。例如:
Decode([21, 22, 23, 24, 25]) = “我爱吃苹果” # 每个 token id 对应一个汉字
Encode(“我爱吃苹果”) = [21, 22, 23, 39] # “苹果”被重新编码为一个 token id
这种差异会带来两个问题:
-
降低推理阶段的 KV Cache 复用
Prefix Cache 依赖 token 序列进行匹配。一旦重新 tokenize 后的 token 与原来不一致,从首个差异位置开始,原有的 KV Cache 就无法继续复用,从而降低推理效率。
-
影响训练稳定性与数据对齐
实践中发现,训练阶段使用的 token id 如果与推理生成时的 token id 不一致,可能会影响训练稳定性。此外,训练过程可能会用到推理生成的 log probability、routed expert 等数据,这些数据都与具体的 token 位置绑定,如果只保存message数组,训练时根据文本重新tokenize,由于token id的变化,会导致这些信息发生错位。
因此,在 Gateway 中,对于每条轨迹,除了保存message数组外,还需要保存对应的 token id 序列;并且使用token id 向 LLM server发送请求,获取生成的token id 进行保存,再转换成文本回复给 agent。这要求在Gateway 中进行 tokenize 和 detokenize 操作,以便在文本和token之间进行转换。
需要注意的是,保存的message 数组与 token id 序列,两者解码之后的文本内容可能并不完全一致。例如,Agent 不需要reasoning content来执行工具,意味着模型输出中的 thinking 过程可能不会在 Agent 与 Gateway 之间传输。但这部分内容可能对训练有价值,因此仍会保留在 token id 序列中。
五、Runtime Manager 设计细节
1. 抽象分层
Runtime Manager 需要同时适配两个维度的差异:
1)Agent 框架差异。Codex、Claude Code、Hermes 等 Agent 在配置文件、启动命令、环境变量上各不相同。
2)运行环境差异。Agent 除了运行在远端沙盒,还有可能运行在Docker 容器或本地进程中。不同运行环境的操作接口不同,比如沙盒通常采用 E2B SDK 进行控制,Docker 容器采用 docker 命令等。
因此,Runtime Manager 将 Agent 适配和运行环境拆分为两个相互独立的抽象:
- Agent Harness:描述如何准备、启动和解析某一种 Agent;
- Sandbox Backend:描述如何创建和操作某一种运行环境。
二者由 Runtime Manager 进行组合:
Runtime Manager
├── Agent Harness
│ ├── CodexHarness
│ ├── ClaudeCodeHarness
│ └── HermesHarness
│
└── Sandbox Backend
├── E2BBackend
├── DockerBackend
└── LocalBackend
通过这种分层,新增 Agent 时只需要实现新的 Harness,新增运行环境时只需要实现新的 Sandbox Backend,而不需要修改另一个维度的适配逻辑。
Runtime Manager 编排一次 Agent 任务的完整生命周期的流程如下所示:
# 根据配置创建 Agent 适配器和运行环境
harness = create_harness(config.agent)
sandbox = create_sandbox(config.runtime)
try:
# 在指定运行环境中完成 Agent 的初始化与配置
harness.setup(context, sandbox)
# 启动 Agent,并等待任务执行完成
result = harness.run(context, sandbox)
return result
finally:
# 无论任务成功、失败还是被取消,都执行资源清理
harness.cleanup(context, sandbox)
sandbox.destroy(context)
2. Hook 机制
在实际运行过程中,不同任务场景可能需要插入不同的处理逻辑。例如,任务完成后需要收集Agent框架日志进行问题分析、在环境中执行脚本进行评估打分、或者进行指标统计等。
这些流程往往与业务强绑定,为此,我们设计了一套可扩展的 Hook 机制,在 Agent 的生命周期中提供标准化的扩展点,把各项附加能力拆分为独立、可插拔的 Hook。
用户可以根据具体场景选择、组合、实现不同的 Hook,多个 Hook 按照配置顺序依次执行,从而在不修改 Runtime Manager核心逻辑下自定义扩展流程。具体逻辑简化如下:
# 根据配置创建 Agent、运行环境和 Hook
harness = create_harness(config.agent)
sandbox = create_sandbox(config.runtime)
hooks = create_hooks(config.hooks)
try:
# 准备 Agent 运行环境
harness.setup(context, sandbox)
# 在 Agent 启动前执行扩展逻辑
hooks.before_run(context, sandbox)
# 启动 Agent,并等待任务执行完成
result = harness.run(context, sandbox)
# 在 Agent 完成后执行产物收集、评估、上传等后处理逻辑
hooks.after_run(context, sandbox, result)
return result
except Exception as error:
# 执行异常处理 Hook,例如收集错误日志和上报运行状态
hooks.on_error(context, sandbox, error)
raise
finally:
# 无论任务成功、失败还是被取消,都执行资源清理
harness.cleanup(context, sandbox)
sandbox.destroy(context)
六、Controller 设计细节
Controller 模块比较简单,属于纯粹的逻辑代码,主要负责接收任务、协调执行顺序等,完成一次完整的 Rollout过程。主要功能可以总结为以下三个部分:
任务编排:为每次任务执行请求其它模块创建独立上下文,如创建Session、沙盒等,并根据资源情况进行调度,任务之间相互隔离。
流程协调:依次协调 Session 创建、环境准备、Agent 启动、轨迹回收和样本生成,并通过Session ID 关联 Agent 实例与 Session。
异常处理与结果汇总:任务异常时终止后续流程并释放资源;任务完成后汇总执行结果、轨迹数据和调用reward计算逻辑,生成训练样本。
七、扩展性设计
1. Gateway 扩展
正如第四章所述,Gateway 并不是简单的模型请求转发服务。每次请求到达后,它还需要完成轨迹匹配、tokenize、detokenize 以及轨迹数据整理等工作。在多模态场景下,还会涉及图片处理和数据转换。这些操作大多属于 CPU 密集型任务。
当大量 Agent 同时连接到单个 Gateway 时,相关计算会集中在同一个进程中,造成 CPU 瓶颈,限制整个系统的吞吐。
因此,我们对Gateway 采用多进程扩展的方式,将不同 Session 分配给不同的Gateway 进程实例,每个实例独立完成请求处理和轨迹管理,从而分散 CPU 压力;同一个 Session 则始终由同一个 Gateway 管理,以保证轨迹状态的一致性。
2. Runtime Manager 扩展
在我们的实践中,Runtime Manager 通过 E2B SDK 来跟沙盒进行通信,完成沙盒环境的创建、配置和销毁。每个 Agent 任务都可能产生多次沙盒调用,在高并发场景下,单个进程的E2B SDK需要同时维护大量 HTTP 请求与连接。
我们发现当并发规模超过1000时,E2B SDK 容易出现请求超时、连接不稳定等问题,从而导致Agent任务失败。
我们的做法是将Runtime Manager扩展到多个进程,每个进程都有单独的E2B SDK,不同的Agent 会分配给不同的Runtime Manager 实例,这样可以分散单个E2B SDK的并发压力,提升Agent任务的稳定性。
八、总结及参考资料
以上,就是我们这段时间在Agentic RL训练框架开发过程的一些总结和思考。在实践过程中,我们也参考了其它优秀的RL框架和Agent框架的设计,在此进行感谢和引用说明:
verl:https://github.com/verl-project/verl
slime:https://github.com/THUDM/slime
ProRL-Agent-Server:https://github.com/NVIDIA-NeMo/ProRL-Agent-Server
openclaw:https://github.com/openclaw/openclaw
hermes-agent:https://github.com/nousresearch/hermes-agent
codex:https://github.com/openai/codex
learn-cluade-code: https://github.com/shareAI-lab/learn-claude-code