vLLM 推理内核深度解析

第15章 多模态推理:图像、视频与音频的一等公民化

作者 杨艺韬 · 9,907 字

"A picture is worth a thousand words — and several thousand tokens."

本章要点

  • 理解 VLM 推理相对纯文本 LLM 的三个新挑战:编码器的计算成本、变长视觉 token、KV Cache 被图像放大一个数量级
  • 量化分析:Qwen2-VL 的一张 1344×1344 高分图 = 9,216 个视觉 token ≈ 752 MB KV Cache (Llama-70B FP16)
  • 读懂 Encoder Cache 的设计动机:Chunked Prefill 场景下 ViT 只能跑一次,输出必须持久化
  • 走一遍多模态预处理的 4 阶段管线:图像解码 → ViT forward → projection → token splice
  • 掌握 MultiModalRegistry 的可插拔架构:新增一个 VLM 模型只需实现处理器接口
  • 对比三种注入方式:拼接型(LLaVA)/ 交叉注意力型(Mllama、BLIP-2 等)/ 2D-RoPE 感知型(Qwen2-VL)
  • 理解视频处理的三种抽帧策略(均匀 / 关键帧 / 自适应)及其 token 成本
  • 看清 V1 路径下 CPU 预处理、encoder cache 与 GPU runner 的职责边界
  • 拿到 VLM 部署的实战参数调优清单

15.1 为什么 VLM 推理比纯文本难一个量级

在讲 vLLM 的多模态机制之前,必须先理解:引入视觉(或音频、视频)模态并不是"多一个输入通道"这么轻量。它改变了推理引擎在调度、缓存、通信、显存管理等多个维度上的根本约束。

15.1.1 三个新挑战

挑战 1:编码器是一次性的重计算

纯文本 LLM 的输入 pipeline 极简:tokenizer → input_ids → embedding lookup → Transformer。输入处理几乎零成本。

VLM 在 Transformer 前多了一个视觉编码器(通常是 ViT 或 Siglip 变体),它要做完整的前向传播——几百万到几十亿参数的计算。这是实打实的 GPU 算力消耗

挑战 2:变长视觉 token

不同分辨率的图片产生不同数量的视觉 token:

图片分辨率 Patch 大小 视觉 token 数
224×224 14 256
336×336 14 576
448×448 14 1,024
672×672 14 2,304
1344×1344 14 9,216

Qwen2-VL 支持 动态分辨率 —— 输入的图片按原始比例分块送进 ViT,token 数量从几百到上万。这让 scheduler 必须在请求进来时就知道 "这个请求会变成多长",不能像纯文本那样"先 tokenize 再看 length"。

挑战 3:KV Cache 被图像放大一个数量级

视觉 token 进入 Transformer 后,和文本 token 一视同仁地占 KV Cache:

Llama-70B FP16, 1344×1344 图片 = 9,216 视觉 token
KV per token = 2 × 80 layers × 8 KV heads × 128 dim × 2 B ≈ 82 KB
  一张图的 KV = 9,216 × 82 KB ≈ 752 MB

用户只发了"这张图有什么?"(10 文本 token),系统却要给它分配近 1 GB KV Cache。同样的显存在纯文本场景能服务几十个请求,在 VLM 场景只能服务 1-2 个。

VLM 的并发天花板通常只有纯文本的 1/10~1/3——这是由物理资源决定的硬约束。

15.1.2 整体数据流

flowchart LR
    IMG["🖼️ 图片输入<br/>JPEG/PNG 字节"] --> DEC["图片解码 + 预处理<br/>(CPU)"]
    TXT["📝 文本输入"] --> TOK["Tokenizer"]
    DEC --> VIT["视觉编码器<br/>ViT Forward<br/>(GPU)"]
    VIT --> PROJ["投影层<br/>维度对齐<br/>(GPU)"]
    PROJ --> CACHE["Encoder Cache<br/>(GPU 显存)"]
    TOK --> MERGE["Token 拼接<br/>Embeddings"]
    CACHE --> MERGE
    MERGE --> LLM["Transformer 主体<br/>(GPU)"]
    LLM --> OUT["输出 token"]
    style IMG fill:#f59e0b,color:#fff,stroke:none
    style VIT fill:#3b82f6,color:#fff,stroke:none
    style CACHE fill:#10b981,color:#fff,stroke:none
    style LLM fill:#8b5cf6,color:#fff,stroke:none

这张图的每一步背后都有工程决策。接下来我们一步步拆解。

15.2 预处理:从 JPEG 字节到 patch 张量

一张图进入 vLLM 的旅程,第一站是 CPU 上的预处理

15.2.1 四个子步骤

# vllm/multimodal/image.py(概念性简化)
def preprocess_image(image_bytes: bytes, model_config: Dict):
    # 1. 图像解码
    img = PIL.Image.open(io.BytesIO(image_bytes))

    # 2. 格式标准化
    img = img.convert("RGB")

    # 3. 分辨率调整(动态分辨率模型会根据原图 aspect ratio 选最近的合法尺寸)
    target_size = find_closest_supported_resolution(img.size, model_config)
    img = img.resize(target_size, Image.BICUBIC)

    # 4. 归一化 + patchify
    tensor = normalize(to_tensor(img), mean=model_config.image_mean, std=model_config.image_std)
    # 划分 patch:[H, W, 3] → [num_patches, patch_size, patch_size, 3]
    patches = patchify(tensor, patch_size=14)
    return patches  # 形状 [num_patches, 14, 14, 3]

这四步主要发生在 CPU 侧,耗时受图片尺寸、格式、处理器实现和缓存命中影响很大。生产上不应该把它当作"网络层的轻量解析";它会直接影响 TTFT,也会影响 EngineCore 看到的 prompt 长度和 encoder 预算。

15.2.2 V0 的痛:GIL 阻塞

V0 时代,API Server 和 EngineCore 在同一个 Python 进程。图像预处理(纯 Python,持 GIL)会直接阻塞 EngineCore 的调度循环——50 个正在 decode 的请求每个都得等。在 VLM 场景下这是灾难性的。

15.2.3 V1 的解法

V1 的进程分离天然化解了这个问题:

graph TB
    subgraph "API Server 进程 (持 GIL 但和引擎独立)"
        A1[收请求] --> A2[图像解码 CPU]
        A2 --> A3[分辨率调整 CPU]
        A3 --> A4[patch 化 CPU]
        A4 --> A5[msgpack 序列化]
    end
    subgraph "EngineCore 进程 (主循环不受 CPU 预处理影响)"
        E1[ZMQ 收包] --> E2[Scheduler]
        E2 --> E3[执行 step]
    end
    A5 -->|ZMQ| E1
    note["CPU 预处理和 GPU 调度在两个进程并行<br/>EngineCore 不再直接承担图像解码"]
    style A2 fill:#f59e0b,color:#fff,stroke:none
    style E3 fill:#10b981,color:#fff,stroke:none
    style note fill:#3b82f6,color:#fff,stroke:none

这条路径的价值不是某个固定倍率,而是职责隔离:API 侧可以处理图片、视频、base64、URL 下载和 HF processor 调用;EngineCore 侧收到的是已经规范化后的请求状态,再由 scheduler 决定哪些 encoder input 在本 step 执行。对 VLM 来说,这比"所有事情都挤在同一个调度循环里"可靠得多。

15.2.4 处理缓存

V1 的 ProcessingCachevllm/multimodal/processing.py)缓存已处理的输入:

# vllm/multimodal/processing.py(概念性)
class ProcessingCache:
    def __init__(self, max_size_gb: float):
        self._cache = LRUCache(max_size_gb * 1024**3)

    def get(self, mm_hash: str) -> Optional[ProcessedMMInputs]:
        return self._cache.get(mm_hash)

    def put(self, mm_hash: str, processed: ProcessedMMInputs):
        self._cache.put(mm_hash, processed)

真实实现比伪代码更谨慎。ProcessingCacheLRUCache 按字节容量控制缓存大小(vllm/multimodal/processing.py:879-998),key 不是简单图片字节,而是 model_id + modality + item + hf_processor_mm_kwargs 的组合哈希。MultiModalHasher 用 blake3 处理字符串、bytes、PIL Image、Tensor、NumPy array 等输入(vllm/multimodal/hasher.py:25-103)。这能避免两个模型共用同一张图却使用不同 resize/normalize 参数时误命中缓存。

缓存也不是无条件开启。create_processor() 会根据 model_config.disable_mm_preprocessor_cache 决定是否传入 cache(registry.py:262-273);_cached_apply_hf_processor() 遇到 passthrough data 时会绕过缓存(vllm/multimodal/processing.py:1353-1363)。这说明多模态缓存的目标是复用确定性的 HF processor 输出,而不是缓存一切输入对象。

15.3 视觉编码器:ViT 前向传播

15.3.1 典型 ViT 结构

标准 ViT(Vision Transformer)把图像切成 14×14 patch,每个 patch 投影成一个 embedding,加 positional encoding 后进入 Transformer 层堆栈:

Input: [num_patches, 3, 14, 14]   比如 [576, 3, 14, 14]
 → Patch Embedding: Linear(3×14×14 → hidden_size)
 → + PE (2D positional, 按 patch 位置编码)
 → Transformer Blocks ×N (每个 block 是 self-attention + MLP)
 → Output: [num_patches, hidden_size]   比如 [576, 1152]

典型 VLM 的视觉编码器参数量:

模型 ViT 参数 主 LLM 参数 ViT 占比
LLaVA-1.5-7B 0.3 B 7 B 4%
Qwen2-VL-7B 0.7 B 7 B 10%
Qwen2-VL-72B 0.7 B 72 B 1%
InternVL2-40B 6 B 34 B 15%

ViT 通常比主 LLM 小一个数量级,但前向传播的延迟占比可能高于参数占比——因为 ViT 在图片 token 数多时是 compute-bound 而非 memory-bound,而 LLM decode 是 memory-bound 瓶颈在带宽。

15.3.2 V1 的 ViT 调用路径

ViT 在每 decode step 都要重新跑吗?不,它只在 prefill 阶段运行。decode 阶段使用的是已经算好、塞进 KV Cache 的视觉 token 嵌入。

# vllm/v1/worker/gpu_model_runner.py(概念性简化)
def execute_model(self, scheduler_output):
    if self.is_multimodal_model:
        # 只对 prefill 阶段有新的视觉 token 的请求跑 ViT
        new_mm_inputs = self._collect_new_mm_requests(scheduler_output)
        if new_mm_inputs:
            mm_embeds = self._execute_mm_encoder(new_mm_inputs)
            self.encoder_cache.put(mm_embeds)

        # 拼接:文本 token 嵌入 + 从 cache 取来的视觉嵌入
        inputs_embeds = self._merge_text_and_mm_embeds(
            text_token_ids=scheduler_output.token_ids,
            mm_embeds=self.encoder_cache.get_for(scheduler_output),
        )
    else:
        inputs_embeds = self.model.get_input_embeddings(input_ids)

    # 主模型 forward(无论是否多模态,从这里开始都是统一路径)
    logits = self.model.forward(inputs_embeds, positions, kv_caches, ...)

15.3.3 投影层:维度对齐

ViT 输出的 hidden size(比如 1152)通常 ≠ 主 LLM 的 hidden size(比如 Llama-7B 是 4096)。需要一个"projection layer"做维度对齐:

# vllm/model_executor/models/llava.py(概念性)
class LlavaMultiModalProjector(nn.Module):
    def __init__(self, vit_hidden: int, llm_hidden: int):
        self.linear_1 = nn.Linear(vit_hidden, llm_hidden)
        self.act = nn.GELU()
        self.linear_2 = nn.Linear(llm_hidden, llm_hidden)

    def forward(self, vit_features):
        x = self.linear_1(vit_features)
        x = self.act(x)
        return self.linear_2(x)

不同模型的 projection 设计差异很大:LLaVA 用两层 MLP,Qwen2-VL 用一个"merger"层合并 2×2 个空间相邻 patch,BLIP-2 用一个复杂的 Q-Former。这些差异都被封装在模型实现里,统一通过 get_input_embeddings 接口对外。

15.4 Encoder Cache:Chunked Prefill 下的必备设施

15.4.1 为什么需要 Encoder Cache

回顾 ch11:Chunked Prefill 把长 prompt 切成多 step 处理。如果一个请求的 prompt 里有一张 9,216 token 的大图,切分情况可能是:

Step 1: prefill 前 4096 token(全部是图片 token 的一部分)
Step 2: prefill 接下来 4096 token(剩余图片 token + 一些文本)
Step 3: prefill 最后 1124 token(文本尾部)

问题:ViT 的输出是"图片作为整体"的嵌入(9,216 个 token 一起产出,不能切块生成)。如果不做缓存,Step 1 跑一次 ViT,Step 2 又跑一次 ViT(但只用了其中后半部分)—— 浪费

Encoder Cache 的做法:ViT 只在第一次遇到这个图片时跑一次,输出存在 GPU 显存里;后续 Step 来取自己需要的那段视觉 token 嵌入。

15.4.2 数据结构

# vllm/v1/core/encoder_cache_manager.py(结构简化)
class EncoderCacheManager:
    def __init__(self, cache_size: int):
        self.cache_size = cache_size
        self.num_free_slots = cache_size
        self.cached: dict[str, set[int]] = {}
        self.freed: list[tuple[str, int]] = []

    def can_allocate(self, request, input_id: int) -> bool:
        return request.get_num_encoder_tokens(input_id) <= self.num_free_slots

    def allocate(self, request, input_id: int) -> None:
        self.cached.setdefault(request.request_id, set()).add(input_id)
        self.num_free_slots -= request.get_num_encoder_tokens(input_id)

这段代码容易误读:EncoderCacheManager 本身不保存 ViT 输出 tensor,它只维护哪个请求的哪个多模态输入已经占用了 encoder cache slot,以及还剩多少 token 预算。真正的 tensor 放在 GPUModelRunner.encoder_cache 里,类型是 dict[str, dict[int, torch.Tensor]]vllm/v1/worker/gpu_model_runner.py:163-164)。scheduler 负责配额和释放,GPU runner 负责实际张量。

这种拆分很有必要。scheduler 在 CPU 侧做的是"是否允许这个 encoder input 在本 step 执行":can_allocate() 看 token 数是否超过剩余 slot,allocate() 扣预算,free_encoder_input() 释放单个 input id(vllm/v1/core/encoder_cache_manager.py:25-64)。GPU runner 做的是"执行之后把输出放在哪里":_execute_mm_encoder() 批量取出 scheduled encoder inputs,按模态分组,调用 model.get_multimodal_embeddings(),再把输出写入 self.encoder_cache[req_id][input_id]vllm/v1/worker/gpu_model_runner.py:844-905)。

15.4.3 和 KV Cache 的关系

有趣的是,Encoder Cache 和 KV Cache 是两个独立但协同的缓存

  • Encoder Cache:存 ViT 输出的视觉 token embeddings,只在 prefill 阶段使用
  • KV Cache:存主 LLM attention 的 K/V,每步都要用

Prefill 完成后,视觉 token 的 K/V 已经写进 KV Cache,Encoder Cache 里对应的 embedding 就可以释放。这个释放由 scheduler 输出里的 free_encoder_input_ids 驱动,GPU runner 在 _update_states() 中删除对应 tensor(vllm/v1/worker/gpu_model_runner.py:311-317)。如果一个请求结束,finished_req_ids 会直接触发整个 encoder_cache 条目的清理(vllm/v1/worker/gpu_model_runner.py:295-299)。

Scheduler 还有一个关键保护:如果 disable_chunked_mm_input 打开,并且本 step 只覆盖到一个多模态 item 的一部分,它会把 num_new_tokens 回滚到多模态 item 之前,避免半个图片 token 区间被调度(scheduler.py:593-601)。如果 encoder cache 没空间,或者单个 encoder input token 数超过本 step 的 encoder budget,也会暂停该请求直到资源可用(scheduler.py:603-623)。这说明 Encoder Cache 不是简单的"保存 ViT 输出",它参与了调度可行性判断。

15.5 多模态的可插拔架构:MultiModalRegistry

新模型加入不需要改核心引擎——靠的是一套可插拔接口。当前源码里可以看到大量模型通过 @MULTIMODAL_REGISTRY.register_processor(...) 注册处理器,覆盖图像、视频、音频、地理空间等不同输入类型;这比只列"几个 VLM 名字"更能说明系统边界。

15.5.1 核心抽象

# vllm/multimodal/registry.py(概念性简化)
class MultiModalRegistry:
    def __init__(self):
        self._processor_factories: Dict[Type[nn.Module], ProcessorFactory] = {}
        self._processing_cache = ProcessingCache(max_size=VLLM_MM_INPUT_CACHE_GIB * 1024**3)

    def register_processor(self, model_cls, factory: ProcessorFactory):
        """注册某个模型的多模态处理器。"""
        self._processor_factories[model_cls] = factory

    def create_processor(self, model_config) -> MultiModalProcessor:
        """根据模型配置创建处理器实例。"""
        factory = self._processor_factories[type(model_config.model)]
        return factory(model_config)

每个 VLM 模型注册一个 ProcessorFactory,它生成一个 MultiModalProcessor,定义三件事:

  1. 怎么从原始 API 请求里提取多模态数据(图片 URL、base64、numpy array 等)
  2. 怎么预处理(resize、normalize、patchify)
  3. 怎么把视觉 token 插入到文本 token 序列里(占位符替换规则)

15.5.2 新增 VLM 模型的三步

以添加对 "MyNewVLM" 的支持为例:

# 1. 实现模型类(继承 SupportsMultiModal 接口)
class MyNewVLM(nn.Module, SupportsMultiModal):
    def __init__(self, config):
        self.vit = ViTBackbone(config.vit_config)
        self.projector = Projection(config.vit_hidden, config.llm_hidden)
        self.language_model = LlamaForCausalLM(config.llm_config)

    def get_input_embeddings(self, input_ids, multimodal_embeddings):
        # 替换 <image> 占位符位置为 vit embeddings
        ...

    def forward(self, input_embeddings, positions, kv_caches, ...):
        return self.language_model(input_embeddings, positions, kv_caches)

# 2. 实现处理器
class MyNewVLMProcessor(MultiModalProcessor):
    def get_mm_max_tokens_per_item(self) -> int:
        return 9216  # 最大视觉 token 数

    def apply(self, inputs) -> MultiModalKwargs:
        # 预处理图片 + 替换占位符
        ...

# 3. 注册
MULTIMODAL_REGISTRY.register_processor(MyNewVLM, MyNewVLMProcessor)

零改动 scheduler、worker、KV cache manager。用户跑 vllm serve my-new-vlm,vLLM 自动走多模态路径。这是第 18 章讨论的"可插拔接口"架构的又一实战。

15.5.3 真实 register_processor 的三工厂设计

上面伪代码用单个 ProcessorFactory、这是简化。翻开 vllm/multimodal/registry.py:197 看真实签名:

def register_processor(
    self,
    processor: MultiModalProcessorFactory[_I],
    *,
    info: ProcessingInfoFactory[_I],              # ← 第二个工厂
    dummy_inputs: DummyInputsBuilderFactory[_I],  # ← 第三个工厂
):
    def wrapper(model_cls: N) -> N:
        if self._processor_factories.contains(model_cls, strict=True):
            logger.warning(
                "Model class %s already has a multi-modal processor "
                "registered to %s. It is overwritten by the new one.",
                model_cls, self)

        self._processor_factories[model_cls] = _ProcessorFactories(
            info=info,
            dummy_inputs=dummy_inputs,
            processor=processor,
        )
        return model_cls
    return wrapper

三个工厂各自的用途是理解多模态注册表的关键:

1. processor: MultiModalProcessorFactory——主处理器:运行时把用户请求里的图片/视频转成模型输入的 token 和 tensor。是章节上面讨论的核心。

2. info: ProcessingInfoFactory——元信息工厂:回答 "这个模型的视觉 token 数上限是多少?每种模态最多几个实例?" 这类 profiling 问题。V1 在 cold start 时通过它预估每张图能产多少 token、据此分配 encoder cache。不主动 profile 就会在第一个大图请求时 OOM。

3. dummy_inputs: DummyInputsBuilderFactory——假输入工厂:生成最坏情况的假图/假视频用于 CUDA Graph warmup 和内存预估。例如 Qwen2-VL 的 dummy input 是一张 4K 分辨率的黑图——这样录制的 CUDA Graph 能覆盖所有实际输入形状。没这个工厂、warmup 时用的假输入太小、实际大图进来会 recompile。

_ProcessorFactories 把这三个打包——保证注册时三者完整、运行时一处可查。单独缺任何一个都会让某条路径崩(processor 缺 → 无法运行;info 缺 → profile 失败;dummy_inputs 缺 → warmup 失败)。

15.5.4 装饰器模式 + 重复注册的 warning 策略

注意 register_processor 返回 wrapper——这让它成为一个装饰器:

@MULTIMODAL_REGISTRY.register_processor(
    processor=MyVLMMultiModalProcessor,
    info=MyVLMProcessingInfo,
    dummy_inputs=MyVLMDummyInputsBuilder,
)
class MyNewVLM(nn.Module, SupportsMultiModal):
    ...

比章节显示的"先声明 class、后调 register" 更紧凑——模型定义和注册声明放一起、不可能漏注册。这是 Python 装饰器模式经典用法。

重复注册策略(line 216-220)值得专门看一眼:

if self._processor_factories.contains(model_cls, strict=True):
    logger.warning(
        "Model class %s already has a multi-modal processor "
        "registered to %s. It is overwritten by the new one.",
        model_cls, self)

logger.warning + 继续覆盖、而不是抛 ValueError——这是和第 13 章量化注册表(raise ValueError 阻止覆盖)不同的选择。多模态注册允许覆盖的理由:

  • 用户调试时可能想临时替换某模型的处理器、比如测试一个修改版本
  • 插件生态里下游 package 可能扩展已有模型的能力

覆盖但记录 warning——让操作可见(出问题时能追踪到"为什么 X 模型的处理器变了")但不阻断工作流。量化注册表不允许覆盖是因为量化方法覆盖会导致数字结果错误且难诊断;多模态处理器覆盖是显式"替换实现"、用户意图明确。同一个库里的两个注册表做了完全相反的策略选择、各有对应工程理由——这是读 vLLM 源码能感受到的工程成熟度。

15.5.5 v0 遗留 API 的 @deprecated 标记

vllm/multimodal/registry.py:91172 两个方法:

@deprecated("Legacy input processor/mapper pipeline has been removed. "
            "Please update your model runner to use "
            "`seq_group_metadata.multi_modal_data` directly without "
            "further processing.")
def create_input_mapper(self, model_config: "ModelConfig"):
    return lambda data, mm_processor_kwargs: data

@deprecated("...")
def init_mm_limits_per_prompt(...):
    pass

V0 时代这些方法负责"把原始多模态数据转成 token",V1 重构后这逻辑搬到了 model runner 里、不再由注册表提供。方法体保留、但标记 deprecated + 给具体迁移指引——让依赖 V0 API 的外部代码能继续编译、同时在日志里明确警告"迁移到新方式"。

create_input_mapper 甚至保留了一个空壳 lambda data, mm_processor_kwargs: data——接口签名依旧、行为变成 noop。新路径不再走这层 mapper(model runner 直接用 multi_modal_data)。这种"空实现 + 警告"的迁移策略让下游代码有个充裕窗口更新、而不是 one-shot 破坏性升级。

这也呼应了第 7 章讲 TraitDefskip_array_during_method_dispatch 的 edition 补丁、第 10 章 serde_derive@deprecated 装饰器——大型开源项目面对长期用户基数时都要在兼容性和演进之间做这种折中。vLLM 的多模态系统从 V0 演化到 V1 这一跨系统重构能对用户尽量平滑,正是这些 deprecation 层的功劳。

15.5.6 真正难点:placeholder 对齐

多模态处理器最容易被低估的部分,不是把图片 resize 成 tensor,而是把"用户 prompt 里的占位符"和"模型 encoder 产出的 feature 数量"精确对齐。用户可能写的是 "<image>\n解释这张图",HF processor 可能会把 <image> 展开成一个或多个特殊 token,模型真正需要的却是一段长度等于视觉 feature size 的 placeholder range。这个 range 一旦错位,后面 KV Cache、position id、encoder cache 都会跟着错。

processing.py 为此引入了一套很重的 prompt update 机制。PromptUpdateDetails 描述替换后的完整 token 序列以及哪些 token 是 embedding;BoundPromptUpdate 把更新规则绑定到 tokenizer,避免同一个字符串在不同 tokenizer 下有不同 token id(vllm/multimodal/processing.py:106-154443-506)。这不是过度设计,因为多模态模型经常复用不同底座:LLaVA、Qwen2-VL、Pixtral 的 placeholder 文字不同,tokenizer 行为也不同。

更新分两类路径。第一类是 token 级匹配:find_token_matches() 找到 prompt token ids 中的目标片段,apply_token_matches() 直接把它替换成展开后的 token 序列(vllm/multimodal/processing.py:778-790)。第二类是 text 级匹配:如果 token 级找不到足够匹配,代码会先 decode 成文本,再用 find_text_matches()apply_text_matches() 修改文本,最后重新 encode(vllm/multimodal/processing.py:1461-1530)。注释里专门举了 "foo""food" 的例子:搜索文字可能跨 token 边界,单靠 token id 匹配会漏掉。这个 fallback 是多模态 prompt 系统必须处理的 tokenizer 现实。

找到占位符之后,还要验证数量。_validate_mm_kwargs() 检查 HF processor 产出的每种 modality item 数是否等于用户输入 item 数;_validate_mm_placeholders() 检查 prompt 中找到的 placeholder 数是否也一致(vllm/multimodal/processing.py:1532-1570)。这两个错误信息都指向处理器实现问题,而不是静默截断。最后 apply() 返回 MultiModalInputs,里面同时带 prompt_token_idsmm_kwargsmm_hashesmm_placeholdersvllm/multimodal/processing.py:1636-1691vllm/multimodal/inputs.py:798-827)。这就是为什么 processing.py 单文件有 1760 行:它承担的是"把自然语言 prompt、HF processor、模型 encoder、调度器 placeholder range"四个世界接起来。

可以把这条链路记成一张表:

阶段 主要对象 失败时的典型症状
解析用户输入 MultiModalDataItems 图片数量、视频数量和 prompt 占位符不一致
调用 HF processor MultiModalKwargs tensor 字段缺失、shape 不符合模型实现
应用 prompt update PromptUpdate / BoundPromptUpdate 找不到 <image> 或展开 token 数错误
生成 placeholder PlaceholderRange scheduler 不知道视觉 token 位于 prompt 哪段
返回内部输入 MultiModalInputs runner 无法把 mm_embeds 插回文本 embedding

这张表也解释了为什么"新增 VLM 模型只写一个 processor"并不轻松。processor 不只是图像预处理函数,它还定义了模型和 prompt 的契约:占位符是什么、每个 item 展开多少 feature、HF processor 是否已经改写 prompt、是否需要 vLLM 自己补 placeholder、hash 是否稳定、dummy input 是否覆盖最大形状。任何一个环节写错,表面上可能只是一个 API 请求失败,根因却在 tokenizer、processor 或模型 forward 的边界上。

15.6 三种注入方式:拼接 / 交叉注意力 / 2D-RoPE

不同 VLM 在"怎么让视觉 token 和文本 token 在 Transformer 里互动"的决策上分成三派:

15.6.1 拼接型(LLaVA、Qwen2-VL、PaliGemma 等)

最简单:视觉 token 作为"特殊 token"直接拼接进文本序列:

Input text: "<image>\nWhat is in the picture?"
After substitution:
<vit_emb_1><vit_emb_2>...<vit_emb_576>\nWhat is in the picture?

视觉 token 和文本 token 一起走 causal attention,互相能看到对方。KV Cache 一视同仁管理。

优点:架构简单、兼容性好、复用 LLM 现有 attention kernel。vLLM 里的 LLaVA、Qwen2-VL、Pixtral、InternVL、PaliGemma 等示例都主要走这类路径。

缺点:视觉 token 全部参与每一步 attention,长图成本高。

15.6.2 交叉注意力型(Mllama、BLIP-2 等)

文本流单独走,视觉信息通过"交叉注意力层"注入:

For each Transformer block:
  text_tokens → self-attention over text only
  text_tokens → cross-attention(Q=text, K/V=visual)
  text_tokens → FFN

优点:视觉 token 数不直接占 KV Cache(它们在 cross-attention K/V 里是静态的);长图更省显存。

缺点:需要额外维护 cross-attention states 和 mask,调度形态比拼接型复杂。vLLM 源码里能看到 mllama.pyMllamaTextCrossAttentionMllamaCrossAttentionDecoderLayer,也能看到 blip2.py 中按 cross_attention_frequency 插入 cross-attention layer;这些都不是"把图片 token 当普通 token 拼进去"那么简单。

15.6.3 2D-RoPE 感知型(Qwen2-VL 的"M-RoPE")

Qwen2-VL 在位置编码上做了创新——不同模态的 token 用不同"时空坐标":

  • 文本 token:位置 = (t, 0, 0),只有时间维度
  • 视觉 token:位置 = (t, h, w),有时间 + 高度 + 宽度三维
  • 视频 token:位置 = (t, h, w),t 在不同帧间递增

这让模型天然理解"这个视觉 token 是图像里哪个位置的"。attention kernel 层面需要改动——vLLM 专门为 Qwen2-VL 实现了 M-RoPE 支持。

graph TB
    subgraph "拼接型(LLaVA, Qwen2-VL)"
        direction LR
        T1[文本 t1]
        V1[视觉 v1]
        V2[视觉 v2]
        V3[视觉 v3]
        T2[文本 t2]
        T1 --> V1 --> V2 --> V3 --> T2
    end
    subgraph "交叉注意力型(Mllama/BLIP-2)"
        VT[视觉 K/V<br/>静态]
        TT[文本流<br/>自回归]
        TT -.->|cross-attn| VT
    end
    subgraph "M-RoPE(Qwen2-VL)"
        MV["视觉 token<br/>位置=(t,h,w)"]
        MT["文本 token<br/>位置=(t,0,0)"]
        MV --> |统一 attention + 3D 位置| MT
    end
    style T1 fill:#3b82f6,color:#fff,stroke:none
    style T2 fill:#3b82f6,color:#fff,stroke:none
    style V1 fill:#f59e0b,color:#fff,stroke:none
    style V2 fill:#f59e0b,color:#fff,stroke:none
    style V3 fill:#f59e0b,color:#fff,stroke:none
    style VT fill:#f59e0b,color:#fff,stroke:none
    style MV fill:#10b981,color:#fff,stroke:none

15.6.4 M-RoPE 在 runner 里的位置

M-RoPE 不是模型文件里一个孤立的小函数,它会影响 runner 的批处理状态。GPUModelRunner 初始化时读取 model_config.uses_mrope,并为 M-RoPE 额外分配三维位置张量:mrope_positions 的形状是 (3, max_num_tokens + 1),CPU 侧也保留一份 pinned buffer(vllm/v1/worker/gpu_model_runner.py:145-155221-236)。这个 "+1" 不是随意多分配,而是为了避免 capture 阶段空 tensor 触发问题,源码注释专门说明了这个细节。

调度过程中,M-RoPE 的位置也不是每次从零算。请求的 prompt 部分会预先算好 req.mrope_positions;当 scheduler 输出本 step 要处理的 token 范围时,_calc_mrope_positions() 把 prompt 范围对应的位置复制到 runner buffer。对于 decode 阶段新生成的 completion token,则用 MRotaryEmbedding.get_next_input_positions_tensor() 根据 mrope_position_delta 继续生成三维位置(vllm/v1/worker/gpu_model_runner.py:717-767)。最后模型 forward 前,如果 uses_mrope 为真,runner 会把 positions 指向三维 mrope_positions;否则仍然使用普通一维 positions(vllm/v1/worker/gpu_model_runner.py:1071-1074)。

这解释了为什么 Qwen2-VL 不能只被描述成"拼接型 VLM"。它确实把视觉 embedding 插进文本序列,但位置编码已经不再是纯文本的一维序号。图像 token 需要携带高度、宽度和时间维度,视频 token 还要让不同帧在时间轴上区分开。对推理引擎来说,这意味着 batching、CUDA graph、position buffer、prefix/chunked prefill 都要知道自己面对的是三维位置,而不是普通 arange(seq_len)

15.7 视频:不只是"多张图片"

15.7.1 抽帧策略

视频本质上是一连串图片,但直接"每帧过 ViT"很快就会爆显存。VLM 通常采用三种抽帧策略:

均匀抽帧:一段 10 秒 30 fps 的视频共 300 帧,均匀选 8 帧。每帧独立过 ViT。简单但信息损失大。

关键帧提取:用传统视觉算法(如场景切换检测、运动分析)选出"有信息量的帧"。需要额外预处理但质量更高。

自适应抽帧:模型根据内容动态决定——一段静态镜头可能只抽 1 帧,一段激烈动作可能抽 10 帧。Qwen2-VL / InternVideo 等近期模型支持。

15.7.2 token 数量的爆炸

以 Qwen2-VL 处理 10 秒视频为例:

  • 抽 8 帧,每帧 672×672 → 2304 token/帧
  • 总视觉 token = 8 × 2304 = 18,432 token
  • KV Cache(70B FP16)= 18,432 × 82 KB ≈ 1.5 GB

一个视频请求就能吃掉单卡一大半 KV 预算。这也是为什么视频理解服务的并发天花板通常 < 10

15.7.3 时序压缩

一些先进的 VLM(如 Qwen2-VL 的 "Video Token Merger")对相邻帧做 token 合并——空间相邻 + 时间相邻的 4-16 个 patch 合并成一个 token。这种压缩把视觉 token 数降 4-16×,显著缓解 KV 压力。

15.8 音频:频谱图化 + 专用编码器

音频的处理流程和图像高度类似,只是预处理步骤不同:

原始音频波形 (16 kHz 采样)
 → 重采样到模型要求的采样率
 → Short-Time Fourier Transform (STFT) → 频谱图 (spectrogram)
 → Mel-scale filter bank → Mel 频谱
 → 音频编码器 (类似 ViT 结构,但第一层是 1D conv)
 → 音频 token embeddings

典型模型:

  • Whisper Multimodal(OpenAI):音频 → transcription + translation
  • Qwen2-Audio(阿里):音频问答、音乐理解
  • GPT-4o(OpenAI,闭源):统一的文本+图像+音频

vLLM 对音频模型的支持在 vllm/model_executor/models/whisper.py / qwen2_audio.py。架构和 VLM 基本一致——都走 MultiModalRegistry,都用 Encoder Cache。

15.9 部署实战:VLM 生产调优

15.9.1 关键参数

vllm serve Qwen/Qwen2-VL-72B-Instruct \
    --tensor-parallel-size 4 \
    --max-model-len 16384 \
    --max-num-seqs 8 \
    --gpu-memory-utilization 0.92 \
    --enable-chunked-prefill \
    --limit-mm-per-prompt '{"image": 4, "video": 1}' \
    --mm-processor-kwargs '{"min_pixels": 200704, "max_pixels": 1003520}'

重点参数:

  • max_num_seqs:VLM 下通常要比纯文本保守,因为每个请求可能带来大量视觉 token 和 encoder 输出
  • --limit-mm-per-prompt:限制每请求的图片/视频数量,防止恶意请求炸显存
  • --mm-processor-kwargs:模型相关的 min/max 分辨率约束,Qwen2-VL 动态分辨率必设

15.9.2 显存规划

VLM 的显存预算要额外考虑:

GPU 可用显存
 = 总显存 × gpu_memory_utilization
 - 主 LLM 权重
 - ViT 权重
 - 激活 buffer
 - Encoder Cache(按 encoder token 预算折算)
 = KV Cache 预算

如果没有给 encoder cache 留预算,当多个 VLM 请求同时到达 prefill 阶段时,ViT 输出可能和 KV Cache、activation buffer 抢显存。vLLM 的配置里没有把 encoder cache 作为一个完全独立的用户显式显存池暴露出来;V1 会根据 scheduler 配置和模型多模态 token 上限计算 max_num_encoder_input_tokensencoder_cache_size,默认都和 max_num_batched_tokens 相关(vllm/config.py:1879-18861999-2000)。所以 VLM 调参不能只看 gpu_memory_utilization,还要同时看 max_num_batched_tokenslimit_mm_per_prompt、图像分辨率上限和是否允许 chunked MM input。

15.9.3 Prometheus 指标

VLM 特有的监控指标:

vllm:mm_input_cache_hit_rate             # 预处理 cache 命中率(RAG 场景会很高)
vllm:mm_encoder_cache_num_items          # 当前 encoder cache 占用
vllm:mm_encoder_forward_time_seconds     # ViT forward 耗时
vllm:prompt_mm_tokens_total              # 累计多模态 token 数

观察 mm_encoder_forward_time_seconds 的 p99 分布能告诉你"哪些图片触发了慢路径"——通常是高分辨率图片 + cache miss 的组合。

15.9.4 四类常见事故

事故 1:prompt 里占位符数量和图片数量不一致。 这类问题通常不是 GPU OOM,而是 BaseMultiModalProcessor_validate_mm_placeholders()_validate_mm_kwargs() 阶段报错。排查顺序是先看用户输入里有几个 image/video item,再看 prompt 中 placeholder 是否被模板吞掉,最后看模型处理器的 _get_prompt_updates() 是否和 HF processor 输出一致。

事故 2:单张图过大导致 encoder budget 不够。 如果 disable_chunked_mm_input 为真,而单个多模态 item 的 token 数超过 max_num_batched_tokenscompute_encoder_budget() 会直接抛出配置错误(vllm/v1/core/encoder_cache_manager.py:136-142)。如果没有禁用 chunked MM input,scheduler 也会在 cache 不够或 encoder budget 用尽时暂停该请求(scheduler.py:603-623)。这类问题不要只调大 max_model_len;真正相关的是 encoder token 数和本 step 的 batch token 预算。

事故 3:缓存命中率看起来很低。 预处理缓存的 key 包含 model_id、modality、item 内容和 hf_processor_mm_kwargs。同一张图片如果以不同格式传入,或运行时传了不同的 min_pixels/max_pixels,都可能变成不同 key。RAG 多轮对话中想复用图片处理结果,要保证模型 ID、processor kwargs 和输入对象表示都稳定。

事故 4:多模态模型走了普通文本假设。 GPU runner 在多模态路径下使用 embeddings 作为模型输入;纯文本路径使用 token ids,因为这样 embedding layer 能包含在 CUDA graph 里(vllm/v1/worker/gpu_model_runner.py:1050-1068)。如果一个模型声明了多模态能力但处理器没有返回正确的 mm_placeholders,runner 仍会进入多模态输入路径,却拿不到能插回 prompt 的 embeddings。表现可能是 shape mismatch,也可能是输出明显错位。

把这四类事故合起来看,VLM 生产调优不是"把显存参数调小一点"。它是三条链路同时稳定:API 输入链路要限制模态数量和图片尺寸;processor 链路要保证 placeholder、hash、kwargs 一致;scheduler/runner 链路要保证 encoder budget 和 cache 生命周期正确。任何一条链路没有被观测,都会把问题推迟到 GPU forward 甚至生成结果阶段才暴露。

15.10 V1 vs V0 在 VLM 场景的架构差异

这一节不写无法从本地源码验证的固定 benchmark 数字,只讨论源码里能看到的架构差异。VLM 的关键变化是:输入处理、encoder budget、encoder cache 和 GPU runner 被拆成了不同职责;CPU 侧的多模态处理不应该阻塞 GPU 调度,GPU 侧也不应该为每个 step 反复执行同一个 encoder input。

15.10.1 V0 的瓶颈

V0 (单进程):
  主循环线程:
    ├─ 接 HTTP 请求
    ├─ 图像解码 (CPU)           ← GIL 被占
    ├─ 图像预处理 (CPU)          ← GIL 被占
    ├─ 调度决策
    ├─ 发给 Worker
    └─ 收 Worker 结果

Worker 线程等调度时,主线程在跑图像预处理,GIL 没释放
→ Worker 的 async 能力被打折

15.10.2 V1 的改进

V1 (多进程):
  API Server 进程:
    ├─ HTTP 接请求
    ├─ 图像解码 / 预处理 (CPU, 独立进程, 不持引擎 GIL)
    └─ msgpack → ZMQ

  EngineCore 进程:
    ├─ ZMQ 收包
    ├─ 调度
    ├─ execute_model → Worker
    └─ 输出回包

  两个进程物理并行,互不阻塞

15.10.3 源码里能看到的三个收益点

第一,scheduler 有 encoder 预算。compute_encoder_budget() 会根据模型和 limit_mm_per_prompt 推导 encoder compute budget 与 encoder cache size;如果用户把某个模态 limit 设为 0,甚至会直接不初始化 encoder cache(vllm/v1/core/encoder_cache_manager.py:87-149)。这让 VLM 请求在进入 GPU runner 前就被纳入调度资源模型。

第二,GPU runner 只执行本 step 被 scheduler 选中的 encoder input。_execute_mm_encoder()scheduler_output.scheduled_encoder_inputs 取任务,按模态分组后调用 model.get_multimodal_embeddings(),再把结果写入 encoder_cachevllm/v1/worker/gpu_model_runner.py:844-905)。如果没有 scheduled encoder input,它直接返回,不会做额外工作。

第三,多模态模型统一走 embeddings 输入。GPUModelRunner 在多模态路径下先收集 mm_embeds,然后调用 model.get_input_embeddings(input_ids, mm_embeds);即使没有新的多模态嵌入,也会用 embeddings 作为输入形式(vllm/v1/worker/gpu_model_runner.py:1041-1064)。这让文本 token 和视觉 token 在模型入口处对齐,代价是文本模型不能复用完全相同的 CUDA graph 路径,所以源码里专门注明纯文本模型仍然使用 token ids 以保持性能。

15.10.4 为什么这些改动只在 VLM 下显著

纯文本请求的输入侧成本主要是 tokenizer,且 prompt token 数和 KV Cache 成本基本同源:token 越多,prefill 越长,KV 越多。VLM 不一样,输入侧先有一个非文本处理链路,之后还要把图片或视频变成 encoder token,再把 encoder output 插回主模型序列。也就是说,"用户看到的一张图"会同时占用 CPU 预处理、encoder compute、encoder cache、placeholder range、主模型 KV Cache 五类资源。V1 把这些资源显式拆开后,scheduler 才能在更早的位置做取舍。

这种拆分也让指标解释更清楚。如果 TTFT 变慢但 GPU 利用率不高,可能是图片下载、解码或 HF processor 慢;如果 GPU 利用率高但 decode 吞吐低,可能是视觉 token 把 KV 预算吃掉;如果只有大图请求卡住,可能是 encoder budget 或 disable_chunked_mm_input 的限制;如果重复问同一张图仍然慢,才需要看 ProcessingCache 的 key 是否稳定。没有这些边界,所有问题都会被笼统地归因于"多模态模型慢",很难定位。

还有一个工程细节:encoder cache 的单位是 token slot,不是字节。不同模型的 encoder hidden size、dtype、投影层输出维度可能不同,但 scheduler 先用 token 数做统一预算,GPU runner 再负责实际 tensor。这个抽象不完美,却足够让调度器不必理解每个 VLM 的内部结构;模型差异被限制在 processor、profiling 和 runner 后处理里。

这也是本章反复强调"接口边界"的原因:VLM 的难点不在某一个神奇算法,而在每一层都只暴露下一层真正需要的信息。这样的边界让模型作者能专注处理器和模型实现,调度器仍然保持相对通用,也让线上排障能沿着输入、处理器、调度、runner 四段逐段定位,而不是在一个巨大的黑盒里猜测。对长期维护来说,这比一次性的性能技巧更重要,也更可靠。

15.10.5 vllm/multimodal/ 12 文件 4738 行真实拆分

把整个多模态核心目录按文件大小排序——

文件 角色
processing.py 1760 本目录最重 37%——BaseMultiModalProcessor + 各 modality 的 prompt-replacement / placeholder-token 逻辑
inputs.py 843 MultiModalInputs / MultiModalKwargs 等数据类——跨边界的 DTO
parse.py 454 多模态项的解析(OpenAI vision API 格式 → vLLM 内部格式)
utils.py 386 image fetch / async download / base64 decode
registry.py 321 §15.5 主角——MultiModalRegistry 的三工厂注册
profiling.py 274 dummy data 生成、用于 worker 启动时探测显存
base.py 218 MultiModalPlugin ABC
video.py 166 视频抽帧(§15.7)
audio.py 105 音频频谱图(§15.8)
hasher.py 103 处理缓存的哈希键(§15.2.4)
image.py 77 图像 Plugin 入口(薄壳)
__init__.py 31 公共 export

vllm/v1/core/encoder_cache_manager.py 仅 149 行——但它不是一个 tensor store,而是调度侧的 token-slot 账本。真正的 encoder 输出 tensor 放在 GPU runner 的 encoder_cache 字典里;这正好印证 §15.4 的结论:多模态缓存拆成了"调度预算"和"实际张量"两层。

下面列的是本章反复提到的几个代表性模型文件,不是全部多模态支持列表。当前源码中 @MULTIMODAL_REGISTRY.register_processor 出现在三十多个模型文件里;表格只选最适合阅读的路径:

模型 注入方式(§15.6)
qwen2_vl.py 1409 M-RoPE(§15.6.3)+ 拼接——本目录最重,因为要实现 dynamic resolution + 时空 RoPE
pixtral.py 1281 拼接型,Mistral 的多模态
llava_onevision.py 954 拼接,LLaVA 系列最新(统一图像 + 视频)
internvl.py 945 拼接
llava.py 869 拼接(最简单的参考实现)
llava_next.py 582 拼接 + 任意分辨率
llava_next_video.py 470 拼接 + 视频时序压缩
paligemma.py 397 拼接,Google 的 multi-task VLM

两条非显然的事实——

  1. processing.py 单文件 1760 行 = 多模态目录 37%——多模态最难的不是 ViT 也不是注入、是"如何把多模态输入对齐到 prompt 里特定的 placeholder token 位置"——<|image|> 标记位的展开 + token id 映射 + position id 重新计算 + 文本 prompt 里同一处可能要展开数千 visual token——这里全是历史包袱
  2. 拼接型不是唯一生产路径——表格里的 LLaVA/Qwen2-VL/Pixtral 等以拼接型为主,但源码里 mllama.pyblip2.py 都有 cross-attention 相关实现。工程结论应该写成"拼接型更贴近 vLLM 的通用调度路径,交叉注意力型需要额外维护 encoder states、mask 和模型专用逻辑",不能写成"vLLM 没有交叉注意力型生产实现"。

15.11 本章小结

多模态把 vLLM 从"纯文本推理引擎"提升到"通用生成模型服务层":

  • 三挑战:编码器计算、变长视觉 token、KV Cache 被图像放大一个数量级
  • 量化认识:Qwen2-VL 高分图 = 9,216 token = 752 MB KV(70B FP16),VLM 并发天花板 < 纯文本 1/3
  • 四阶段预处理管线:图像解码 → ViT forward → projection → token 拼接
  • Encoder Cache 是 Chunked Prefill 下的必备设施,ViT 只跑一次,输出持久化
  • 可插拔 MultiModalRegistry 让新 VLM 加入零改动核心代码
  • 三种注入方式:拼接(LLaVA/Qwen2-VL/Pixtral 等)、交叉注意力(Mllama/BLIP-2 等)、M-RoPE(Qwen2-VL 创新)
  • 视频三策略:均匀 / 关键帧 / 自适应抽帧;token 数爆炸决定并发天花板
  • 音频:频谱图化 + 专用编码器,架构和 VLM 一致
  • 部署调优max_num_seqs 要降、limit-mm-per-prompt 要设、Encoder Cache 要预留显存
  • V1 架构红利:CPU 预处理、encoder budget、encoder cache 和 GPU runner 职责拆开,避免重复执行同一多模态 encoder input

物理事实:vllm/multimodal/ 12 文件 4738 行,processing.py 单文件 1760 行占 37%——揭示"对齐 placeholder token"是多模态最难的工程问题之一;@MULTIMODAL_REGISTRY.register_processor 分布在三十多个模型文件里,说明多模态支持靠处理器注册扩展,而不是靠核心调度器为每个模型写分支。


源码导航

  • MultiModal 核心:vllm/multimodal/
  • Registry:vllm/multimodal/registry.py
  • 处理器基类:vllm/multimodal/processing.py
  • 图像处理:vllm/multimodal/image.py
  • 视频处理:vllm/multimodal/video.py
  • 音频处理:vllm/multimodal/audio.py
  • 具体 VLM 实现:vllm/model_executor/models/llava.py / qwen2_vl.py / internvl.py / pixtral.py / 等)
  • Encoder Cache:vllm/v1/core/encoder_cache_manager.py
  • GPU 上的多模态执行:vllm/v1/worker/gpu_model_runner.py_execute_mm_encoder

论文

  • Liu et al., "Visual Instruction Tuning (LLaVA)", NeurIPS 2023 (arXiv:2304.08485)
  • Wang et al., "Qwen2-VL: Enhancing Vision-Language Model's Perception of the World at Any Resolution", 2024 (arXiv:2409.12191)
  • Alayrac et al., "Flamingo: a Visual Language Model for Few-Shot Learning", NeurIPS 2022 (arXiv:2204.14198)
  • Chen et al., "InternVL: Scaling up Vision Foundation Models and Aligning for Generic Visual-Linguistic Tasks", CVPR 2024