执行摘要
- 一句话:Qwen-VL 视频采样加服务端上限,防止请求放大解码量
- 推荐动作:值得精读:改动虽小(+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 机制重构的落地情况。
功能与动机
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」。
实现拆解
- 新增服务端上限常量(入口层):在
vllm/multimodal/video.py 的 Qwen3VLVideoBackend 与 Qwen2VLVideoBackend 两个类中各加入 _MAX_FRAMES: ClassVar[int] = 768 与 _MAX_FPS: ClassVar[int] = 30,与既有 TestGLMGASamplingCaps 所覆盖的 GLMGA 采样器写法保持一致,把「上限」从散落的字面量提升为类级、可被子类覆盖但不随请求变化的契约。
- 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 兜住,不会绕过封顶。
- 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 逐帧对齐的索引语义没有改变。
- 覆盖边界说明(非代码改动):PR body 说明收敛点是
compute_frames_index_to_sample,OpenCV、TorchCodec、PyNvVideoCodec、DeepStream 四条解码路径都经由该方法;video/jpeg 的 load_base64 走 num_frames、不经这些采样器;Rust 前端直接拒绝 media_io_kwargs,不受影响。
- 测试与配套:作者原本在
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(模块 视频采样;类别 source;类型 core-logic;符号 Qwen3VLVideoBackend, Qwen2VLVideoBackend, compute_frames_index_to_sample, _MAX_FRAMES): 唯一变更文件;Qwen3VLVideoBackend 与 Qwen2VLVideoBackend 新增类级 _MAX_FRAMES/_MAX_FPS 并对请求侧 max_frames、fps 做 min 封顶,是所有视频解码后端(OpenCV/TorchCodec/PyNvVideoCodec/DeepStream)共用的采样收敛点。
关键符号:Qwen3VLVideoBackend.compute_frames_index_to_sample, Qwen2VLVideoBackend.compute_frames_index_to_sample
关键源码片段
vllm/multimodal/video.py
唯一变更文件;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()
评论区精华
两处关键评审意见都来自 Isotr0py,且都改变了最终产物:
- 测试被要求移除:对
tests/multimodal/test_video.py 的评论为「Let's remove these added tests.」,作者回复「This should have been fixed」,最终提交中该测试文件不再出现。结果是本次安全修复没有专属回归测试。
- 设计上更优解被延后:对
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 可用,但认为根本解法是让这些参数不可被请求覆盖,属于被显式记下的后续重构债。
- claude[bot] 的自动评审因来自 fork 被禁用,最终由 Isotr0py 执行
/ci run(Buildkite #88769)并 APPROVED、合入。
- 移除本 PR 新增的 Qwen 采样上限测试 (testing): 测试被移除,Qwen 侧封顶逻辑改为依赖既有
TestGLMGASamplingCaps 的同类模式作为间接依据,本次变更没有专属回归测试。
- 用 static kwargs 取代运行时 clamp 的更优解 (design): 认可当前 min 封顶作为短期修复,但根本方案是让 fps/max_frames 不可被请求覆盖;明确记为后续重构项,未在本 PR 落地。
- fork PR 自动评审被禁用 (other): 由 Isotr0py 手动执行
/ci run(Buildkite #88769)、APPROVED 并合入,未引入额外自动化审查。
风险与影响
关联脉络
- PR #54323 [Bugfix][Multimodal] Validate base64 video payloads, matching image and audio: 同属多模态视频输入侧的健壮性/安全加固,改动的
vllm/multimodal/media/video.py 与本 PR 的 vllm/multimodal/video.py 处于同一条 MediaConnector → VideoMediaIO → 视频后端 链路上,可对照理解「请求侧载荷校验 / 采样侧上限」两类防线。
- PR #56366 [Bugfix][Rust Frontend][Multimodal] Align DeepSeek V4.1 and Kimi K3 media with rendered placeholders: 同属前端多模态媒体处理主线;该 PR 说明 Rust 前端与 Python 前端在多模态媒体处理上的差异,而本 PR 明确以「Rust frontend rejects media_io_kwargs and is unaffected」划分影响边界,两者对照可确认封顶的覆盖面。
参与讨论