Prhub

#47959 [Rust Frontend] Integrate MM video support

原始 PR 作者 BugenZhao 合并时间 2026-07-10 16:15 文件变更 16 提交数 10 评论 9 代码增减 +1571 / -516

执行摘要

Rust 前端集成视频多模态并重构模态架构

PR #47530 升级了 llm-multimodal 版本,该版本包含了基础视频支持。此 PR 的目标是添加 OpenAI 兼容 API 的 video_url 聊天内容支持,并将现有的多模态路径从图像专用代码重构为按模态准备的通用架构,便于未来添加更多模态类型。

值得精读,尤其对多模态扩展和 Rust 前端架构感兴趣的读者。注意 SSRF 风险需后续修复,架构设计(ModalitySupport + PreparedMedia + expand_prompt_token_ids)具有良好的可扩展性,可为将来添加新模态提供参考。

讨论亮点

Review 中主要围绕以下几点展开讨论:

  • SSRF 安全风险depthfirst-app[bot] 指出用户提供的视频 URL 直接通过 MediaConnector 获取,缺乏 SSRF 保护(如私有 IP 拦截、scheme 白名单),可能泄露内部网络信息。该问题未在 PR 中解决,但被标记为需要注意。
  • Qwen3-VL 占位符偏移正确性chatgpt-codex-connector[bot] 认为 expand.rs 的 left-to-right 匹配可能使 PlaceholderRange.offset 指向错误位置。作者 BugenZhao 解释上游 llm-multimodal 的修复已调整模板,offset 现在正确指向 vision_start 之前,与 transformers>=5.10 行为一致。最终 reviewer Isotr0py 验证通过。
  • 预处理器配置 fallbackIsotr0py 询问是否应为图像处理器也添加 processor_config.json fallback,作者随后更新代码使图像和视频共享一致的 fallback 逻辑。此外,Isotr0py 指出默认视频获取配置忽略了模型特定的 min_frames/max_frames 字段,作者承认但认为超出当前 PR 范围。
  • 性能副本sagearc 问能否避免张量多次副本(Rust 拆分 + 发送到 Python),作者表示留待后续优化。

实现拆解

  1. 请求模型扩展:在 rust/src/chat/src/request.rsChatContentPart 枚举中新增 VideoUrl 变体,添加 video_url 构造函数和相应序列化支持。在 rust/src/server/src/routes/openai/chat_completions/convert.rs 中将 OpenAI API 的 video_url 内容块转换为 Rust 内部表示(ChatContentPart::VideoUrl)。

  2. 模态无关基础设施重构:重写 rust/src/chat/src/multimodal.rs,将原先仅支持图像的 MultimodalModelInfo 改为持有 image: Option<ModalitySupport>video: Option<ModalitySupport>,每个 ModalitySupport 包含处理器、占位符信息及预处理器配置。原 resolve_image_processor 泛化为 resolve_vision_processor,同时服务图像和视频。新增 expandimagevideo 子模块。

  3. 图像处理提取:创建 rust/src/chat/src/multimodal/image.rs,从原文件中提取 prepare_imagespreprocess_imagesbuild_image_items 方法,专注于图像批预处理和每项特征构建。

  4. 视频处理新建:创建 rust/src/chat/src/multimodal/video.rs,实现 prepare_videospreprocess_video_clipbuild_video_item。视频帧通过 tokio::task::spawn_blocking 在阻塞线程中预处理,优先使用 RGB 快速路径,回退到物化帧。每项视频的张量作为完整范围扁平字段发送,无需跨项切片。

  5. 占位符扩展提取:创建 rust/src/chat/src/multimodal/expand.rs,将原先内联的 placeholder 替换逻辑抽取为 expand_prompt_token_ids,通过 ExpansionLane 管理多个模态的替换队列,支持交错标记且一次遍历完成。返回每个模态的 PlaceholderRange,携带 is_embed 布尔掩码。

  6. 聊天模板渲染器适配:修改 rust/src/chat/src/renderer/hf/mod.rs,将 MultimodalRenderInfo 从单一 placeholder_token 改为 image_token: Option<String>video_token: Option<String>,在 TemplateContentPart 枚举中新增 Video 变体,并在渲染函数中根据对应 token 是否存在来拒绝或替换视频内容。

  7. 配置与错误处理:修改 rust/src/chat/src/error.rs 增加 MediaConnectorError 转换和新错误变体;修改 rust/src/text/src/backend/hf/model_files.rs 加载视频预处理器配置;修改 rust/src/chat/src/backend/hf.rs 传递视频相关文件;修改 rust/src/chat/src/multimodal/tensor.rs 支持视频主键 pixel_values_videos

文件 模块 状态 重要度
rust/src/chat/src/multimodal/expand.rs 多模态 added 9.24
rust/src/chat/src/multimodal/video.rs 多模态 added 9.24
rust/src/chat/src/multimodal.rs 多模态 modified 8.92
rust/src/chat/src/renderer/hf/mod.rs 渲染器 modified 8.46
rust/src/chat/src/request.rs 请求模型 modified 7.17
rust/src/chat/src/multimodal/image.rs 多模态 added 8.37

关键符号

from_prepared prepared_media llama4_prepared qwen3_image_prepared qwen3_video_prepared llama4_single_tile_replacement llama4_multi_tile_replacement assert_bool_mask preprocess_video_clip build_video_item from_paths_resolves_video_config_from_dedicated_file_or_processor_config build_video_item_names_primary_tensor_and_layouts resolve_image_processor resolve_vision_processor new prompt_replacements prompt_replacements_for resolve load_preprocessor_config from_loaded video_request string_content_format_replaces_video_with_placeholder_text openai_content_format_normalizes_video_url_for_template video_parts_are_rejected_when_model_lacks_video_support preprocess_images build_image_items video_url chat_content_video_url_part_round_trips_through_serde

关键源码片段

rust/src/chat/src/multimodal/expand.rs core-logic

核心新模块,实现多模态占位符统一扩展逻辑,是架构重构的关键抽象。

//! Prompt placeholder expansion shared across modalities.use std::collections::{HashMap, VecDeque};
use llm_multimodal::{Modality, PromptReplacement};
use vllm_engine_core_client::protocol::multimodal::PlaceholderRange;
use vllm_engine_core_client::protocol::tensor::WireTensor;
use super::PreparedMedia;
use crate::error::{Error, Result, bail_multimodal};/// One modality's queue of pending placeholder replacements.
struct ExpansionLane<'a> {
    modality: Modality,
    marker_token_id: u32,
    embed_token_id: u32,
    placeholder_token: String,
    replacements: VecDeque<&'a PromptReplacement>,
}impl<'a> ExpansionLane<'a> {
    fn from_prepared(media: &'a PreparedMedia) -> Option<Self> {
        if media.replacements.is_empty() { return None; }
        Some(Self {
            modality: media.modality,
            marker_token_id: media.placeholder.marker_token_id,
            embed_token_id: media.placeholder.embed_token_id,
            placeholder_token: media.placeholder.token.clone(),
            replacements: media.replacements.iter().collect(),
        })
    }
}/// Replace rendered placeholder markers with model-specific replacement
/// tokens across all modalities in one left-to-right pass.
pub(super) fn expand_prompt_token_ids(
    prompt_token_ids: &mut Vec<u32>,
    prepared: &[PreparedMedia],
) -> Result<HashMap<Modality, Vec<PlaceholderRange>>> {
    let mut lanes = prepared.iter().filter_map(ExpansionLane::from_prepared).collect::<Vec<_>>();
    if lanes.is_empty() { return Ok(HashMap::new()); }    let replacement_growth = lanes.iter()
        .flat_map(|lane| lane.replacements.iter())
        .fold(0usize, |total, replacement| total.saturating_add(replacement.tokens.len().saturating_sub(1)));
    let expanded_len = prompt_token_ids.len().saturating_add(replacement_growth);
    let mut expanded = Vec::with_capacity(expanded_len);
    let mut ranges = HashMap::<Modality, Vec<PlaceholderRange>>::new();    for &token in prompt_token_ids.iter() {
        let lane = lanes.iter_mut()
            .find(|lane| lane.marker_token_id == token && !lane.replacements.is_empty());
        let Some(lane) = lane else { expanded.push(token); continue; };
        let replacement = lane.replacements.pop_front().expect("lane queue is non-empty");
        debug_assert_eq!(replacement.modality, lane.modality);
        if replacement.tokens.is_empty() {
            bail_multimodal!("placeholder token `{}` expanded to no tokens", lane.placeholder_token);
        }
        let replacement_len = replacement.tokens.len();
        let is_embed = {
            let mask = replacement.tokens.iter()
                .map(|&token| token as u32 == lane.embed_token_id).collect::<Vec<_>>();
            WireTensor::from_bool(vec![replacement_len], mask).map_err(Error::Multimodal)?
        };
        let expanded_offset = expanded.len();
        expanded.extend(replacement.tokens.iter().map(|&token| token as u32));
        ranges.entry(lane.modality).or_default().push(PlaceholderRange {
            offset: expanded_offset,
            length: replacement_len,
            is_embed: Some(is_embed),
        });
    }    for lane in &lanes {
        if !lane.replacements.is_empty() {
            bail_multimodal!(
                "placeholder token `{}` was not found in tokenized prompt for {} remaining `{}` item(s)",
                lane.placeholder_token, lane.replacements.len(), lane.modality
            );
        }
    }
    *prompt_token_ids = expanded;
    Ok(ranges)
}
rust/src/chat/src/multimodal/video.rs core-logic

视频模态处理核心,实现预处理、特征构建和快速路径回退逻辑。

//! Video-modality preparation.use std::sync::Arc;
use itertools::izip;
use llm_multimodal::{FieldLayout, Modality, PreprocessedEncoderInputs, VideoClip};
use vllm_engine_core_client::protocol::dtype::ModelDtype;
use vllm_engine_core_client::protocol::multimodal::{
    MmBatchedField, MmField, MmFieldElem, MmFlatField, MmKwargsItem, MmSharedField, MmSlice,
    SliceSpec,
};
use super::{ModalitySupport, MultimodalModelInfo, PreparedItem, PreparedMedia, tensor};
use crate::error::{Error, Result, bail_multimodal, multimodal};impl MultimodalModelInfo {
    /// Preprocess video clips one at a time and build per-item features.
    pub(super) async fn prepare_videos(
        &self,
        clips: Vec<Arc<VideoClip>>,
        uuids: Vec<Option<String>>,
        model_dtype: ModelDtype,
    ) -> Result<PreparedMedia> {
        let support = self.video.as_ref().ok_or_else(|| Error::UnsupportedModality {
            modality: Modality::Video.to_string(),
        })?;
        let mut replacements = Vec::with_capacity(clips.len());
        let mut items = Vec::with_capacity(clips.len());
        for (clip, uuid) in izip!(&clips, uuids) {
            let preprocessed = self.preprocess_video_clip(support, Arc::clone(clip)).await?;
            let mut clip_replacements = self.spec
                .prompt_replacements_for(&self.context, &preprocessed, Modality::Video)?;
            if clip_replacements.len() != 1 {
                bail_multimodal!(
                    "expected exactly one prompt replacement per video clip, got {}",
                    clip_replacements.len()
                );
            }
            replacements.push(clip_replacements.pop().unwrap());
            items.push(self.build_video_item(preprocessed, clip.hash.clone(), uuid, model_dtype)?);
        }
        Ok(PreparedMedia {
            modality: Modality::Video,
            placeholder: support.placeholder.clone(),
            replacements,
            items,
        })
    }    /// Preprocess one decoded video clip, preferring the borrowed-RGB fast path.
    async fn preprocess_video_clip(
        &self,
        support: &ModalitySupport,
        clip: Arc<VideoClip>,
    ) -> Result<PreprocessedEncoderInputs> {
        let config = support.config.clone();
        let processor = support.processor;
        tokio::task::spawn_blocking(move || {
            if let Some(rgb_video) = clip.rgb_video() {
                match rgb_video.frame_refs() {
                    Ok(frame_refs) => match processor.preprocess_video_rgb(&frame_refs, &config) {
                        Ok(preprocessed) => return Ok(preprocessed),
                        Err(error) => warn!(
                            error = %error.as_report(),
                            "RGB fast path failed; falling back to materialized frames"
                        ),
                    },
                    Err(error) => warn!(error, "invalid RGB frame refs; falling back"),
                }
            }
            let frames = clip.materialized_frames().map_err(|error| multimodal!("{error}"))?;
            Ok(processor.preprocess_video(&frames, &config)?)
        }).await.map_err(|error| multimodal!("video preprocessing task failed: {error}"))?
    }
}
rust/src/chat/src/multimodal.rs core-logic

架构重构核心,从单图像处理器改为图像 / 视频双重模态支持。

/// Resolved multimodal support for one loaded model.
#[derive(Clone)]
pub struct MultimodalModelInfo {
    context: MultimodalModelContext,
    spec: ResolvedMultimodalSpec,
    // 之前是单一的 image_processor: ResolvedImageProcessor,
    // 现在每个支持的模态对应一个 Optional ModalitySupport
    image: Option<ModalitySupport>,
    video: Option<ModalitySupport>,
    media_connector: Arc<MediaConnector>,
}// ModalitySupport 同时包含处理器、占位符和配置
#[derive(Clone)]
pub(super) struct ModalitySupport {
    pub processor: &'static dyn VisionPreProcessor,
    pub placeholder: ResolvedPlaceholder,
    pub config: PreProcessorConfig,
}impl MultimodalModelContext {
    /// 视觉处理器同时服务图像和视频模态
    fn resolve_vision_processor(&self) -> Option<&'static dyn VisionPreProcessor> {
        static REGISTRY: LazyLock<VisionProcessorRegistry> =
            LazyLock::new(VisionProcessorRegistry::with_defaults);
        REGISTRY.find(&self.model_id, self.model_type.as_deref())
    }
}impl MultimodalModelInfo {
    // from_loaded 方法中根据模型能力加载图像和视频支持
    pub fn from_loaded(files: &ResolvedModelFiles) -> Result<Option<Self>> {
        // ...
        let image_support = if has_image {
            Some(ModalitySupport::new(placeholder_image, processor, preprocessor_config))
        } else { None };
        let video_support = if has_video {
            let video_config = video::load_video_preprocessor_config(
                files.video_preprocessor_config, files.processor_config
            )?;
            Some(ModalitySupport::new(placeholder_video, processor, video_config))
        } else { None };
        // ...
    }
}

评论区精华

SSRF 安全风险 安全

depthfirst-app[bot] 指出视频 URL 未经 SSRF 保护直接由 MediaConnector 获取,可能泄露内部网络信息。建议添加 URL 验证或使用安全 HTTP 客户端。

结论:未在 PR 中解决,但被标记为需要注意,后续可通过 URL 白名单或私有 IP 拦截修复。 · unresolved

Qwen3-VL 占位符偏移正确性 正确性

chatgpt-codex-connector[bot] 认为 expand.rs 的 left-to-right 匹配可能使 offset 指向错误位置(在 vision_start 之前而不是之后)。BugenZhao 解释上游 llm-multimodal 已调整模板,offset 现在指向正确。

结论:通过上游修复解决,reviewer Isotr0py 验证通过。 · 已解决

预处理器配置 fallback 设计

Isotr0py 询问是否应为图像处理器也添加 processor_config.json fallback,作者随后更新代码。此外,Isotr0py 指出默认视频获取配置忽略了 model-specific 的 min_frames/max_frames,需要后续跟进。

结论:图像 fallback 已与视频保持一致;视频获取配置超出 PR 范围,留待后续。 · 已解决

风险与影响

  • SSRF 漏洞:用户提供的 video_url 直接传入 MediaConnector 获取,无私有 IP 过滤或 scheme 限制,可能被用于内网探测或 SSRF 攻击。文件:multimodal.rsextract_media_partserror.rs 中的 MediaConnectorError 转换。
  • 视频预处理性能:视频帧的 RGB 解码与预处理在 tokio::task::spawn_blocking 中执行,高并发下可能阻塞异步运行时,增加请求延迟。文件:video.rspreprocess_video_clip
  • 模型兼容性:不同模型(如 Qwen3-VL, Llama4)的视频处理器配置字段各异,当前 llm-multimodal 默认配置可能不适用于所有模型,需持续上游对齐。文件:multimodal.rs 的视频配置加载部分。
  • 缺少测试覆盖:PR 未包含视频功能的端到端集成测试,仅 Rust 单元测试覆盖了序列化路径。回归风险较高。
  • 用户:可通过 OpenAI 兼容 API 发送 video_url 聊天内容,支持多帧视频输入,丰富了交互能力。仅影响使用 Rust 前端的部署。
  • 系统:新增视频预处理与特征构建流水线,增加 CPU 和内存开销,但仅在用户发送视频请求时触发。架构从图像专用重构为每模态通用,未来添加新模态(如音频)更简单。
  • 团队:需要维护图像和视频两条模态路径,但共享了 PreparedMediaExpansionLaneexpand_prompt_token_ids 等公共抽象。Rust 与 Python 前端的视频支持保持同步。
SSRF 风险 视频预处理性能 模型兼容性 缺少测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论