Prhub

#56729 [Security] Cap Qwen-VL video sampling knobs

原始 PR 作者 jperezdealgaba 合并时间 2026-09-14 18:22 文件变更 1 提交数 1 评论 5 代码增减 +10 / -4

执行摘要

Qwen-VL 视频采样加服务端上限,防止请求放大解码量

PR body 明确指出问题根因:「Qwen2-VL and Qwen3-VL video samplers ignore num_frames and bound sampling on request-controlled max_frames and fps. A client can raise those knobs through media_io_kwargs and force the frontend to decode every source frame.」即采样上限完全由请求参数决定,缺少服务端天花板,属于可被客户端放大的资源消耗问题(安全类)。修复方式是复用 GLMGA 采样器已有的类级上限写法(_MAX_FRAMES / _MAX_FPS),保证「请求可以调低采样数,但不能越过 768 帧或 30 fps」。

值得精读:改动虽小(+10/-4),但它是 request-controlled 参数落到资源消耗路径上的一类典型安全收口,理解 media_io_kwargs → VideoMediaIO → compute_frames_index_to_sample 这条链路对排查多模态输入侧问题很有价值。重点看两处设计决策:一是「在采样器内部 clamp」与 Isotr0py 建议的「把 fps/max_frames 变成不可被请求覆盖的 static kwargs」之间的取舍(后者被明确延后为 follow-up refactor);二是评审要求删除新增测试后,Qwen 封顶逻辑处于无测试保护状态。追这条线的读者应关注同系列 glm5next/Molmo2/OpenPangu 封顶 PR 与后续 kwargs 机制重构的落地情况。

讨论亮点

两处关键评审意见都来自 Isotr0py,且都改变了最终产物:

  1. 测试被要求移除:对 tests/multimodal/test_video.py 的评论为「Let's remove these added tests.」,作者回复「This should have been fixed」,最终提交中该测试文件不再出现。结果是本次安全修复没有专属回归测试。
  2. 设计上更优解被延后:对 vllm/multimodal/video.py 第 456 行(Qwen2-VL 的采样帧数计算行)评论「I think a better solution is making fps and max_frames static kwargs, which can't be overrided by arguments from request. But anyway, let's leave it to be done in followup refactoring.」——评审认可当前 clamp 可用,但认为根本解法是让这些参数不可被请求覆盖,属于被显式记下的后续重构债。
  3. claude[bot] 的自动评审因来自 fork 被禁用,最终由 Isotr0py 执行 /ci run(Buildkite #88769)并 APPROVED、合入。

实现拆解

  1. 新增服务端上限常量(入口层):在 vllm/multimodal/video.py 的 Qwen3VLVideoBackend 与 Qwen2VLVideoBackend 两个类中各加入 _MAX_FRAMES: ClassVar[int] = 768 与 _MAX_FPS: ClassVar[int] = 30,与既有 TestGLMGASamplingCaps 所覆盖的 GLMGA 采样器写法保持一致,把「上限」从散落的字面量提升为类级、可被子类覆盖但不随请求变化的契约。
  2. Qwen3-VL 采样封顶:在 Qwen3VLVideoBackend.compute_frames_index_to_sample 中,把 fps = target.fps 改为 fps = min(target.fps, cls._MAX_FPS),把 max_frames = kwargs.get("max_frames", 768) 改为 min(kwargs.get("max_frames", cls._MAX_FRAMES), cls._MAX_FRAMES)。由于后续 num_frames = min(max(num_frames, min_frames), max_frames, total_frames_num) 会再与 max_frames 夹紧,min_frames 即便被请求调大也仍被 max_frames 兜住,不会绕过封顶。
  3. Qwen2-VL 采样封顶:在 Qwen2VLVideoBackend.compute_frames_index_to_sample 中对 max_frames 做同样的 min 封顶(在向下取整到 temporal_patch_size 之前),并把帧率封顶内联进采样帧数计算 n = total_frames_num / original_fps * min(target.fps, cls._MAX_FPS),随后仍走原有的 min/max 夹紧与 math.floor(n / temporal_patch_size) * temporal_patch_size,因此对 HF transformers 逐帧对齐的索引语义没有改变。
  4. 覆盖边界说明(非代码改动):PR body 说明收敛点是 compute_frames_index_to_sample,OpenCV、TorchCodec、PyNvVideoCodec、DeepStream 四条解码路径都经由该方法;video/jpeg 的 load_base64 走 num_frames、不经这些采样器;Rust 前端直接拒绝 media_io_kwargs,不受影响。
  5. 测试与配套:作者原本在 tests/multimodal/test_video.py 增加了用例,评审(Isotr0py)要求删除,最终 diff 只包含源码文件(additions=10 / deletions=4,changed_files_count=1)。PR body 以「Existing TestGLMGASamplingCaps tests already cover the same clamping pattern」作为覆盖依据,但这是 GLMGA 后端而非 Qwen 后端的既有测试,本 PR 未新增 Qwen 专属断言。
文件 模块 状态 重要度
vllm/multimodal/video.py 视频采样 modified 6.14

关键符号

Qwen3VLVideoBackend.compute_frames_index_to_sample Qwen2VLVideoBackend.compute_frames_index_to_sample

关键源码片段

vllm/multimodal/video.py core-logic

唯一变更文件;Qwen3VLVideoBackend 与 Qwen2VLVideoBackend 新增类级 _MAX_FRAMES/_MAX_FPS 并对请求侧 max_frames、fps 做 min 封顶,是所有视频解码后端(OpenCV/TorchCodec/PyNvVideoCodec/DeepStream)共用的采样收敛点。

# vllm/multimodal/video.py(head 版本,两个 Qwen-VL 采样后端的关键实现)
#
# 修复主线:把「服务端上限」提升为类级常量,请求侧传入的 max_frames / fps
# 只能在其之下调整,无法越过 768 帧、30 fps 的硬顶。该写法对齐已有的 GLMGA 采样器。
​
@VIDEO_LOADER_REGISTRY.register(
    "qwen3_vl",
    video_processor=("Qwen3VLVideoProcessor", "Cosmos3EdgeVideoProcessor"),
)
class Qwen3VLVideoBackend(VideoBackend):
    # 服务端硬上限:类属性,不随请求参数变化
    _MAX_FRAMES: ClassVar[int] = 768
    _MAX_FPS: ClassVar[int] = 30
​
    @classmethod
    def compute_frames_index_to_sample(
        cls,
        source: VideoSourceMetadata,
        target: VideoTargetMetadata,
        **kwargs,
    ) -> list[int]:
        total_frames_num = source.total_frames_num
        original_fps = source.original_fps
        # target.fps 来自请求(media_io_kwargs 可覆盖),这里先取 min 封顶:
        # 请求可以调低帧率,但调不高过 _MAX_FPS
        fps = min(target.fps, cls._MAX_FPS)
        max_frame_idx = source.total_frames_num - 1
        min_frames = kwargs.get("min_frames", 4)
        # 修复前是裸的 kwargs.get("max_frames", 768),请求可传任意大值;
        # 修复后先与 _MAX_FRAMES 取小,保证采样帧数不突破服务端上限
        max_frames = min(kwargs.get("max_frames", cls._MAX_FRAMES), cls._MAX_FRAMES)
​
        # 与 HF transformers 的 Qwen3-VL 采样保持一致:先按帧率推导采样帧数,
        # 再用 [min_frames, max_frames] 与视频总帧数夹紧;
        # 即便请求调大 min_frames,也仍被 max_frames 兜住
        num_frames = int(total_frames_num / original_fps * fps)
        num_frames = min(max(num_frames, min_frames), max_frames, total_frames_num)
        indices = np.linspace(0, max_frame_idx, num_frames).round().astype(int).tolist()
        return indices
​
​
@VIDEO_LOADER_REGISTRY.register(
    "qwen2_vl",
    video_processor="Qwen2VLVideoProcessor",
)
class Qwen2VLVideoBackend(VideoBackend):
    """Qwen2-VL / Qwen2.5-VL fps 采样后端,索引需与 HF transformers 逐帧对齐。"""
​
    _MAX_FRAMES: ClassVar[int] = 768
    _MAX_FPS: ClassVar[int] = 30
​
    @classmethod
    def compute_frames_index_to_sample(
        cls,
        source: VideoSourceMetadata,
        target: VideoTargetMetadata,
        **kwargs,
    ) -> list[int]:
        total_frames_num = source.total_frames_num
        original_fps = source.original_fps
        temporal_patch_size = kwargs.get("temporal_patch_size", 2)
        min_frames = kwargs.get("min_frames", 4)
        # 与 Qwen3-VL 相同的封顶逻辑,作用在向下取整到 temporal_patch_size 之前
        max_frames = min(kwargs.get("max_frames", cls._MAX_FRAMES), cls._MAX_FRAMES)
​
        # 帧率未知(VFR / 流式 / 损坏容器)时直接报错,避免除零
        if original_fps <= 0:
            raise ValueError(
                "Qwen2-VL video sampling needs a known source fps, but the "
                "container reported 0 (variable or unknown frame rate)."
            )
​
        max_frames = (
            math.floor(min(max_frames, total_frames_num) / temporal_patch_size)
            * temporal_patch_size
        )
        # fps 的封顶发生在计算采样帧数时:先 min 再进入 min/max 夹紧与取整
        n = total_frames_num / original_fps * min(target.fps, cls._MAX_FPS)
        n = min(max(n, min_frames), max_frames, total_frames_num)
        n = math.floor(n / temporal_patch_size) * temporal_patch_size
​
        # torch.arange 与 transformers 的 float32 索引计算保持一致;
        # 尾部可能越界一帧,统一 clamp 到最后一帧
        indices = torch.arange(0, total_frames_num, total_frames_num / n).int()
        return torch.clamp(indices, max=total_frames_num - 1).tolist()

评论区精华

移除本 PR 新增的 Qwen 采样上限测试 测试

Isotr0py 在 `tests/multimodal/test_video.py` 上评论「Let's remove these added tests.」,作者回复「This should have been fixed」,最终提交中测试文件不再出现,PR 只保留源码改动。

结论:测试被移除,Qwen 侧封顶逻辑改为依赖既有 `TestGLMGASamplingCaps` 的同类模式作为间接依据,本次变更没有专属回归测试。 · 已解决(按评审要求移除,但留下覆盖缺口)

用 static kwargs 取代运行时 clamp 的更优解 设计

Isotr0py 在 `vllm/multimodal/video.py` 第 456 行(Qwen2-VL 采样帧数计算行)评论:「I think a better solution is making `fps` and `max_frames` static kwargs, which can't be overrided by arguments from request. But anyway, let's leave it to be done in followup refactoring.」

结论:认可当前 min 封顶作为短期修复,但根本方案是让 fps/max_frames 不可被请求覆盖;明确记为后续重构项,未在本 PR 落地。 · 开放(延后至 follow-up refactoring)

fork PR 自动评审被禁用 other

claude[bot] 提示「This pull request is from a fork — automated review is disabled.」,需要维护者手动触发评审。

结论:由 Isotr0py 手动执行 `/ci run`(Buildkite #88769)、APPROVED 并合入,未引入额外自动化审查。 · 已解决

风险与影响

  1. 缺少测试覆盖(主要风险):新增的 Qwen 封顶逻辑没有任何直接测试,原计划的 tests/multimodal/test_video.py 用例被移除;现有 TestGLMGASamplingCaps 只覆盖 GLMGA 后端。若后续「static kwargs」重构时误删这两个 min 调用,CI 不会报警。
  2. 静默行为变更:之前可通过 media_io_kwargs 合法抬高 max_frames(>768)或 fps(>30)的客户端,会在无任何日志/告警的情况下拿到被截断的采样结果,采样帧数变少可能影响长视频理解的输出质量,属于语义上的静默降级。
  3. 同类后端封顶不统一:PR body 明确列出 glm5next、Molmo2、OpenPangu 由 #55727、#56207、#56390 各自处理,本 PR 只收口 Qwen2/Qwen3-VL。在这批 PR 齐备前,media_io_kwargs 放大路径在其他采样后端仍然可用,安全姿态不一致。
  4. 相邻既有缺陷未处理:Qwen3VLVideoBackend.compute_frames_index_to_sample 仍缺少 Qwen2-VL 那样的 original_fps <= 0 保护(base/head 均如此,非本 PR 引入),total_frames_num / original_fps 在 VFR/损坏容器下仍有除零风险。
  5. 封顶依赖类常量:_MAX_FRAMES / _MAX_FPS 是 ClassVar,任何子类或后续改动都可覆盖;它们只约束采样索引数量,min_frames、temporal_patch_size 等相邻 kwargs 仍由请求注入(虽然被 max_frames 与总帧数夹紧)。

对用户/客户端:默认行为不变(默认 fps=2、max_frames 默认 768,本就在上限之内);只有显式通过 media_io_kwargs 抬高参数的请求会感知到采样数被截断。
对系统:收窄了单请求可触发的视频解码工作量上限,覆盖 chat completions、responses、embeddings、tokenize、invocations、batch 与离线 LLM 全部经 MediaConnector → VideoMediaIO → 采样器链 的入口,并同时覆盖四种解码后端,收益面广而改动面极小。
对团队:确立「采样上限 = 类级常量」的收敛模式,并把「参数不可被请求覆盖」登记为后续重构方向;由于 Qwen 侧无测试,重构时需要人工补覆盖。

缺少测试覆盖 请求参数被静默截断 同类采样后端封顶不统一 封顶依赖类常量可被覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论