vLLM内核探秘-第15章 多模态推理

《vLLM 内核探秘》完整目录

第15章 多模态推理

"A picture is worth a thousand words --- and a thousand tokens."

:::tip 本章要点

  • 理解视觉语言模型(VLM)的推理流程与纯文本 LLM 的差异
  • 掌握图像编码器缓存的设计:为什么要缓存编码器输出
  • 深入多模态输入的预处理管线:从原始图片到 Token embedding
  • 理解多模态输入对调度器的影响
  • 认识 vLLM 支持的主流 VLM 架构 :::

15.1 VLM 推理的特殊挑战

源码版本 :本章基于 vLLM v0.8.5。多模态注册表 vllm/multimodal/registry.py,模型编码器执行 vllm/v1/worker/gpu_model_runner.py:1043-1061

视觉语言模型(如 Qwen2-VL、LLaVA、Pixtral)在 Transformer 之前增加了一个视觉编码器(通常是 ViT),将图片转换为一组"视觉 Token",然后与文本 Token 一起送入 Transformer。

flowchart LR IMG["🖼️ 图片输入"] --> VIT["视觉编码器\n(ViT)"] TXT["📝 文本输入"] --> TOK["Tokenizer"] VIT -->|"视觉 Token\n(几百~几千个)"| MERGE["Token 合并"] TOK -->|"文本 Token"| MERGE MERGE --> LLM["Transformer\n(统一处理)"] LLM --> OUT["输出 Token"]

这带来了三个新挑战:

  1. 编码器计算------视觉编码器的前向传播是 CPU/GPU 密集型操作,可能耗时数十毫秒
  2. 变长视觉 Token------不同分辨率的图片产生不同数量的视觉 Token
  3. KV Cache 膨胀------一张图片可能产生几百甚至上千个视觉 Token,大量消耗 KV Cache

让我们用具体数字理解"KV Cache 膨胀"的严重程度。以 Qwen2-VL 为例,它支持动态分辨率输入:

图片分辨率 视觉 Token 数 等价文本 Token KV Cache 占用(70B FP16)
336×336 576 576 ~47 MB
672×672 2,304 2,304 ~188 MB
1344×1344 9,216 9,216 ~752 MB

一张高分辨率图片产生的 KV Cache 占用(752 MB)相当于一个 9000 Token 的纯文本请求------但用户可能只发了一句"这张图片里有什么?"(10 个文本 Token)。这意味着调度器必须将视觉 Token 纳入资源预算计算,否则会导致 KV Cache OOM。

这也解释了为什么 VLM 场景的并发能力通常远低于纯文本场景------每个请求的资源消耗被图片大幅放大了。

15.2 编码器缓存

V1 引入了编码器缓存(Encoder Cache),将视觉编码器的输出缓存起来:

graph LR IMG["原始图片"] --> PP["预处理<br/>(CPU)"] PP --> ENC["视觉编码器<br/>(GPU)"] ENC --> CACHE["编码器缓存"] CACHE --> TF["Transformer<br/>(与文本 Token 合并)"] style IMG fill:#f59e0b,color:#fff,stroke:none style CACHE fill:#10b981,color:#fff,stroke:none style TF fill:#3b82f6,color:#fff,stroke:none

为什么需要缓存?因为分块预填充

当一个包含图片的请求被分块预填充时,图片的视觉 Token 可能跨越多个块。如果不缓存编码器输出,每个块都要重新运行视觉编码器------这是巨大的浪费。

有了编码器缓存,视觉编码器只运行一次,输出被存储在缓存中,后续的预填充块直接从缓存中读取视觉 embedding。

15.3 多模态输入的预处理管线

从一张原始图片到 Transformer 能消费的 embedding,要经过四步处理:

flowchart LR A["原始图片<br/>JPEG/PNG"] --> B["图片预处理<br/>Resize · Normalize<br/>(CPU, 非阻塞)"] B --> C["视觉编码器<br/>ViT Forward<br/>(GPU)"] C --> D["投影层<br/>对齐维度<br/>(GPU)"] D --> E["Token 拼接<br/>视觉 + 文本<br/>送入 Transformer"] style A fill:#f59e0b,color:#fff,stroke:none style B fill:#8b5cf6,color:#fff,stroke:none style C fill:#3b82f6,color:#fff,stroke:none style D fill:#10b981,color:#fff,stroke:none style E fill:#ec4899,color:#fff,stroke:none

步骤 1:图片预处理(CPU) ------缩放到模型要求的分辨率、像素归一化、转换为 Tensor。V1 中这一步是非阻塞的------在独立线程中执行,不影响 EngineCore 的主循环。预处理结果可以被缓存,相同图片不需要重复处理。

步骤 2:视觉编码器(GPU)------通常是一个预训练的 ViT(Vision Transformer)。输入一张 336×336 的图片,输出 576 个视觉 Token(每个 14×14 patch 一个 Token)。对于高分辨率图片(如 Qwen2-VL 支持的动态分辨率),输出的 Token 数量可能更多------一张 1344×1344 的图片会产生约 9,000 个视觉 Token。

步骤 3:投影层------视觉编码器输出的维度可能与 LLM 的隐藏维度不匹配。投影层(通常是一两个 Linear 层)将视觉 Token 的维度对齐到 LLM 的 hidden_size。

步骤 4:Token 拼接 ------视觉 Token 被插入到文本 Token 序列中。不同架构的插入位置不同:LLaVA 替换 <image> 占位符的位置;Qwen2-VL 在特殊标记 <|vision_start|>...<|vision_end|> 之间插入。

15.4 多模态输入对调度的影响

视觉 Token 对调度器的影响比看起来大得多。

KV Cache 预算 ------视觉 Token 和文本 Token 一样消耗 KV Cache 块。一张高分辨率图片产生 9,000 个视觉 Token,需要 ⌈9000/16⌉ = 563 个 KV Cache 块。这相当于一个 9,000 Token 的纯文本请求的显存消耗------但用户可能只发了一张图片加一句话。

调度器需要在分配预算时将视觉 Token 计入总 Token 数

python 复制代码
# 简化
total_tokens = len(text_tokens) + num_image_tokens
num_blocks_needed = ceil(total_tokens / block_size)

预填充时间------视觉编码器的计算 + 大量视觉 Token 的注意力计算,使得多模态请求的预填充显著慢于纯文本。调度器的分块策略需要为多模态请求预留更大的时间窗口。

V1 的性能优势 ------V1 的多进程架构在 VLM 场景下优势更加明显。图片预处理(CPU 密集)在 API Server 进程中完成,不阻塞 EngineCore 的调度循环。V0 中所有这些都在同一个 Python 进程内,GIL 导致图片预处理会阻塞 GPU 计算的编排。vLLM 团队报告 V1 在 VLM 工作负载上的吞吐量提升超过 1.7 倍

15.5 支持的 VLM 架构

vLLM 支持多种 VLM 架构,它们在视觉 Token 的注入方式上有差异:

graph TB subgraph "拼接型(LLaVA, Qwen2-VL)" direction LR TXT1["文本 Token"] --> VIS1["视觉 Token"] --> TXT2["文本 Token"] end subgraph "交叉注意力型(Flamingo)" direction TB VTOK["视觉 Token"] --> |"Cross-Attn"| TTOK["文本 Transformer Layer"] end style TXT1 fill:#3b82f6,color:#fff,stroke:none style VIS1 fill:#f59e0b,color:#fff,stroke:none style TXT2 fill:#3b82f6,color:#fff,stroke:none style VTOK fill:#f59e0b,color:#fff,stroke:none style TTOK fill:#3b82f6,color:#fff,stroke:none

拼接型(如 LLaVA、Qwen2-VL、PaliGemma)------视觉 Token 在 embedding 层直接替换文本序列中的占位符,之后所有 Token 一起进入 Transformer。这种方式最简单,也最常见。对 vLLM 来说,视觉 Token 就是普通的 Token,只是 embedding 值来自编码器而非词表。

交叉注意力型(如 Flamingo、一些 BLIP2 变体)------视觉 Token 不进入主序列,而是通过专门的交叉注意力层与文本 Token 交互。这种方式需要在注意力计算中增加额外的 KV 来源。

多模态处理代码位于 vllm/multimodal/,采用了可插拔的预处理管线设计。核心是 MultiModalRegistryregistry.py:80):

python 复制代码
# vllm/multimodal/registry.py:80-89
class MultiModalRegistry:
    """A registry that dispatches data processing according to the model."""
    def __init__(self):
        self._processor_factories = ClassRegistry[nn.Module, _ProcessorFactories]()
        self._processing_cache = ProcessingCache(VLLM_MM_INPUT_CACHE_GIB)

每种模态(图片、音频、视频)通过注册表注入自己的处理器工厂。ProcessingCache 缓存了已处理的多模态输入------如果相同的图片被多个请求引用,预处理只执行一次。

15.6 VLM 推理的实践建议

场景 建议 原因
单图对话 默认配置即可 单图的视觉 Token 通常 < 1000
多图/高分辨率 降低 max_num_seqs 视觉 Token 大量占用 KV Cache
视频理解 限制帧数 + 降低分辨率 避免 KV Cache OOM
批量图片标注 启用编码器缓存 相同图片模板不重复编码
延迟敏感场景 增大 max_num_batched_tokens 减少分块次数降低 TTFT

GPU 上的多模态执行流程

GPUModelRunner.execute_model()gpu_model_runner.py:1043-1061)中,多模态请求的处理流程与纯文本不同:

python 复制代码
# gpu_model_runner.py:1043-1061 (简化)
if self.is_multimodal_model:
    # 1. 运行多模态编码器(如 ViT)
    self._execute_mm_encoder(scheduler_output)
    # 2. 收集编码器输出
    mm_embeds = self._gather_mm_embeddings(scheduler_output)
    # 3. 将文本 token 和视觉 embedding 统一为 inputs_embeds
    input_ids = self.input_ids[:num_scheduled_tokens]
    if mm_embeds:
        inputs_embeds = self.model.get_input_embeddings(input_ids, mm_embeds)
    else:
        inputs_embeds = self.model.get_input_embeddings(input_ids)
sequenceDiagram participant S as 调度器 participant MR as ModelRunner participant VIT as 视觉编码器 participant LLM as Transformer S->>MR: execute_model(含多模态请求) MR->>VIT: _execute_mm_encoder(图片数据) VIT-->>MR: 视觉 embedding MR->>MR: _gather_mm_embeddings + get_input_embeddings Note over MR: 将文本 token embedding<br/>和视觉 embedding 拼接 MR->>LLM: forward(inputs_embeds, positions, kv_caches) LLM-->>MR: logits MR->>MR: 采样 → 输出 token

关键观察:视觉编码器只在预填充阶段运行一次(或分块预填充的第一块)。之后的解码步骤中,视觉 Token 的 KV Cache 已经存在于 GPU 显存中,不需要再运行视觉编码器。这就是编码器缓存(15.2 节)的价值。

15.7 音频与视频支持

除了图片,vLLM 还在扩展对其他模态的支持:

音频------语音模型(如 Whisper 系列的多模态变体)需要将音频波形转换为频谱图,再通过音频编码器产生音频 Token。处理流程与图片类似,但预处理步骤不同(重采样、分帧、STFT)。

视频------视频输入本质上是多帧图片。处理策略有两种:均匀抽帧(每 N 帧取 1 帧),或关键帧提取。每帧独立通过视觉编码器,然后所有帧的视觉 Token 拼接后送入 Transformer。一段 10 秒 30fps 的视频如果抽取 8 帧,可能产生 8 × 576 = 4608 个视觉 Token------这对 KV Cache 是巨大的压力。

多模态推理是 vLLM 快速发展的方向。随着 GPT-4o、Gemini 2.0 等全模态模型的出现,推理引擎对多模态的支持越来越关键。

15.7 本章小结

  • VLM 挑战------编码器计算、变长视觉 Token、KV Cache 膨胀
  • 编码器缓存------运行一次,缓存输出,支持分块预填充复用
  • 调度影响------视觉 Token 计入显存预算,预填充更慢
  • 可插拔架构------支持交叉注意力、拼接、MoE 等多种注入方式

源码导航

  • 多模态处理:vllm/multimodal/
  • VLM 模型实现:vllm/model_executor/models/(带 _vl 后缀的文件)
相关推荐
冬奇Lab7 小时前
Agent 系列(23):Web Agent——让 Agent 真正浏览网页
人工智能·llm·agent
程序员小假8 小时前
Agent 推理太慢?从同步阻塞到异步事件驱动的架构演进指南
agent
缓步前行的微尘12 小时前
Claude Code JSONL Transcript — 完整学习指南
agent
葫芦和十三18 小时前
多模态融合|是数据形态工程,不是 Prompt 工程
openai·agent·ai编程
不好听61318 小时前
Tool:让大模型长出手脚
llm·agent
用户3299016750519 小时前
给 AI 返回数据加 TS 类型,别全标 any
agent
冬奇Lab1 天前
Agent 系列(22):Context Engineering 深度——三种上下文管理策略的量化对比
人工智能·agent
葫芦和十三1 天前
渐进发现|代码库不是文档库
langchain·agent·ai编程
米小虾1 天前
告别单打独斗:2026年多Agent协作架构实战指南
人工智能·agent