Prhub

#6051 [trainer,cfg,rollout,algo] feat: Multi-Teacher OPD

原始 PR 作者 JacobHelwig 合并时间 2026-04-20 12:31 文件变更 22 提交数 35 评论 13 代码增减 +817 / -354

执行摘要

实现多教师在线策略蒸馏框架

PR body 指出需要 'Classes and configs for managing multiple sets of teacher models and servers for multi-teacher OPD',动机是让一个学生能从多个教师学习不同能力(如纯文本数学推理与多模态几何推理),每个样本可基于数据源字段路由到最合适的教师。评论中作者也强调教师服务器流式计算的隔离优势。

建议所有涉及蒸馏功能或资源编排的工程师精读此 PR。核心设计模式(资源池分割、请求路由、配置数据契约)值得借鉴。特别关注 _validate_replica_node_alignment_resolve_teacher_key 的实现,它们体现了对多节点部署的严谨考虑。在部署多教师场景前,建议增加集成测试覆盖节点对齐和路由降级路径。

讨论亮点
  • 断言过于严格:gemini-code-assist 指出 assert "teacher_model" in self.teacher_models 在用户完全覆盖默认字典时会导致崩溃,建议改为优雅检查。作者在后续提交中可能已调整(当前提交记录显示有 "Re-key" 提交)。
  • 多节点限制不当:gemini-code-assist 认为 nnodes=1 的限制过于严格,而 _validate_replica_node_alignment 已能保证节点边界正确,建议移除。作者在提交 "Drop coarse nnodes=1 guard" 中移除了该限制。
  • 未使用的 Router 进程:gemini-code-assist 指出 _initialize_router 启动的 sidecar 从未被使用(实际使用 Ray LoadBalancer),造成资源浪费。作者在提交 "Drop unused teacher-side router sidecar" 中清理了该代码。
  • num_replicas 命名:wuxibin89 建议将 world_size 改为 num_replicas 更直观,因为用户只需指定副本数而无需计算总 GPU。作者接受了建议,在配置中改为 num_replicas
  • 配置注释不足:wuxibin89 要求对复杂的 _resolve_teacher_models 逻辑添加注释,作者在后续提交中补充了文档字符串。

实现拆解

  1. 配置数据契约重构:在 verl/workers/config/distillation.py 中将 DistillationTeacherModelConfig 增加 key(路由标识)、num_replicas(副本数)、per_replica_world_size(计算属性)等字段;DistillationConfig 新增 teacher_models 字典(按 key 索引的教师配置)、teacher_key(样本路由字段名)和 n_gpus_per_node。同时统一资源池定义,将原来从 teacher_model 读取的 n_gpus_per_node 提升到上层。
  2. 教师管理拆分:在 verl/experimental/teacher_loop/teacher_model.py 中将原 TeacherModelManager 改造成仅管理单个教师,新增 MultiTeacherModelManager 类负责按 key 持有多个 TeacherModelManager 实例并分配资源池。TeacherModelManager 增加了 _validate_replica_node_alignment 校验,确保每个副本的子资源池不跨节点边界。
  3. 请求路由重写verl/experimental/teacher_loop/teacher_manager.pyAsyncTeacherLLMServerManager 不再继承 AsyncLLMServerManager,而是内部维护 server_managers: dict[str, AsyncLLMServerManager],并通过 _resolve_teacher_key 根据样本 routing_key 选择对应服务器。采样参数获取函数也改为接收具体 DistillationTeacherModelConfig
  4. AgentLoopWorker 适配verl/experimental/agent_loop/agent_loop.pyAgentLoopWorker.__init__ 参数 teacher_serversteacher_load_balancer_handle 类型从列表变为字典,_compute_teacher_logprobs 方法传入 routing_key 并转发给 AsyncTeacherLLMServerManager。同时移除 colocate 模式的 wake_up/sleep 调用。
  5. 训练入口与资源池初始化verl/trainer/main_ppo_sync.pymain_ppo.py 中资源池分配逻辑改为从 distillation.n_gpus_per_nodedistillation.nnodes 读取,并利用 MultiTeacherModelManager 实例化教师管理器。ray_trainer.pyfully_async_policy 的 agent_loop 导入对应更新。
  6. 推理引擎统一verl/workers/rollout/replica.py 以及 vLLM、SGLang、TRTLLM 的异步服务器均增加 name_suffix 参数,避免多教师下 Ray actor 名称冲突。
  7. 示例脚本:新增 examples/on_policy_distillation_trainer/run_qwen3_mopd_gsm8k_geo3k.sh,展示为两个数据源配置不同教师(Qwen3-4B-Instruct 和 Qwen3-VL-4B-Instruct)蒸馏 Qwen3-VL-2B-Instruct 的用法。
文件 模块 状态 重要度
verl/experimental/teacher_loop/teacher_model.py 教师管理 modified 8.96
verl/workers/config/distillation.py 配置层 modified 8.85
verl/experimental/teacher_loop/teacher_manager.py 教师路由 modified 7.74
verl/experimental/agent_loop/agent_loop.py Agent 循环 modified 7.32
verl/trainer/main_ppo_sync.py 训练器 modified 6.56
verl/trainer/main_ppo.py 训练器 modified 6.27
verl/workers/rollout/replica.py 推理引擎 modified 6.21
verl/workers/rollout/vllm_rollout/vllm_async_server.py 推理引擎 modified 5.62

关键符号

MultiTeacherModelManager.__init__ TeacherModelManager._initialize_llm_servers TeacherModelManager._validate_replica_node_alignment AsyncTeacherLLMServerManager.__init__ AsyncTeacherLLMServerManager._resolve_teacher_key AsyncTeacherLLMServerManager.compute_teacher_logprobs_single AgentLoopWorker._compute_teacher_logprobs DistillationTeacherModelConfig.per_replica_world_size DistillationTeacherModelConfig.world_size DistillationTeacherModelConfig.check_configured DistillationConfig.__post_init__ AgentLoopWorker.init_resource_pool_mgr

关键源码片段

verl/experimental/teacher_loop/teacher_model.py data-contract

核心数据结构变更,新增 MultiTeacherModelManager 类并重构 TeacherModelManager,包含资源池分割验证核心逻辑。

# verl/experimental/teacher_loop/teacher_model.py ( 关键片段 )
# MultiTeacherModelManager 负责将整个教师资源池按各教师所需的 GPU 数量切分,
# 并为每个教师实例化一个 TeacherModelManager。@auto_await
async def _run_all(tasks: list[asyncio.Task]):
    await asyncio.gather(*tasks)class MultiTeacherModelManager:
    """管理多个教师模型,每个教师有独立的 TeacherModelManager。"""
​
    def __init__(self, config: DictConfig, resource_pool: RayResourcePool):
        # 解析配置,获取每个教师的配置和所需总 GPU 数
        distillation_config: DistillationConfig = omega_conf_to_dataclass(config.distillation)
        total_world_size = 0
        teacher_configs: dict[str, DistillationTeacherModelConfig] = {}
        for key, cfg in distillation_config.teacher_models.items():
            cfg.check_configured()
            teacher_configs[key] = cfg
            total_world_size += cfg.world_size # num_replicas * per_replica_world_size
​
        # 校验资源池总大小是否匹配
        if resource_pool.world_size != total_world_size:
            raise ValueError(...)
​
        # 按每个教师的 world_size 依次切割资源池,构造子 TeacherModelManager
        self.teacher_managers: dict[str, TeacherModelManager] = {}
        start = 0
        for key, cfg in teacher_configs.items():
            sub_pool_size = cfg.world_size
            sub_resource_pool = RayResourcePool(
                bundles=resource_pool.bundles[start:start + sub_pool_size]
            )
            self.teacher_managers[key] = TeacherModelManager(
                distillation_config=distillation_config,
                teacher_model_config=cfg,
                resource_pool=sub_resource_pool,
            )
            start += sub_pool_sizeclass TeacherModelManager:
    """管理单个教师的推理服务器。"""
​
    def __init__(
        self,
        distillation_config: DistillationConfig,
        teacher_model_config: DistillationTeacherModelConfig,
        resource_pool: RayResourcePool,
    ):
        self.distillation_config = distillation_config
        self.teacher_model_config = teacher_model_config
        self.resource_pool = resource_pool
        self._initialize_llm_servers()
        self._initialize_load_balancer_handle()
​
    def _initialize_llm_servers(self):
        # 使用 teacher_model_config.per_replica_world_size 和 num_replicas 计算
        per_replica_world_size = self.teacher_model_config.per_replica_world_size
        num_replicas = self.teacher_model_config.num_replicas
        expected_pool_size = num_replicas * per_replica_world_size
        if self.resource_pool.world_size != expected_pool_size:
            raise ValueError(...)
​
        gpus_per_node = self.distillation_config.n_gpus_per_node
        name_suffix = (self.teacher_model_config.key or "").replace("/", "_")
        self.rollout_replicas = [
            rollout_replica_class(
                replica_rank=rank,
                config=rollout_config,
                model_config=model_config,
                gpus_per_node=gpus_per_node,
                is_teacher_model=True,
                name_suffix=name_suffix,
            ) for rank in range(num_replicas)
        ]
        split_resource_pools = split_resource_pool(self.resource_pool, split_size=per_replica_world_size)
        self._validate_replica_node_alignment(split_resource_pools, per_replica_world_size, gpus_per_node)
        _run_all([server.init_colocated(pool) for server, pool in zip(...)])
        # ... 设置 server_handles, server_addresses
​
    def _validate_replica_node_alignment(self, replica_pools, per_replica_world_size, gpus_per_node):
        """确保每个副本的子资源池不会跨节点过多,从而避免跨节点推理延迟。"""
        for i, pool in enumerate(replica_pools):
            expected_nodes = math.ceil(per_replica_world_size / gpus_per_node)
            # 检查 pool 中的 bundles 跨越的节点数是否符合预期
            actual_nodes = len(set(b.node_id for b in pool.bundles))
            if actual_nodes > expected_nodes:
                raise ValueError(
                    f"Replica {i} spans {actual_nodes} nodes, expected at most {expected_nodes}"
                )
verl/workers/config/distillation.py dependency-wiring

配置数据契约核心文件,定义多教师场景下的 DistillationTeacherModelConfig 和 DistillationConfig,影响整个蒸馏流程的初始化。

# verl/workers/config/distillation.py ( 关键片段 )
# 展示多教师配置模型的核心定义@dataclass
class DistillationTeacherModelConfig(BaseConfig):
    """单个教师模型的配置。"""
    _mutable_fields = BaseConfig._mutable_fields | {"num_replicas", "key"}
​
    key: Optional[str] = None # 路由标识,如 "gsm8k"
    model_path: Optional[str] = None # 教师模型路径
    inference: RolloutConfig = field(default_factory=RolloutConfig)
    num_replicas: Optional[int] = 0 # 该教师启动的推理副本数
​
    @property
    def per_replica_world_size(self) -> int:
        """每个副本需要的 GPU 数(TP * DP * PP)。"""
        return (
            self.inference.tensor_model_parallel_size
            * self.inference.data_parallel_size
            * self.inference.pipeline_model_parallel_size
        )
​
    @property
    def world_size(self) -> int:
        """该教师占用的总 GPU 数。"""
        return self.num_replicas * self.per_replica_world_size
​
    def check_configured(self):
        if self.model_path is None:
            raise ValueError("model_path must be specified")
        if self.key is None:
            raise ValueError("key must be specified")
        if self.num_replicas is None or self.num_replicas <= 0:
            raise ValueError("num_replicas must be set > 0")@dataclass
class DistillationConfig(BaseConfig):
    """顶层蒸馏配置,支持多教师。"""
    _mutable_fields = BaseConfig._mutable_fields | {"teacher_models"}
​
    enabled: bool = False
    n_gpus_per_node: int = 8 # 每节点 GPU 数,用于资源池分配
    nnodes: int = 1
    teacher_models: dict[str, DistillationTeacherModelConfig] = field(default_factory=dict)
    teacher_key: str = "data_source" # 用作路由的数据字段名
    distillation_loss: DistillationLossConfig = field(default_factory=DistillationLossConfig)
​
    def __post_init__(self):
        # 将 teacher_models 重新按 key 索引(来自 config 的键)
        rekeyed = {}
        for config_key, cfg in self.teacher_models.items():
            cfg.key = cfg.key or config_key # 若未显式设置 key,使用配置字典的键
            rekeyed[cfg.key] = cfg
        self.teacher_models = rekeyed
verl/experimental/teacher_loop/teacher_manager.py dependency-wiring

AsyncTeacherLLMServerManager 重构为核心路由组件,不再继承 AsyncLLMServerManager,内部按教师 key 维护多个 server_manager。

# verl/experimental/teacher_loop/teacher_manager.py ( 关键片段 )
# AsyncTeacherLLMServerManager 内部持有多个 AsyncLLMServerManager,每个对应一个教师class AsyncTeacherLLMServerManager:
    """多教师路由管理器,内部持有多个 AsyncLLMServerManager。"""
​
    def __init__(
        self,
        config: DictConfig,
        servers: dict[str, list[tuple[str, ray.actor.ActorHandle]]],
        load_balancer_handle: dict[str, ray.actor.ActorHandle],
    ):
        self.distillation_config: DistillationConfig = omega_conf_to_dataclass(config.distillation)
        self.distillation_loss_config = self.distillation_config.distillation_loss
        self.teacher_key = self.distillation_config.teacher_key
        self.teacher_model_configs: dict[str, DistillationTeacherModelConfig] = \
            self.distillation_config.teacher_models
​
        # 校验 servers 和 load_balancer_handle 的键与教师配置键一致
        expected = set(self.teacher_model_configs)
        assert set(servers.keys()) == expected, ...
        assert set(load_balancer_handle.keys()) == expected, ...
​
        # 为每个教师创建一个 AsyncLLMServerManager
        self.server_managers: dict[str, AsyncLLMServerManager] = {
            key: AsyncLLMServerManager(
                config=config,
                servers=servers[key],
                load_balancer_handle=load_balancer_handle[key],
            )
            for key in self.teacher_model_configs
        }
​
    def _resolve_teacher_key(self, routing_key: Optional[str]) -> str:
        """根据样本的路由键确定使用哪个教师。单教师场景无需路由键。"""
        if len(self.teacher_model_configs) == 1:
            return next(iter(self.teacher_model_configs))
        if routing_key is None:
            raise ValueError(
                f"Multi-teacher requires routing key via `{self.teacher_key}`, "
                f"but got None. Configured teachers: {sorted(self.teacher_model_configs)}"
            )
        if routing_key not in self.teacher_model_configs:
            raise ValueError(f"No teacher for routing key {routing_key!r}.")
        return routing_key
​
    async def compute_teacher_logprobs_single(
        self,
        sequence_ids: list[int],
        multi_modal_data: Optional[dict] = None,
        routing_key: Optional[str] = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        teacher_key = self._resolve_teacher_key(routing_key)
        teacher_config = self.teacher_model_configs[teacher_key]
        server_manager = self.server_managers[teacher_key]
        # 使用该教师专用的采样参数
        sampling_params = _get_teacher_sampling_params(teacher_config, self.distillation_loss_config)
        teacher_output = await server_manager.generate(
            request_id=uuid4().hex,
            prompt_ids=sequence_ids,
            sampling_params=sampling_params,
            image_data=multi_modal_data.get("images"),
            video_data=multi_modal_data.get("videos"),
        )
        teacher_ids = torch.tensor(teacher_output.extra_fields["prompt_ids"], dtype=torch.int32)
        teacher_logprobs = torch.tensor(teacher_output.extra_fields["prompt_logprobs"])
        return teacher_ids, teacher_logprobs

评论区精华

断言 teacher_model 导致的崩溃风险 正确性

gemini-code-assist 指出 `assert "teacher_model" in self.teacher_models` 在用户完全覆盖配置文件字典时将导致 AssertionError,建议改为优雅检查。

结论:作者在后续提交中通过重写 teacher_models 的重索引逻辑解决了该问题(见 "Re-key" 系列提交)。 · 已解决

多节点限制 nnodes=1 过于严格 设计

gemini-code-assist 认为 `DistillationConfig.__post_init__` 中强制 nnodes=1 阻断了合法的多节点多教师部署,而 `_validate_replica_node_alignment` 已足够保障节点边界。

结论:作者在提交 "Drop coarse nnodes=1 guard" 中移除了这一限制。 · 已解决

未使用的 Router sidecar 进程 性能

gemini-code-assist 指出 `_initialize_router` 启动的 sidecar 地址未被使用,浪费资源并可能引起端口冲突。

结论:作者在提交 "Drop unused teacher-side router sidecar" 中删除了相关方法。 · 已解决

num_replicas 命名字段选择 设计

wuxibin89 建议将 `world_size` 改为 `num_replicas`,因为用户只需知道副本数,总 GPU 可自动计算。

结论:作者接受建议,在 `DistillationTeacherModelConfig` 中改用 `num_replicas` 字段。 · 已解决

配置解析逻辑缺少说明注释 documentation

wuxibin89 要求对 `_resolve_teacher_models` 的复杂重索引逻辑添加注释,说明其设计意图和用法。

结论:作者在后续提交中补充了 docstring 和配置文件的注释。 · 已解决

风险与影响

  • 配置复杂性风险:多教师配置引入 teacher_models 字典和 teacher_key,用户需要正确设置每个教师的 num_replicasper_replica_world_size 等参数,错误配置可能导致资源分配失败或 GPU 浪费。涉及文件 verl/workers/config/distillation.py
  • 资源池对齐校验遗漏_validate_replica_node_alignment 在特定节点拓扑下可能误报或漏报,若未充分测试多节点场景,可能导致初始化静默失败或跨节点副本挂起。涉及 verl/experimental/teacher_loop/teacher_model.py
  • 路由逻辑正确性_resolve_teacher_key 的行为默认使用单教师,当数据集不含有效 routing_key 时可能抛出 ValueError,但生产环境可能希望有默认教师降级。涉及 verl/experimental/teacher_loop/teacher_manager.py
  • 缺少测试覆盖:本次 PR 包含大量核心逻辑变更,但未发现对应的单元测试或集成测试,尤其在多教师路由、资源池分割、节点对齐等关键路径上缺少验证。
  • 用户影响:用户现在可以为不同数据源配置不同的教师模型,从而让学生从多个专家模型中蒸馏知识。但需要理解新的配置字段(teacher_modelsteacher_keynum_replicas),旧配置(teacher_modeln_gpus_per_nodennodes)不再兼容。
  • 系统影响:每个教师模型需要独立的 GPU 资源池,总体 GPU 消耗增加。同时,推理服务器的 Ray actor 名称通过 name_suffix 避免冲突,但多教师场景下资源管理更复杂。
  • 团队影响:代码从 TeacherModelManager 拆分为 MultiTeacherModelManager + TeacherModelManager,职责更清晰,但后续维护者需要理解两层管理器的交互。
缺少测试覆盖 配置复杂性 资源分配错误 多节点部署风险 路由降级未定义

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论