视觉语言模型(VLM)
前情回顾:上一 Part 我们看过 CoT 和 Thinking 模型,重点是让 LLM 在文字里更会推理。 本 Part 目标:今天换一个问题:图片不是文字,LLM 为什么也能“看图说话”?
你可以先把困惑说成一句话:LLM 只会处理 token,可图片是一堆像素。那一张猫的照片,怎么变成 LLM 能读懂的 token 呢?
Vision-Language Model(VLM),中文可以叫视觉语言模型。这里讨论的是聊天式 VLM:输入可以包含图片和文字,输出主要是文字。更广义的 VLM 还包括图文检索、caption、VQA、多图/视频理解等任务。比如你给它一张菜单照片,再问“最便宜的饮料是什么?”,它需要先看图,再用语言回答。
这一节我们不调用现成模型,而是从零搭一个极简版 VLM。路径很清楚:
- 先把图片切成小块,也就是 patch。
- 再把每个 patch 变成向量,也就是视觉 token。
- 最后把视觉 token 和文本 token 拼在一起,送进 Transformer。
听起来像“给 LLM 装一双眼睛”,但这双眼睛不是直接看像素,而是先把像素翻译成 LLM 熟悉的 embedding。
1. LLM 为什么不能直接读图片?
先定义两个词。
Token 是模型一次处理的最小单位。文本里,一个 token 可能是一个字、一个词,或者一个词的一部分。比如“猫坐下”可能被 tokenizer 变成几个整数 ID。
Embedding 是 token 的向量表示。比如 token id 12 本身只是编号,模型真正计算时会把它查表变成 [0.3, -0.1, ...] 这样的向量。
问题来了: 图片不是一串 token,而是一个二维像素网格。
文字: "一只猫坐在垫子上"
-> Tokenizer -> [12, 45, 78, 3, 90, 23]
-> Embedding -> [6 个向量]
图片: 224 x 224 像素的猫照片
-> ??? -> LLM 现在还不知道怎么处理
所以 VLM 的第一性问题是:怎么把图片也变成一串 embedding?
最朴素的答案是:把图片切成很多小块,每个小块当成一个“视觉单词”。这样图片就从二维网格变成了一维序列。
2. Patchify:先把图片切成小块
Patchify 的意思是“把图片切成 patch”。Patch 就是一小块图片。
比如一张 224 x 224 的图片,如果每个 patch 是 16 x 16,那么横向有 14 块,纵向也有 14 块,所以一共有 14 x 14 = 196 个 patch。这个设置来自 ViT 里的经典例子;真实 VLM 也常用 14、16、32、336 分辨率、多裁剪或动态分辨率。参考:ViT。
原始图片: 224 x 224 像素
patch 大小: 16 x 16
224 / 16 = 14
14 x 14 = 196 个 patch
┌──┬──┬──┬──┐
│ │ │ │ │
├──┼──┼──┼──┤
│ │猫│ │ │ <- 猫脸可能落在其中几个 patch 里
├──┼──┼──┼──┤
│ │ │ │ │
└──┴──┴──┴──┘
为什么不直接把整张图变成一个 token?因为一个 token 太粗了,模型很难知道猫脸、文字、背景分别在哪里。切成 patch 以后,模型至少保留了“局部区域”的信息。
下面先不用任何视觉模型,只用 unfold 手动看一下切块后的 shape。
# 先手算 shape,再用代码验证 patchify
import torch
import torch.nn as nn
torch.manual_seed(42)
IMG_SIZE = 224
PATCH_SIZE = 16
# 模拟一张 RGB 图片: [颜色通道, 高度, 宽度]
fake_image = torch.randn(3, IMG_SIZE, IMG_SIZE)
print("原始图片 shape:", fake_image.shape)
print("解释: 3 个颜色通道,每个通道是一张 224 x 224 的表格")
# unfold 会沿着高度、宽度切窗口。stride=16 表示窗口之间不重叠。
patches = fake_image.unfold(1, PATCH_SIZE, PATCH_SIZE)
patches = patches.unfold(2, PATCH_SIZE, PATCH_SIZE)
print("\n切块后 shape:", patches.shape)
print("解释: [3通道, 14行patch, 14列patch, 16高, 16宽]")
num_patches = (IMG_SIZE // PATCH_SIZE) ** 2
patch_dim = 3 * PATCH_SIZE * PATCH_SIZE
patches_flat = patches.permute(1, 2, 0, 3, 4).reshape(num_patches, patch_dim)
print("\n展平后 shape:", patches_flat.shape)
print(f"关键观察: 一张图变成了 {num_patches} 个 patch")
print(f"每个 patch 有 {patch_dim} 个数字,也就是 3 x 16 x 16")
3. Patch Embedding:再把小块变成向量
现在每个 patch 有 768 个数字,因为 。
但 LLM 里每个文本 token 的 embedding 通常是固定维度,比如 512、768 或 4096。视觉 patch 如果也想混进 LLM,就必须变成同样维度的向量。
这个步骤叫 Patch Embedding:把一个 patch 里的像素数字,投影成一个 d_model 维向量。
一个 patch: 768 个像素数字
Patch Embedding: Linear(768 -> d_model)
输出: 1 个视觉 token,维度是 d_model
文本 token: Embedding 查表 -> 1 个文本 token,维度也是 d_model
维度一样,后面才可以拼接。
实际 ViT 里经常用 Conv2d(kernel_size=patch_size, stride=patch_size) 一步完成“切块 + 线性投影”。这里重点不是 CNN 那种多层边缘/纹理提取,而是用卷积写法高效实现“每个 patch 共享同一个线性投影”。
import torch
import torch.nn as nn
class PatchEmbedding(nn.Module):
"""把图片切成 patch,并把每个 patch 映射成一个视觉 token。"""
def __init__(self, img_size=224, patch_size=16, in_channels=3, d_model=512):
super().__init__()
self.img_size = img_size
self.patch_size = patch_size
self.num_patches = (img_size // patch_size) ** 2
# kernel_size=patch_size: 每次看一个 patch
# stride=patch_size: 下一个窗口刚好跳到下一个 patch
self.proj = nn.Conv2d(
in_channels,
d_model,
kernel_size=patch_size,
stride=patch_size,
)
def forward(self, x):
"""
参数:
x: [batch, 3, 224, 224] 的图片
返回:
[batch, num_patches, d_model] 的视觉 token
"""
x = self.proj(x) # [batch, d_model, 14, 14]
x = x.flatten(2) # [batch, d_model, 196]
x = x.transpose(1, 2) # [batch, 196, d_model]
return x
patch_emb = PatchEmbedding(img_size=224, patch_size=16, d_model=512)
dummy_img = torch.randn(2, 3, 224, 224)
visual_tokens = patch_emb(dummy_img)
print("输入图片:", dummy_img.shape)
print("视觉 token:", visual_tokens.shape)
print("关键观察: batch 里每张图都有 196 个视觉 token,每个 token 是 512 维")
4. 视觉 token 怎么和文本 token 融合?
到这里,图片已经变成了 [batch, 196, d_model]。文本也会通过 embedding 变成 [batch, text_len, d_model]。
既然最后一维都是 d_model,最简单的融合方式就是:沿着序列长度这一维拼接。
视觉 token: [vis_1] [vis_2] ... [vis_196]
文本 token: [请] [描述] [这] [张] [图]
拼接后:
[vis_1] [vis_2] ... [vis_196] [请] [描述] [这] [张] [图]
这就是 LLaVA 这类 Visual Token 方案的直觉:从张量形状看,LLM 接到的是一串 embedding;但工程上通常会用 <image> 占位符、特殊 token 或固定插入位置,让 tokenizer/template 和模型都知道哪里应该替换成视觉特征。
# 模拟视觉 token 和文本 token 拼接
import torch
import torch.nn as nn
d_model = 512
text_vocab_size = 1000
# 假设 tokenizer 把“请描述这张图”变成 5 个 id
text_ids = torch.tensor([[5, 12, 78, 3, 90]])
text_embedding = nn.Embedding(text_vocab_size, d_model)
text_tokens = text_embedding(text_ids)
visual_tokens = torch.randn(1, 196, d_model)
combined = torch.cat([visual_tokens, text_tokens], dim=1)
print("视觉 token:", visual_tokens.shape)
print("文本 token:", text_tokens.shape)
print("拼接后:", combined.shape)
print("关键观察: 196 个图片 token + 5 个文本 token = 201 个 token")
5. 还差一个 Projector:把视觉空间翻译到语言空间
刚才的 PatchEmbedding 是教学版,直接把像素投影到了 d_model。真实 VLM 通常多一步:
- Vision Encoder 先把图片编码成视觉特征。
- Projector 再把视觉特征映射到 LLM 的 embedding 空间。
为什么要 Projector?因 为 Vision Encoder 和 LLM 是两个模型,它们的向量空间不天然一致。
打个比方:Vision Encoder 说的是“视觉方言”,LLM 说的是“语言方言”。Projector 就像翻译器,把视觉特征翻译成 LLM 能接住的 embedding。
图片 -> Vision Encoder -> 视觉特征 [B, 196, d_vis]
-> Projector -> 语言空间 [B, 196, d_llm]
文本 -> Token Embedding -----------------------> [B, T, d_llm]
最后拼接: [B, 196 + T, d_llm]
LLaVA 早期使用线性层连接 CLIP vision encoder 和 LLM;LLaVA-1.5 常用两层 MLP connector。其他 VLM 可能使用 Q-Former、Perceiver Resampler、cross-attention 或更复杂的 projector。参考:LLaVA、LLaVA-1.5。
import torch
import torch.nn as nn
class LLaVAProjector(nn.Module):
"""把视觉特征映射到 LLM 的 embedding 空间。"""
def __init__(self, vis_dim=1024, llm_dim=4096):
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(vis_dim, llm_dim),
nn.GELU(),
nn.Linear(llm_dim, llm_dim),
)
def forward(self, visual_features):
"""
参数:
visual_features: [batch, num_patches, vis_dim]
返回:
[batch, num_patches, llm_dim]
"""
return self.mlp(visual_features)
projector = LLaVAProjector(vis_dim=1024, llm_dim=4096)
vision_features = torch.randn(2, 196, 1024)
llm_space_features = projector(vision_features)
print("Vision Encoder 输出:", vision_features.shape)
print("Projector 输出:", llm_space_features.shape)
print("关键观察: patch 数量没变,只是每个 token 的维度对齐到了 LLM")
6. 三种主流 VLM 架构
现在你已经懂了最朴素的 Visual Token 路线。为了教学,可以先把常见方案压缩成三类;真实模型经常混合使用这些方法。
| 方案 | 图片怎么进 LLM | LLM 结构要改吗 | 代表模型 |
|---|---|---|---|
| Visual Token | 图片 token 直接拼进序列 | 不需要 | LLaVA |
| Cross-Attention | 文本 token 去查询视觉特征 | 需要 | Flamingo |
| Q-Former | 用少量 query 压缩视觉特征 | 通常不需要 | BLIP-2 |
6.1 Visual Token:最直观
图片变成 196 个 token,直接放到文本前面。优点是 LLM 主体不用改,工程上最快。缺点也明显:序列变长,attention 计算更贵。