视觉语言模型推理引擎工程实战:从 vLLM 到 SGLang 的架构演进

1. 为什么 VLM 推理是全新的工程难题

2025-2026 年,以 Qwen2-VL、InternVL3、LLaVA-OneVision 为代表的视觉语言模型(Vision-Language Model, VLM)迅速走向生产部署。但很少有人意识到:把一个多塞进 LLM 推理引擎并不像听起来那么简单。

传统 LLM 推理的核心瓶颈在内存带宽——每次 token 生成都要搬动整个 KV Cache。VLM 在其之上叠加了全新的维度灾难:

  • 视觉 Token 爆炸:一张 1280×720 的图像,经过 ViT 编码器后可能产生 1,568 个 patch token。一次 prompt prefill 的 token 数比纯文本场景大 5-20 倍。
  • 异构计算图:ViT 编码与 LLM 解码的硬件利用率截然不同。ViT 是 compute-bound(矩阵乘密集),LLM decode 是 memory-bound(带宽密集)。
  • KV Cache 潮汐:视觉部分的 KV Cache 在 prefill 结束后基本固定,而文本部分持续增长——传统 PagedAttention 的均匀分块策略在此失效。

这篇文章不是再讲一遍 vLLM 教程。我们深入看生产级 VLM 推理系统的真实工程挑战和解法。

2. 视觉编码器:被低估的性能杀手

2.1 ViT 分块与 Token 密度

大多数 VLM 采用标准的 ViT patch 策略(通常 14×14 或 16×16),但在高分辨率场景下,简单的预处理会导致灾难性后果:


分辨率 1344×1344, patch_size=14 → (1344/14)^2 = 9,216 tokens

假设 hidden_size=3584, bf16 → 单张图的 KV Cache:
  9216 × 3584 × 2(layer) × 32(layers) ≈ 20.1 GB

这就是为什么 VLM 推理不了大图的根源。解决方案不是简单地缩图,而是需要更精细的视觉 token 管理。

2.2 分辨率动态裁剪工程实践

生产系统常用的策略是自适应分辨率分块:


def adaptive_resize(image, min_pixels=3136, max_pixels=1003520):
    """
    Qwen2-VL 风格自适应分辨率
    将图像裁剪为 perimeter = sqrt(max_pixels) 的最大整数倍 tile
    """
    width, height = image.size
    # 基础分辨率对齐到 28 的倍数(2个text grid)
    unit = 28
    # 计算网格
    h_bar = max(min_pixels // unit, round(height / unit))
    w_bar = max(min_pixels // unit, round(width / unit))
    
    if h_bar * w_bar > max_pixels // (unit * unit):
        beta = math.sqrt(height * width / (max_pixels))
        h_bar = math.floor(height / unit / beta)
        w_bar = math.floor(width / unit / beta)
    
    new_h = h_bar * unit
    new_w = w_bar * unit
    return image.resize((new_w, new_h), Image.LANCZOS)

但这只是开始。真正的问题是:裁剪后 token 数量不确定,导致显存碎片化。

2.3 视觉编码器 INT8 量化陷阱

视觉编码器的量化比 LLM 量化更敏感。原因在于 ViT 中 Attention 的 softmax 对数值范围极其敏感:


# ViT Attention 的 Q·K^T 数值量级远大于 LLM
# LLM token ~ [-5, 5] → QK 量级可控
# ViT patch 差异大且 patch 间相关性高 → QK 容易溢出

# 错误做法:直接对 ViT 做 per-tensor INT8 → 精度崩塌
# 正确做法:per-channel weight + per-token dynamic activation

# TensorRT-LLM 中的配置示例
config = tensorrt_llm.models.VITConfig(
    quantize_weights=True,
    weight_type="int8",          # per-channel weight only
    use_polished_attention=True,   # 使用 flash attention 的 polished 变体
    qk_norm=True,                 # Qwen2-VL 风格的 QK norm
)

关键认知:视觉编码器要保留 BF16 用于 softmax 和 attention weight,仅对 GEMM 做 INT8 量化。强行 FP8 极易导致 OCR、文档理解任务精度断崖式下跌。

3. KV Cache 的异构管理革命

3.1 视觉 KV Cache 的特殊性

LLM 推理引擎假设所有 token 的 KV Cache 生命周期相同——从 decode 开始到请求结束。但 VLM 中:

  • 视觉 token:prefill 阶段进入,之后只读不变(KV Cache 不再更新)
  • 文本 token:prefill 阶段进入后,decode 阶段每个新 token 都会追加 KV

这意味着视觉 KV Cache 分配后就是冻结的、不可分页的。传统 PagedAttention 假设的"统一分页管理"在这里需要扩展。

3.2 vLLM 中的视觉 KV Cache v2 方案

vLLM V1 引擎引入了 视觉 KV Cache 与文本 KV Cache 的分离管理:


class VisionKVPool:
    """
    视觉 KV Cache 专用池——一次性预分配,不参与 paging
    """
    def __init__(self, num_layers, num_heads, head_size, block_size=4096):
        # 按最大视觉 token 数预分配连续显存
        self.cache = torch.zeros(
            num_layers, 2,  # K and V
            self.max_visual_tokens, num_heads, head_size,
            dtype=torch.bfloat16, device="cuda"
        )
        # 简单的 bump allocator + LRU 淘汰
        self.allocator = BumpAllocator(self.max_visual_tokens)
        self.lru = OrderedDict()  # request_id → allocated_slots
    
    def allocate(self, request_id, num_visual_tokens):
        slots = self.allocator.alloc(num_visual_tokens)
        if slots is None:
            # LRU 淘汰最老的视觉 cache
            evicted_id, evicted_slots = self.lru.popitem(last=False)
            self.allocator.free(evicted_slots)
            slots = self.allocator.alloc(num_visual_tokens)
        self.lru[request_id] = slots
        return slots

3.3 RadixAttention 的扩展

SGLang 的 RadixAttention 对 VLM 做了特殊优化——视觉前缀共享:

当多张图像相似(如连续视频帧、同文档的连续页),SGLang 通过感知哈希(perceptual hash)检测视觉输入的相似度,如果哈希距离低于阈值,直接复用之前的视觉 KV Cache,跳过 ViT 编码和 KV 写入:


# SGLang 视觉 cache 复用伪代码
def check_visual_cache_reuse(image, cache_threshold=0.92):
    img_hash = dhash(image)  # 差异感知哈希
    for cached_hash, cached_kv in visual_cache_pool.items():
        similarity = hash_similarity(img_hash, cached_hash)
        if similarity > cache_threshold:
            return cached_kv  # 直接复用,省掉 ViT
    return None

实测收益:在视频流分析场景,流程不变化时能减少 60%+ 的 ViT 编码时间。

4. 调度器与微观批处理的重新设计

4.1 ViT Encode 与 LLM Decode 的流水线冲突

传统 Continuous Batching 假设每个 forward pass 是同构的。但 VLM 的 prefill 和 decode 阶段行为完全不同:

阶段 ViT GEMM LLM Attention LLM FFN 瓶颈
Prefill (Visual) Heavy Heavy 中等 Compute
Prefill (Text) None Heavy Heavy Memory bandwidth
Decode step None Light Light Memory bandwidth

这意味着简单的"把所有 token 混在同一个 batch 里 forward"会导致严重的 pipeline bubble。

4.2 工程解法:阶段感知调度

生产级 VLM 推理引擎必须引入两阶段调度:


class VLMScheduler:
    def __init__(self):
        self.visual_queue = deque()   # 等待 ViT 编码的请求
        self.prefill_queue = deque()  # 等待 LLM prefill 的请求
        self.decode_queue = deque()   # 正在 decode 的请求
        
    def schedule(self):
        batch = []
        
        # 阶段1:优先调度 ViT 编码(compute-bound,独占 GPU)
        if self.visual_queue and not self.decode_queue:
            visual_batch = []
            while self.visual_queue and len(visual_batch) < MAX_VIT_BATCH:
                visual_batch.append(self.visual_queue.popleft())
            return Stage.VIT_ENCODER, visual_batch
        
        # 阶段2:混合 prefill + decode(memory-bound)
        prefill_tokens = 0
        while self.prefill_queue and prefill_tokens + self.prefill_queue[0].num_tokens < MAX_PREFILL_TOKENS:
            req = self.prefill_queue.popleft()
            batch.append(req)
            self.decode_queue.append(req)
            prefill_tokens += req.num_tokens
        
        # decode batch
        while self.decode_queue and len(batch) < MAX_BATCH_SIZE:
            batch.append(self.decode_queue.popleft())
        
        return Stage.LLM_FORWARD, batch

关键设计决策:ViT 编码期间不混解码。原因:ViT 是 compute-bound,decode 是 memory-bound,混合调度不仅不能提升吞吐,反而因为 CUDA kernel 交错导致两者的效率都下降。

5. 生产部署的工程暗礁

5.1 多图场景的显存建模

LLM 推理的显存公式比较简单:模型权重 + 序列长度 × token_cache_size。但多图场景需要更复杂的模型:


Total_GPU_Memory = 
    model_weights
  + Σ(visual_i_kvcache)  # 每张图的视觉 KV Cache(只增不减)
  + text_kvcache_pages × active_requests  # 文本 KV Cache(paged)
  + prefill_activation_peak  # 峰值激活值

致命问题:视觉 KV Cache 只进不出!

一个实际事故:某 OCR 服务处理 500 页 PDF,每页一张图(约 2000 visual tokens),500 × 2000 × 32 layers × 128 heads × dim = 显存用完。

解法:视觉 KV Cache 流式驱逐 + LRU:


class VisionCacheManager:
    def __init__(self, max_cache_mb=4096):
        self.max_bytes = max_cache_mb * 1024 * 1024
        self.current_bytes = 0
        self.cache_map = {}  # page_id → (k_tensor, v_tensor, size_bytes, last_access)
    
    def access(self, page_id):
        if page_id in self.cache_map:
            entry = self.cache_map.pop(page_id)
            self.cache_map[page_id] = entry  # 移到末尾(LRU)
            return entry.kv
        return None
    
    def put(self, page_id, kv_tensors, size_bytes):
        while self.current_bytes + size_bytes > self.max_bytes:
            evicted_id, evicted_entry = self.cache_map.popitem(last=False)
            self.current_bytes -= evicted_entry.size_bytes
            del evicted_entry  # 显式释放 CUDA显存
        self.cache_map[page_id] = CacheEntry(kv_tensors, size_bytes)
        self.current_bytes += size_bytes

5.2 长视频推理的 Token 预算管理

2026 年的 VLM 已经开始处理分钟级视频(Qwen2-VL-72B 声称支持 20+ 分钟)。核心挑战是帧数 × 每帧 token 数 = token 爆炸。


def compute_video_token_budget(video_fps, duration_seconds, 
                                 frames_per_chunk=4, 
                                 dynamic_fps=True):
    """
    视频 token 预算管理系统
    核心思想:不是每帧都需要 ViT 编码
    """
    total_frames = video_fps * duration_seconds
    
    if dynamic_fps:
        # 动态帧采样:场景变化大的时候多采样,静态场景少采样
        key_frames = detect_scene_changes(total_frames)
        # 每个 key frame 附近取 frames_per_chunk 帧
        effective_frames = len(key_frames) * frames_per_chunk
    else:
        effective_frames = total_frames // frames_per_chunk
    
    tokens_per_frame = estimate_visual_tokens(resolution)
    total_tokens = effective_frames * tokens_per_frame
    
    # 预算检查
    if total_tokens > MAX_VIDEO_TOKENS:
        # 降级:降低分辨率或减少帧数
        scale_factor = math.sqrt(MAX_VIDEO_TOKENS / total_tokens)
        tokens_per_frame = int(tokens_per_frame * scale_factor)
        effective_frames = MAX_VIDEO_TOKENS // tokens_per_frame
        total_tokens = effective_frames * tokens_per_frame
    
    return {
        "effective_frames": effective_frames,
        "tokens_per_frame": tokens_per_frame,
        "total_tokens": total_tokens,
    }

5.3 KV Cache 的 CUDA Graph 兼容问题

CUDA Graph 是 LLM 推理的标配优化技术,但 VLM 面临两个兼容性问题:

  1. 动态 shape:不同图像分辨率导致 ViT 输出 token 数不同,CUDA Graph 要求的固定 shape 被打破
  2. 条件分支:prefill 阶段需要执行 ViT,decode 阶段不需要,CUDA Graph 无法处理动态控制流

工程解法:分模式 CUDA Graph:


class VLMGraphManager:
    """
    为 VLM 维护多个 CUDA Graph——按 token 数分桶
    """
    def __init__(self):
        self.graph_pool = {}  # num_tokens → CUDAGraph
    
    def get_or_create_graph(self, num_tokens):
        # 按照桶形量化,减少 graph 数量
        bucket_size = 256
        bucket_key = ((num_tokens + bucket_size - 1) // bucket_size) * bucket_size
        
        if bucket_key not in self.graph_pool:
            graph = torch.cuda.CUDAGraph()
            s = torch.cuda.Stream()
            s.wait_stream(torch.cuda.current_stream())
            with torch.cuda.stream(s):
                with torch.cuda.graph(graph, pool=graph_pool):
                    # 使用 bucket_key 作为固定 shape 创建 graph
                    static_output = model.forward_bucketed(bucket_key)
            self.graph_pool[bucket_key] = (graph, static_output)
        
        bucket_graph, static_output = self.graph_pool[bucket_key]
        # 将输入数据 copy 到 graph 对应的固定 tensor
        copy_inputs_to_graph_placeholders(num_tokens)
        bucket_graph.replay()
        return static_output[:num_tokens]  # 裁剪出实际需要的部分

6. 2026 年的工程趋势总结

回顾 VLM 推理引擎的演进,我们可以看到几个清晰的趋势:

从"通用 LLM 加上图片功能"走向"原生多模态推理引擎"。这不是简单的模块替换,而是需要在调度器、缓存管理、硬件利用效率层面的系统性重新设计。

值得关注的方向:

  1. MLA (Multi-head Latent Attention) 在 VLM 上的扩展:DeepSeek-V3 提出的 MLA 在 LLM 上展现了卓越的 KV Cache 压缩效果,将其应用到 ViT 的视觉 KV Cache 上是自然的延伸——尤其在处理高分辨率视频时,MLA 可以将视觉 token 的 KV 占用压缩 2-4 倍。
  1. Speculative Decoding 与视觉草稿:扩展 speculative decoding 到多模态场景——用小模型先预测可能的视觉区域注意力分布,大模型只在关键区域做精确计算。
  1. VLM 的 PD 分离部署:将 ViT 编码和 LLM 推理部署在不同 GPU 上(ViT 用 A100-40G,LLM 用 H100-80G),通过网络传递视觉 KV Cache,实现真正的异构流水线。
  1. 边缘端 VLM 推理:以 Qwen2-VL-2B、InternVL2-1B 为代表的小型 VLM 正在开启手机端、IoT 设备上的多模态推理。这对量化(INT4 AWQ)、KV Cache 压缩(GQA → MQA)、以及内存管理提出了更极致的要求。

VLM 推理引擎,本质上是在一个已经极其复杂的系统上,再叠加一个维度更多的计算范式。工程上没有银弹,只有对每一层抽象的精细控制和对硬件行为模式的深刻理解。


作者注:本文基于 vLLM v0.6.x、SGLang v0.4.x 以及 TensorRT-LLM 的工程实践。具体的 API 和内部实现可能随版本迭代变化——VLM 推理是一个仍在剧烈演进中的领域。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部