Prhub

#7115 [rollout, vllm] feat: pluggable router with FQN/YAML plugins

原始 PR 作者 touch869 合并时间 2026-09-01 23:08 文件变更 17 提交数 2 评论 14 代码增减 +987 / -246

执行摘要

将负载均衡器提取为独立可插拔路由器模块,支持 FQN/YAML 插件扩展。

本 PR 是实现 Issue #6383 的关键第一步,设计合理,实现了可插拔路由器架构,且保持了良好的向后兼容性。建议精读 router.py 中的协议定义和插件加载机制,以及 llm_server.py 中客户端 RPC 载荷的惰性过滤逻辑。这些设计决策对于扩展 verl 的路由能力具有重要价值。

讨论亮点
  1. get_kv_events_endpoints 的必要性与设计:审核者 wuxibin89 质疑其用途,touch869 解释它是供外部路由器(如 uni-agent)获取每个副本的 kv-events ZMQ 端点以进行订阅。提出了三种设计方案,最终保持独立 getter。
  2. 端口分配策略:wuxibin89 建议使用 get_free_port(),但 touch869 指出由于 vLLM 内部对端口按数据并行等级进行偏移,get_free_port() 无法感知此偏移,会导致端口冲突,因此必须使用确定性的端口分配方案(_assign_kv_events_ports)。
  3. 参数命名讨论:wuxibin89 建议将 prompt_ids 重命名为 session_ids,touch869 最初同意并重命名,但 wuxibin89 随后指出 prompt_ids 是 tokenized prompt,更准确,应恢复为 prompt_ids
  4. 依赖性变更要求:wuxibin89 指出本 PR 需要包含 PR #7613(随机化 least-loaded tie-break)的更改,以避免所有会话雪崩到单一副本。

实现拆解

  1. 核心路由器模块创建:在 verl/workers/rollout/router.py 中定义 RequestLoadBalancer 协议(10 个方法)和默认实现 GlobalRequestLoadBalancer(迁移自 llm_server.py,行为不变)。新增 get_router_handle() 工厂函数,根据 router_config_path 配置决定加载默认实现还是外部插件。
  2. 插件加载机制实现:在 router.py 中实现插件加载链:resolve_config_path(共享工具)→ _load_router_yaml(OmegaConf 加载,拒绝 Hydra defaults 块)→ _resolve_router_class(通过 FQN 动态导入)→ _create_plugin_extension(将整个 YAML 字典作为 router_kwargs 传入插件构造函数)。
  3. 客户端 RPC 载荷重构:修改 verl/workers/rollout/llm_server.py 中的 LLMServerClient。客户端本地打包所有 generate() 参数,通过惰性查询路由器声明的 require_acquire_fields()require_release_fields() 方法,仅序列化路由器声明的字段到 acquire/release RPC 调用中。_release_server 签名变更为可选接收 request_id
  4. 配置与集成更新:在 verl/workers/config/rollout.pyRolloutConfig 中新增 router_config_path: Optional[str] = None 配置项,并同步更新 rollout.yaml 及所有生成的 trainer 配置文件。teacher_model.py 中的 _initialize_load_balancer_handle 改为使用 get_router_handle() 工厂。
  5. 工具函数迁移与兼容性:将 resolve_config_pathverl/experimental/agent_loop/utils.py 迁移至 verl/utils/import_utils.py,并在原位置重新导出以保持兼容。agent_loop.py 中的导入路径相应更新。
  6. 测试配套:新增 tests/workers/rollout/test_router_on_cpu.py,包含 22 个测试用例,覆盖 YAML 加载、插件扩展、声明式 RPC 过滤、遗留签名兼容等。更新 test_basic_agent_loop.py 中的导入路径。
文件 模块 状态 重要度
verl/workers/rollout/router.py 路由器 added 9.36
verl/workers/rollout/llm_server.py 服务器客户端 modified 8.89
tests/workers/rollout/test_router_on_cpu.py 路由器测试 added 8.41
verl/utils/import_utils.py 工具函数 modified 7.31
verl/experimental/teacher_loop/teacher_model.py 教师模型 modified 6.15

关键符号

get_router_handle _create_plugin_extension _create_global_sticky_inflight RequestLoadBalancer.acquire_server RequestLoadBalancer.require_acquire_fields LLMServerClient._acquire_server LLMServerClient._release_server

关键源码片段

verl/workers/rollout/router.py entrypoint

核心新模块,定义了路由器协议、默认实现和插件加载机制。

# verl/workers/rollout/router.py ( 核心协议与工厂函数 )from typing import Protocol, Any
import logging
from omegaconf import OmegaConf
from verl.utils.import_utils import load_class_from_fqn, resolve_config_pathclass RequestLoadBalancer(Protocol):
    """所有路由策略必须满足的结构化接口。"""
    def require_acquire_fields(self) -> list[str]:
        """声明在 acquire_server RPC 中需要的 generate() 参数名。
        例如,KV-cache 感知路由可能需要 ['prompt_ids'];
        默认路由器返回 [],仅路由基于 request_id。"""
        ...
    def require_release_fields(self) -> list[str]:
        """声明在 release_server RPC 中需要的标识字段。
        通常仅需要 ['request_id'] 用于从 acquire 时的簿记中查找;
        默认路由器返回 [],仅基于 server_id 计数。"""
        ...
    def acquire_server(self, request_id: str, **extra) -> tuple[str, Any]:
        """为给定请求获取一个服务器。
        **extra 中仅包含 require_acquire_fields() 声明的字段。"""
        ...
    def release_server(self, server_id: str, request_id: str | None = None) -> None:
        """请求完成后释放服务器。
        内容感知路由器可通过 request_id 从自身的 acquire 时簿记中查找 prompt 长度。"""
        ...
    # ... 其他方法如 add_servers, remove_servers, get_all_servers, get_status, clear_sticky_cache, get_total_inflightdef get_router_handle(
    servers: dict[str, Any],
    router_config_path: str | None = None,
    load_balancer_cls: type | None = None,
    full_determinism: bool = False,
) -> Any:
    """工厂函数:根据配置创建路由器 Ray Actor。
    优先级:1) 显式传入的 load_balancer_cls;2) router_config_path 指定的插件;3) 默认 GlobalRequestLoadBalancer。"""
    if load_balancer_cls is not None:
        # 显式指定类,直接实例化(例如,verl-omni 子类)
        return ray.remote(load_balancer_cls).remote(servers=servers)
    if router_config_path:
        return _create_plugin_extension(servers, router_config_path)
    return _create_global_sticky_inflight(servers, full_determinism=full_determinism)def _create_plugin_extension(servers: dict, config_path: str):
    """从 YAML 文件加载并实例化外部路由器插件。"""
    config_path = resolve_config_path(config_path)
    config = OmegaConf.load(config_path)
    config_dict = OmegaConf.to_container(config, resolve=True)
    router_class_fqn = config_dict.pop("router_class")
    router_cls = load_class_from_fqn(router_class_fqn)
    return ray.remote(router_cls).remote(servers=servers, router_kwargs=config_dict)
verl/workers/rollout/llm_server.py dependency-wiring

核心重构文件,移除了内联负载均衡器,调整了客户端以支持声明式 RPC 载荷过滤。

# verl/workers/rollout/llm_server.py ( 客户端 RPC 载荷重构 )class LLMServerClient:
    def __init__(self, config, load_balancer_handle=None, **kwargs):
        self.config = config
        self._load_balancer = load_balancer_handle
        # 惰性缓存路由器声明的字段列表
        self._lb_require_acquire_fields: list[str] | None = None
        self._lb_require_release_fields: list[str] | None = None
​
    async def _acquire_server(self, request_id: str, **extra) -> tuple[str, ray.actor.ActorHandle]:
        """原子获取服务器,仅序列化路由器声明的字段。"""
        if self._lb_require_acquire_fields is None:
            # 首次调用时,并发查询路由器声明的字段,结果被缓存
            acquire_fields, release_fields = await asyncio.gather(
                self._load_balancer.require_acquire_fields.remote(),
                self._load_balancer.require_release_fields.remote(),
            )
            self._lb_require_acquire_fields = list(acquire_fields)
            self._lb_require_release_fields = list(release_fields)
        # 从本地打包的 extra 中筛选出路由器声明的字段,用于 RPC
        fields = {name: extra[name] for name in self._lb_require_acquire_fields if name in extra}
        return await self._load_balancer.acquire_server.remote(request_id=request_id, **fields)
​
    def _release_server(self, server_id: str, request_id: str | None = None) -> None:
        """释放服务器,fire-and-forget。仅序列化声明的 release 字段。"""
        pool = {"request_id": request_id}
        fields = {name: pool[name] for name in self._lb_require_release_fields if name in pool}
        self._load_balancer.release_server.remote(server_id=server_id, **fields)
​
    @rollout_trace_op
    async def generate(self, request_id, *, prompt_ids, sampling_params, ...):
        """生成请求。所有参数本地打包,但仅部分字段被序列化到路由器 RPC。"""
        server_id, server = await self._acquire_server(
            request_id,
            prompt_ids=prompt_ids, # 可能被过滤
            sampling_params=sampling_params, # 可能被过滤
            ...
        )
        try:
            output = await server.generate.remote(...)
            return output
        finally:
            self._release_server(server_id, request_id=request_id) # request_id 可能被过滤

评论区精华

`get_kv_events_endpoints` 的必要性与设计 设计

wuxibin89 质疑此 getter 的用途,touch869 解释它是供外部路由器获取 kv-events ZMQ 端点以进行订阅,属于外部插件所需接口的一部分。提出了三种设计方案。

结论:保持独立 getter(Option 1),因为它是外部插件所需的接口,且与 rollout 配置分离。 · 已解决

端口分配策略:`get_free_port` 与确定性分配 正确性

wuxibin89 建议使用 `get_free_port()` 代替确定性端口分配。touch869 解释 vLLM 内部会对端口按数据并行等级进行偏移,`get_free_port()` 无法感知此偏移,会导致端口冲突。

结论:必须使用确定性的端口分配方案(`_assign_kv_events_ports`),因为 `get_free_port()` 与 vLLM 的内部偏移机制不兼容。 · 已解决

参数命名:`prompt_ids` vs `session_ids` style

wuxibin89 建议将 `prompt_ids` 重命名为 `session_ids`。touch869 最初同意并重命名。但 wuxibin89 随后指出 `prompt_ids` 是 tokenized prompt,更准确,应恢复为 `prompt_ids`。

结论:恢复为 `prompt_ids`,因为它是已 token 化的 prompt 序列,比 `session_id` 更精确。 · 已解决

需要包含 PR #7613 的更改 other

wuxibin89 指出本 PR 需要包含 PR #7613(随机化 least-loaded tie-break)的更改,以避免所有会话雪崩到单一副本。

结论:本 PR 应包含 PR #7613 的更改。 · 已解决

风险与影响

向后兼容性风险:重构涉及客户端 RPC 载荷格式变更,但默认路由器声明 [],保持了 wire format 不变,且测试验证了遗留子类兼容性。
插件安全风险:通过 FQN 动态加载外部类,存在潜在的代码注入风险。缓解措施包括:配置路径解析限定在项目根目录内,YAML 配置拒绝 Hydra defaults 块。
性能风险:声明式 RPC 载荷过滤在首次调用时增加两次轻量级 RPC(查询声明),但结果被缓存,后续调用无开销。仅序列化声明字段减少了长上下文场景下的序列化开销。
配置复杂性风险:新增 router_config_path 配置项,但默认为 null,不影响现有用户。文档已更新。
测试覆盖风险:新增测试全面覆盖核心逻辑,但端到端测试未直接验证外部插件的实际路由效果。

用户影响:用户无感知,除非主动配置 router_config_path。现有配置和行为完全不变。
系统影响:解耦了路由器与服务器管理,为系统引入新的扩展点。路由策略变更可能影响 rollout 吞吐和延迟。
团队影响:为后续实现高级路由策略(如 KV-cache 感知路由)提供了标准接口和基础设施,降低了团队成员开发新路由策略的门槛。
API 影响LLMServerClient._acquire_server_release_server 签名扩展,但向后兼容。新增 get_rollout_config() RPC 端点。

plugin-security backward-compatibility rpc-serialization-change

关联 Issue

#6383 [RFC] KV-cache-aware request load balancer for rollout servers

完整报告

参与讨论