Prhub

#7802 [sglang, rollout] fix: forward video features to SGLang

原始 PR 作者 HeatherLiuzh 合并时间 2026-09-15 14:44 文件变更 8 提交数 1 评论 10 代码增减 +82 / -4

执行摘要

SGLang rollout 补齐视频特征转发,修复视频 VLM 盲答

SGLang rollout 长期不支持视频输入:verl/workers/rollout/sglang_rollout/async_sglang_server.py 中 # video_data=video_data 被注释并留有 TODO: support video input for sglang,且没有任何告警。作者在 PR body 中描述后果为 “SGLang therefore receives input_ids containing thousands of <|video_pad|> placeholder tokens but no visual features at all, and the model produces fluent, well-structured, entirely hallucinated answers”,而 vLLM 会正常转发,所以两个后端在视频任务上完全不可比(作者实测 SGLang 改前 val acc 0.4968 ≈ 语言先验水平,改后 0.77186 vs vLLM 0.7868)。问题由两个缺口叠加:一是类型不匹配,SGLang 的 video_data 只接受 path/URL/base64/VideoData/dict,不接受 verl 传的 list[tuple[torch.Tensor, dict]],简单取消注释会在 async_load_mm_data 失败,这正是 TODO 未完成的原因;二是 verl 的 VLContinuousTokenMixin.render_tokens_with_mm 用 build_multimodal_processor_inputs 算出 pixel_values_videos / video_grid_thw 后只返回 input_ids,基类 docstring 明确写着 “Pixel tensors are intentionally not returned here.”,特征算完即丢。

值得精读,尤其是维护 rollout/多模态链路的同学:本案是“静默降级类 bug”的教科书样本——TODO 注释无告警、特征算完即丢、两个后端行为不一致,三者叠加后表现为模型输出流畅但完全虚构。建议重点看三处设计决策:(1) 用 format="processor_output" 复用 verl 已算好的 processor 输出而不是让 SGLang 重新抽帧,避免 fps 采样不一致这一隐形 desync;(2) 把后端差异收敛到 LLMServerClient.generate 的一处分支,并用无条件传参 + TypeError 换取“漏改即失败”的可见性;(3) teacher/OPD 路径与 rollout 路径共用同一 payload,避免修复只覆盖一半。阅读时建议同时关注作者未解决的遗留:非 Qwen 视频家族的静默丢弃、多轮 ToolAgentLoop 重复传帧、以及缺失的单测(作者愿意贡献 CPU-only mRoPE 对齐测试,可作为后续 PR 的补位点)。

讨论亮点

三条 reviewer 意见都来自 wuxibin89,且都在 author 回复后落实到代码:

  1. 覆盖范围——“ToolAgentLoop should be adjusted as well, or can we put it in a common place?”;作者同意抽公共 helper,最终 build_sglang_video_payload 落到 AgentLoopBase,两个 loop 都改为单行调用。
  2. teacher 路径——“OPD teacher model should be adjusted as well: .../teacher_manager.py#L128-L136”;作者确认 TeacherManager.compute_teacher_logprobs_single 转发的是原始 video_data,SGLang teacher 会像 rollout 一样丢视频,随后把 payload 经 AgentLoopOutput.mm_processor_output → _compute_teacher_logprobs → TeacherManager → client.generate 打通。
  3. 键名归属——“Is these keys: pixel_values_videos / video_grid_thw / second_per_grid_ts specific to Qwen series model?”;作者回答这些确实是 Qwen2-VL / 2.5-VL / 3-VL 的 processor 输出名,但恰好就是 SGLang collect_mm_items_from_processor_output(base_processor.py 的 ATTR_NAME_TO_MODALITY)映射为 Modality.VIDEO 的键,生产端与消费端本就对齐;非 Qwen 视频家族需两侧同时补键,当前行为是“视频被丢弃(与改前一致)而不是让服务崩溃”,建议作为 follow-up,该跟进项在讨论中未关闭。作者还主动 flag 了 ToolAgentLoop 的 caveat:多轮场景每轮都会重发初始 prompt 的视频 payload(与原始 video_data 的传法一致),tool 返回的视频仍不支持(tool_agent_loop.py 会抛 "Multimedia type 'video' is not currently supported");并声明两个红灯 CI 任务属于本 PR 未触碰的既有失败。

实现拆解

  1. 打开特征出口(verl/utils/tokenizer/continuous_token.py):ContinuousTokenBuilder.build_initial_tokens、render_tokens_with_mm 以及 VLContinuousTokenMixin 的同名两个方法新增可选参数 mm_inputs_out;mixin 在 build_multimodal_processor_inputs 之后执行 mm_inputs_out.update({k: v for k, v in processor_output.items() if k != "input_ids"}) 回填。文本 builder 与 DeepSeekV4ContinuousTokenBuilder.build_initial_tokens 用 del mm_inputs_out 显式忽略;MiniMaxVLContinuousTokenBuilder.render_tokens_with_mm 透传给 super(),DeepSeekVL2ContinuousTokenBuilder.build_initial_tokens 接受并忽略——这两个 override 不改就会在调用方传关键字时抛 TypeError。作者故意无条件传参:漏改的 builder 首调即大声失败,而不是静默退化为盲答。

  2. 构造 SGLang payload(verl/experimental/agent_loop/agent_loop.py):新增静态方法 AgentLoopBase.build_sglang_video_payload(videos, mm_inputs),返回 [{"format": "processor_output", "pixel_values_videos": ..., "video_grid_thw": ..., "second_per_grid_ts": ...}];无视频或 processor 未产出 pixel_values_videos 时返回 None。AgentLoopOutput 增加 mm_processor_output 字段承载该 payload。选择透传 processor 输出而非让 SGLang 自行解码,是因为若无 video_metadata,SGLang 会退化到 fps=24 重新抽帧,作者实测 video_grid_thw 从 [[7, 30, 40]] 变成 [[2, 30, 40]],等于把“无视频”换成“另一组帧”,同样静默破坏 rollout/actor 一致性。

  3. 两个 agent loop 接入:single_turn_agent_loop.py 的 run() 中建 mm_inputs = {} → ct_build_initial_tokens(..., mm_inputs_out=mm_inputs) → build_sglang_video_payload(videos, mm_inputs) → server_manager.generate(mm_processor_output=...),并把 payload 写入 AgentLoopOutput;tool_agent_loop.py 把 payload 暂存在 AgentData.mm_processor_output(_handle_pending_state 构建),在 _handle_generating_state 每轮 generate 时透传,与其既有 video_data 的传递方式保持一致。

  4. 后端分派与请求落地(verl/workers/rollout/llm_server.py、async_sglang_server.py):LLMServerClient.generate 先 kwargs.pop("mm_processor_output", None)(两个 server 的 remote 签名都不接受 **kwargs),当 rollout.name == "sglang" 时用 payload 覆盖 video_data,vLLM 分支保持原始帧;这套写法与同函数既有的 vLLM-only priority 分派同构。async_sglang_server.py 删除 TODO 注释行,把 "video_data": video_data 放回 GenerateReqInput 请求字典——这是本次 bug 的直接落点。

  5. 蒸馏 teacher(OPD)路径与配套:AgentLoopBase._compute_teacher_logprobs 用 getattr(output, "mm_processor_output", None) 取值(缺字段时安全 no-op),经 TeacherManager.compute_teacher_logprobs_single 传入 client.generate;teacher client 复用同一个 LLMServerClient,因此 per-backend 分派自动生效。测试与部署配套:无用户可见配置变更;PR checklist 明确未新增单测/CI,理由是端到端视频 rollout 需要视频 VLM 权重与 GPU,作者提出可贡献 CPU-only 的 mRoPE 对齐测试,最终以人工验证(vLLM 输入不变性逐样本对比、与 SGLang MRotaryEmbedding.get_rope_index 的位置 id 逐元素比对、AST 检查 7 个 builder 实现签名)替代。

文件 模块 状态 重要度
verl/experimental/agent_loop/agent_loop.py 智能体循环 modified 7.25
verl/utils/tokenizer/continuous_token.py 分词构建 modified 6.43
verl/workers/rollout/llm_server.py 请求分派 modified 6.13
verl/experimental/agent_loop/single_turn_agent_loop.py 单轮循环 modified 5.67
verl/experimental/agent_loop/tool_agent_loop.py 工具循环 modified 5.67
verl/workers/rollout/sglang_rollout/async_sglang_server.py 异步服务端 modified 5.59
verl/experimental/teacher_loop/teacher_manager.py 教师蒸馏 modified 4.89
verl/utils/tokenizer/deepseek.py 分词构建 modified 4.59

关键符号

AgentLoopBase.build_sglang_video_payload VLContinuousTokenMixin.render_tokens_with_mm VLContinuousTokenMixin.build_initial_tokens ContinuousTokenBuilder.build_initial_tokens AgentLoopBase.ct_build_initial_tokens LLMServerClient.generate AgentLoopBase._compute_teacher_logprobs TeacherManager.compute_teacher_logprobs_single SingleTurnAgentLoop.run ToolAgentLoop._handle_pending_state ToolAgentLoop._handle_generating_state AsyncSglangServer.generate

关键源码片段

verl/experimental/agent_loop/agent_loop.py core-logic

新增 AgentLoopBase.build_sglang_video_payload 公共 helper 与 AgentLoopOutput.mm_processor_output 字段,并把 payload 贯通到蒸馏 teacher 路径,是整个修复的中枢。

class AgentLoopOutput(BaseModel):
    """Agent loop output."""
​
    prompt_ids: list[int]
    response_ids: list[int]
    response_mask: list[int]
    multi_modal_data: Optional[dict[str, Any]] = None
    """Multi-modal data for multi-modal tools."""
    mm_processor_kwargs: Optional[dict[str, Any]] = None
    """Processor/backend kwargs that must stay aligned across rollout and training paths."""
    # 新增字段:承载 SGLang 视频 payload(见 build_sglang_video_payload),
    # 同时供 OPD teacher 路径复用,保证 SGLang teacher 拿到的是 processor 特征而不是原始帧;无视频时为 None。
    mm_processor_output: Optional[list[dict[str, Any]]] = None
​
​
class AgentLoopBase:
    @staticmethod
    def build_sglang_video_payload(videos, mm_inputs: dict[str, Any]) -> Optional[list[dict[str, Any]]]:
        """把已经算好的视频特征包装成 SGLang 的 ``video_data``。
​
        SGLang 无法接收原始帧,它的 ``video_data`` 只接受 path / URL / base64 字符串或 dict;
        ``mm_inputs`` 是 tokenization 期间通过 ``mm_inputs_out`` 钩子捕获的 processor 输出。
        打上 ``format="processor_output"`` 标签后,``BaseMultimodalProcessor`` 会原样透传该 dict,
        避免 SGLang 自行重新抽帧、进而与 actor 侧的视频采样不一致。
        """
        # 没有视频、或该模型 processor 未产出视频像素时返回 None,调用方可以直接把返回值透传给 generate(...)
        if not videos or "pixel_values_videos" not in mm_inputs:
            return None
        payload = {
            "format": "processor_output",
            "pixel_values_videos": mm_inputs["pixel_values_videos"],
            "video_grid_thw": mm_inputs["video_grid_thw"],
        }
        # second_per_grid_ts 只有部分模型(如 Qwen2.5-VL)会产出,存在才带上
        if "second_per_grid_ts" in mm_inputs:
            payload["second_per_grid_ts"] = mm_inputs["second_per_grid_ts"]
        return [payload]
​
    async def _compute_teacher_logprobs(
        self,
        output: AgentLoopOutput,
        prompt_ids: list[int],
        response_ids: list[int],
        validate: bool,
        sample_kwargs: Optional[dict[str, Any]] = None,
    ) -> None:
        """计算单条样本的 teacher logprobs(蒸馏 / OPD 路径)。"""
        if self.distillation_enabled and not validate:
            # 省略 routing_key 解析等与视频无关的逻辑
            teacher_ids, teacher_logprobs = await self.teacher_server_manager.compute_teacher_logprobs_single(
                sequence_ids=prompt_ids + response_ids,
                multi_modal_data=output.multi_modal_data,
                mm_processor_kwargs=output.mm_processor_kwargs,
                # getattr 兜底:不带该字段的 AgentLoopOutput 是安全 no-op,不会因属性缺失而报错
                mm_processor_output=getattr(output, "mm_processor_output", None),
                routing_key=routing_key,
            )
            output.extra_fields["teacher_ids"] = teacher_ids
            output.extra_fields["teacher_logprobs"] = teacher_logprobs
verl/utils/tokenizer/continuous_token.py core-logic

为 build_initial_tokens / render_tokens_with_mm 增加 mm_inputs_out 出口,是“特征算完即丢”这一根因的修复点;同时需要适配 MiniMax / DeepSeekVL2 等 override 以免 TypeError。

class VLContinuousTokenMixin:
    def render_tokens_with_mm(
        self,
        messages: list[dict[str, Any]],
        images: list[Any],
        *,
        videos: list[Any] | None = None,
        audios: list[Any] | None = None,
        add_generation_prompt: bool = True,
        tools: list[dict[str, Any]] | None = None,
        mm_inputs_out: dict[str, Any] | None = None, # 新增:回填 processor 中除 input_ids 外的特征
    ) -> list[int]:
        """通过 processor 渲染消息(包含全部模态)。
​
        ``mm_inputs_out`` 非空时,会接收 ``pixel_values_videos`` / ``video_grid_thw`` 等 processor 特征。
        SGLang 在请求时需要这些特征,因为它的 ``video_data`` 接不了原始帧;没有这个钩子,这些张量
        算完就会被直接丢掉。
        """
        template_kwargs = dict(self.chat_template_kwargs)
        if tools:
            template_kwargs["tools"] = tools
​
        # 用 processor 的 chat template 渲染,保证占位文本与 legacy rollout 路径一致
        text = apply_chat_template(
            self.processor,
            messages,
            tokenize=False,
            add_generation_prompt=add_generation_prompt,
            **template_kwargs,
        )
​
        # processor kwargs 是构造期捕获的常量,保证初始 prompt 与增量渲染保持一致
        proc_kwargs = dict(self.mm_processor_kwargs or {})
        processor_output = build_multimodal_processor_inputs(
            self.processor,
            text=[text],
            images=images if images else None,
            videos=videos if videos else None,
            audio=audios if audios else None,
            mm_processor_kwargs=proc_kwargs if proc_kwargs else None,
        )
        # 只回填 input_ids 之外的字段:token 本身仍按原路径返回,不改变既有调用方行为
        if mm_inputs_out is not None:
            mm_inputs_out.update({k: v for k, v in processor_output.items() if k != "input_ids"})
        return normalize_token_ids(processor_output["input_ids"])
​
    def build_initial_tokens(
        self,
        messages: list[dict[str, Any]],
        *,
        tools: list[dict[str, Any]] | None = None,
        images: list[Any] | None = None,
        videos: list[Any] | None = None,
        audios: list[Any] | None = None,
        mm_inputs_out: dict[str, Any] | None = None,
    ) -> list[int]:
        # 把 mm_inputs_out 一路透传到 render_tokens_with_mm,agent loop 只需要跟这一层打交道
        return self.render_tokens_with_mm(
            messages,
            images,
            videos=videos,
            audios=audios,
            add_generation_prompt=True,
            tools=tools,
            mm_inputs_out=mm_inputs_out,
        )
verl/workers/rollout/llm_server.py core-logic

LLMServerClient.generate 是后端分派点:对 SGLang 用 processor payload 覆盖 video_data,对 vLLM 保留原始帧,并 pop 掉 server 签名不接受的 mm_processor_output 关键字。

class LLMServerClient:
    async def generate(
        self,
        request_id: str,
        prompt_ids: list[int],
        sampling_params: dict[str, Any],
        image_data: Optional[list[Any]] = None,
        video_data: Optional[list[Any]] = None,
        audio_data: Optional[list[Any]] = None,
        mm_processor_kwargs: Optional[dict[str, Any]] = None,
        **kwargs: Any,
    ) -> TokenOutput:
        # 省略 _acquire_server 的完整参数列表(prompt_ids / sampling_params / image_data / ...)
        server_id, server = await self._acquire_server(...)
        try:
            multimodal_kwargs = {}
            if audio_data is not None:
                multimodal_kwargs["audio_data"] = audio_data
            if mm_processor_kwargs:
                multimodal_kwargs["mm_processor_kwargs"] = mm_processor_kwargs
​
            # SGLang 不能接收原始帧:它的 video_data 只接受 path / url / base64 或 processor_output dict。
            # 因此对 SGLang 后端统一改发预计算好的 payload(模型 processor 未产出 pixel_values_videos 时为
            # None,例如非 Qwen-VL 家族)——宁可丢视频,也不要给 SGLang 传它无法解析的张量。
            # vLLM 继续使用原始帧、不会进入这个分支。两个 server 的签名都不接受 **kwargs,所以这里必须 pop。
            mm_processor_output = kwargs.pop("mm_processor_output", None)
            if self.config.actor_rollout_ref.rollout.name == "sglang":
                video_data = mm_processor_output
​
            # priority 只有 vLLM rollout server 支持;写法与上面的后端分派保持一致
            priority = kwargs.pop("priority", 0)
            priority_kwargs = (
                {"priority": priority}
                if priority != 0 and self.config.actor_rollout_ref.rollout.name == "vllm"
                else {}
            )
            output: TokenOutput = await server.generate.remote(
                request_id=self._vllm_request_id(request_id), # 每轮都换新的 request_id
                prompt_ids=prompt_ids,
                sampling_params=sampling_params,
                image_data=image_data,
                video_data=video_data,
                **multimodal_kwargs,
                **priority_kwargs,
                **kwargs,
            )
            global_steps = output.extra_fields.get("global_steps")
            output.extra_fields.setdefault("min_global_steps", global_steps)
            output.extra_fields.setdefault("max_global_steps", global_steps)
            return output
        finally:
            self._release_server(server_id, request_id=request_id)

评论区精华

ToolAgentLoop 覆盖与公共 helper 抽取 设计

wuxibin89 在 single_turn_agent_loop.py 上评论:“`ToolAgentLoop` should be adjusted as well, or can we put it in a common place?”;作者回复 ToolAgentLoop 具有完全相同的形状(ct_build_initial_tokens → generate(video_data=...)),认同应抽公共 helper 而不是复制。

结论:payload 构造下沉为 AgentLoopBase.build_sglang_video_payload,SingleTurnAgentLoop 与 ToolAgentLoop 都改为单行调用;ToolAgentLoop 通过 AgentData.mm_processor_output 暂存、在 _handle_generating_state 每轮透传。 · 已解决

OPD teacher(蒸馏)路径同样丢弃视频 正确性

wuxibin89 指出 “OPD teacher model should be adjusted as well: .../teacher_manager.py#L128-L136”;作者确认 TeacherManager.compute_teacher_logprobs_single 转发的是原始 video_data,teacher client 又是同一个 LLMServerClient,因此 SGLang teacher 会像 rollout 一样丢视频。

结论:payload 经 AgentLoopOutput.mm_processor_output → _compute_teacher_logprobs(getattr 兜底,缺字段安全 no-op)→ TeacherManager.compute_teacher_logprobs_single → client.generate,per-backend 分派自动生效。 · 已解决

pixel_values_videos 等键是否 Qwen 专属 设计

wuxibin89 在 agent_loop.py:382 提问:“Is these keys: `pixel_values_videos / video_grid_thw / second_per_grid_ts` specific to Qwen series model?”;作者解释它们来自 Qwen2-VL / 2.5-VL / 3-VL 的 processor,但恰好就是 SGLang collect_mm_items_from_processor_output(base_processor.py 的 ATTR_NAME_TO_MODALITY)映射为 Modality.VIDEO 的键,生产端与消费端对齐。

结论:非 Qwen 视频家族需要两侧同时补键;当前 payload 为 None 时对 SGLang 会清空 video_data,即视频被丢弃(与改前一致)而不是让服务崩溃,作者建议作为 follow-up,讨论中未关闭该跟进项。 · follow-up

缺少自动化测试与 CI 红灯归属 测试

PR checklist 未勾选测试项,作者解释端到端视频 rollout 测试需要视频 VLM 权重和 GPU,并提出可贡献 CPU-only 的 mRoPE 对齐测试;同时说明两个红灯 CI 任务(如 test_preprocess_thd_engine_preserves_1d_zigzag_roll_alignment)属于本 PR 未触碰的代码。

结论:以人工验证替代自动化测试:vLLM 逐样本首字符差异 0/463、与 SGLang MRotaryEmbedding.get_rope_index 的位置 id 逐元素一致、AST 检查 7 个 builder 实现签名;合入时没有新增任何测试。 · 待处理

风险与影响

  1. 行为变更显著但方向为正:SGLang 视频任务从“盲答”变为真正使用视觉特征,rollout 分布发生质变(val acc 0.4968 → 0.77186,KL 0.093–0.114 → 0.013–0.016)。与改动前的 SGLang 实验基线不再可比,跨版本对齐实验需重新跑;同时需注意作者提示的 greedy 验证不可 bit 级复现(同后端两次仅 6/469 字节一致),A/B 必须开 rollout.full_determinism=True。
  2. 缺少自动化测试覆盖:没有单测或 CI 用例,LLMServerClient.generate 中“pop → 按后端覆盖 video_data”这条分支未来若被改动(例如有人清理 kwargs 或调整调用顺序)不会有任何测试报警;build_sglang_video_payload 的 None 语义与 payload 键集合也缺少回归保护。
  3. 多轮重复传特征:ToolAgentLoop 在每轮 _handle_generating_state 都携带初始 prompt 的 mm_processor_output,与 prompt 已经扩展后的 token 序列配合时依赖 SGLang 每轮重新消费同一份特征,作者已将其标为 caveat,但缺少多轮视频的实际验证。
  4. 非 Qwen 视频家族静默降级:build_sglang_video_payload 在 processor 无 pixel_values_videos 时返回 None,LLMServerClient.generate 对 SGLang 无条件覆盖 video_data,因此这类模型仍然是“静默丢视频”,与 PR 力图消除的失败模式同类,只是不会崩溃;需要两侧同时注册新模态键才能真正支持。
  5. 内存与序列化开销:mm_inputs_out 与 payload 以引用方式持有 pixel_values_videos 等张量并挂在 AgentData / AgentLoopOutput 上,多轮 rollout 与 teacher 调用会额外携带这些张量;max_frames=32 时量级可控,但属于新增的常驻开销,未见显式释放。
  6. vLLM 兼容性:改动明确保持 vLLM 走原始帧分支,作者用“差异样本 0/463 在首字符分叉”论证输入未变;不过 mm_processor_output 会被 pop 并丢弃,逻辑正确性依赖这一行 pop 不早于 vLLM 的 video_data 使用。

用户侧:所有使用 rollout.name=sglang 做视频 VLM RL 训练的用户直接受益,视频问答从无效变为可用,vLLM 用户无感知;采用 SGLang 作为蒸馏 teacher(OPD)的视频任务同样被修复。系统侧:在 tokenizer → agent loop → rollout client → SGLang server 之间新增了一条跨层数据契约 mm_processor_output,四层都要保持签名兼容,后续新增 VL builder 必须接受 mm_inputs_out 否则首调即 TypeError;同时把“后端差异适配”集中到了 LLMServerClient.generate,与既有的 priority 分派形成统一模式。团队侧:vLLM 与 SGLang 在视频任务上的对齐不再依赖各后端自行解码,评测可比性恢复;但非 Qwen 视频家族的支持仍需跟进,且没有任何自动化测试守护这条链路。

核心 rollout 路径行为变更 视频输入语义变化需重跑基线 缺少自动化测试覆盖 非 Qwen 视频家族静默降级 多轮重复发送视频特征

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论