语言模型的世界里只有 token:一串整数进去,一串整数的概率出来。一张 224×224 的照片是 15 万个 0 到 255 之间的数,而且相邻像素几乎一样,信息稀疏地铺在一大片冗余里。要让模型”看图”,得回答两个问题:图像怎么变成模型能吃的 token,这些 token 的含义怎么和文字对上;以及反过来,模型输出的东西怎么变回像素。
把图切成 token:ViT
最朴素的想法是把每个像素当一个 token。224×224 就是 5 万个 token,attention 是序列长度的平方,一层就是 25 亿个注意力分数,不现实;而且单个像素几乎不携带语义。
ViT(Vision Transformer)的做法是把图像切成 P×P 的不重叠小块(patch),每块拉平成一个 P·P·3 维向量,用同一个线性层投影到 d 维,于是一张图变成 (H/P)·(W/P) 个 token,后面接标准 Transformer。224×224、P = 16 时是 196 个 token,从 5 万压到了 200 左右,同时每个 token 保留了一小块局部结构。
“切块再乘同一个矩阵”恰好就是一个 kernel_size = stride = P 的卷积,所以代码里一般直接这样写。两种写法可以对照验证:
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(0)
x = torch.randn(2, 3, 224, 224)
patch_embed = nn.Conv2d(3, 768, kernel_size=16, stride=16)
tokens = patch_embed(x).flatten(2).transpose(1, 2)
# 显式切 patch 再乘同一个权重:步长等于核大小时,卷积就是不重叠 patch 上共享的线性层
patches = F.unfold(x, 16, stride=16).transpose(1, 2)
manual = patches @ patch_embed.weight.view(768, -1).T + patch_embed.bias
print(tokens.shape, patches.shape, torch.allclose(manual, tokens, atol=1e-4))torch.Size([2, 196, 768]) torch.Size([2, 196, 768]) True每个 patch 恰好是 16·16·3 = 768 维,投影后也是 768 维。之后加上位置编码,就和文字 token 一样进 Transformer 了。
这里埋着一个后面反复出现的账:token 数随分辨率平方增长。分辨率翻倍,token 变 4 倍,attention 计算变 16 倍。CLIP 最好的编码器 ViT-L/14 用 336×336 输入,(336/14)² = 576 个 token;而位置编码是按训练分辨率学的,换分辨率就要插值。
一个 ViT 用 14×14 的 patch。输入从 224×224 换成 448×448,图像 token 数和单层 attention 的计算量大约怎么变?
token 数是 (H/P)·(W/P),边长翻倍就是 4 倍;attention 和序列长度平方成正比,4 的平方是 16。
patch 大小不变时,图越大切出来的块越多;attention 的平方项正是高分辨率贵的根源,所以后面才要做 token 合并和压缩。
让图和文字说同一种语言:CLIP
ViT 给了图像 token,但这些 token 的含义是什么?如果用 ImageNet 的 1000 个类别标签监督训练,编码器只学到 1000 个概念,“一只坐在键盘上的橘猫”这种描述就超出了它的世界。
CLIP 换了监督信号:网上本来就有海量带说明文字的图片。它训练一个图像编码器和一个文本编码器,让匹配的图文在同一个向量空间里靠近、不匹配的远离。具体做法是对比学习:一个 batch 有 B 对图文,两边都编码成向量并做 L2 归一化,两两点积得到 B×B 相似度矩阵,对角线是正确配对。对每一行(每张图)做 softmax 交叉熵,正确答案是对角线那格;对每一列(每段文字)也做一遍,两者取平均。
import torch, torch.nn.functional as F
torch.manual_seed(0)
B, d = 4, 8
concept = torch.randn(B, d) # 4 个"概念",图和文各自带噪声地看到它
img = F.normalize(concept + 0.3 * torch.randn(B, d), dim=-1)
txt = F.normalize(concept + 0.3 * torch.randn(B, d), dim=-1)
labels = torch.arange(B)
for tau in (1.0, 0.07):
logits = img @ txt.T / tau
loss = (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels)) / 2
print(f"tau={tau:<4} loss {loss.item():.3f} 图1 对 4 段文字的概率 {logits[0].softmax(-1).numpy().round(3)}")
print("相似度矩阵:\n", (img @ txt.T).numpy().round(2))tau=1.0 loss 0.909 图1 对 4 段文字的概率 [0.402 0.228 0.143 0.227]
tau=0.07 loss 0.046 图1 对 4 段文字的概率 [0.999 0. 0. 0. ]
相似度矩阵:
[[ 0.92 0.36 -0.11 0.35]
[ 0.62 0.88 -0.34 0.69]
[-0.22 -0.04 0.98 -0.58]
[ 0.36 0.6 -0.26 0.8 ]]余弦相似度被限制在 [-1, 1],直接做 softmax 太平,正确配对只拿到 0.40 的概率。除以温度 τ(CLIP 初始化为 0.07,并且是可学习的)把差距放大十几倍,分布立刻变尖。τ 越小,模型对”差一点”的难负例越敏感。看矩阵第二行:图 2 和文字 4 的相似度 0.69,离正确的 0.88 不远,这种难负例正是对比学习最有价值的信号。
CLIP 用了约 4 亿图文对、约 32K 的 batch。batch 大小在这里不只是效率问题:负例全来自同一个 batch,batch 越大负例越多越难,学得越好;可全局 softmax 要把所有设备上的向量收集到一起算,大 batch 很贵。训练完之后,CLIP 有一个漂亮的用法:zero-shot 分类。把每个类名写成一句话 a photo of a dog,编码成文本向量,看图像向量离哪句最近。CLIP 这样在 ImageNet 上的 zero-shot 准确率,超过了在 120 万张 ImageNet 图上监督训练的 ResNet-50。
SigLIP 改了一处:把每行的 softmax 换成每格独立的 sigmoid 二分类,“这一对图文匹配吗”,对角线标签为 1,其余为 0。每格的损失只和这一对有关,不需要对整行归一化,batch 大小和损失形式就解耦了。结果是小 batch 下更好,训练成本也低得多。现在的 VLM 大多用 SigLIP 系的编码器。
CLIP 学到的是语义:图里有一只猫、它在键盘上。它不擅长细节:猫的每根毛在哪、图里小字写了什么。这个区分决定了后面理解和生成会走不同的路。
为什么 SigLIP 在小 batch 上比 CLIP 表现更好?
softmax 交叉熵的难度和 batch 内负例数量绑在一起,SigLIP 让每个图文对的判断彼此独立,batch 变小时损失形式不变。
SigLIP 照样用矩阵里所有非对角格当负例;速度优势存在,但这里比的是同等 batch 下的效果;编码器大小不是两者的区别。
接到语言模型上:encoder + projector + LLM
有了一个”懂语义”的图像编码器,也有了强大的 LLM,最省事的做法是把两者接起来,只学中间一段翻译:
图像 → 视觉编码器(预训练好的 CLIP / SigLIP ViT)→ patch 特征
→ projector(线性层或小 MLP)→ 和文字 token embedding 同一维度
→ 与文字 token 拼成一条序列 → LLM → 生成文字LLaVA 是这个模板最简单的版本:CLIP ViT-L/14 编码器,一个线性层 W,一个 Vicuna(微调过的 LLaMA)。数据是用 GPT-4 造的:把 COCO 图片的人工 caption 和物体框坐标交给只能读文字的 GPT-4,让它写出关于这张图的对话、细节描述和推理问答,约 15.8 万条,再和原图配对。训练分两阶段:
- 对齐:冻结编码器和 LLM,只训 W,让视觉特征落进 LLM 的 embedding 空间。
- 指令微调:编码器仍冻结,一起训 W 和 LLM。
在 LLM 眼里,图像 token 就是一些”不来自词表”的 embedding,和文字 token 共用同一套 attention。训练的 loss 也只算回答文字,图像 token 不需要预测,这和 后训练 里 SFT 只对 assistant 回答算 loss 是同一个思路。后来的 LLaVA-OneVision 换成 SigLIP 编码器、两层 MLP projector 和 Qwen-2,主要改进都在数据上,架构几乎没变。
把编码器接到 LLM 上,并不会自动带来”看懂”。如果训练数据只有表面的 caption,模型可能学会靠语言先验猜答案而忽略图像:问”图里的香蕉是什么颜色”,答”黄色”,不管那根香蕉是不是绿的。数据要逼着模型去看图,才会有真正依赖图像的理解。
关于 encoder + projector + LLM 架构的 VLM,下面哪些说法正确?
LLaVA 的对齐阶段冻结两端只训一个线性层,图像特征投影后就是普通的输入向量。语言先验问题来自数据,而不是架构。
projector 通常只是线性层或两层 MLP;CLIP 学的是语义对齐,细小文字这类细节在低分辨率编码时就丢了,需要高分辨率输入和 OCR 类数据。
细节不够怎么办:分辨率与 token 账
把所有图都缩到 336×336,OCR、图表、截图这类任务就吃亏:小字缩没了。可提高分辨率,token 数按平方涨,每个视觉 token 在 LLM 里都要占 KV cache(见 推理系统)。这是一个预算问题,几种典型解法:
| 方法 | 做法 | 代表 |
|---|---|---|
| AnyRes 切块 | 大图切成若干块,每块按编码器原生分辨率分别编码后拼接,再加一张缩略图保留全局 | LLaVA 1.5 / OneVision |
| 固定数量压缩 | 用一层 cross-attention 把任意多的 patch 特征压成固定 256 个 token | Qwen-VL |
| 原生分辨率 + 合并 | 按图片实际尺寸切 14×14 patch,再把相邻 2×2 个合成一个 token,数量降为 1/4 | Qwen2-VL / Qwen3-VL |
算一笔账:672×672 的图用 AnyRes 切成 2×2 块、每块 336×336,加一张缩略图,共 5 × 576 = 2880 个视觉 token。而同一张图按 Qwen2-VL 的方式:(672/14)² = 2304 个 patch,2×2 合并后是 576 个 token。一张图占掉几千个 token 并不罕见,视频更是一帧接一帧地累加,所以”token budget”是多模态模型设计的核心约束。
位置编码也要跟着变。文字是一维的,图像有高和宽,视频还多一条时间轴。Qwen2-VL 的 MRoPE 把 RoPE 的旋转维度分成时间、高、宽三组,每组用对应坐标算旋转角。文字 token 三个坐标相同,退化成普通 RoPE;图像 token 的高、宽坐标取它在网格里的行列。RoPE 本身见 Transformer 架构。
训练配方上有几条共性:多阶段(先只训 projector 对齐,再放开更多参数,最后指令微调);数据重质量(单图的图表、OCR 数据能迁移到多图推理和视频);平衡模态(视频样本 token 多、信息密度低,Qwen3-VL 对每条样本的 loss 做平方根归一化,免得长视频样本压过文字)。
按 Qwen2-VL 的方式编码一张 448×448 的图:14×14 切 patch,再把每 2×2 个相邻 patch 合成 1 个 token。进入 LLM 的视觉 token 大约有多少?
448 / 14 = 32,共 32 × 32 = 1024 个 patch,2×2 合并后除以 4,得到 256 个。
1024 是合并前的 patch 数;576 是 336×336 的 CLIP ViT-L/14 的 patch 数;64 是多做了一轮 2×2 合并。
反过来:让模型画图
前面都是”理解”,输出只有文字。如果想让模型输出图像,就要回答第二个问题:输出怎么变回像素。CLIP 的特征只保留语义,从它重建不出清晰的图,所以生成需要另一种表示。主流有两条路。
第一条:把图像变成离散 token。 既然语言模型擅长预测下一个整数,那就把图像也变成一串整数。VQ-VAE 是一个中间带离散瓶颈的自编码器:编码器把图像压成 h×w 的向量网格,每个向量换成 codebook 里最近的码字,记下它的整数 ID;解码器从码字网格重建图像。这样一张图就是 h·w 个”图像词”,和文字 token 一起放进同一个词表,一个自回归 Transformer 用同一个 next-token 交叉熵既能看图也能画图。
难点在”找最近码字”是 argmin,不可导,梯度传不回编码器。解决办法是 straight-through 估计:前向用量化后的值,反向假装量化不存在,梯度原样传给编码器输出。
import torch, torch.nn.functional as F
torch.manual_seed(0)
z_e = torch.randn(1, 4, 4, 8, requires_grad=True) # 编码器输出:4x4 网格,每格 8 维
codebook = torch.randn(16, 8, requires_grad=True) # 16 个码字
flat = z_e.reshape(-1, 8)
ids = torch.cdist(flat, codebook).argmin(1)
z_q = codebook[ids].view_as(z_e)
# 前向值是 z_q;减号后半部分被 detach,所以反向时 z_st 对 z_e 的导数是 1
z_st = z_e + (z_q - z_e).detach()
print("图像 token:\n", ids.view(4, 4).tolist())
print("前向值等于量化结果:", torch.allclose(z_st, z_q))
z_st.sum().backward()
print("z_e 拿到梯度:", z_e.grad is not None, " 梯度全为 1:", bool((z_e.grad == 1).all()))图像 token:
[[14, 13, 9, 0], [2, 12, 2, 2], [8, 8, 6, 11], [5, 5, 14, 8]]
前向值等于量化结果: True
z_e 拿到梯度: True 梯度全为 1: True这张”图”变成了 16 个整数。码字本身不会因为 straight-through 得到更新,所以训练时还要加两项损失:一项把码字拉向编码器输出,一项(commitment loss)约束编码器输出别离码字太远。实际的图像 tokenizer 还会加感知损失和对抗损失,让重建更清晰。
Meta 的 Chameleon 就走这条路:512×512 的图编码成 32×32 = 1024 个 token,codebook 有 8192 个码字,和文字一起训练。代价有三处:文字 token 熵低、图像 token 熵高,混在一起训练时 logit 会漂移、不稳定,Chameleon 要加 QK-norm 和 z-loss 才能训下去;码字有限,细小文字这类细节在 tokenizer 那一步就丢了;一张图 1024 个 token,生成时要一个个解码。
第二条:扩散模型。 不离散化,而是学”从噪声里一步步把图还原出来”。训练时随机取一个步数 t,给原图 x₀ 加上对应强度的高斯噪声:
让网络根据 x_t、t 和文字条件预测加进去的噪声 ε,损失就是预测噪声和真实噪声的均方误差。 从接近 1(几乎是原图)降到接近 0(几乎是纯噪声)。生成时从纯噪声出发,用预测的噪声一步步去掉,几十到上千步后得到图像。
直接在像素上扩散太贵,latent diffusion 先用一个 VAE 把图压到低维连续 latent(比如 512×512×3 压成 64×64×4),在 latent 上扩散,最后解码回像素。去噪网络如果用 Transformer,就是 DiT:把 latent 切成 patch 当 token,和开头的 ViT 是同一套做法。文字条件通过 cross-attention 注入;classifier-free guidance 在训练时随机丢掉文字条件,采样时用”有条件预测减无条件预测”放大文字的影响。
两条路的取舍很清楚:扩散在连续空间里工作,没有量化损失,画质通常更好,但要迭代多步,而且和语言模型的 next-token 框架不统一;离散 token 能让一个模型用一个损失同时理解和生成,代价是细节损失和长序列。
为什么用 VQ-VAE 把图像离散化之后,一个自回归模型就能同时生成文字和图像?
自回归模型只关心"下一个整数是什么",图像码字和文字 token 都只是词表里的 ID,训练和采样流程完全一样;生成出图像码字后交给 VQ-VAE 解码器还原像素。
码字表示的是图像局部的视觉模式,和文字含义无关;解码器只负责从码字重建图像;码字网格展平成序列后仍然需要位置编码区分它在哪一行哪一列。
记住这三件事
- ViT 把图切成 patch,每个 patch 是一个 token(等价于步长等于核大小的卷积);token 数随分辨率平方增长,token budget 是多模态设计的核心约束。
- CLIP/SigLIP 用图文对比学习把图像和文字对齐到同一个语义空间;VLM 就是”预训练视觉编码器 + 小 projector + LLM”,难点主要在数据。
- 理解要语义、生成要细节:生成侧要么用 VQ-VAE 离散 token 走统一的 next-token 框架,要么用扩散模型在连续 latent 里迭代去噪。
延伸阅读
- Stanford CS336 Lecture 17(多模态)
- Dosovitskiy et al. 2020, An Image is Worth 16x16 Words(ViT)
- Radford et al. 2021, Learning Transferable Visual Models From Natural Language Supervision(CLIP);Zhai et al. 2023, Sigmoid Loss for Language Image Pre-Training(SigLIP)
- Liu et al. 2023, Visual Instruction Tuning(LLaVA);Wang et al. 2024, Qwen2-VL
- van den Oord et al. 2017, Neural Discrete Representation Learning(VQ-VAE);Chameleon Team 2024, Chameleon
- Ho et al. 2020, Denoising Diffusion Probabilistic Models