vLLM 推理内核深度解析

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

作者 杨艺韬 · 12,754 字 · 发布于 · 更新于

一张图胜过千言万语——也胜过好几千个 token。一张 1344×1344 的高分图经 Qwen2-VL 的 2×2 合并后仍有 2,304 个视觉 token,KV Cache 因此被放大一个数量级。

本章要点

  • 理解 VLM 推理相对纯文本 LLM 的三个新挑战:编码器的计算成本、变长视觉 token、KV Cache 被图像放大一个数量级
  • 量化分析:一张 1344×1344 高分图 = 9,216 个 14×14 patch,Qwen2-VL 再做 2×2 空间合并后得到 2,304 个视觉 token ≈ 720 MiB KV Cache(Llama-3-70B FP16)
  • 读懂 Encoder Cache 的设计动机:Chunked Prefill 场景下 ViT 只能跑一次,输出必须持久化
  • 走一遍多模态预处理的 4 阶段管线:图像解码 → ViT forward → projection → token splice
  • 掌握 MultiModalRegistry 的可插拔架构:新增一个 VLM 模型只需实现处理器接口
  • 对比三种注入方式:拼接型(LLaVA、BLIP-2 等)/ 交叉注意力型(Mllama,v0.8.5 仍是 V0-only)/ M-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 变体),它要做完整的前向传播——几亿到几十亿参数的计算(见 §15.3.1 的参数表)。这是实打实的 GPU 算力消耗。

挑战 2:变长视觉 token

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

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

注意 patch 数不等于送进 LLM 的视觉 token 数:LLaVA 系列基本一对一,Qwen2-VL 则在 ViT 之后有一个 merger,把空间相邻的 2×2 个 patch 合成一个 token(vision_config.spatial_merge_size = 2,见 vllm/model_executor/models/qwen2_vl.py:468-473、543-553),所以表里的数要再除以 4。

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

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

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

Llama-3-70B FP16,1344×1344 图片 → 9,216 patch → 2×2 合并后 2,304 视觉 token
KV per token = 2 (K/V) × 80 layers × 8 KV heads × 128 head_dim × 2 B = 320 KiB
  一张图的 KV = 2,304 × 320 KiB ≈ 720 MiB

用户只发了"这张图有什么?"(10 文本 token),系统却要给它分配几百 MB 量级的 KV Cache。同样的显存在纯文本场景能服务几十个请求,在 VLM 场景往往只剩个位数。

VLM 的并发天花板显著低于纯文本——具体低多少取决于分辨率、合并倍率和模型的 KV 头数,但这是由物理资源决定的硬约束,不是调参能绕过的。

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:#333,stroke:none
    style VIT fill:#2563eb,color:#fff,stroke:none
    style CACHE fill:#10b981,color:#333,stroke:none
    style LLM fill:#7c3aed,color:#fff,stroke:none

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

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

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

15.2.1 四个子步骤

# 概念性伪代码:HF processor 在 CPU 侧做的事
# (vLLM 自己不实现这段,它调用模型自带的 HF AutoProcessor;
#  vllm/multimodal/image.py 里只有 ImageMediaIO 这类"把字节/base64/文件读成 PIL.Image"的 IO 壳)
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 的痛:预处理跑在引擎线程上

V0 的调用链是 LLMEngine.add_request() → InputPreprocessor.preprocess()(vllm/engine/llm_engine.py:758),而 preprocess() 最终经 _process_multimodal() 调 mm_registry.create_processor(...) 加 mm_processor.apply(...)(vllm/inputs/preprocess.py:226-252)。V0 的 OpenAI 服务默认经 MQLLMEngine 把引擎放在独立进程,但前端只把原始 prompt(连同已拉取、解码好的 PIL 图片)pickle 过去(vllm/engine/multiprocessing/client.py:642-651),预处理由引擎进程主循环里的 engine.add_request() 完成(vllm/engine/multiprocessing/engine.py:300)。也就是说resize、normalize、HF processor 调用都发生在引擎进程的主循环线程里,和调度、执行串行排队。一张大图的处理时间就是所有正在 decode 的请求多等的时间。

15.2.3 V1 的解法:把处理搬到前端进程

V1 把这段搬进了 vllm/v1/engine/processor.py,它跑在前端(API server / AsyncLLM)进程里,EngineCore 收到的是已经处理好的 MultiModalKwargs:

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 不再承担 HF 预处理"]
    style A2 fill:#f59e0b,color:#333,stroke:none
    style E3 fill:#10b981,color:#333,stroke:none
    style note fill:#2563eb,color:#fff,stroke:none

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

15.2.4 处理缓存

ProcessingCache(vllm/multimodal/processing.py,由 MultiModalRegistry 持有,V0 与 V1 的预处理都经过它)缓存已处理的输入:

# vllm/multimodal/processing.py:879(概念性)
class ProcessingCache:
    def __init__(self, capacity_gb: float):
        self._cache = LRUCache(capacity_gb * GiB_bytes, getsizeof=...)

    def get(self, model_id, modality, input_item, input_kwargs):
        key = MultiModalHasher.hash_kwargs(model_id=model_id,
                                           **{modality: input_item}, **input_kwargs)
        return self._cache.get(key)

    def put(self, model_id, modality, input_item, input_kwargs, output_kwargs):
        ...  # 同样的 key,value 是该 item 的 MultiModalKwargsItem

真实实现比伪代码更谨慎。ProcessingCache 用 LRUCache 按字节容量控制缓存大小(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(vllm/multimodal/registry.py:262-273);_cached_apply_hf_processor() 遇到 passthrough data 时会绕过缓存(vllm/multimodal/processing.py:1353-1363)。这说明多模态缓存的目标是复用确定性的 HF processor 输出,而不是缓存一切输入对象。

V1 在这之上还叠了一层,专门解决跨进程传输:MirroredProcessingCache(vllm/v1/engine/mm_input_cache.py,全文只有 82 行)在前端进程(源码里叫 P0)和 EngineCore 进程(P1)各放一份大小相同、内容镜像的 LRU 缓存,两边都用 VLLM_MM_INPUT_CACHE_GIB 作为容量。前端调 get_and_update_p0():如果这个 mm_hash 自己缓存里已经有了,就把对应的 MultiModalKwargs 换成 None 再发出去;引擎侧调 get_and_update_p1(),看到 None 就从自己那份镜像缓存里取回来(vllm/v1/engine/mm_input_cache.py:40-82)。

这一层省的不是计算,是序列化和 ZMQ 传输——pixel values 动辄几十 MB,同一张图在多轮对话里反复出现时,第二次之后就只在消息里传一个哈希。代价是两端缓存必须严格同步,所以源码注释里专门强调"Both Client and Server must use the same cache size"。

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

15.3.1 典型 ViT 结构

VLM 常用的 ViT(Vision Transformer,如 CLIP ViT-L/14、SigLIP;原始 ViT 论文用的是 16×16)把图像切成 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 阶段运行。视觉 token 的嵌入在 prefill 时经主模型各层算出 K/V 写进 KV Cache,decode 阶段只读这些 K/V。

# vllm/v1/worker/gpu_model_runner.py:1043(概念性简化)
def execute_model(self, scheduler_output):
    if self.is_multimodal_model:
        # 只跑本 step 被 scheduler 选中的 encoder input,结果写进 self.encoder_cache
        self._execute_mm_encoder(scheduler_output)
        # 从 encoder_cache 里按 placeholder range 取出本 step 需要的那几段
        mm_embeds = self._gather_mm_embeddings(scheduler_output)
        # 由模型自己把视觉嵌入插回文本 embedding 的占位位置
        inputs_embeds = self.model.get_input_embeddings(input_ids, mm_embeds)
        input_ids = None
    else:
        # 纯文本模型直接喂 token ids,好让 embedding layer 留在 CUDA Graph 里
        inputs_embeds = None

    # 主模型 forward(从这里开始两条路径合流)
    hidden_states = self.model(input_ids=input_ids,
                               positions=positions,
                               inputs_embeds=inputs_embeds, ...)

15.3.3 投影层:维度对齐

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

# vllm/model_executor/models/llava.py:82(概念性)
class LlavaMultiModalProjector(nn.Module):
    def __init__(self, vit_hidden: int, llm_hidden: int):
        super().__init__()
        # 源码用 ColumnParallelLinear / RowParallelLinear 以支持张量并行,
        # 激活函数由 config 的 projector_hidden_act 决定
        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

回顾第 11 章: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(剩余 1,024 个图片 token + 100 个文本 token)

问题:ViT 的输出是"图片作为整体"的嵌入(9,216 个 token 一起产出,不能切块生成)。如果不做缓存,Step 1 跑一次 ViT,Step 2、Step 3 又各跑一次 ViT(每次只用其中一段)—— 浪费。V0 正是如此:它没有 encoder cache,每个 prefill 块只要与某张图的占位区间相交,就会把这张图的 mm_kwargs 再送进模型 forward 重算一遍(vllm/worker/model_runner.py:674-687、vllm/multimodal/base.py:56-96;模型侧见 vllm/model_executor/models/llava.py:734-740 的 V0 分支,按源码判断、未实跑)。

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

15.4.2 数据结构

# vllm/v1/core/encoder_cache_manager.py:15(结构简化)
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,每步都要用

一旦某个多模态 item 的占位区间全部算完(start_pos + num_tokens <= num_computed_tokens),它的视觉 token 的 K/V 已经写进 KV Cache,Encoder Cache 里对应的 embedding 就可以释放(vllm/v1/core/sched/scheduler.py:671-683),不必等整个 prefill 结束。这个释放由 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 区间被调度(vllm/v1/core/sched/scheduler.py:593-601)。如果 encoder cache 没空间,或者单个 encoder input token 数超过本 step 剩余的 encoder budget,本 step 只调度到该多模态 item 之前的文本 token;若前缀缓存命中已越过 item 起点,则本 step 不给该请求调度任何 token,等资源可用再继续(vllm/v1/core/sched/scheduler.py:603-623)。这说明 Encoder Cache 不是简单的"保存 ViT 输出",它参与了调度可行性判断。

前缀缓存同样要认得图片。V1 的块哈希把块内涉及的每个多模态 item 的 mm_hash 放进 extra_keys(vllm/v1/core/kv_cache_utils.py:281-346,机制见第 10 章 §10.2.4),mm_hash 由 model_id、item 内容和 hf_processor_mm_kwargs 一起算出(vllm/multimodal/processing.py:1572-1591)。所以同样的占位 token 后面接不同图片不会误命中;同一张图在多轮对话里重复出现时,其所在块可以命中,命中覆盖了整个 item 的区间时 scheduler 也不会再调度它的 encoder(上面 _try_schedule_encoder_inputs 里 start_pos + num_encoder_tokens <= num_computed_tokens 的 continue 分支)。只要开了前缀缓存,即使关掉预处理缓存,前端也会计算 mm_hash(vllm/v1/engine/processor.py:54-56)。V0 则干脆不支持:对多模态模型传 --enable-prefix-caching 会被告警并强制关闭(vllm/engine/arg_utils.py:1547-1553)。

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

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

15.5.1 核心抽象

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

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

    def create_processor(self, model_config) -> MultiModalProcessor:
        """根据模型配置创建处理器实例。"""
        model_cls = self._get_model_cls(model_config)  # 按 architectures 解析出模型类
        factory = self._processor_factories[model_cls]
        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, *, vllm_config, prefix: str = ""):
        super().__init__()
        config = vllm_config.model_config.hf_config
        self.vit = ViTBackbone(config.vit_config)
        self.projector = Projection(config.vit_hidden, config.llm_hidden)
        self.language_model = ...  # 按 vllm_config 构造文本主干

    def get_multimodal_embeddings(self, **kwargs):
        # V1 runner 的 _execute_mm_encoder() 调这里:pixel_values → ViT → projector
        ...

    def get_input_embeddings(self, input_ids, multimodal_embeddings=None):
        # 替换 <image> 占位符位置为 vit embeddings(通常用 merge_multimodal_embeddings)
        ...

    def forward(self, input_ids, positions, intermediate_tensors=None,
                inputs_embeds=None, **kwargs):
        # v0.8.5 的 forward 不再接收 kv_caches,KV 经 forward context 取得
        ...

# 2. 实现处理信息 + 处理器
class MyNewVLMProcessingInfo(BaseProcessingInfo):
    def get_supported_mm_limits(self) -> Mapping[str, Optional[int]]:
        return {"image": None}  # None = 不限个数

class MyNewVLMProcessor(BaseMultiModalProcessor[MyNewVLMProcessingInfo]):
    def _get_mm_fields_config(self, hf_inputs, hf_processor_mm_kwargs):
        ...
    def _get_prompt_updates(self, mm_items, hf_processor_mm_kwargs, out_mm_kwargs):
        # 告诉框架 <image> 要展开成多少个 placeholder token
        ...

# 3. 注册(真实签名见 §15.5.3,需要三个工厂;另需实现 MyNewVLMDummyInputsBuilder)
#    register_processor(...) 返回的是装饰器,必须作用到模型类上才生效,
#    通常直接写成 §15.5.4 的 @ 装饰器形式
MULTIMODAL_REGISTRY.register_processor(
    MyNewVLMProcessor,
    info=MyNewVLMProcessingInfo,
    dummy_inputs=MyNewVLMDummyInputsBuilder,
)(MyNewVLM)

零改动 scheduler、worker、KV cache manager。模型架构名再登记进 ModelRegistry(第 7 章),用户跑 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——元信息工厂:BaseProcessingInfo 提供 model_id、get_hf_config()、get_hf_processor(),以及"每种模态最多允许几个实例"的 get_supported_mm_limits() / get_allowed_mm_limits()(vllm/multimodal/processing.py:1001-1054)。注意它并不直接回答"视觉 token 上限是多少"——那个数在 v0.8.5 里是跑出来的,见下一条。

3. dummy_inputs: DummyInputsBuilderFactory——假输入工厂:生成最坏情况的假图/假视频,供启动时的显存 profiling 和 encoder 预算推导使用。基类 _get_dummy_images() 造的是纯色(color=255)图(vllm/multimodal/profiling.py:117-127),尺寸由模型自己决定:Qwen2-VL 的 get_image_size_with_most_features() 把 9999999×9999999 交给 _get_vision_info(),由其中的 smart_resize() 按 image processor 的 max_pixels 算回允许的最大尺寸(vllm/model_executor/models/qwen2_vl.py:895-901)。

"每张图最多多少 token"正是这两个工厂配合算出来的:MultiModalRegistry.get_max_tokens_per_item_by_modality() 建一个 MultiModalProfiler,用 dummy_inputs 造出最大假输入,真跑一遍 processor,再数产出的 placeholder token(vllm/multimodal/registry.py:98-122、vllm/multimodal/profiling.py:267-274)。compute_encoder_budget() 拿这个数去定 encoder compute budget 和 encoder cache size。没有这条 profiling 链路,scheduler 就不知道一个多模态 item 会占多少 encoder slot。

_ProcessorFactories 把这三个打包——保证注册时三者完整、运行时一处可查。info 和 dummy_inputs 是没有默认值的关键字参数,漏写任何一个在注册时就会抛 TypeError,不会拖到运行期;而三者各管一段:processor 负责请求处理,info 提供元信息与数量上限,dummy_inputs 支撑启动时的 profiling 与 encoder 预算推导。

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 装饰器模式经典用法。

重复注册策略(vllm/multimodal/registry.py: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 模型的处理器变了")但不阻断工作流。量化注册表不允许覆盖,是为了不让第三方扩展悄悄劫持 "fp8" 这类标准名字(第 13 章 §13.6);多模态处理器的覆盖则是针对某个模型类的显式"替换实现"、用户意图明确。同一个库里的两个注册表做了完全相反的策略选择,各自对应不同的使用场景。

15.5.5 旧管线遗留 API 的 @deprecated 标记

MultiModalRegistry 里的 create_input_mapper(vllm/multimodal/registry.py:91-95)与 init_mm_limits_per_prompt(vllm/multimodal/registry.py:172-176)两个方法,has_processor(vllm/multimodal/registry.py:239-243)同理:

@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

这些方法原属旧的 input mapper / input processor 插件管线(ImagePlugin、AudioPlugin 等按模态把原始数据映射成模型输入)。v0.8.5 通过 PR #15686「Remove legacy input mapper/processor from V0」把这套管线从 V0 里删掉,此后 V0 与 V1 都统一走 BaseMultiModalProcessor,不再由注册表提供 mapper。方法体保留、但标记 deprecated + 给具体迁移指引——让还在调用旧 API 的外部代码能导入运行,同时在告警里明确写出"迁移到新方式"。

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

大型开源项目面对长期用户基数时都要在兼容性和演进之间做这种折中。vLLM 的多模态输入从按模态插件的 mapper 管线迁到合并式处理器是一次跨模块重构,能对下游用户尽量平滑,靠的正是这些 deprecation 层——这一点在第 18 章讨论"接口稳定性"时还会再遇到。

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-154、443-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)。两者都直接抛 RuntimeError 而不是静默截断:前者的报错指向处理器实现(_call_hf_processor 与 _get_mm_fields_config 不一致),后者的报错提示要么 prompt 里的占位符缺失或写错,要么处理器实现(_call_hf_processor 与 _get_prompt_updates)不一致。最后 apply() 返回 MultiModalInputs,里面同时带 prompt_token_ids、mm_kwargs、mm_hashes 和 mm_placeholders(vllm/multimodal/processing.py:1636-1691;vllm/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 三种注入方式:拼接 / 交叉注意力 / M-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:排在后面的文本 token 能看到前面的视觉 token(因果掩码下反过来不行)。KV Cache 一视同仁管理。

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

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

15.6.2 交叉注意力型(Mllama)

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

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

优点:视觉 token 不进入文本的自注意力序列,只在交叉注意力层产生 K/V,prefill 之后不再增长(它们在 cross-attention K/V 里是静态的)。不过这部分 K/V 仍要占 KV Cache:vLLM V0 为它单独维护一张 cross-attention block table(vllm/core/block_manager.py:102)。

缺点:需要额外维护 cross-attention states 和 mask,调度形态比拼接型复杂。vLLM 源码里能看到 mllama.py 的 MllamaTextCrossAttention(vllm/model_executor/models/mllama.py:815)和 MllamaCrossAttentionDecoderLayer(:981),后者按 config.cross_attention_layers 插进文本解码器的层序列里(:1067)——这才是"视觉信息通过 LLM 的交叉注意力层注入"。

这里有个容易搞混的对照:BLIP-2 不属于这一类。 blip2.py 里确实有按 cross_attention_frequency 插入的 cross-attention(vllm/model_executor/models/blip2.py:256),但它在 Q-Former 内部——是可学习的 query token 去 attend 图像特征。Q-Former 出来之后,Blip2ForConditionalGeneration.get_input_embeddings() 用的仍是 merge_multimodal_embeddings(),把结果按 _IMAGE_TOKEN_ID 占位符塞回文本 embedding(vllm/model_executor/models/blip2.py:637-647)。从 LLM 的角度看,BLIP-2 是拼接型;它只是把"图片 → 固定数量 query token"这一步做得比 LLaVA 的两层 MLP 复杂而已。

代价也写在源码里:v0.8.5 的 MllamaForConditionalGeneration 声明了 SupportsV0Only(vllm/model_executor/models/mllama.py:1174),也就是说它只能跑在 V0 引擎上,走不了本章讲的 V1 encoder cache 路径。这条标记很能说明问题——交叉注意力型模型要在 V1 里跑起来,需要 encoder-decoder 的 KV Cache 与调度支持,而 compute_encoder_budget() 里那句 # TODO: handle encoder-decoder models once we support them. 说明这块在该版本尚未完成。

15.6.3 M-RoPE 位置感知型(Qwen2-VL 的多模态旋转位置编码)

Qwen2-VL 在位置编码上做了创新——把 RoPE 拆成时间、高度、宽度三个分量,不同模态的 token 用不同"时空坐标":

  • 文本 token:位置 = (t, t, t),三个分量取同一个序号,退化为普通 1D-RoPE
  • 图像 token:位置 = (t, h, w),时间分量对整张图保持不变,高度、宽度分量按 patch 所在行列取值
  • 视频 token:位置 = (t, h, w),t 在不同帧间递增

这让模型天然理解"这个视觉 token 是图像里哪个位置的"。需要改动的是旋转位置编码层而不是 attention kernel——vLLM 在 vllm/model_executor/layers/rotary_embedding.py:930 实现了 MRotaryEmbedding,runner 侧的配合见 §15.6.4。

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)"
        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,t,t)"]
        MV --> |统一 attention + 3D 位置| MT
    end
    style T1 fill:#2563eb,color:#fff,stroke:none
    style T2 fill:#2563eb,color:#fff,stroke:none
    style V1 fill:#f59e0b,color:#333,stroke:none
    style V2 fill:#f59e0b,color:#333,stroke:none
    style V3 fill:#f59e0b,color:#333,stroke:none
    style VT fill:#f59e0b,color:#333,stroke:none
    style MV fill:#10b981,color:#333,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-155、221-236)。这个 "+1" 不是随意多分配:源码注释说明,多出的一个 dummy 位置是为了让切片后的张量不连续(non-contiguous),从而能配合 torch compile 工作。同一处注释还指出,启用 M-RoPE 时位置 id 一律是三维的,纯文本输入三个维度取值相同,等价于 1D-RoPE。

调度过程中,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 帧。

后两种都不在 vLLM 里:v0.8.5 服务端按 URL / base64 拉取视频时,VideoMediaIO 只做均匀抽帧,默认 num_frames=32,用 np.linspace 选帧下标(vllm/multimodal/video.py:76-86、111-122)。要关键帧或自适应,得在调用方先抽好帧再传进来。

15.7.2 token 数量的爆炸

以 Qwen2-VL 处理 10 秒视频为例(注意要把两级合并都算进去):

  • 抽 8 帧,每帧 672×672 → 每帧 2,304 个 14×14 patch
  • 空间合并 2×2 → 每帧 576 个视觉 token
  • 时间维度上 temporal_patch_size = 2,相邻两帧合成一组(vllm/model_executor/models/qwen2_vl.py:436-446)→ 8 帧变 4 组
  • 总视觉 token ≈ 4 × 576 = 2,304 token;换成 70B FP16 的 KV 就是 2,304 × 320 KiB ≈ 720 MiB

这还只是 8 帧。抽帧数、分辨率、max_pixels 任何一个放大,token 数就线性涨上去,而每个 token 在 70B 上都是三百多 KB 的 KV。视频理解服务的并发天花板通常是个位数,靠的不是调参而是先把抽帧和分辨率压下来。

15.7.3 时序压缩

上一节算的两级合并就是这类压缩的典型形态:Qwen2-VL 的 merger 把空间相邻的 spatial_merge_size² = 4 个 patch 合成一个 token,视频再按 temporal_patch_size = 2 把相邻帧成组,合起来是 8× 的压缩。别的模型会选别的倍率,但思路一致——在进 LLM 之前先把冗余的时空 patch 合掉,因为一旦变成视觉 token 就要按 token 计 KV Cache。

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

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

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

v0.8.5 里带音频输入的模型有 whisper.py、qwen2_audio.py、ultravox.py、phi4mm.py(音频编码器在 phi4mm_audio.py),以及 granite_speech.py、minicpmo.py、qwen2_5_omni_thinker.py 等,它们都通过 @MULTIMODAL_REGISTRY.register_processor 注册,走的是和 VLM 同一套处理器/注册表机制。

但不能一句"架构完全一致"带过:WhisperForConditionalGeneration 在这个版本上带着 SupportsV0Only 标记(vllm/model_executor/models/whisper.py:638-639),也就是只跑 V0 引擎,用不上本章讲的 V1 encoder cache——原因和 Mllama 一样,它是 encoder-decoder 结构,而 compute_encoder_budget() 里明写着 # TODO: handle encoder-decoder models once we support them.。相比之下 qwen2_audio.py 和 ultravox.py 是 decoder-only + 音频前端,走的就是标准的"编码 → 占位符替换 → 拼接进文本 embedding"路径。

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 \
    --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:限制每请求的图片/视频数量,防止恶意请求炸显存;不设时每种模态默认上限 V0 是 1、V1 是 999(vllm/config.py:2807),V1 下尤其要显式设
  • --mm-processor-kwargs:模型相关的 min/max 分辨率约束,Qwen2-VL 动态分辨率强烈建议显式设(例中 200704 / 1003520 像素对应每图 256~1,280 个视觉 token)
  • 注意不用再写 --enable-chunked-prefill:V1 里 _set_default_args_v1() 无条件把它设为 True(vllm/engine/arg_utils.py:1568-1569);对 VLM 真正需要判断的是要不要反过来打开 --disable-chunked-mm-input

15.9.2 显存规划

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

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

encoder cache 这一项不需要用户手工扣:V1 的 profile_run() 会按 encoder 预算造一批最坏情况的假多模态输入跑一遍 encoder,并把输出暂存在 encoder_cache 里再做主模型的 dummy run,这样 encoder 输出的峰值就计入显存测量、从 KV Cache 预算中扣掉(vllm/v1/worker/gpu_model_runner.py:1585-1658,按源码判断、未实跑)。vLLM 的配置里也没有把 encoder cache 作为一个独立的用户显式显存池暴露出来;V1 会根据 scheduler 配置和模型多模态 token 上限计算 max_num_encoder_input_tokens 与 encoder_cache_size,两者初值都等于 max_num_batched_tokens、当前不可单独配置,再由 compute_encoder_budget() 抬到不小于单个 item 的最大 token 数(vllm/config.py:1879-1886、1999-2000;vllm/v1/core/encoder_cache_manager.py:144-147)。所以 VLM 调参不能只看 gpu_memory_utilization,还要同时看 max_num_batched_tokens、limit_mm_per_prompt、图像分辨率上限和是否允许 chunked MM input。

15.9.3 可观测性:v0.8.5 没有多模态专属指标

这里要先泼一盆冷水:v0.8.5 的 V1 指标体系里没有任何 vllm:mm_* 指标。把 vllm/v1/metrics/loggers.py 里注册的 metric 名字全列出来,只有 num_requests_running、gpu_cache_usage_perc、gpu_prefix_cache_queries / hits、prompt_tokens_total、time_to_first_token_seconds、request_prefill_time_seconds 这一类通用项,encoder cache 占用、ViT forward 耗时、预处理缓存命中率都没有导出。

所以 VLM 的线上定位只能靠通用指标做差分推断:

现象 用到的现有指标 指向
TTFT 的 p99 拉长,但 gpu_cache_usage_perc 不高 time_to_first_token_seconds + request_queue_time_seconds 图片下载 / 解码 / HF processor 在 CPU 侧慢
request_prefill_time_seconds 明显高于同长度纯文本 request_prefill_time_seconds + request_prompt_tokens ViT forward 本身的开销
gpu_cache_usage_perc 长期贴顶、num_requests_waiting 堆积 两者对比 视觉 token 把 KV 预算吃掉了,该降分辨率或 max_num_seqs
明显的 prefix cache 无法复用 gpu_prefix_cache_queries / gpu_prefix_cache_hits 多轮对话里图片内容或 processor kwargs 在变,导致 mm_hash 不同(§15.4.3),或图片前面的文本在变

如果确实需要 encoder 侧的细粒度观测,当前只能自己在 _execute_mm_encoder() 前后打点,或者升级到后续版本——这一点在做 VLM 容量规划前值得先确认清楚,别照抄不存在的指标名字写告警。

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 为真,而 profiling 得出的单个多模态 item 最大 token 数超过 max_num_batched_tokens,compute_encoder_budget() 会经由 _compute_encoder_budget_multimodal() 在启动时直接抛出配置错误(vllm/v1/core/encoder_cache_manager.py:136-142)。运行期 scheduler 也会在 cache 不够或本 step 剩余 encoder budget 不足时,把该请求本 step 的调度截到图片之前(vllm/v1/core/sched/scheduler.py:603-623 的 can_allocate 分支)。这类问题不要只调大 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 (OpenAI 服务默认经 MQLLMEngine 拆成两个进程):
  API Server 进程:
    ├─ 接 HTTP 请求
    ├─ 拉取 / 解码图片 → PIL (CPU)
    └─ pickle 原始 prompt → ZMQ

  引擎进程主循环线程(串行):
    ├─ 收新请求 → add_request()
    ├─ HF processor:resize / normalize / 占位展开 (CPU)   ← 占住主循环
    ├─ 调度决策
    ├─ 执行模型
    └─ 回传输出

HF processor 处理大图期间,主循环既不调度也不发起下一步执行
→ 所有在跑请求的下一个 token 都跟着等

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_cache(vllm/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 在模型入口处对齐,代价是 embedding 层落在 CUDA graph 之外(多模态模型即使本 step 只有文本也是如此),所以源码里专门注明纯文本模型仍然使用 token ids,好让 embedding 层留在 CUDA graph 里。

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 MultiModalDataParser + ModalityDataItems 家族——把用户传进来的 mm_data 归一成按模态分组、可计数可切片的 MultiModalDataItems
utils.py 386 MediaConnector(URL / base64 / 本地文件的同步与异步拉取)、encode_*_base64、group_mm_inputs_by_modality
registry.py 321 §15.5 主角——MultiModalRegistry 的三工厂注册
profiling.py 274 dummy data 生成、用于 worker 启动时探测显存
base.py 218 MultiModalPlaceholderMap + MediaIO ABC——placeholder 区间映射和媒体读取抽象
video.py 166 VideoMediaIO——视频读取与抽帧(§15.7)
audio.py 105 AudioMediaIO——音频读取与重采样(§15.8)
hasher.py 103 处理缓存的哈希键(§15.2.4)
image.py 77 ImageMediaIO / ImageEmbeddingMediaIO——bytes / base64 / 文件 → PIL.Image(薄壳)
__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 拼接 + 视频(逐帧空间池化压 token)
paligemma.py 397 拼接,Google 的 multi-task VLM

两条非显然的事实——

  1. processing.py 单文件 1760 行 = 多模态目录 37%——多模态最难的不是 ViT 也不是注入、是"如何把多模态输入对齐到 prompt 里特定的 placeholder token 位置"——<|image|> 标记位的展开 + token id 映射 + 文本 prompt 里同一处可能要展开数千 visual token
  2. 拼接型是 V1 的主路径——表格里的模型都通过 merge_multimodal_embeddings()(llava_next.py 用的是它的变体 embed_multimodal())把视觉嵌入塞回占位符。图像 VLM 里在 LLM 解码器中插 cross-attention 层的只有 mllama.py(另有 florence2.py、whisper.py 这类 encoder-decoder 模型,解码器同样交叉注意力到编码器输出),它们在 v0.8.5 都被标记为 SupportsV0Only(vllm/model_executor/models/mllama.py:1174 等),跑不进本章讲的 V1 encoder cache 路径。工程结论是"拼接型更贴近 vLLM 的通用调度路径;交叉注意力型要额外维护 encoder states 和 mask,在该版本还没有并入 V1"。

15.11 本章小结

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

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

源码导航

  • 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