【Agentic RL / 强化学习框架 / VERL】Uni-Agent Gateway 深度解析

【Agentic RL / 强化学习框架 / VERL】Uni-Agent Gateway 深度解析

目录

  • [【Agentic RL / 强化学习框架 / VERL】Uni-Agent Gateway 深度解析](#【Agentic RL / 强化学习框架 / VERL】Uni-Agent Gateway 深度解析)
    • [0x00 概要](#0x00 概要)
    • [0x01. Gateway 是为了解决什么问题?](#0x01. Gateway 是为了解决什么问题?)
    • [0x02. RFC 的核心方案:两个新抽象](#0x02. RFC 的核心方案:两个新抽象)
      • [2.1 AgentFramework:训练框架侧的薄抽象](#2.1 AgentFramework:训练框架侧的薄抽象)
      • [2.2 AgentGateway:Serving 层拥有的轨迹采集子系统](#2.2 AgentGateway:Serving 层拥有的轨迹采集子系统)
      • [2.3 落地:从 RFC 到 Uni-Agent](#2.3 落地:从 RFC 到 Uni-Agent)
        • [What's done:](#What's done:)
        • WIP:
        • [Future directions:](#Future directions:)
        • 完整对应
    • [0x03. Gateway 的架构设计](#0x03. Gateway 的架构设计)
      • [3.1 整体架构](#3.1 整体架构)
      • [3.2 分层职责](#3.2 分层职责)
      • [3.3 分布式部署与负载均衡](#3.3 分布式部署与负载均衡)
      • [3.4 Per-Session 端点隔离](#3.4 Per-Session 端点隔离)
      • [3.5 配置注入链路](#3.5 配置注入链路)
    • [0x04. 核心设计机制:黑盒与白盒 Agent 的统一](#0x04. 核心设计机制:黑盒与白盒 Agent 的统一)
      • [4.1 问题本质](#4.1 问题本质)
      • [4.2 Gateway 的统一方案](#4.2 Gateway 的统一方案)
      • [4.3 三类 Agent 执行模型](#4.3 三类 Agent 执行模型)
      • [4.4 Session 完整生命周期](#4.4 Session 完整生命周期)
    • [0x05. 请求处理机制:从 HTTP 到 Trajectory](#0x05. 请求处理机制:从 HTTP 到 Trajectory)
      • [5.1 单次请求处理流程](#5.1 单次请求处理流程)
      • [5.2 双锁机制](#5.2 双锁机制)
      • [5.3 增量编码与前缀复用](#5.3 增量编码与前缀复用)
      • [5.4 为什么一个 Session 会产生多个 Trajectory](#5.4 为什么一个 Session 会产生多个 Trajectory)
      • [5.5 Trajectory 数据结构](#5.5 Trajectory 数据结构)
      • [5.6 MessageCodec 编解码](#5.6 MessageCodec 编解码)
    • [0x06. 当前局限与演进方向](#0x06. 当前局限与演进方向)
      • [6.1 当前局限](#6.1 当前局限)
      • [6.2 RFC 分阶段规划](#6.2 RFC 分阶段规划)
      • [6.3 演进方向](#6.3 演进方向)
    • [0x07. 总结](#0x07. 总结)
    • [附录A. 架构图与数据流](#附录A. 架构图与数据流)
      • [A.1 整体架构图](#A.1 整体架构图)
      • [A.2 黑盒 Agent vs 白盒 Agent 的数据流对比](#A.2 黑盒 Agent vs 白盒 Agent 的数据流对比)
      • [A.3 核心组件图](#A.3 核心组件图)
      • [A.4 请求数据流图](#A.4 请求数据流图)
      • [A.5 Session 完整生命周期数据流](#A.5 Session 完整生命周期数据流)
      • [A.6 与 Uni-Agent 其他组件的交互](#A.6 与 Uni-Agent 其他组件的交互)
    • [附录B. Gateway 内部实现补充](#附录B. Gateway 内部实现补充)
      • [B.1 核心类及其职责](#B.1 核心类及其职责)
      • [B.2 GatewayManager 最小负载调度](#B.2 GatewayManager 最小负载调度)
      • [B.3 GatewaySession 生成流程](#B.3 GatewaySession 生成流程)
      • [B.4 MessageCodec 编解码流程](#B.4 MessageCodec 编解码流程)
      • [B.5 轨迹物化策略](#B.5 轨迹物化策略)
      • [B.6 HTTP 端点设计](#B.6 HTTP 端点设计)
      • [B.7 配置注入链路](#B.7 配置注入链路)
    • [附录C:Token In, Token Out: Gateway 如何处理 Token 级数据一致性](#附录C:Token In, Token Out: Gateway 如何处理 Token 级数据一致性)
      • [C.1 什么是 TITO 问题](#C.1 什么是 TITO 问题)
      • [C.2 隐藏的坑](#C.2 隐藏的坑)
      • [C.3 难点 1: 文本 ↔ Token 的往返一致性](#C.3 难点 1: 文本 ↔ Token 的往返一致性)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.4 难点 2: 多轮对话的前缀一致性](#C.4 难点 2: 多轮对话的前缀一致性)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.5 难点 3: Agent 中途切换上下文,产生多个 Trajectory](#C.5 难点 3: Agent 中途切换上下文,产生多个 Trajectory)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.6 难点 4: Token 预算控制](#C.6 难点 4: Token 预算控制)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.7 难点 5: Loss Mask 的跨轨迹传递](#C.7 难点 5: Loss Mask 的跨轨迹传递)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.8 难点 6: 采样参数边界](#C.8 难点 6: 采样参数边界)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.9 难点 7: 并发状态保护](#C.9 难点 7: 并发状态保护)
        • 问题
        • [Gateway 的解法](#Gateway 的解法)
      • [C.10 完整 TITO 数据流示意图](#C.10 完整 TITO 数据流示意图)
      • [C.11 对比:无 Gateway 的 Agent 训练(传统方式)](#C.11 对比:无 Gateway 的 Agent 训练(传统方式))
    • [附录D. 会话安全性](#附录D. 会话安全性)
      • [D.1 生命周期状态机](#D.1 生命周期状态机)
      • [D.2 采样参数白名单](#D.2 采样参数白名单)
      • [D.3 Token 预算管理](#D.3 Token 预算管理)
      • [D.4 HTTP 端点与错误处理](#D.4 HTTP 端点与错误处理)
    • [附录E:Forge 的设计哲学](#附录E:Forge 的设计哲学)
    • [附录F:与 Dressage 设计哲学的对比分析](#附录F:与 Dressage 设计哲学的对比分析)
      • [F.1 设计理念的共鸣](#F.1 设计理念的共鸣)
      • [F.2 Proxy vs Gateway:同一个思想的不同实现](#F.2 Proxy vs Gateway:同一个思想的不同实现)
      • [F.3 黑盒 Agent 接入方式对比](#F.3 黑盒 Agent 接入方式对比)
      • [F.4 Segment 概念对比](#F.4 Segment 概念对比)
      • [F.5 设计哲学的一致性](#F.5 设计哲学的一致性)
      • [F.6 两个框架的核心差异](#F.6 两个框架的核心差异)
      • [F.7 对 Gateway 未来演进的启示](#F.7 对 Gateway 未来演进的启示)
    • [0xFF 参考](#0xFF 参考)

0x00 概要

最近看了Uni-Agent官方同学的视频,遂发现,Gateway 是最新增加的,在我之前解读的代码中,并没有这部分。

又找了 VERL RFC #5790「Agent Abstractions and Trajectory Gateway for VERL」。RFC #5790 提出 Gateway 架构------在 Agent(黑盒/白盒)和 RL 训练器之间插入一个 Token 级转换层,将 OpenAI 兼容的聊天 API 调用映射为带精确 loss mask 的 token 轨迹,使 Agent 只需处理业务逻辑而无需感知底层 token 级训练约束。

然后,又发现 ++Uni-Agent Gateway 就是该RFC的实现++。

因此结合 Uni-Agent 代码实现来剖析 Uni-Agent Gateway 子系统。

0x01. Gateway 是为了解决什么问题?

1.1 VERL 原有架构的困境

VERL 的 Agent 训练最初走的是 AgentLoopManager -> AgentLoopBase 路径。RFC 指出,这套架构将三类截然不同的职责紧密耦合在同一个组件中:

耦合点 具体表现 带来的问题
LLM 基础设施管理 AgentLoop 内部直接管理 LLM 服务器的启动、负载均衡 无法复用,无法独立升级
Agent 生命周期 每种新 Agent 类型需要专门的适配代码 接入成本高,SWE-agent、AWS AgentCore、阿里云 Remote Agent 各自 ad-hoc
轨迹采集 tokenization、轨迹记录、loss mask 构建嵌入在 AgentLoop 中 不可复用,黑盒 Agent 无法采集轨迹

RFC 原文精准概括了后果:

Each new agent framework requires dedicated adapter code inside the agent loop. Trajectory collection logic is embedded in the agent loop itself, making it non-reusable. Only coroutine-based agents are natively supported; subprocess and remote agents require ad-hoc integration.

1.2 黑盒和白盒如何统一

更为根本的困境:VERL 原生只支持协程式 Agent,框架直接持有运行时状态,这在白盒场景下很自然;但面对黑盒 Agent(外部 CLI 子进程、远程 HTTP 服务),框架根本无法侵入 Agent 内部采集 token 级轨迹。白盒和黑盒走的是两条完全不同的集成路径

  • 白盒 Agent(white-box):VERL 原生的协程式 Agent。框架直接持有 Agent 的运行时状态,可以在 Agent 推理的每一步精准地插入训练逻辑(tokenization、轨迹记录、loss mask 构建)。这条路径天然适合 VERL 自己的 AgentLoop。
  • 黑盒 Agent(black-box) :外部 Agent 程序,例如 SWE-agent 的 CLI 子进程、AWS AgentCore、阿里云 Remote Agent 等。这些 Agent 对 VERL 来说是一个不透明的外部进程,它们只知道调用 OpenAI Chat Completions API,框架无法直接接触到 Agent 的推理过程,无法侵入其内部去采集 token 级轨迹。

Gateway 的核心设计意图 就是用同一个 OpenAI-compatible HTTP 接口把这两类 Agent 统一起来

  • 对白盒 Agent:Framework 直接调用 Gateway 的 API,Gateway 在中间层做 tokenization、轨迹记录。Agent 代码不需要知道底层训练基础设施的存在。
  • 对黑盒 Agent :Framework 启动 Agent 子进程时,把 OPENAI_BASE_URL 注入为 Gateway 的 session URL。Agent 像往常一样调用 Chat Completions API,完全不知道自己在和 Gateway 通信 ,完全不知道自己的请求被 Gateway 拦截了,它只是正常调用 OpenAI API。Gateway 在中间透明地拦截请求、做 tokenization、记录轨迹、再转发给推理后端。Gateway 是唯一的 token 真相源 (single canonical tokenization authority)------ 所有 messages → token_ids 的转换都发生在这里,使用推理后端一致的 tokenizer 和 chat template,保证了训练数据的一致性。

用 RFC 的原话来说:"any OpenAI-compatible agent system to be integrated into VERL's training loop without modifications to agent code"

用 Uni-Agent 官方的PPT 来看:

Unified Gateway (Application),Plug in Any Agent Harness for RL Training(把任何Agent Harness接入到RL训练中)

  • Example: Claude Code,Mini-SWE-Agent,Hermes Agent
  • Bridge the harness gapbetween Training and Inference(比如训练和推理用的不同的Harness)

这就是 Gateway 存在的第一性原理。下面展开分析 Gateway 具体解决的几个子问题。

1.3 解决的子问题

训练与推理对同一接口的矛盾需求

  • 推理侧 :Agent Runner(无论是白盒还是黑盒)期望的是标准的 OpenAI-compatible /v1/chat/completions 接口 ------ 发消息、收回复,简单直接。
  • 训练侧 :RL 训练需要精确到 token 级别的 prompt_idsresponse_idsresponse_masklogprobs,以及完整的 reward 元数据。

如果让 Agent Runner 直接对接底层推理引擎(vLLM/SGLang),那么训练所需的 token 级轨迹数据就会丢失。Gateway 会在中间层「拦截」每次推理请求,在返回 OpenAI 兼容响应的同时,默默记录下完整的 token 轨迹。

多轮对话的会话状态管理

Agent 场景天然是多轮的 ------ 模型生成 tool_calls 后执行工具,结果追加到 messages 后再次推理。Gateway 维护了会话级别的状态message_historyactive_trajectorytool_schemas 等。当新请求的 messages 是历史 messages 的前缀时(即增量追加),Gateway 只编码增量部分,复用已缓存的 token 序列,显著降低重复编码开销。

对于黑盒 Agent:Agent 子进程每轮对话都向 Gateway 发送完整的 messages 列表。Gateway 通过前缀检测自动识别增量部分,只 tokenize 新增的消息,既节省了计算资源,又保证了轨迹的连续性。Agent 仍然只是按 OpenAI API 的规范发送请求,完全不变。

分布式负载均衡

训练场景下,成百上千个 Agent Session 并发运行。Gateway 通过 GatewayManager 在多个 GatewayActor 之间做最小负载调度(least-loaded),将 session 均匀分散到不同节点。

多推理后端抽象

Gateway 不关心底层是 vLLM、SGLang 还是其他推理引擎。它通过 LLMServerClient(VERL 提供的抽象)的 generate() 接口与后端交互,输入是 token_ids + sampling_params,输出是 token_ids + log_probs + stop_reason。这使得同一套 Gateway 代码可以适配不同推理后端。

0x02. RFC 的核心方案:两个新抽象

RFC 使用如下手段来上述三个耦合点(LLM infrastructure management, agent lifecycle, and trajectory collection):

  • 定义‌AgentFramework ‌作为面向智能体采样的轻量标准接口。它统一约定核心契约为generate_sequences(prompts: DataProto) -> DataProto,内部执行逻辑、奖励计算流程与批处理策略可由具体实现自定义。
  • 将轨迹采集能力抽离为‌AgentGateway‌,这一服务端子系统可兼容任意框架实现,负责处理分词、前缀一致性校验与轨迹组装,这类逻辑与智能体的启动、管理方式完全解耦。
  • 将基础设施管理能力(大模型服务初始化、负载均衡)从框架层剥离,让AgentFramework仅聚焦面向训练侧的生成语义逻辑,无需承担服务侧的资源管控职责。

具体而言,RFC #5790 提出将拆解为两个独立的新抽象。

2.1 AgentFramework:训练框架侧的薄抽象

AgentFramework 不是 server,是 训练器侧的批处理编排器generate_sequences(prompts: TensorDict) 接收一个 batch 的 prompts,为每个 sample 创建 Gateway session,驱动 Agent Runner 走完多轮对话,从 Gateway 收回轨迹列表,发给 RewardLoopWorker 评分,最后拼成 TensorDict 写入 TransferQueue 供同步训练消费。它不监听任何端口,是在 AgentFrameworkWorker(另一个 Ray actor)内被调用。

python 复制代码
class AgentFramework(ABC):
    @classmethod
    @abstractmethod
    def from_config(cls, *, config, **kwargs) -> AgentFramework: ...

    @abstractmethod
    async def generate_sequences(self, prompts: TensorDict) -> None:
        """Run agent sessions and write finalized trajectories to TransferQueue."""
        ...

AgentFramework负责 Agent 生命周期管理和reward抽象、批量编排、奖励计算、DataProto 组装。它不管理 LLM 基础设施,不处理 tokenization,不采集轨迹。这些正交职责被移到了第二个抽象。

:RFC 原始定义中 generate_sequences 签名为 (prompts: DataProto) -> DataProto,Uni-Agent 实现中改为 (prompts: TensorDict) -> None(通过 TransferQueue 异步写入),语义等价但更符合 VERL 基于 TransferQueue 的异步训练架构。

2.2 AgentGateway:Serving 层拥有的轨迹采集子系统

AgentGatewaygateway.py:27,即 _GatewayActor / GatewayActor = ray.remote(_GatewayActor)):RFC #5790 的核心创新,定位在 Agent 和推理后端之间的 双协议桥 。它既是 @ray.remote 的 Ray actor(可跨节点远程调用 .remote() 方法),也是由 FastAPI + uvicorn 启动的 HTTP server(__init__ 中创建 FastAPI() 并注册路由,start() 触发 uvicorn 启动),核心是 GatewaySession 的业务编排器。负责四件事:

  1. 对外暴露标准 OpenAI Chat Completions API------Agent 完全无感知,通过 HTTP JSON 交互,负责请求路由与会话生命周期(create / finalize / abort)
  2. 对内连接推理后端 (vLLM / SGLang)------通过 token 级接口 generate(token_ids, sampling_params) 通信,将 HTTP JSON 转化为 GatewaySession.run_generation() 的内部调用
  3. 在中间成为 the single canonical tokenization authority------所有 Token 化统一由 Gateway 完成,Agent 无需感知 tokenizer、chat template、processor,消除了文本↔Token 映射的多源不一致
  4. 组装 token 级轨迹数据 ------将多轮对话的 Token 序列拼装为带精确 loss mask 的 Trajectory,最终通过 Ray 通信返回训练器

AgentGateway 本质上是一个 "HTTP <-> Ray actor" 双协议桥:一端是 Agent 的标准 OpenAI 调用,另一端是训练器的 Ray remote 接口。

RFC 架构图:

python 复制代码
VERL Training Loop
    |
    ├── AgentFramework
    │     Agent lifecycle management + Reward computation + Batch orchestration
    │
    └── Serving Runtime
          Owns: LLMServerManager, load balancer, Gateway subsystem
          ├── GatewayManager (internal session-routing component)
          ├── Gateway Actor 1 (Ray actor, FastAPI)
          ├── Gateway Actor 2 (Ray actor, FastAPI)
          └── Gateway Actor N (Ray actor, FastAPI)

关键决策:AgentFramework 和 AgentGateway 彼此独立。Gateway 不知道哪个 Framework 在使用它,Framework 不知道 Gateway 内部的轨迹组装逻辑。两者仅通过 Session API 交互。

2.3 落地:从 RFC 到 Uni-Agent

2026 年 6 月 1 日,经与 VERL 维护者讨论后决定:gateway + framework 模块不放在 VERL 主仓,而是落到 Uni-Agent 仓库。最终通过 PR #25 合入。

What's done:

  • The core implementation for AgentGateway, including the gateway actor and the gateway serving runtime.
  • A high-level example implementation of an OpenAI-request-compatible framework
  • adaptation to the new main_ppo_sync.py entrance based on Transfer Queue.
  • Multi-modal and tool parsing support
  • Many AI-generated unit tests and a few smoke tests that may need to be trimmed down.
  • A Deepeyes recipe implemented with gateway

WIP:

  • A deepeyes_with_gateway recipe
  • A SWE-agent recipe
  • A CLIAgentFramework example implementation
  • Integrate Gateway/Framework configs into the current VERL config system
  • CI hygiene

Future directions:

  • Turn-wise/completion-wise trajectory collection
  • Multi-agent support
  • Default deployment strategy for gateway actors

完整对应

RFC 概念与 Uni-Agent 代码的完整对应:

RFC 概念 Uni-Agent 实现 文件位置
AgentGateway (Ray Actor) _GatewayActor / GatewayActor gateway/gateway.py:27/230
AgentGatewayManager GatewayManager gateway/manager.py:18
AgentFramework (ABC) AgentFramework framework/base.py
OpenAICompatibleAgentFramework 同名类 framework/framework.py:188
GatewaySession GatewaySession + SessionHandle session/session.py:105, types.py:14
Trajectory dataclass Trajectory gateway/session/types.py:32
POST /sessions/{id}/v1/chat/completions 已实现 gateway.py:85
POST /sessions/{id}/complete 演化为 POST /sessions/{id}/reward_info gateway.py:90
Prefix consistency check _is_request_context_prefix() session/session.py:361
CliAgentFramework 未实现 RFC 列为 WIP
Token-request ingress 未实现 Future directions

0x03. Gateway 的架构设计

3.1 整体架构

下图是Design Overview。

Gateway 不隶属于 Framework,而是作为 serving runtime 的一部分存在。实际上,Gateway 并非是"再加一层适配器",而是重新切分了 trainer / serving / agent 三层之间的责任边界 ------ 把 agent 集成拆成两个正交抽象 AgentFramework + AgentGateway,中间用一组 Session API 解耦。

这带来三个保证:

  • LLM 基础设施的独立性:Gateway 的启动、负载均衡由 serving 层统一管理
  • 轨迹采集的通用性:任何 Framework 实现共享同一套 Gateway
  • 配置分离:GatewayActorConfig 是 frozen dataclass,只携带模型静态配置;LLMServerClient 由 GatewayManager 在构造时注入

下图来自 Uni-Agent 官方PPT,我们可以与上图印证。

3.2 分层职责

层级 组件 职责 关键文件
调度层 GatewayManager 管理 Actor 池,最小负载调度,session 路由 manager.py
HTTP 层 _GatewayActor FastAPI 路由,OpenAI 协议适配,JSON 序列化 gateway.py
业务层 GatewaySession 会话状态机,轨迹物化,多轮对话管理 session/session.py
编解码层 MessageCodec 消息规范化,chat template 渲染,token 编解码,工具解析 session/codec.py

每一层只依赖下一层的接口,不跨层访问。例如 GatewayManager 只知道 GatewayActor 的 Ray remote 方法,完全不感知 GatewaySession 的存在。

3.3 分布式部署与负载均衡

RFC 设计了多 Gateway Actor 模型以避免单点瓶颈。实现(manager.py:25-58):

  1. 获取所有存活 CPU 节点的 NodeID
  2. 通过 NodeAffinitySchedulingStrategy 将 gateway_count 个 Actor 均匀散布到不同节点
  3. 每个 Actor 注入 GatewayActorConfig + LLMServerClient
  4. 启动所有 Actor 的 FastAPI HTTP Server

最小负载调度(manager.py:60-63):

python 复制代码
def _select_gateway_index(self) -> int:
    return min(range(len(self.gateways)),
               key=lambda i: self.active_sessions_per_gateway[i])

关键并发安全:路由表和负载计数器在 await 之前同步更新(manager.py:85-86),避免并发协程读到过期计数导致所有 session 挤压到同一 Gateway。

3.4 Per-Session 端点隔离

每个 session 拥有独立的 URL 命名空间,这是让黑盒 Agent 零代码修改接入的关键:

复制代码
/sessions/{session_id}/v1/chat/completions  ->  chat 推理(OpenAI 兼容)
/sessions/{session_id}/reward_info          ->  奖励元数据注入
  • 黑盒 Agent:设置 OPENAI_BASE_URL,Agent 完全不知道在和 Gateway 通信
  • 白盒 Agent:Framework 直接使用 SessionHandle.base_url
  • Framework:通过 reward_info 端点注入 session 级别奖励元数据

3.5 配置注入链路

python 复制代码
build_gateway_manager()
  ├── OmegaConf -> HFModelConfig (tokenizer, processor)
  ├── OmegaConf -> rollout config (prompt_length, response_length, tool_parser_name)
  ├── 构造 GatewayActorConfig (frozen, 模型静态配置)
  ├── 构造 GatewayManager(llm_client, gateway_count, gateway_actor_config)
  │     ├── 获取存活 CPU 节点列表
  │     ├── 创建 gateway_count 个 GatewayActor
  │     │     └── 每个 Actor 内部: GatewayActorConfig -> MessageCodec
  │     └── start() 所有 Actor 的 FastAPI server
  │
  └── 注入 AgentFrameworkWorker
        └── OpenAICompatibleAgentFramework
              └── AgentRunner 通过 SessionHandle.base_url 调用 Gateway

0x04. 核心设计机制:黑盒与白盒 Agent 的统一

4.1 问题本质

  • 白盒 Agent:VERL 原生协程式 Agent。框架直接持有运行时状态,可在每一步精准插入 tokenization、轨迹记录、loss mask 构建。
  • 黑盒 Agent:外部 Agent 程序(SWE-agent CLI、AWS AgentCore、阿里云 Remote Agent)。对 VERL 来说是不透明的外部进程,只知道自己调用 OpenAI API。

4.2 Gateway 的统一方案

Gateway 用一个 OpenAI-compatible HTTP 接口把两类 Agent 统一到同一条训练链路:

python 复制代码
+------------------------------------------------------------------+
|                                                                   |
|   [Black-box Agent]          [White-box Agent]                    |
|   (SWE-agent CLI)            (VERL AgentLoop)                     |
|        |                            |                             |
|        |  POST /v1/chat/completions |  直接调用 Gateway API        |
|        |  (完全不知道 Gateway)        |  (知道 Gateway 存在)         |
|        |                            |                             |
|        +----------+-----------------+                             |
|                   |                                               |
|                   v                                               |
|        +---------------------+                                    |
|        |   Gateway           |   <- 唯一的 token 真相源             |
|        |   (tokenization,    |     所有 messages -> token_ids     |
|        |    trajectory,      |     的转换都在这里                   |
|        |    prefix mgmt)     |                                    |
|        +----------+----------+                                    |
|                   |                                               |
|                   v                                               |
|        +---------------------+                                    |
|        |   Framework         |   <- 训练框架                       |
|        |   (lifecycle,       |     拿到轨迹后打分、                 |
|        |    reward, TQ)      |     写入 TransferQueue             |
|        +---------------------+                                   |
|                                                                  |
+------------------------------------------------------------------+

Agent 不知道 trajectory 的存在,Framework 不需要知道 Agent 是黑盒还是白盒。Gateway 是两者之间唯一的契约点。

4.3 三类 Agent 执行模型

RFC 定义了三种执行模型,Uni-Agent Gateway 全部支持:

执行模型 典型场景 接入方式 完成检测 状态
协程(inline_async) VERL 原生 AgentLoop Framework 直接调用 Gateway API 协程返回 已实现
Ray Task(ray_task) SWE-agent CLI、Terminal Bench OPENAI_BASE_URL 环境变量注入 Ray remote task 返回 已实现
远程服务(Remote) AWS AgentCore、阿里云 Remote Agent HTTP 远程调用 未实现(RFC 列为 WIP) 未实现

三种模型共享完全相同的 Gateway Session API。

4.4 Session 完整生命周期

RFC 中给出的单条会话的完整执行流程如下:

  1. Framework 向AgentGatewayManager发起会话创建请求,管理器会选中一个Gateway实例并返回该会话专属的基础访问地址base_url
  2. Framework 启动智能体(支持子进程、协程或远程调用形式),将上述base_url注入为智能体的大模型服务端点。
  3. Agent 向分配的Gateway实例发送标准OpenAI聊天补全请求。每一次请求中,Gateway会完成分词、前缀一致性校验、路由至推理后端、记录token级交互数据,最终返回标准OpenAI格式响应,整个拦截过程对智能体完全透明。
  4. Agent 执行完毕(对应进程退出、协程返回,或主动调用可选的/complete接口)。
  5. Framework 通过AgentGatewayManager完成会话收尾,获取组装完成的完整轨迹数据。
  6. Framework 计算与轨迹对齐的奖励值,将最终生成的样本封装为DataProto格式。

对应Gateway的具体细节如下图。

python 复制代码
              GatewayManager                GatewayActor
              ==============                ============
                   |                              |
  create_session --+--> _select_gateway_index()   |
                   |    (least-loaded)             |
                   |    Ray remote call ---------->+--> create_session()
                   |                              |    build SessionHandle
                   |    <--- SessionHandle -------+    new GatewaySession()
                   |
  [Agent calls POST /v1/chat/completions repeatedly]
    -- 黑盒Agent: 通过 OPENAI_BASE_URL 注入
    -- 白盒Agent: 直接使用 SessionHandle
                   |
                   |                   run_generation() x N turns
                   |                   (state: active_trajectory,
                   |                    message_history, trajectories[])
                   |
  [Optional: POST /reward_info]                   |
                   |    Ray remote call ---------->+--> set_reward_info()
                   |
  finalize_session-+--> _get_gateway_index()      |
                   |    Ray remote call ---------->+--> finalize()
                   |                              |    _materialize_active_trajectory()
                   |    <--- Trajectory[] --------+    phase = FINALIZED
                   |
  [Framework scores trajectories, writes to TQ]
                   |
  abort_session ---+--> _get_gateway_index()      |
  (on error)       |    Ray remote call ---------->+--> abort()
                   |                              |    phase = ABORTED

0x05. 请求处理机制:从 HTTP 到 Trajectory

这是 Gateway 最核心的执行路径,对应 RFC 的 Request Handling 描述。每收到一条聊天补全请求,网关会执行以下操作:

  1. 消息级前缀校验‌:将传入的标准化消息与会话已记录的消息历史比对。若前缀匹配,仅对新增的增量消息分词,追加到已累积的token序列后;若前缀不匹配,当前轨迹直接收尾,新轨迹以全量分词的方式重新启动。
  2. 推理路由‌:将提示词token ID通过推理后端的token级生成API(如AsyncLLMServerManager)发送,接收返回的响应token ID与对数概率。
  3. 交互记录‌:保存当前轮次的提示词ID、响应ID和对数概率,更新会话的累积token序列与消息历史。
  4. 响应重构 ‌:对响应ID执行反分词,组装出标准OpenAI聊天补全响应返回给智能体。开启工具调用时,会从原始token输出中还原出结构化的tool_calls字段。

GatewaySession.run_generation()session/session.py:142-223)实现了完整流程:

5.1 单次请求处理流程

python 复制代码
Agent (黑盒或白盒)
    |
    | POST /sessions/{id}/v1/chat/completions
    v
+-----------------------------------------------------------------+
| _GatewayActor._handle_chat_completions()                        |
|  1. Validate: stream? n? response_format? tool_choice?          |
|  2. Lookup session by session_id                                |
|  3. Call session.run_generation(payload, backend)               |
|  4. Wrap GenerationOutcome as ChatCompletionResponse JSON        |
+--------------------------+---------------------------------------+
                           |
                           v
+-----------------------------------------------------------------+
| GatewaySession.run_generation()                                  |
|                                                                  |
|  +-- async with generation_lock: (serialize session) ---------+  |
|  |                                                             |  |
|  |  1. codec.normalize_request(payload)                        |  |
|  |     -> normalize messages / tools / chat_template_kwargs    |  |
|  |                                                             |  |
|  |  2. async with request_lock: (protect state read/write)     |  |
|  |     a. Check phase == ACTIVE                                |  |
|  |     b. _prepare_generation_inputs()                         |  |
|  |        |-- prefix check: same context?                      |  |
|  |        |-- YES: encode_incremental(new_msgs), mask=0        |  |
|  |        |-- NO:  materialize old trajectory, encode_full()   |  |
|  |        |-- budget check: exceeded? return length_exhausted  |  |
|  |        |-- build_sampling_params(payload)                   |  |
|  |        |-- clamp max_tokens to remaining budget             |  |
|  |        -> returns EncodedData                               |  |
|  |                                                             |  |
|  |  3. RELEASE request_lock (generation outside lock)          |  |
|  |                                                             |  |
|  |  4. backend.generate(request_id, prompt_ids, sampling_params)|  |
|  |     -> returns token_ids, log_probs, stop_reason            |  |
|  |                                                             |  |
|  |  5. codec.decode_response(response_ids, tools, stop_reason) |  |
|  |     -> assistant_msg, finish_reason                         |  |
|  |                                                             |  |
|  |  6. async with request_lock: (re-acquire to commit state)   |  |
|  |     a. Check phase == ACTIVE again                          |  |
|  |     b. Append materialized_trajectory (if context changed)  |  |
|  |     c. Update active_trajectory, message_history            |  |
|  |     -> return GenerationOutcome                             |  |
|  |                                                             |  |
|  +-------------------------------------------------------------+  |
+-----------------------------------------------------------------+
                           |
                           v
+-----------------------------------------------------------------+
| _GatewayActor serializes JSON response                           |
|  {"id":"chatcmpl-...","object":"chat.completion",               |
|   "choices":[{"index":0,"message":{...},"finish_reason":"stop"}],|
|   "usage":{"prompt_tokens":N,"completion_tokens":M,...}}         |
+-----------------------------------------------------------------+

注,在 _register_routes 函数中,已经注册了该API。

python 复制代码
        @self._app.post("/sessions/{session_id}/v1/chat/completions")
        async def _chat_completions(session_id: str, request: Request):
            payload = await request.json()
            return await self._handle_chat_completions(session_id=session_id, payload=payload)

5.2 双锁机制

GatewaySessionsession/session.py:139-140)使用两把异步锁:

  • generation_lock:在最外层,确保同一 session 推理串行。满足 Agent 多轮对话天然的串行性。
  • request_lock :只保护状态读写的临界区。实际的推理过程(backend.generate(),耗时可达数秒)在锁外执行。生成前后各加一次 request_lock,生成期间不持锁,允许其他操作(如 set_reward_info)并发执行。

5.3 增量编码与前缀复用

RFC 特别强调了 prefix consistency check。GatewaySession._prepare_generation_inputs() 实现三条编码路径:

条件 处理方式
首轮请求(无 active_trajectory) 全量编码整个 messages
续轮请求,messages 是历史前缀 仅编码增量 messages,追加到 response_ids(mask=0)
续轮请求,messages 上下文变化 物化当前轨迹,重新全量编码

前缀判定通过 MessageCodec.canonicalize_message_for_prefix_comparison()codec.py:355-372)实现,对 tool_calls 的 arguments 做 JSON 规范化后再比较,避免表示差异导致误判。

对黑盒 Agent 的关键意义:黑盒 Agent 每轮都发送完整 messages 历史(OpenAI API 规范),Gateway 通过前缀匹配自动识别新增部分,无需 Agent 配合。

5.4 为什么一个 Session 会产生多个 Trajectory

RFC 原话解释了这一设计决策:

A session produces multiple trajectories when the Gateway detects a message prefix mismatch mid-session, possibly due to context compression, skill switching, or other agent-side context rewrites. The Gateway does not need to understand why the context changed; it only enforces consistency within each trajectory.

Gateway 不需要理解上下文为什么变化,只负责在每个 trajectory 内部保持 token 序列的 prefix 一致性。物化时机:

  1. 上下文变化时(_prepare_generation_inputs):前缀不匹配,立即物化
  2. finalize 时(finalize()):将 active_trajectory 物化,注入 reward_info
  3. 预算耗尽时:返回 length_exhausted_trajectory,携带 extra_fields={"finish_reason": "length"}

5.5 Trajectory 数据结构

RFC 定义与 Uni-Agent 实现的对比:

python 复制代码
# RFC 定义
@dataclass
class Trajectory:
    uid: str # 来自数据集层(每个 prompt 一个),用于跟原始 batch 对齐。
    session_id: int # 是 group sampling 内的索引(N 个分组),用于 GRPO 这类需要分组归一化的算法。
    trajectory_id: int # 是单次 sampling 内多条 trajectory 的索引(M 条),对应"一次 session 因 prefix mismatch 拆分出多个 trajectory"的情况。
    reward_info: dict
    prompt_ids: list[int]
    response_ids: list[int]
    response_logprobs: list[float] # 来自 serving:可以直接做 importance sampling、PPO ratio,不必再跑一次 ref/actor 前向
    loss_mask: list[int]  # 1=response, 0=prompt,直接对 response token 做 loss。

# Uni-Agent 实现 (session/types.py:32)
@dataclass
class Trajectory:
    prompt_ids: list[int]
    response_ids: list[int]
    response_mask: list[int]          # RFC 中叫 loss_mask
    response_logprobs: list[float] | None = None
    reward_info: dict[str, Any] = field(default_factory=dict)
    reward_score: float | None = None
    num_turns: int = 0
    routed_experts: torch.Tensor | np.ndarray | None = None
    multi_modal_data: dict[str, Any] | None = None
    extra_fields: dict[str, Any] = field(default_factory=dict)

差异:uid/session_id/trajectory_id 由 Framework 层管理;loss_mask 改名 response_mask;新增 reward_score、routed_experts、multi_modal_data、extra_fields。

当网关在会话中途检测到消息前缀不匹配时(可能由上下文压缩、技能切换或其他智能体侧的上下文重写引发),单个会话会产生多条轨迹。网关无需理解上下文变更的具体原因,仅负责确保每条轨迹内部的一致性。在最终输出的DataProto中,每条轨迹将独立占据一行。

下图中,一prompt请求一次答案可能有很多条可以学习的轨迹。

5.6 MessageCodec 编解码

MessageCodec 是一个 模型作用域的编解码器------持有与 Trainer DataLoader 完全相同的 tokenizer/processor/chat_template,确保 Gateway 编码出的 token 序列在分布上与训练数据一致,同时白名单过滤采样参数、规范化 tool_calls 结构、识别多轮消息的前缀关系。

编码(codec.py:211-296):

  • encode_full():全量编码。有 processor 时先 render chat template 再处理多模态;无 processor 时直接 tokenizer 编码
  • encode_incremental() :增量编码。去掉 system_prompt 前缀(ids[len(self._system_prompt):]

解码(codec.py:298-332):

  • 有 tool_parser 且 tools 不为空 -> extract_tool_calls() 解析工具调用
  • 否则 -> tokenizer.decode,映射 stop_reason 到 OpenAI finish_reason

0x06. 当前局限与演进方向

6.1 当前局限

问题 说明 RFC 状态
不支持 streaming stream=true 降级为非流式响应 Phase 1 未要求
不支持 n>1 单次请求只能生成一个候选 Phase 1 未要求
不支持 response_format 不支持 structured output Phase 1 未要求
tool_choice 仅支持 auto/none 不支持指定具体 function 或 required Phase 1 未要求
单 session 串行生成 generation_lock 确保串行 设计如此
负载均衡为本地视角 只看自己管理的 Actor 池 Phase 1 未要求
Session 无持久化 状态在 Actor 内存中,崩溃丢失 Phase 1 未要求
无认证/限流 端点无安全机制 Phase 1 未要求
前缀比对为 O(n^2) 每次遍历 message_history 做 canonicalize Phase 1 未要求
无动态扩缩容 Actor 数量启动时固定 Phase 1 未要求
缺少 token-request ingress VERL 原生 AgentLoop 迁移需要 RFC Phase 2
缺少 CliAgentFramework 子进程 Agent 的参考实现 RFC 列为 WIP

6.2 RFC 分阶段规划

阶段 内容 Uni-Agent 状态
Phase 1 chat-completions 路径的 Gateway + OpenAICompatibleAgentFramework 已完成
Phase 2 添加 token-request ingress 扩展点 未开始
Phase 3 AgentLoopManager 和 VERL 原生 AgentLoop 迁移到 Gateway 未开始

6.3 演进方向

  1. Streaming 支持:SSE 流式响应 + token 级轨迹增量采集
  2. Session 持久化:定期 checkpoint 到外部存储,支持 Actor 重启恢复
  3. 全局负载均衡:引入全局调度器或一致性哈希
  4. 前缀比对优化:使用 token 序列 hash 替代逐消息 canonicalize
  5. 动态扩缩容:根据负载自动增减 Gateway Actor
  6. 安全加固:API key 认证、速率限制、请求大小限制
  7. 显式 Segment 模型:引入 segment_index 和 parent_traj_id,精细控制 reward 广播
  8. Partial Rollout 支持:token 边界暂停/恢复生成,配合 weight version 追踪
  9. prefix-sharing 存储:多个 trajectory 共享共同前缀,减少内存占用

0x07. 总结

Gateway 是 VERL RFC #5790 在 Uni-Agent 中的完整落地实现。其核心设计动机是用一个 OpenAI-compatible 的 HTTP 接口把黑盒 Agent(子进程、远程服务)和白盒 Agent(VERL 协程式 AgentLoop)统一到同一条训练链路上。

关键设计原则:

  • 对 Agent 透明,对训练框架可见:Agent 不知道 Gateway 存在,Framework 不知道 Agent 是黑盒还是白盒
  • Gateway 是唯一的 token 真相源:所有 messages -> token_ids 的转换都在 Gateway 中完成,使用一致的 tokenizer 和 chat template
  • 分层职责:GatewayManager(调度)/ GatewayActor(HTTP)/ GatewaySession(业务)/ MessageCodec(编解码)四层各司其职
  • 关注点分离:配置(GatewayActorConfig)与运行时(LLMServerClient)解耦,后端生命周期由 Manager 独立管理
  • 异步安全:双锁机制(generation_lock + request_lock)确保多轮对话的并发安全

当前 Gateway 处于 RFC Phase 1 阶段,chat-completions 路径的核心能力已完整实现,包括多模态支持、工具调用解析、多轮对话管理、轨迹物化。Phase 2 的 token-request ingress 和 Phase 3 的 AgentLoopManager 迁移仍待推进。同时,streaming、response_format、n>1 等 OpenAI 高级特性,以及 session 持久化、全局负载均衡、动态扩缩容等工程化能力,是后续迭代的重点方向。

附录A. 架构图与数据流

A.1 整体架构图

python 复制代码
+------------------------------------------------------------------+
|                        Trainer (Driver)                          |
|  +-------------------------------------------------------------+ |
|  |              AgentFrameworkRolloutAdapter                   | |
|  |  +--------------------+  +-----------------------------+   | |
|  |  |  GatewayManager     |  |  AgentFrameworkWorker       |   | |
|  |  |  (driver-side)      |  |  (Ray Actor)                |   | |
|  |  |                    |  |  +------------------------+  |   | |
|  |  |  actor_pool[0..N]  |  |  | OpenAICompatible        |  |   | |
|  |  |  session_routing   |  |  | AgentFramework          |  |   | |
|  |  |  load_balancing    |  |  |                         |  |   | |
|  |  +---------+----------+  |  |  AgentRunner[] -----+   |  |   | |
|  |            |             |  +------------------------+  |   | | |
|  |            | Ray remote  +------------+----------------+   | | |
|  +------------+-------------------------+--------------------+ | |
|               |                         |                        | |
+---------------+-------------------------+------------------------+ |
                |                         |                          |
                v                         v                          |
+---------------------------+  +-----------------------------------+  |
|      GatewayActor[0]      |  |        GatewayActor[1..N]          |  |
|  +---------------------+  |  |  (same structure, diff nodes)     |  |
|  |  FastAPI Server      |  |  +-----------------------------------+  |
|  |  /sessions/{id}/v1/  |  |                                         |
|  |  /sessions/{id}/     |  |                                         |
|  |    reward_info       |  |                                         |
|  +----------+----------+  |                                         |
|  +----------v----------+  |                                         |
|  |  MessageCodec        |  |                                         |
|  |  (tokenizer/processor|  |                                         |
|  |   tool_parser)       |  |                                         |
|  +---------------------+  |                                         |
|  +---------------------+  |                                         |
|  |  GatewaySession[]   |  |                                         |
|  |  (per-session state)|  |                                         |
|  +----------+----------+  |                                         |
|             |              |                                         |
+-------------+--------------+                                         |
              |                                                        |
              | generate(token_ids, sampling_params)                    |
              v                                                        |
+--------------------------+                                           |
|   LLMServerClient        |                                           |
|   (vLLM / SGLang / ...)  |                                           |
|   +--------------------+ |                                           |
|   |  GPU Workers        | |                                           |
|   +--------------------+ |                                           |
+--------------------------+                                           |

A.2 黑盒 Agent vs 白盒 Agent 的数据流对比

python 复制代码
+------------------------------------------------------------------+
|                    BLACK-BOX AGENT (子进程/远程)                   |
|                                                                   |
|  Framework                                                        |
|     | 1. create_session() -> SessionHandle (base_url)             |
|     |                                                             |
|     | 2. 启动 Agent 子进程                                         |
|     |    export OPENAI_BASE_URL = {base_url}                      |
|     |    swagent run ...                                          |
|     v                                                             |
|  +----------------+                                               |
|  | Agent 子进程    |  3. 每轮调用 POST /v1/chat/completions        |
|  | (完全不知道     |     (Agent 认为自己在调 OpenAI API)            |
|  |  Gateway 存在) |                                               |
|  +-------+--------+                                               |
|          |                                                        |
|          v                                                        |
|  +----------------+     4. Gateway 透明拦截,tokenize,            |
|  | Gateway        |        记录轨迹,转发给推理后端                 |
|  | (唯一 token    |                                               |
|  |  真相源)       |     5. 返回标准 OpenAI 响应给 Agent             |
|  +-------+--------+                                               |
|          |                                                        |
|  Framework                                                        |
|     | 6. Agent 子进程退出 / 通过 reward_info 端点通知完成          |
|     | 7. finalize_session() -> Trajectory[] (token 级轨迹)        |
|     | 8. _score_trajectories() -> 写入 TransferQueue              |
|                                                                   |
+------------------------------------------------------------------+

+------------------------------------------------------------------+
|                   WHITE-BOX AGENT (协程式)                         |
|                                                                   |
|  Framework                                                        |
|     | 1. create_session() -> SessionHandle (base_url)             |
|     |                                                             |
|     | 2. 在协程中运行 AgentRunner                                  |
|     |    AgentRunner 直接调用 Gateway API                          |
|     v                                                             |
|  +----------------+                                               |
|  | AgentRunner     |  3. 每轮调用 POST /v1/chat/completions        |
|  | (协程, 知道     |     (使用 Gateway 提供的 session URL)          |
|  |  Gateway 存在)  |                                               |
|  +-------+--------+                                               |
|          |                                                        |
|          v                                                        |
|  +----------------+     4. Gateway tokenize,记录轨迹,            |
|  | Gateway        |        转发给推理后端                           |
|  | (唯一 token    |                                               |
|  |  真相源)       |     5. 返回标准 OpenAI 响应给 AgentRunner       |
|  +-------+--------+                                               |
|          |                                                        |
|  Framework                                                        |
|     | 6. AgentRunner 协程返回                                      |
|     | 7. finalize_session() -> Trajectory[] (token 级轨迹)        |
|     | 8. _score_trajectories() -> 写入 TransferQueue              |
|                                                                   |
+------------------------------------------------------------------+

关键对比:
  - 黑盒 Agent:Agent 完全不知道 Gateway 存在,通过环境变量注入 URL
  - 白盒 Agent:Agent 知道 Gateway 存在,直接使用 SessionHandle
  - 两者共享完全相同的 Gateway Session API 和轨迹采集路径
  - Framework 不需要区分 Agent 是黑盒还是白盒

A.3 核心组件图

python 复制代码
+------------------------------------------------------------------+
|                        gateway 模块                               |
|                                                                   |
|  +-----------------+     +-------------------------------+        |
|  | GatewayManager   |     | _GatewayActor (Ray Actor)      |        |
|  |                  |     |                               |        |
|  | - gateways[]     |---->| - _codec: MessageCodec        |        |
|  | - session_route  |     | - _sessions: dict             |        |
|  | - load_counter   |     | - _app: FastAPI               |        |
|  |                  |     | - _backend: LLMServerClient   |        |
|  | + create_session |     |                               |        |
|  | + finalize       |     | + start() / shutdown()        |        |
|  | + abort_session  |     | + create_session()            |        |
|  | + shutdown       |     | + finalize_session()          |        |
|  +-----------------+     | + abort_session()             |        |
|                           +----------+--------------------+        |
|                                      | 1:N                         |
|                           +----------v--------------------+        |
|                           | GatewaySession                |        |
|                           |                               |        |
|                           | - phase: SessionPhase         |        |
|                           | - message_history             |        |
|                           | - active_trajectory           |        |
|                           | - trajectories[]              |        |
|                           | - reward_info                 |        |
|                           | - request_lock / gen_lock     |        |
|                           |                               |        |
|                           | + run_generation()            |        |
|                           | + set_reward_info()           |        |
|                           | + finalize() / abort()        |        |
|                           +----------+--------------------+        |
|                                      | uses                        |
|                           +----------v--------------------+        |
|                           | MessageCodec                  |        |
|                           |                               |        |
|                           | - _tokenizer                  |        |
|                           | - _processor                  |        |
|                           | - _tool_parser                |        |
|                           | - _system_prompt              |        |
|                           |                               |        |
|                           | + normalize_request()         |        |
|                           | + encode_full()               |        |
|                           | + encode_incremental()        |        |
|                           | + decode_response()           |        |
|                           | + build_sampling_params()     |        |
|                           | + extract_multi_modal_data()  |        |
|                           +-------------------------------+        |
|                                                                   |
|  +----------------------------------------------------------+    |
|  | session/ sub-package (data models)                        |    |
|  |                                                          |    |
|  |  SessionHandle    Trajectory    TrajectoryBuffer          |    |
|  |  - session_id     - prompt_ids  - prompt_ids              |    |
|  |  - base_url       - response_ids - response_ids           |    |
|  |  - reward_info_url - response_mask - response_mask        |    |
|  |                    - reward_info  - response_logprobs     |    |
|  |                    - num_turns                             |    |
|  |                                                          |    |
|  |  ChatCompletionRequest / ChatCompletionResponse (TypedDict)|   |
|  |  GenerationOutcome / EncodedData (internal dataclass)     |    |
|  +----------------------------------------------------------+    |
+------------------------------------------------------------------+

A.4 请求数据流图

python 复制代码
Agent (黑盒子进程 或 白盒协程)
    |
    | POST /sessions/{id}/v1/chat/completions
    | (OpenAI JSON payload)
    v
+-----------------------------------------------------------------+
| _GatewayActor._handle_chat_completions()                        |
|                                                                  |
|  1. Validate payload: stream? n? response_format? tool_choice?  |
|  2. Lookup session (by session_id)                               |
|  3. Call session.run_generation(payload, backend)               |
|  4. Wrap GenerationOutcome as ChatCompletionResponse JSON        |
+--------------------------+---------------------------------------+
                           |
                           v
+-----------------------------------------------------------------+
| GatewaySession.run_generation()                                  |
|                                                                  |
|  +-- async with generation_lock: -----------------------------+  |
|  |                                                             |  |
|  |  1. codec.normalize_request(payload)                        |  |
|  |     -> normalize messages / tools / chat_template_kwargs    |  |
|  |                                                             |  |
|  |  2. async with request_lock:                                |  |
|  |     a. Check phase == ACTIVE                                |  |
|  |     b. _prepare_generation_inputs()                         |  |
|  |        |-- prefix check: same context?                      |  |
|  |        |-- YES: encode_incremental(new_msgs)                |  |
|  |        |-- NO:  materialize old trajectory, encode_full()   |  |
|  |        |-- budget check: exceeded? return length_exhausted  |  |
|  |        |-- build_sampling_params(payload)                   |  |
|  |        |-- clamp max_tokens to remaining budget             |  |
|  |        -> returns EncodedData                               |  |
|  |                                                             |  |
|  |  3. RELEASE request_lock (generation happens outside lock)  |  |
|  |                                                             |  |
|  |  4. backend.generate(request_id, prompt_ids, sampling_params)|  |
|  |     -> returns output: token_ids, log_probs, stop_reason    |  |
|  |                                                             |  |
|  |  5. codec.decode_response(response_ids, tools, stop_reason) |  |
|  |     -> assistant_msg, finish_reason                         |  |
|  |                                                             |  |
|  |  6. async with request_lock:                                |  |
|  |     a. Check phase == ACTIVE again                          |  |
|  |     b. Append materialized_trajectory (if context changed)  |  |
|  |     c. Update active_trajectory, message_history            |  |
|  |     d. Update image_data, video_data, tool_schemas          |  |
|  |     -> return GenerationOutcome                             |  |
|  |                                                             |  |
|  +-------------------------------------------------------------+  |
+-----------------------------------------------------------------+
                           |
                           v
+-----------------------------------------------------------------+
| _GatewayActor serializes JSON response                           |
|                                                                  |
|  {                                                               |
|    "id": "chatcmpl-...",                                         |
|    "object": "chat.completion",                                  |
|    "choices": [{                                                 |
|      "index": 0,                                                 |
|      "message": { "role": "assistant", "content": "..." },       |
|      "finish_reason": "stop"                                     |
|    }],                                                           |
|    "usage": { "prompt_tokens": N, "completion_tokens": M, ... }  |
|  }                                                               |
+-----------------------------------------------------------------+

A.5 Session 完整生命周期数据流

python 复制代码
              GatewayManager                GatewayActor
              ==============                ============
                   |                              |
  create_session --+--> _select_gateway_index()   |
                   |    (least-loaded)             |
                   |                              |
                   |    Ray remote call ---------->+--> create_session()
                   |                              |    build SessionHandle
                   |                              |    (urls with session_id)
                   |                              |    new GatewaySession()
                   |    <--- SessionHandle -------+
                   |                              |
  [Agent uses SessionHandle.base_url to          |
   POST /v1/chat/completions repeatedly]          |
   -- 黑盒Agent: 通过环境变量 OPENAI_BASE_URL      |
   -- 白盒Agent: 直接使用 SessionHandle            |
                   |                              |
                   |                   run_generation() x N turns
                   |                              |
                   |                   (state accumulates in
                   |                    active_trajectory,
                   |                    message_history,
                   |                    trajectories[])
                   |
  [Optional: POST /reward_info]                   |
                   |                              |
                   |    Ray remote call ---------->+--> set_reward_info()
                   |                              |
  finalize_session-+--> _get_gateway_index()      |
                   |    Ray remote call ---------->+--> finalize()
                   |                              |    _materialize_active_trajectory()
                   |    <--- Trajectory[] --------+    phase = FINALIZED
                   |                              |
  [Framework scores trajectories, writes to TQ]   |
                   |                              |
  abort_session ---+--> _get_gateway_index()      |
  (on error)       |    Ray remote call ---------->+--> abort()
                   |                              |    phase = ABORTED

A.6 与 Uni-Agent 其他组件的交互

python 复制代码
+------------------------------------------------------------------+
|                        Uni-Agent 全栈                             |
|                                                                   |
|  +------------------+                                             |
|  | Trainer (VERL)   |                                             |
|  | - PPO/GRPO loop  |                                             |
|  | - Reward model   |                                             |
|  +--------+---------+                                             |
|           |                                                       |
|           v                                                       |
|  +------------------+     +---------------------------+           |
|  | entry.py          |     | framework.py              |           |
|  |                   |     |                           |           |
|  | build_gateway_    |     | OpenAICompatible          |           |
|  |   manager()       |---->| AgentFramework            |           |
|  |                   |     |                           |           |
|  | AgentFramework    |     | _run_session():           |           |
|  | RolloutAdapter    |     |   create_session()        |           |
|  +------------------+     |   AgentRunner(session)     |           |
|                            |     |-- 黑盒: 启动子进程     |           |
|                            |     |-- 白盒: 协程调用      |           |
|                            |   finalize_session()      |           |
|                            |   _score_trajectories()   |           |
|                            |   write_to_tq()           |           |
|                            +-------------+-------------+           |
|                                          |                         |
|                    +---------------------+-----------+             |
|                    |                                 |             |
|                    v                                 v             |
|  +-----------------------------+    +-----------------------------+|
|  | GatewayManager              |    | AgentRunner (user code)     ||
|  | (driver-side)               |    |                             ||
|  |                             |    | Uses SessionHandle.base_url ||
|  | GatewayActor[0]             |    | to call OpenAI-compatible   ||
|  | GatewayActor[1]             |    | /v1/chat/completions        ||
|  | ...                         |    |                             ||
|  | GatewayActor[N]             |    | e.g. search_agent,          ||
|  +-------------+---------------+    | swe_agent, lark_agent      ||
|                |                    +-----------------------------+|
|                |                                                  |
|                v                                                  |
|  +-----------------------------+                                 |
|  | LLMServerClient             |                                 |
|  | (vLLM / SGLang / VEfaaS)   |                                 |
|  +-----------------------------+                                 |
|                                                                   |
|  +-----------------------------+                                 |
|  | RewardLoopWorker            |                                 |
|  | (scores final trajectory)   |                                 |
|  +-----------------------------+                                 |
|                                                                   |
|  +-----------------------------+                                 |
|  | TransferQueue (TQ)          |                                 |
|  | (async training data pipe)  |                                 |
|  +-----------------------------+                                 |
+------------------------------------------------------------------+

附录B. Gateway 内部实现补充

B.1 核心类及其职责

文件位置 职责
GatewayManager manager.py:18 管理 GatewayActor 池,最小负载调度,session 路由
_GatewayActor gateway.py:27 Ray Actor 实现,FastAPI 路由,JSON 序列化,session 生命周期代理
GatewayActor gateway.py:230 ray.remote(_GatewayActor) 的公开别名
GatewaySession session/session.py:105 会话状态机,轨迹物化,多轮对话管理
MessageCodec session/codec.py:128 消息规范化,token 编解码,工具解析,采样参数合并
GatewayActorConfig config.py:17 frozen dataclass,携带模型静态配置
SessionHandle session/types.py:14 会话地址(base_url + reward_info_url),跨包传递
Trajectory session/types.py:32 训练轨迹(prompt_ids + response_ids + mask + logprobs + reward)
TrajectoryBuffer session/session.py:32 活跃轨迹的 mutable token 缓冲区
EncodedData session/session.py:51 _prepare_generation_inputs() 的内部输出
GenerationOutcome session/session.py:84 run_generation() 的业务结果
SessionPhase session/session.py:17 三态枚举:ACTIVE / FINALIZED / ABORTED
MalformedRequestError session/codec.py:15 请求格式错误异常

B.2 GatewayManager 最小负载调度

GatewayManagermanager.py:60-63 通过 _select_gateway_index() 实现最小负载调度:

python 复制代码
def _select_gateway_index(self) -> int:
    return min(
        range(len(self.gateways)),
        key=lambda index: self.active_sessions_per_gateway[index]
    )

关键设计:在 create_session() 中,路由表和负载计数器在 await 之前同步更新manager.py:85-86),避免并发协程读到相同的过期计数而导致所有 session 挤压到最低索引的 Gateway。

B.3 GatewaySession 生成流程

run_generation()session/session.py:142-223 中实现了完整的生成流程:

  1. normalize_request():规范化 messages、tools、chat_template_kwargs
  2. _prepare_generation_inputs():根据前缀判定选择编码策略,管理 token 预算
  3. backend.generate():发起推理,释放 request_lock 期间不持锁
  4. decode_response():解码 token 输出,解析 tool_calls
  5. 状态提交:在 request_lock 下更新 message_history、active_trajectory 等

两把锁的精妙配合:

  • generation_lock 在最外层,确保同一 session 的推理是串行的
  • request_lock 只保护状态读写的临界区,实际的推理过程(backend.generate())在锁外执行
  • 生成前后各加一次 request_lock,生成期间不持锁,允许其他操作(如 set_reward_info)并发执行

B.4 MessageCodec 编解码流程

MessageCodeccodec.py:128-380)有 6 个公开方法,解决 TITO 中编码/解码/边界控制三个核心难点:

功能清单如下:

方法 功能 解决的难点
normalize_request() 校验 + 规范化 OpenAI 请求:消息结构、tool_calls 的 JSON arguments、tool_choice="none" 时清空 tools、提取 chat_template_kwargs 防止 Agent 端数据结构脏数据穿透到 token 层(难点 1 的前置防御)
encode_full() 全量编码 messages → token IDs。有 processor 时走 apply_chat_template + processor(text, images, videos),无 processor 时直接 tokenizer.encode 难点 1:文本→Token 的精确映射,确保与训练时编码路径一致
encode_incremental() 增量编码,与 encode_full 的区别是末尾去掉 self._system_prompt 长度,避免重复编码 system prompt 难点 2:多轮对话的效率------不重复编码不变部分
decode_response() 模型输出的 token IDs → assistant message + finish_reason。有 tool_parser 时解析 function calling,否则 tokenizer.decode + stop_reason 映射 难点 1:Token→文本的逆向映射
canonicalize_message_for_prefix_comparison() 规范化 tool_calls.arguments(JSON 字符串先 parse 再比较),消除 '{"a":1}' vs {"a": 1} 的表示差异 难点 2 的核心:前缀匹配的误判防御
build_sampling_params() 白名单合并:base_sampling_params + Agent 请求中白名单参数(temperature/top_p/top_k/max_tokens)的覆盖 难点 6:防止 Agent 注入不受控的采样参数

编码路径codec.py:211-296):

  • encode_full() :全量编码。根据是否有 processor 选择路由:
    • 有 processor(多模态):先通过 chat template 渲染为文本,再通过 processor 处理图像/视频
    • 无 processor(纯文本):直接通过 tokenizer 的 chat template 编码
  • encode_incremental() :增量编码。编码后去掉 system_prompt 前缀(ids[len(self._system_prompt):]),因为 system prompt 已缓存在 buffer 的 prompt_ids 中

解码路径codec.py:298-332):

  • 如果有 tool_parser 且 tools 不为空,调用 tool_parser.extract_tool_calls() 解析工具调用
  • 否则直接 tokenizer.decode,并映射 stop_reason 到 OpenAI finish_reason

前缀比较codec.py:355-372):

python 复制代码
def canonicalize_message_for_prefix_comparison(self, message):
    # 对 tool_calls 的 arguments 做 JSON 规范化
    # "arguments": "{\"x\":1}" -> ("json", {"x": 1})
    # "arguments": {"x": 1}   -> ("json", {"x": 1})

B.5 轨迹物化策略

GatewaySession 中有三个关键的轨迹物化时机:

  1. 上下文变化时_prepare_generation_inputs):当新请求的 messages 不是历史的前缀,当前 active_trajectory 被立即物化并追加到 trajectories[]
  2. finalize 时finalize()):将 active_trajectory 物化,所有 trajectories 注入 reward_info 后返回
  3. 预算耗尽时 :返回 length_exhausted_trajectory,携带 extra_fields={"finish_reason": "length"}

B.6 HTTP 端点设计

端点 方法 功能
/sessions/{id}/v1/chat/completions POST OpenAI 兼容的 chat 推理
/sessions/{id}/reward_info POST 注入奖励元数据

错误处理(gateway.py:64-83):

  • 4xx 错误 -> invalid_request_error
  • 5xx 错误 -> internal_server_error
  • 统一返回 {"error": {"message": ..., "type": ..., "code": null, "param": null}} 格式

B.7 配置注入链路

python 复制代码
entry.py:build_gateway_manager()
  |
  |-- 从 OmegaConf 读取 model config, rollout config
  |-- 构造 GatewayActorConfig (tokenizer, processor, tool_parser, ...)
  |-- 构造 GatewayManager(llm_client, gateway_count, gateway_actor_config)
  |     |
  |     |-- 获取存活 CPU 节点列表
  |     |-- 创建 gateway_count 个 GatewayActor
  |     |     |-- 每个 actor 注入 GatewayActorConfig + LLMServerClient
  |     |     |-- 每个 actor 内部创建 MessageCodec
  |     |-- 启动所有 actor 的 FastAPI server
  |     |-- 返回 GatewayManager 实例
  |
  |-- 将 GatewayManager 注入 AgentFrameworkWorker
  |-- AgentFrameworkWorker 内部创建 OpenAICompatibleAgentFramework
        |
        |-- AgentRunner 通过 SessionHandle.base_url 调用 Gateway

附录C:Token In, Token Out: Gateway 如何处理 Token 级数据一致性

从 RFC 可以看出,token in / token out 是在 Gateway 完成的。

Each AgentGateway instance is a Ray actor running a FastAPI HTTP server that exposes the OpenAI Chat Completions API. It manages multiple concurrent sessions, each maintaining independent trajectory state. The Gateway is the single canonical tokenization authority for the chat-completions path --- all messages -> token_ids conversions happen here, using the inference backend's tokenizer and chat template.

C.1 什么是 TITO 问题

RL 训练中所有计算都在 token 粒度上------policy gradient loss、KL penalty、reward 分配都是逐 token 的。训练器需要四样东西:

数据 含义 用途
prompt_ids 输入模型的 prompt token ID 序列 拼接 input_ids
response_ids 模型生成的所有 token ID(含插入的上下文) 计算 loss
response_mask(亦称 loss_mask 1=模型输出(参与 loss),0=上下文(不参与 loss) 控制 loss 计算位置
response_logprobs 每个生成 token 的 log 概率 KL 惩罚

Agent 和模型之间天然隔着一层对话 API (OpenAI /v1/chat/completions):Agent 发的是文本消息,但训练器要的是 token 序列。Gateway 是整个系统里唯一做文本↔Token 转换的地方,这就是"Token In, Token Out"问题------Gateway 吞进文本消息,吐出 token 级训练数据。

python 复制代码
Agent(消息层:text / JSON)
  |
  | POST /sessions/{id}/v1/chat/completions(text in)
  v
+----------- Gateway(the single canonical tokenization authority)----------+
|                                                                           |
|   encode_full(messages) → token_ids(全量编码)                            |
|   encode_incremental(messages) → token_ids(增量编码,mask=0)              |
|   decode_response(response_ids) → assistant_msg(回溯到文本)               |
|   Trajectory → { prompt_ids, response_ids, response_mask, logprobs }      |
|                                                                           |
+---- 下面是 Gateway 的 "in/out" ---- token IDs 进,token IDs 出 -------------+
  |
  | LLMServerClient.generate(token_ids, sampling_params)
  v
Inference Backend(vLLM / SGLang,无感知 Gateway 存在)
  |
  v
Trainer(消费 Trajectory,计算 loss)

C.2 隐藏的坑

我们的模型在 rollout 阶段生成的 response token 和训练端的 text tokenize 得到的 tokens,真的一样吗?

答案是否定的。

比如,大模型的 tokenization 当中使用的 BPE 算法决定了tokenize 和 detokenize是一对不可逆的过程。具体来说,一个 text sequence 在 tokenize 时得到的 token sequence 是唯一的;但是不止一个 token sequence 可以被 detokenize 为同一个 text sequence。

我们举一个例子,在 Qwen3-8B 的 tokenizer.json当中

python 复制代码
      "H": 39,
      "ING": 1718,
      "HAV": 72239,
      "AVING": 83722,

所以39, 8372272239, 1718都会被detokenize为HAVING。但是HAVING被tokenize的时候,得到的只有72239, 1718。如何解决这个Retokenization Drift的问题呢?++那就是 RL infra 内部用 token id 的形式去维护agent 的历史++。

另外,其它部分也有可能改变序列内容,比如会话压缩(Context Compression)、历史消息修改(History Mutation)、滑动窗口截断(Window Truncation)、工具调用结果摘要、系统提示注入/修改。

根本原因在于:

Chat Completions 接口天然是字符串级的,而训练需要 token 级的数据。 两边协议不通。且 RL 训练需要精确 token id,Anthropic 协议层进出的是字符串。虽然可以试图在字符串接口上重建 token 级的因果链,但这条因果链在每一个 token → string → token 的转换中都可能断裂,在每一个 Agent 内部操作中都可能被重写。这不是一个可以通过修 bug 彻底解决的问题。这是坐在 Chat Completions 接口上做 RL 的结构性困境

C.3 难点 1: 文本 ↔ Token 的往返一致性

问题

Agent 通过 OpenAI API 发送 JSON 消息:

json 复制代码
{
  "messages": [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Calculate 2+3."}
  ]
}

Gateway 要把这些消息编码为 token IDs 推给推理后端,解码后把文本还给 Agent。如果编码路径与 Trainer 的 DataLoader 不一致,token 分布就漂移了

Gateway 的解法

MessageCodeccodec.py:128-380)是 TITO 的转换核心,持有与 Trainer DataLoader 完全相同的 tokenizer、processor、chat template。

python 复制代码
编码(encode_full, codec.py:211-253):
  有 processor(多模态模型):apply_chat_template(tokenize=False) → processor(text, images, videos) → normalize_token_ids
  无 processor:             apply_chat_template(tokenizer, ...) → normalize_token_ids

解码(decode_response, codec.py:298-332):
  有 tool_parser + tools:extract_tool_calls(response_ids) → tool_calls + content
  无 tool_parser / 无 tools:tokenizer.decode(response_ids) → plain text
  stop_reason → OpenAI finish_reason 映射

关键约束:同一套 tokenizer + chat_template + processor 贯穿训练和推理 ,Gateway 不是自己做 tokenization,而是复用训练时已经初始化好的组件。换言之,Gateway 是 the single canonical tokenization authority,Agent 不碰 tokenizer。


C.4 难点 2: 多轮对话的前缀一致性

问题

这是 TITO 最核心的难点 。Agent 每轮都发送 完整 的消息历史(OpenAI API 是无状态的)。例如:

python 复制代码
# 第一轮 Agent 发送
messages = [sys, user_q1]          -> encode -> [token_A, token_B]  
                                     model -> [token_C, token_D]
                                     decode -> assistant reply

# 第二轮 Agent 发送
messages = [sys, user_q1, asst_r1, user_q2]  -> agent 又发了全部历史

Gateway 需要判断:哪些 token 是模型生成的,哪些只是插入的新上下文?

python 复制代码
message_history = [sys, user_q1, asst_r1]   # 历史
new_messages    = [user_q2]                  # 真正新增的

encode_incremental([user_q2]) → [token_E, token_F]     mask=[0, 0]
model 生成                        → [token_G, token_H] mask=[1, 1]

response_ids  = [token_E, token_F, token_G, token_H]
response_mask = [   0,       0,       1,       1     ]
                  ↑ 插入的上下文,不计算 loss    ↑ 模型输出,参与 loss

为什么这个 mask 如此重要?因为训练时损失只在 mask=1 的位置计算。如果把上下文 token 也计入损失,梯度就全错了。

Gateway 的解法

GatewaySession._is_request_context_prefix()session.py:361-374)逐消息规范化后做前缀比较;_prepare_generation_inputs() 走三条路径:

条件 处理 mask 结果
首轮(active_trajectory is None encode_full(messages),创建新 buffer 后续 model output 全部为 1
续轮,前缀匹配 copy 旧 buffer,encode_incremental(new_msgs) 尾部追加,mask=0 新消息是上下文,不参与 loss
续轮,前缀不匹配 物化旧 buffer 为 Trajectoryencode_full(messages) 从头开始 新 trajectory,model output 全部为 1

关键实现(session.py:243-286):

python 复制代码
# 续轮、前缀匹配:只把新增消息作为 context 追加,mask=0
buffer = self._copy_trajectory_buffer(self.active_trajectory)
incremental_messages = messages[len(self.message_history):]
incremental_ids = self._codec.encode_incremental(incremental_messages)
buffer.response_ids.extend(incremental_ids)
buffer.response_mask.extend([0] * len(incremental_ids))  # context, 不参与 loss

前缀微差防御(codec.py:355-372):tool_calls.arguments 做 JSON 规范化后再比较,避免 '{"a":1}'{"a": 1} 的表示差异导致误判。


C.5 难点 3: Agent 中途切换上下文,产生多个 Trajectory

问题

Agent 可能因为以下原因在会话中途改变上下文:

  • Context compression: Agent 压缩历史,减少 KV cache
  • Skill switching: Agent 切换到不同技能,重置部分上下文
  • Long context: Agent 发现超出窗口,主动删减

这些操作使得新请求的 messages 不再是历史的前缀。Gateway 不能假设 Agent 只能追加消息。

Gateway 的解法

_is_request_context_prefix() 返回 False 时(session.py:287-297):

python 复制代码
# 上下文变化:物化当前轨迹,重新全量编码
materialized_trajectory = self._build_materialized_trajectory(active=self.active_trajectory)
# 清空 buffer,用新 messages 全量编码
buffer = TrajectoryBuffer(prompt_ids=encode_full(messages))

一个 Session 会产生多个 Trajectory。训练器仍然可以正确处理,因为每个 Trajectory 是 prefix-consistent 的 token 片段。Session 结束时会一次性返回所有轨迹:

python 复制代码
# session.py:327-338
async def finalize(self) -> list[Trajectory]:
    self._materialize_active_trajectory()
    self.phase = SessionPhase.FINALIZED
    return [replace(trajectory, reward_info=...) for trajectory in self.trajectories]

物化触发点:

  1. 上下文变化 :前缀不匹配时在 _prepare_generation_inputs 中物化
  2. finalize :Session 结束时物化最后一个 active_trajectory
  3. 预算耗尽 :直接返回 length_exhausted_trajectory,不发起推理

C.6 难点 4: Token 预算控制

问题

Session 必须有 token 预算来防止 OOM。但在多轮对话中,max_tokens 参数来自 Agent 请求,Agent 可能不知道已经用了多少预算。

Gateway 的解法

每个 GatewaySession 持有 response_length 预算。_prepare_generation_inputs() 末尾做双重钳制(session.py:301-305):

python 复制代码
remaining = self._response_length - len(buffer.response_mask)
if remaining is not None and "max_tokens" in sampling_params:
    sampling_params["max_tokens"] = min(sampling_params["max_tokens"], remaining)

如果增量编码后累计 token 已经超过 response_length,直接构造 length_exhausted_trajectorysession.py:264-282),携带 extra_fields={"finish_reason": "length"}run_generation() 检测到长度耗尽后立即返回空的 assistant message,不调用 backend.generate()

注意:prompt_length 也被存储但在 Session 层未做强制检查,属于预留字段。


C.7 难点 5: Loss Mask 的跨轨迹传递

问题

TITO 最终产出要给训练器。训练器期望的格式(TransferQueue 字段)包含 loss_mask(即 response_mask)、input_ids(= prompt_ids + response_ids)、position_idsrollout_log_probsrm_scores 等。Gateway 是 token 粒度的记录者,但需要把这些数据正确拼装成训练器可消费的 TensorDict。

Gateway 的解法

OpenAICompatibleAgentFramework._trajectory_to_tq_field_and_tag()framework.py:611-688)做数据组装:

python 复制代码
prompts = torch.tensor(trajectory.prompt_ids)     # 全量 prompt tokens
responses = torch.tensor(trajectory.response_ids)  # 全量 response tokens
response_mask = torch.tensor(trajectory.response_mask)  # loss mask
input_ids = torch.cat([prompts, responses])       # 拼接完整序列
attention_mask = torch.ones_like(input_ids)
# 多模态 position_ids
position_ids = compute_position_id_with_mask(...)
# loss_mask 同时写入两个字段保证兼容性
field["loss_mask"] = response_mask
field["response_mask"] = response_mask
# log_probs
field["rollout_log_probs"] = trajectory.response_logprobs
# reward score 广播到最后一个位置
rm_scores[-1] = trajectory.reward_score
field["rm_scores"] = rm_scores

关键保证response_mask 中的 1 精确对应模型生成的 token,0 对应继续轮次中新增的上下文 token。这直接决定了训练时哪些位置计算 policy gradient loss。


C.8 难点 6: 采样参数边界

问题

黑盒 Agent 可能发送任意采样参数(temperature=2.0frequency_penalty=0.5 等)。但 RL 训练需要对采样参数做严格控制------某些参数必须由训练配置驱动,不能被 Agent 用户覆盖。

Gateway 的解法

MessageCodec.build_sampling_params()codec.py:374-380)维护白名单:

python 复制代码
_ALLOWED_REQUEST_SAMPLING_PARAM_KEYS = frozenset({
    "temperature", "top_p", "top_k", "max_tokens",
})

def build_sampling_params(self, payload):
    params = dict(self._base_sampling_params)  # 代码中配置的默认值
    for key in self._allowed_keys:
        if key in payload:
            params[key] = payload[key]  # Agent 只能覆盖白名单参数
    return params

Agent 发送的任何其他采样参数(frequency_penaltypresence_penaltystop 等)均被忽略。白名单可在 GatewayActorConfig 中扩展。


C.9 难点 7: 并发状态保护

问题

TITO 流程涉及 多个异步操作

  1. Agent 发送请求(需要在 request_lock 下读取状态、准备输入)
  2. 推理后端生成(延迟高,不应持有锁)
  3. 写入结果(需要重新持锁提交状态,并检查 session 是否已被其他操作终止)

同时,set_reward_info 可能在推理过程中并发调用。

Gateway 的解法

双锁机制session.py:137-140):

python 复制代码
generation_lock: 最外层,确保同一 session 推理串行
request_lock:    只保护状态读写的临界区

执行时序:

python 复制代码
1. async with generation_lock:     # 串行化整个 generation
2.   async with request_lock:      # 准备输入,读状态
3.     _prepare_generation_inputs()
4.                                # 释放 request_lock
5.   backend.generate(...)         # 推理(不持锁,允许并发 set_reward_info)
6.   decode_response(...)
7.   async with request_lock:      # 重新持锁,提交状态
8.     commit active_trajectory

生成前后各检查一次 phase == ACTIVE,防止在生成期间被 abort() 烫了 session。


C.10 完整 TITO 数据流示意图

python 复制代码
Trainer
  |
  | prompts (TensorDict: uid, raw_prompt, ...)
  v
AgentFramework.generate_sequences(prompts)
  |
  v
GatewayManager.create_session(session_id)
  |-- 返回 SessionHandle(含 base_url, reward_info_url)
  v
AgentRunner(inline_async / ray_task)
  |
  |---(多轮循环)------------------------------------------------|
  |                                                                 |
  |  POST /sessions/{id}/v1/chat/completions(标准 OpenAI API)     |
  |    → _GatewayActor → GatewaySession.run_generation()           |
  |    → encoder → backend.generate() → decoder                     |
  |    ← JSON ChatCompletionResponse                                 |
  |                                                                 |
  |  POST /sessions/{id}/reward_info(可选注入奖励元数据)            |
  |                                                                 |
  |-----------------------------------------------------------------|
  v(Agent 返回)
  v
GatewayManager.finalize_session(session_id)
  |-- 返回 list[Trajectory]
  v
Score trajectories(RewardLoopWorker.compute_score)
  |-- 只评分 session 的最后一个 trajectory,分数广播到所有同 session 轨迹
  v
_write_session_trajectories_to_tq()
  |-- Trajectory → TransferQueue fields:
  |   prompts / responses / input_ids / attention_mask /
  |   position_ids / response_mask (=loss_mask) /
  |   rollout_log_probs / rm_scores / num_turns
  v
TransferQueue → Trainer(同步消费)
  |-- 用 response_mask 计算 policy gradient
  |-- 用 rollout_log_probs 计算 KL penalty
  v
Policy Update

C.11 对比:无 Gateway 的 Agent 训练(传统方式)

传统 AgentLoop 的工作方式:

python 复制代码
Trainer -> DataLoader -> AgentLoopWorker
                           |
                           |-- Agent 通过 HTTP 回调 Trainer 的
                           |   "remote_generate()" 方法
                           |
                           |-- 编解码在 Agent 代码内手工完成
                           |   耦合在 Agent 的业务逻辑中
                           |
                           |-- Loss mask 由 Agent 自行维护,
                           |   不同类型 Agent 的实现可能不一致
                           |
                           |-- 无统一 TITO 抽象

Gateway 的 TITO 方案将这层关注点独立出来,使 Agent 只需要处理业务逻辑(通过标准 OpenAI API),Gateway 负责 token 级的数据一致性。训练器则消费 Gateway 产生的标准格式轨迹。

附录D. 会话安全性

D.1 生命周期状态机

GatewaySessionsession/session.py:17-28)实现三态状态机:

python 复制代码
ACTIVE -> FINALIZED  (正常结束,产出轨迹)
ACTIVE -> ABORTED    (异常终止,丢弃轨迹)

进入 FINALIZED 或 ABORTED 后所有后续请求被拒绝(409),保证轨迹数据完整性。

D.2 采样参数白名单

MessageCodec.build_sampling_params()codec.py:374-380)只允许 temperature、top_p、top_k、max_tokens 等白名单参数透传。黑盒 Agent 可能设置任意采样参数,Gateway 通过白名单过滤确保训练过程可控。

D.3 Token 预算管理

session 级别维护 prompt_length 和 response_length 预算:

  • 每次生成前,max_tokens 与剩余 response 预算取 min
  • 增量编码超过剩余预算时,直接返回 length_exhausted_trajectory,不发起推理

D.4 HTTP 端点与错误处理

端点 方法 功能
/sessions/{id}/v1/chat/completions POST OpenAI 兼容的 chat 推理
/sessions/{id}/reward_info POST 注入奖励元数据

错误处理(gateway.py:64-83):4xx -> invalid_request_error,5xx -> internal_server_error,统一返回 {"error":{"message":...,"type":...,"code":null,"param":null}}

附录E:Forge 的设计哲学

出自 Forge: 大规模原生Agent RL系统,其提出的分离方案非常经典,我们可以印证学习。

Forge 将 Agent 的执行逻辑与底层的训推引擎彻底解耦。RL 系统由 3 个核心模块组成:

  • Agent :该层抽象了通用 Agent(涵盖白盒和黑盒架构)及其运行环境。它负责协调环境交互,使 Agent 成为一个纯粹的 Trajectory Producer。通过将环境交互与 LLM generation 解耦,Agent 可以专注于核心业务逻辑(如 context management 和复杂的环境交互等),而无需关心底层的训练和推理细节。

  • 中间件抽象层:作为桥梁,该层在物理上将 Agent 侧与训练/推理引擎隔离。

    1. Gateway Server:充当标准化通信网关,处理 Agent 与 LLM 之间的交互请求。通过通用标准协议,它有效地将底层模型的复杂性与 Agent 的高层行为逻辑隔离开来。
    2. Data Pool:作为分布式数据存储,异步收集 trajectory 和 process signal。它充当生成和训练解耦的缓冲区,允许灵活的数据处理和批处理策略。
  • 训练与推理引擎:

    1. Rollout Engine:专用于高吞吐量 Token 生成,响应 Agent 的生成请求。
    2. Train Engine:通过 Scheduler 从 Data Pool 中 fetch 数据,更新 Agent model,并与采样引擎保持同步,确保 Agent 使用最新的策略分布进行探索。

不同 Agent 脚手架会导致显著的性能偏差。借助该模块化设计,Forge 在无需修改 Agent 内部代码的情况下,使用大量的 Agent 框架进行了训练。这种"引擎与 Agent 完全解耦"的架构确保了模型能在各类环境中泛化,目前我们已集成了数百种框架和数千种不同的工具调用格式。

许多用户的真正在用的 Agent 实际上是闭源的,Forge 完全无法感知内部的 Agent loop 逻辑。为了确保模型在不透明架构上也能对脚手架针对性优化,Forge 采用了以下方案:

  • 非侵入式集成:Forge 不感知 Agent 内部的实现细节,内部只需要将请求打到 RL 服务的 GateWay,框架内部即可进行数据收集和训练,因此在实际 RL 训练时可以兼容任意上下文操作(如记忆压缩、历史重写),任意内部的 Agent Loop(例如 Deep Think、Multi-Agent 等等)。
  • 多框架泛化:通过将训练循环与 Agent 内部状态解耦,Minimax-M2.5 广泛适配大量黑盒 Agent------无论是以沙盒+MCP 环境为主的代码 Agent(例如将 Opencode Agent 直接视为一个黑盒 Agent 来训练),还是使用激进上下文缩减策略的 Agent(如 Truncate BC)。实验表明,该方法在完全不透明的黑盒系统上依然能带来稳定的提升。

附录F:与 Dressage 设计哲学的对比分析

F.1 设计理念的共鸣

Dressage 是另一个 Agentic RL 框架,其设计哲学与 Uni-Agent Gateway 有深刻共鸣:"把 Agent 运行时拆成几条彼此正交的轴,然后把所有轨迹重新收敛到 token-level training evidence 上。"

这与 Gateway 的核心设计动机 ------ 统一黑盒/白盒 Agent,以 Gateway 为唯一 token 真相源 ------ 在底层逻辑上完全一致。

F.2 Proxy vs Gateway:同一个思想的不同实现

Dressage 的 Proxy 和 Uni-Agent 的 Gateway 在功能上高度对应:

维度 Dressage Proxy Uni-Agent Gateway
对外接口 OpenAI-compatible chat completions OpenAI-compatible chat completions
对内连接 SGLang router LLMServerClient (vLLM/SGLang)
记录内容 token IDs, logprobs, loss mask, weight version, turn/session/instance id, tool call, MoE routing prompt_ids, response_ids, response_mask, response_logprobs, reward_info, routed_experts, multi_modal_data
核心原则 "trajectory 不是事后从日志里猜出来的对象,而是在生成过程中被持续构建出来的数据结构" Gateway 是 "the single canonical tokenization authority"
黑盒支持 BlackboxServer + 内部 LLM proxy 通过 OPENAI_BASE_URL 注入 Gateway session URL

描述 Proxy 的一句话,同样适用于 Gateway:

"无论 agent 是 whitebox 还是 blackbox,只要它需要调用模型,就必须经过 Proxy。Proxy 不只是转发请求,它会在每一步记录训练需要的 token-level evidence。"

F.3 黑盒 Agent 接入方式对比

维度 Dressage Uni-Agent Gateway
接入机制 BlackboxServer 运行在 sandbox 内部,对 Paddock 暴露统一 HTTP 协议,内部适配 opencode/openclaw 等 backend 通过 OPENAI_BASE_URL 环境变量注入 Gateway session URL,Agent 子进程完全无感知
适配层 每个 backend 有独立 adapter 不需要 adapter ------ 任何 OpenAI-compatible Agent 即可
上下文注入 BlackboxServer 内部有轻量 LLM proxy,用于注入 session/turn/instance 等上下文 Gateway 通过 URL 路径(/sessions/{id}/v1/)自动识别 session
复杂度 需要额外的 BlackboxServer 进程和 adapter 层 零额外进程,零代码修改

Dressage 对 BlackboxServer 的描述如下:"BlackboxServer 运行在 sandbox 内部,对 Paddock 暴露统一 HTTP 协议,对内部则适配 opencode、openclaw 等具体 backend。Rollout manager 不需要理解每个 agent backend 的细节。"

Uni-Agent Gateway 的方案更轻量:不需要 BlackboxServer 中间层,因为 Agent 本身就已经是 OpenAI-compatible 的,Gateway 直接通过 URL 注入完成接入。

F.4 Segment 概念对比

Dressage 引入了显式的 segment 概念:

"Agent 轨迹经常会被切开。原因可能是历史被压缩,message 前缀被改写,工具 schema 发生变化,或者增量 tokenization 无法安全继续。"

复制代码
trajectory
  ├── segment 0
  ├── segment 1
  └── segment 2  <- anchor segment

Uni-Agent Gateway 的 trajectories[] 本质上就是 Dressage 的 segments ------ 每次 prefix 一致性被打破,Gateway 就物化当前 active_trajectory 并追加到 trajectories[]。区别在于:

维度 Dressage Uni-Agent Gateway
segment 概念 显式建模,有 segment_indexparent_traj_id 隐式建模,通过 trajectories[] 列表顺序表达
anchor segment 最后一个 segment 承载终局 reward _score_trajectories() 只对最后一个 trajectory 打分,然后广播
reward 广播 reward post-process 把 advantage 广播给 sibling segments _score_trajectories() 中将 score 广播到所有 trajectory
训练语义 所有 segment 都参与训练,但用 prompt-equal denominator 保持公平 所有 trajectory 写入 TQ,每个 trajectory 成为独立训练样本

Dressage对 segment 训练公平性的强调:"segment 让更多 token 进入训练,但 reward、调度、统计和梯度缩放仍然以 trajectory / prompt 为边界保持一致。"

F.5 设计哲学的一致性

Dressage 的哲学是:"不把 Agent 简化成一段输出,不把 sandbox 简化成一个 subprocess,不把 transcript 当成训练数据,也不把训练系统重写一遍。它做的是连接,把复杂系统中的边界理顺,让真实 Agent 可以稳定地进入强化学习训练闭环。"

Uni-Agent Gateway 遵循同样的哲学 ------ 它不重写 Agent、不重写推理后端、不重写训练系统。它做的是在 Agent 和推理后端之间插入一层透明的中间层,这层中间层只做一件事:把 OpenAI 格式的请求转换为 token 级的训练证据

F.6 两个框架的核心差异

维度 Dressage Uni-Agent Gateway
训练底座 slime(Megatron + Ray + SGLang) VERL(Ray + vLLM/SGLang)
Sandbox 抽象 Paddock + SandboxProvider(显式分离) 由 AgentRunner 自行管理,Gateway 不感知
黑盒 Agent 接入 BlackboxServer(需额外进程) 环境变量注入(零额外进程)
Segment 管理 显式 segment 模型 + reward post-process 隐式列表 + Framework 层 score 广播
Partial rollout GenerationController + token version 未实现
MoE routing Proxy 捕获 routed experts,写入 segment Trajectory 中有 routed_experts 字段
TITO (turn-incremental) Proxy 在 concat 模式下记录每轮 token delta Gateway 通过 encode_incremental() 实现

F.7 对 Gateway 未来演进的启示

Dressage 的几个设计为 Gateway 的未来演进提供了有价值的参考:

  1. 显式 Segment 模型 :当前 Gateway 的 trajectories[] 是隐式的,引入显式的 segment_indexparent_traj_id 可以让训练侧更精细地控制 reward 广播和训练样本权重
  2. Partial Rollout 支持 :Dressage 的 GenerationController 可以在 token 边界暂停和恢复生成,配合 weight version 追踪,实现跨权重更新的轨迹连续性。Gateway 当前的 generation_lock 是串行的,未来可以引入更细粒度的暂停/恢复机制
  3. Token Version 追踪:在模型权重更新频繁的训练场景中,追踪每个 token 生成的权重版本可以避免跨版本 token 的 loss 混淆
  4. Prompt-equal Denominator:Dressage 强调 "切得越碎,梯度越大" 的问题,Gateway 当前将每个 trajectory 作为独立训练样本写入 TQ,未来可能需要类似的公平性保证
  5. True Staleness Control :Dressage 在proxy 完成了true staleness control,核心是在两个粒度(trajectory 粒度batch 粒度)上约束 rollout 与 training 之间的版本偏移。Gateway 没有这部分功能,可以借鉴下。

0xFF 参考

Uni-Agent:大规模训练 Agent 的统一框架

从各家技术文章看26年Agentic RL Infra优化方向

破解Agentic RL:三大不变量与八个关键维度的深度解析

Forge: 大规模原生Agent RL系统

Agentic RL的第一步:从 Token Level 转向 Turn Level

深度:黑盒Agent框架下的非MDP轨迹:Agentic RL训练中的轨迹拼接与组装

简直是隔靴搔痒!!:当黑盒Agent只能基于Chat Completions 接口做 RL

黑盒Agent训练框架:verl RFC #5790 详细解读:Agent Abstractions and Trajectory Gateway

RFC Agent Abstractions and Trajectory Gateway for VERL

Retokenization Drift

Dressage 的 True Staleness Control

Claude Code 黑盒 Agent 训练的实现:slime/coding_agent_rl 源码精读(上篇)

Claude Code 黑盒 Agent 训练的实现:slime/coding_agent_rl 源码精读(下篇)