ViT 与 patch-token 原语 本节摘要:在谈任何多模态之前,图像必须先变成一段 Transformer 能消化的 token 序列。2020 年的 ViT(Vision Transformer)论文给出了答案:把图像切成 16×16 像素的 patch,做一次线性投影,再加一个位置嵌入。五年后,2026 年的每一个前沿模型(Claude Opus 4.7 原生 2576px、Gemini 3.1 Pro、Qwen3.5-Omni)仍是这样起步的——编码器从 ViT 换成了 DINOv2、SigLIP 2,加了 register token,位置编码换成了 2D-RoPE,但这个原语没变。
本节摘要:在谈任何多模态之前,图像必须先变成一段 Transformer 能消化的 token 序列。2020 年的 ViT(Vision Transformer)论文给出了答案:把图像切成 16×16 像素的 patch,做一次线性投影,再加一个位置嵌入。五年后,2026 年的每一个前沿模型(Claude Opus 4.7 原生 2576px、Gemini 3.1 Pro、Qwen3.5-Omni)仍是这样起步的——编码器从 ViT 换成了 DINOv2、SigLIP 2,加了 register token,位置编码换成了 2D-RoPE,但这个原语没变。本节将完整读完 patch-token 这条流水线,并用标准库 Python 从零实现,让 Phase 12 余下章节对「视觉 token」都有一个具体的心理模型。
阅读完本节,你应当能够:
H×W×3 的图像,转换成带正确位置编码的 patch token 序列。Transformer 处理的是向量序列。文本天生就是序列(字节或 token)。而图像是三通道像素的二维网格——不是序列。如果你把每个像素都展平,一张 224×224 的 RGB 图就变成了 150528 个 token,在如此长度上做自注意力(对序列长度的平方复杂度)根本不可行。
2020 年前的做法是在前面外挂一个 CNN 特征提取器:ResNet 产出 7×7、每点 2048 维的特征图,把这 49 个 token 喂给 Transformer。能用,但继承了 CNN 的归纳偏置(平移等变性、局部感受野),也丢掉了 Transformer 对尺度的胃口。
Dosovitskiy 等人(2020)问了一个直白的问题:能不能跳过 CNN? 把图像切成固定大小的 patch(比如 16×16 像素),每个 patch 线性投影成一个向量,加位置嵌入,把这段序列喂给一个原版 Transformer。在当时这是异端——没有卷积的视觉。配上足够多的数据(先 JFT-300M,后 LAION),它在 ImageNet 上击败了 ResNet 并持续提升。
到 2026 年,ViT 这个原语已是毫无争议的地基。每一个开源 VLM 的视觉塔都是它的某种后代(DINOv2、SigLIP 2、CLIP、EVA、InternViT)。问题不再是「该不该用 patch」,而是「用什么 patch 大小、什么分辨率方案、什么预训练目标、什么位置编码」。
给定形状 (H, W, 3) 的图像 x 与 patch 大小 P,你把图像切成 (H/P) × (W/P) 个互不重叠的 patch 网格。每个 patch 是一个 P×P×3 的像素立方体。把每个立方体展平成 3P² 维向量,套上一个共享的线性投影 W_E(形状 3P², D),把每个 patch 映射到模型的隐藏维度 D。
对 ViT-B/16 这个经典配置:
16×16×3 = 768 个像素值,投影到 D = 768。[CLS] token → 序列长度 197。patch 投影在数学上等价于一个卷积核大小为 P、步幅为 P、输出通道为 D 的二维卷积。生产代码就是这么实现的——nn.Conv2d(3, D, kernel_size=P, stride=P)。「线性投影」是概念说法;「卷积核」是高效实现。
patch 本身没有顺序——Transformer 看到的是一袋子 patch。早期 ViT 加的是可学习的一维位置嵌入(每个位置一个 768 维向量,共 197 个)。能用,但把模型绑死在了训练分辨率上:推理时改网格,就得对位置表做插值。
现代视觉骨干用 2D-RoPE(Qwen2-VL 的 M-RoPE、SigLIP 2 的默认)或分解的二维位置。2D-RoPE 根据 patch 的(行,列)索引旋转 query 和 key 向量,让模型从旋转角推断出相对二维位置。没有位置表,模型在推理时可以处理任意网格大小。
图像级的表示到底是什么?三种选择并存:
[CLS] token。在 patch 序列前 prepend 一个可学习向量。所有 Transformer 块跑完后,CLS token 的隐藏状态就是图像表示。继承自 BERT。原版 ViT、CLIP 在用。这个选择对下游任务很关键。CLS 适合分类。对于把 patch token 喂进 LLM 的 VLM,你完全不池化——每个 patch 都变成一个 LLM 输入 token。register 在交接给 LLM 前丢弃(它是脚手架,不是内容)。
2020 年的 ViT 用 JFT-300M 上的监督分类预训练。很快被取代:
你选什么预训练,决定了骨干擅长什么:CLIP/SigLIP 适合与文本的语义匹配,DINOv2 适合密集视觉特征,MAE 适合作为下游微调的起点。
ViT 缩放(Zhai 等人 2022)确立了一个 ViT 的质量在模型大小、数据量、算力上服从可预测的规律。固定算力下:
ViT-g/14(10 亿参数、patch 14、分辨率 224 → 256 token)与 SigLIP SO400m/14(4 亿参数、patch 14)是 2026 年开源 VLM 的两员主力编码器。
完整计算见 code/main.py。对 224 分辨率下的 ViT-B/16:
patch_embed = 3 * 16 * 16 * 768 + 768 = 591k cls + pos = 768 + 197 * 768 = 152k block = 4 * 768^2 (QKVO) + 2 * 4 * 768^2 (MLP) + 2 * 2*768 (LN) = 12 * 768^2 + 3k = 7.1M 12 blocks = 85M final LN = 1.5k total ≈ 86M
加载任何 checkpoint 前,都用这种方式粗估每个 ViT。骨干大小决定了你下游任何 VLM 的显存下限。
2026 年大多数开源 VLM 出厂的编码器是原生分辨率(NaFlex)下的 SigLIP 2 SO400m/14。它有:
这个配置里的每一个决策,都能追溯到一篇你可以读到的论文。
code/main.py 是一个 patch 分词器与几何计算器。它接收 (图像 H, W, patch P, 隐藏 D, 深度 L),输出:
# 输入: image (H, W, 3), patch_size P, 投影矩阵 W_E (3P^2, D) H, W, _ = image.shape assert H % P == 0 and W % P == 0 grid_h, grid_w = H // P, W // P # 网格行列数 patches = [] # 切出每个 P×P×3 patch for i in range(grid_h): for j in range(grid_w): cube = image[i*P:(i+1)*P, j*P:(j+1)*P, :] # P×P×3 patches.append(cube.flatten()) # 长度 3P^2 的向量 # 共 grid_h * grid_w 个 patch,栈成 (N, 3P^2) x = stack(patches) tokens = x @ W_E # (N, D) —— 这就是 patch token # 加 2D-RoPE 或可学习位置嵌入 tokens = add_position(tokens, rows=grid_h, cols=grid_w)
💡 设计要点:
nn.Conv2d(3, D, kernel_size=P, stride=P)一次卷积等价于上面的「切 + 展平 + 投影」三步,且对 GPU 友好得多。理解原理用上面的循环,写生产代码用卷积。
def vit_geometry(H, W, P, D, L, has_cls=True): grid_h, grid_w = H // P, W // P n_patches = grid_h * grid_w seq_len = n_patches + (1 if has_cls else 0) # 参数量(粗估,不含位置嵌入的小项) patch_embed = 3 * P * P * D # 投影矩阵 pos_embed = seq_len * D if learnable_1d else 0 per_block = 12 * D * D # QKVO 4D^2 + MLP 8D^2 blocks = L * per_block total = patch_embed + pos_embed + blocks return {"seq_len": seq_len, "tokens": n_patches, "params": total}
| 配置 | 分辨率 | patch | 网格 | token 数 | 参数量 |
|---|---|---|---|---|---|
| ViT-B/16 | 224 | 16 | 14×14 | 196 | ~86M |
| ViT-L/14 | 336 | 14 | 24×24 | 576 | ~304M |
| DINOv2 ViT-g/14 | 224 | 14 | 16×16 | 256 | ~1.1B |
| SigLIP SO400m/14 | 384 | 14 | ~28×28 | 729 | ~400M |
跑一遍代码,把参数量对上论文里的数字;调一调 patch 大小与分辨率,感受 token 数的成本。
主流开源视觉塔都已内置 patch 分词,API 形态各异:
timm.create_model("vit_base_patch16_224", pretrained=True) 一行加载,内部即 Conv2d(3, 768, kernel_size=16, stride=16) + 可学习 1D 位置嵌入,默认带 CLS token。ViTModel.from_pretrained(...) 暴露 patch_embeddings 与 position_embeddings,便于做 ablation;切到 SiglipModel / Dinov2Model 时接口一致,但池化策略不同(CLASS → mean → register)。nn.Conv 做分词,2D-RoPE 用 jax.vmap 向量化旋转,原生支持任意网格,无需位置表插值。工程取舍:需要可复现的论文数字,用 timm;需要接 HuggingFace 流水线,用 transformers;需要原生分辨率与最高吞吐,用各项目的 JAX 实现移植版。
本节产出 outputs/skill-patch-geometry-reader.md。给定一个 ViT 配置(patch 大小、分辨率、隐藏维度、深度),它产出 token 数、参数量、显存估算及理由说明。每次为 VLM 选视觉骨干时都用这个 skill——它能避免「token 数爆炸把 LLM 上下文塞满」的意外。
算 Qwen2.5-VL:原生 1280×720 输入、patch 大小 14,patch-token 序列长度是多少?和只用 CLS 表示相比差多少倍?
视频预算:一帧 1080p(1920×1080)在 patch 14 下产出多少 token?30 FPS、5 分钟视频共多少视觉 token?哪一项最省成本:池化、帧采样,还是 token 合并?
均值池化:用纯 Python 在 196 个 patch token 上实现均值池化,验证它与 DINOv2 模型 forward 在请求 pooled embedding 时返回的结果一致。
读论文:读《Vision Transformers Need Registers》(arXiv:2309.16588)第 3 节,用两句话说明 register 吸收的是什么伪影、为什么这对下游密集预测重要。
支持 patch-n'-pack:改 code/main.py,给定一组不同分辨率的图像,产出一个打包序列与块对角注意力掩码。等你做到第 06 节时回头验证。
P×P patch、线性投影到 D 维,就是「视觉 token」的原语。Conv2d(3, D, kernel_size=P, stride=P),高效且等价。12D²,加 patch 嵌入与位置嵌入,加载 checkpoint 前先算清显存下限。下一节,我们将进入 CLIP——用对比学习把视觉 token 与文本 token 映射进同一个语义空间,这是后续几乎所有 VLM 跨模态融合的地基。