外观
具身智能学习-LLM基础
LLM:从续写文本到理解指令
上一节搭建了 Transformer 这个计算引擎,本节展示它如何驱动 LLM 实现语言理解与生成——这不仅是 GPT 系列的核心原理,也是后续 VLM/VLA 的语言基础。
1. 大语言模型(LLM)基础
VLA 模型的「大脑」:理解 LLM 如何处理序列、生成输出,以及如何将动作也当作语言来预测。
2022 年底 ChatGPT 横空出世,到 2025 年 DeepSeek 让硅谷集体反思「算力军备竞赛是否走偏」,LLM 已经从实验室走进了每个人的手机。但在 VLA 场景下,LLM 的角色不是聊天——它是机器人的 中央决策引擎。
阅读指南
本节内容较多,按重要性分为两个部分:
- Part A(必读):1-3 + 8——LLM 的核心原理,理解后续章节的基础
- Part B(核心):4-5——指令微调和动作 token 化,VLA 的关键
Part A(必读)
2. VLA 中 LLM 的角色
2.1 为什么 VLA 需要 LLM?
VLA(Vision-Language-Action)的核心思想是:把机器人控制问题转化为序列生成问题。
传统机器人控制:
状态 → [控制算法] → 动作
需要手工设计状态表示和控制策略
VLA 的思路:
图像 + 语言指令 → [LLM] → 动作序列
利用 LLM 强大的序列建模能力LLM 在 VLA 中承担的功能:
| 功能 | 说明 |
|---|---|
| 指令理解 | 理解 "把红色方块放到蓝色盘子里" 这样的自然语言指令 |
| 视觉推理 | 结合视觉特征,理解当前场景(物体位置、状态) |
| 动作生成 | 输出机器人应该执行的动作序列 |
| 常识推理 | 利用预训练知识进行推理(如:杯子要正着放) |
2.2 VLA 中 LLM 的输入输出
输入:[视觉 Token] + [语言 Token]
↓
LLM
↓
输出:[动作 Token] 或 [连续动作值]
具体例子:
输入:<img_1><img_2>...<img_256> Pick up the red block
输出:<action_1><action_2>...<action_7> (7-DoF 机械臂动作)3. 自回归生成原理
3.1 什么是自回归(Autoregressive)生成?
核心思想:自回归生成的核心是一个 token 一个 token 地生成,每次生成都依赖于之前生成的所有 token。
数学表达:
P(x1,x2,...,xT)=t=1∏TP(xt∣x1,x2,...,xt−1)
直观理解:
生成句子 "I love cats"
Step 1: P("I" | <BOS>) → 生成 "I"
Step 2: P("love" | "I") → 生成 "love"
Step 3: P("cats" | "I love") → 生成 "cats"
Step 4: P(<EOS> | "I love cats") → 结束3.2 因果掩码(Causal Mask)
在 Transformer 中,Self-Attention 默认会让每个位置看到所有位置。要实现自回归,需要用 Causal Mask(因果掩码) 来阻止看到未来信息。
位置 1 位置 2 位置 3 位置 4
Causal Mask: 位置 1 [ 0 -∞ -∞ -∞ ]
位置 2 [ 0 0 -∞ -∞ ]
位置 3 [ 0 0 0 -∞ ]
位置 4 [ 0 0 0 0 ]
0 = 可以看到
-∞ = 看不到(softmax 后变成 0)代码实现:
代码:因果掩码与因果注意力
import torch
import torch.nn as nn
def create_causal_mask(seq_len: int, device: torch.device) -> torch.Tensor:
"""创建因果掩码(上三角为 -inf,阻止每个位置看到其后的 token)"""
mask = torch.triu(torch.ones(seq_len, seq_len, device=device), diagonal=1)
mask = mask.masked_fill(mask == 1, float('-inf'))
return mask # (seq_len, seq_len)
def causal_attention(Q, K, V, mask=None):
"""带因果掩码的注意力计算"""
d_k = Q.shape[-1]
# (B, H, L, L):计算每对位置的相关性分数
scores = torch.matmul(Q, K.transpose(-2, -1)) / (d_k ** 0.5)
if mask is not None:
scores = scores + mask # -inf 位置在 softmax 后趋近于 0,相当于屏蔽未来信息
attn_weights = torch.softmax(scores, dim=-1)
output = torch.matmul(attn_weights, V)
return output3.3 Prefill 与 Decode 阶段
LLM 推理分为两个阶段:
阶段 1:Prefill(预填充)
处理所有输入 token,一次性并行计算
输入:[视觉token] + [语言token]
输出:所有位置的 KV Cache(供 Decode 阶段复用)
阶段 2:Decode(解码)
一个 token 一个 token 地生成输出
每步只处理 1 个新 token,复用已有 KV Cache
直到生成 EOS 或达到最大长度关键区别:Prefill 是批量并行的(快),Decode 是逐步串行的(慢,是推理延迟的主要来源)。从硬件利用角度看,Prefill 阶段是计算密集型——需要对所有输入 token 做大量矩阵乘法,GPU 算力是瓶颈;Decode 阶段则是访存密集型——每步只处理 1 个新 token,但需要从显存中读取整个 KV Cache 来计算注意力,显存带宽成为瓶颈。这也是为什么长序列生成时 Decode 阶段的延迟远高于 Prefill。
3.4 KV Cache
问题:每生成一个 token,都要重新计算之前所有 token 的注意力?
解决方案:KV Cache(键值缓存)缓存之前的 Key 和 Value,新 token 只需要计算自己的 K、V 并追加。
不用 KV Cache(慢):
Step 1: 计算 [t1] 的 K,V
Step 2: 计算 [t1, t2] 的 K,V(t1 重复计算了!)
Step 3: 计算 [t1, t2, t3] 的 K,V(t1, t2 重复计算了!)
用 KV Cache(快):
Step 1: 计算 [t1] 的 K,V,存入 cache
Step 2: 计算 [t2] 的 K,V,拼接到 cache
Step 3: 计算 [t3] 的 K,V,拼接到 cache核心思想(伪代码):
# KV Cache 核心逻辑(概念示意)
cache_k, cache_v = [], [] # 每层维护一个缓存
for new_token in decode_loop:
# 只对新 token 计算 K、V
k_new = linear_k(new_token) # (B, 1, head_dim)
v_new = linear_v(new_token) # (B, 1, head_dim)
# 将新 K、V 追加到历史缓存中(避免重复计算)
cache_k = torch.cat([cache_k, k_new], dim=1) # (B, seq+1, head_dim)
cache_v = torch.cat([cache_v, v_new], dim=1)
# 用完整缓存做注意力,只输出新 token 对应的位置
output = attention(q=linear_q(new_token), k=cache_k, v=cache_v)KV Cache 的核心价值
值得注意的是,KV Cache 的显存开销与序列长度成正比——每多生成一个 token,就要多缓存一组 K、V 向量(每层、每个注意力头各一份)。对于长上下文场景(如数千 token 的输入),KV Cache 可能占据数 GB 显存,成为部署时的主要显存瓶颈。工程上常用 PagedAttention、量化 KV Cache 等技术来缓解这一问题。
4. Tokenization
4.1 为什么需要 Tokenization?
LLM 无法直接处理文本,需要将文本转换为数字序列,这一过程称为 Tokenization(分词):
"Hello world" → Tokenizer → [15496, 995] → Embedding → 向量序列4.2 主流 Tokenization 方法
| 方法 | 代表 | 原理 | 词表大小 |
|---|---|---|---|
| BPE | GPT-2, LLaMA | 基于频率合并字符对 | 32K-128K |
| WordPiece | BERT | 基于似然合并 | 30K |
| Unigram | T5, SentencePiece | 基于概率剪枝 | 32K |
| SentencePiece | LLaMA, Mistral | BPE/Unigram 的实现 | 可配置 |
4.3 BPE(Byte Pair Encoding)详解
BPE(Byte Pair Encoding)是一种基于频率的子词分词算法。它从单字符词表开始,迭代合并最常共现的字符对,直到词表达到目标大小。
训练过程:
初始:将所有文本拆成单字符
词表 = {'a', 'b', 'c', ..., 'z', ' ', ...}
迭代合并最频繁的相邻对:
Round 1: 't' + 'h' → 'th' (假设 "th" 出现最多)
Round 2: 'th' + 'e' → 'the'
Round 3: 'i' + 'n' → 'in'
...
直到词表达到目标大小(如 32000)编码过程:
输入: "the cat"
查找最长匹配的 token:
"the" → 在词表中 → token_id = 1234
" " → token_id = 220
"cat" → 在词表中 → token_id = 5678
输出: [1234, 220, 5678]4.4 使用 HuggingFace Tokenizer
from transformers import AutoTokenizer
# 加载 LLaMA 的 tokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
# 编码(文本 → token ids)
tokens = tokenizer.encode("Pick up the red block")
print(tokens) # [1, 29875, ...] 对应 <s>, Pick, up, the, red, block
# 解码(token ids → 文本)
print(tokenizer.decode(tokens)) # "<s> Pick up the red block"编码和解码是互逆操作。实际使用中,批量编码可通过 tokenizer(texts, padding=True, return_tensors="pt") 完成,会自动处理 padding 和 attention mask。
4.5 特殊 Token
# 常见特殊 token 及其 ID
print(tokenizer.bos_token, tokenizer.bos_token_id) # <s> 1 —— 句子开始
print(tokenizer.eos_token, tokenizer.eos_token_id) # </s> 2 —— 句子结束除 BOS/EOS 外,不同模型还会定义 pad_token(填充)和 unk_token(未知 token)等特殊标记,具体取决于模型的 tokenizer 配置。
4.6 VLA 中的 Tokenization 考量
在 VLA 中,除了文本,还需要处理视觉 token 和动作 token:
完整输入序列:
[BOS] [IMG_1] [IMG_2] ... [IMG_N] [TEXT_1] [TEXT_2] ... [TEXT_M] [ACT_1] [ACT_2] ...
三种 token 的来源:
1. 视觉 token:图像经过视觉编码器 + 投影层得到
2. 文本 token:通过标准 tokenizer 编码
3. 动作 token:动作值离散化后得到(见 5. 指令微调)Part B(核心)
5. 指令微调(Instruction Tuning)
5.1 从预训练到指令遵循
预训练 LLM 的问题:只会"续写",不会"遵循指令"
预训练模型输入:
"What is the capital of France?"
预训练模型输出:
"What is the capital of Germany? What is the capital of Spain?..."
(它只是在续写类似的问题,而不是回答问题)
指令微调后:
输入:"What is the capital of France?"
输出:"The capital of France is Paris."5.2 SFT(Supervised Fine-Tuning)
SFT(Supervised Fine-Tuning,监督微调)的核心思想:用(指令, 回复)对来微调模型
训练数据格式:
{
"instruction": "Translate the following to French",
"input": "Hello, how are you?",
"output": "Bonjour, comment allez-vous?"
}
转换为训练序列:
<s> [INST] Translate the following to French: Hello, how are you? [/INST]
Bonjour, comment allez-vous? </s>
损失函数:只在 response 部分计算交叉熵损失代码:SFT 损失函数
def compute_sft_loss(logits, labels, response_mask):
"""
SFT 损失:只在回复部分计算
logits: (B, L, V) 模型对每个位置、每个词表 token 的预测分布
labels: (B, L) 目标 token ID
response_mask: (B, L) 1 表示回复部分,0 表示指令部分
"""
# 移位对齐:logits[t] 用来预测 labels[t+1]
shift_logits = logits[..., :-1, :].contiguous() # (B, L-1, V)
shift_labels = labels[..., 1:].contiguous() # (B, L-1)
shift_mask = response_mask[..., 1:].contiguous()
# 逐 token 计算交叉熵,不做 reduction,保留每个位置的 loss
loss_fct = nn.CrossEntropyLoss(reduction='none')
loss = loss_fct(
shift_logits.view(-1, shift_logits.size(-1)), # (B*(L-1), V)
shift_labels.view(-1) # (B*(L-1),)
)
loss = loss.view(shift_labels.shape) # (B, L-1)
# 只在 response 部分计算平均损失,指令部分不监督
loss = (loss * shift_mask).sum() / shift_mask.sum()
return loss5.3 对话模板(Chat Template)
不同模型有不同的对话格式:
# LLaMA 2 Chat 格式
llama2_template = """<s>[INST] <<SYS>>
{system_message}
<</SYS>>
{user_message} [/INST] {assistant_message} </s>"""
# Mistral 格式
mistral_template = """<s>[INST] {user_message} [/INST] {assistant_message}</s>"""
# ChatML 格式 (Qwen, etc.)
chatml_template = """<|im_start|>system
{system_message}<|im_end|>
<|im_start|>user
{user_message}<|im_end|>
<|im_start|>assistant
{assistant_message}<|im_end|>"""5.4 RLHF(Reinforcement Learning from Human Feedback)
RLHF(Reinforcement Learning from Human Feedback,基于人类反馈的强化学习)在 SFT 之后进一步对齐人类偏好:
RLHF 流程
RLHF 流程:
1. SFT:用高质量数据微调
2. 训练 Reward Model:学习人类偏好
3. PPO 强化学习:用 reward 信号优化策略
简化理解:
- SFT 学会"能"回答问题
- RLHF 学会"好好"回答问题5.5 VLA 中的指令微调
VLA 的 SFT 数据格式:
# VLA 训练样本
{
"image": "path/to/image.jpg", # 视觉输入
"instruction": "Pick up the red block", # 语言指令
"actions": [0.1, -0.2, 0.05, 0.0, 0.0, 0.1, 1.0] # 7-DoF 动作
}
# 转换为序列
# [IMG_TOKENS] + [TEXT_TOKENS] + [ACTION_TOKENS]
# 损失只在 [ACTION_TOKENS] 部分计算关键区别
VLA 训练数据格式与普通 LLM 的区别
普通 LLM 的训练序列只包含文本 token;VLA 的序列则由三类异构 token 拼接而成:视觉 token(由图像编码器提取后投影到语言空间)、文本 token(自然语言指令)和动作 token(连续动作值离散化后的 ID)。三类 token 在同一条序列中按顺序排列:[IMG_1...IMG_N] [TEXT_1...TEXT_M] [ACT_1...ACT_K]。损失函数只在动作 token 部分计算,视觉和语言部分不参与监督——这与普通 SFT 中"只在 response 部分计算损失"的思路完全一致,只是 response 从文字变成了动作。这种设计让 LLM 骨干网络天然复用,同时将机器人控制问题转化为标准的 next-token 预测。
6. 动作 Token 化
6.1 动作 Token 化的动机
动作 Token 化的核心洞察:既然 LLM 擅长预测下一个 token,为什么不把机器人动作也变成 token 来预测?
传统思路:LLM 输出一个连续向量,再回归成动作
缺点:回归任务与 LLM 预训练目标不一致
VLA 思路:把动作离散化成 token,LLM 直接预测动作 token
优点:复用 LLM 的序列建模能力6.2 动作空间介绍
机器人动作空间通常是多维连续空间:
| 动作类型 | 维度 | 说明 |
|---|---|---|
| 末端执行器位置 | 3 | (x, y, z) 空间坐标 |
| 末端执行器姿态 | 3-4 | (roll, pitch, yaw) 或四元数 |
| 夹爪开合 | 1 | 0=闭合, 1=张开 |
| 总计(7-DoF) | 7 | 常见的机械臂动作空间 |
一个动作向量示例:
action = [
0.05, # delta_x: 向右移动 5cm
-0.02, # delta_y: 向后移动 2cm
0.10, # delta_z: 向上移动 10cm
0.0, # delta_roll
0.0, # delta_pitch
-0.1, # delta_yaw: 绕 z 轴旋转
1.0 # gripper: 张开
]6.3 动作离散化(Action Discretization)
方法 1:均匀分桶(Uniform Binning)
RT-2 采用的方法,将每个动作维度均匀离散化:
代码:动作离散化
def discretize_action(action, num_bins=256, action_min=-1.0, action_max=1.0):
"""
将连续动作离散化为 token ID
action: (action_dim,) 连续动作值,例如 7-DoF
num_bins: 每个维度的离散桶数,越大精度越高
返回: (action_dim,) 离散 token ID,范围 [0, num_bins-1]
"""
# 裁剪到有效范围,防止越界
action = np.clip(action, action_min, action_max)
# 归一化到 [0, 1]
normalized = (action - action_min) / (action_max - action_min)
# 映射到 [0, num_bins-1] 并取整
discrete = (normalized * (num_bins - 1)).astype(np.int32)
return discrete
def undiscretize_action(discrete, num_bins=256, action_min=-1.0, action_max=1.0):
"""
将离散 token 还原为连续动作(discretize_action 的逆操作)
"""
normalized = discrete.astype(np.float32) / (num_bins - 1) # [0, 1]
action = normalized * (action_max - action_min) + action_min
return action
# 示例
action = np.array([0.05, -0.02, 0.10, 0.0, 0.0, -0.1, 1.0])
discrete = discretize_action(action, num_bins=256, action_min=-1, action_max=1)
print(discrete) # 例如: [141, 123, 166, 128, 128, 115, 255]方法 2:K-Means 聚类
对历史动作数据进行聚类,用聚类中心作为离散动作:
from sklearn.cluster import KMeans
class KMeansActionTokenizer:
"""基于 K-Means 的动作离散化:用聚类中心代替均匀分桶,更贴合实际数据分布"""
def __init__(self, n_clusters=512):
self.n_clusters = n_clusters
self.kmeans = None
def fit(self, actions):
"""
actions: (N, action_dim) 历史动作数据,用于拟合聚类中心
"""
self.kmeans = KMeans(n_clusters=self.n_clusters, random_state=42)
self.kmeans.fit(actions)
def encode(self, action):
"""连续动作 → 最近聚类中心的 ID(离散 token)"""
return self.kmeans.predict(action.reshape(1, -1))[0]
def decode(self, token_id):
"""离散 token → 聚类中心(还原为近似连续动作)"""
return self.kmeans.cluster_centers_[token_id]6.4 将动作 Token 加入词表
代码:VLA Tokenizer 实现
class VLATokenizer:
"""
VLA 的 Tokenizer:在原文本词表后追加动作 token 空间。
核心设计:为每个动作维度分配独立的 token 区间,避免不同维度的 bin 相互混淆。
例如(256 bins,7 维):
dim_0 → token [32000, 32255]
dim_1 → token [32256, 32511]
...
dim_6 → token [33536, 33791]
"""
def __init__(self, text_tokenizer, num_action_bins=256, action_dim=7):
self.text_tokenizer = text_tokenizer
self.num_action_bins = num_action_bins
self.action_dim = action_dim
# 动作 token 从文本词表末尾开始编号
self.action_token_start = len(text_tokenizer)
def encode_action(self, action):
"""
action: (action_dim,) 连续动作
返回: (action_dim,) 动作 token IDs
"""
discrete = discretize_action(action, self.num_action_bins)
token_ids = []
for dim_idx, bin_idx in enumerate(discrete):
# 每个维度有独立的 token 区间,偏移量 = dim_idx × num_bins
token_id = self.action_token_start + dim_idx * self.num_action_bins + bin_idx
token_ids.append(token_id)
return token_ids
def decode_action(self, token_ids):
"""
token_ids: (action_dim,) 动作 token IDs
返回: (action_dim,) 连续动作
"""
discrete = []
for dim_idx, token_id in enumerate(token_ids):
# 逆推 bin_idx:减去基础偏移和维度偏移
bin_idx = token_id - self.action_token_start - dim_idx * self.num_action_bins
discrete.append(bin_idx)
action = undiscretize_action(np.array(discrete), self.num_action_bins)
return action6.5 RT-2 的动作 Token 化方案
RT-2 的一个关键设计决策是复用已有的数字文本 token("0"~"255")来表示动作,而不是新增专用 token。这样做的好处是无需修改词表大小和 Embedding 层,可以直接在冻结的 VLM 上微调;同时,预训练阶段模型已经见过大量数字文本,对数字的语义有一定理解,降低了从零学习动作表示的难度。代价是动作 token 与普通数字文本共享同一表示空间,模型需要仅靠上下文来区分"这是一个动作值"还是"这是一段普通数字"。
RT-2 的具体实现细节:
RT-2 动作 token 设计:
1. 每个动作维度用 256 bins 离散化
2. 动作维度:7 (x, y, z, roll, pitch, yaw, gripper)
3. 总共需要 7 × 256 = 1792 个新 token
新 token 的文本表示:
bin 0 → "0"
bin 1 → "1"
...
bin 255 → "255"
输出序列示例:
"128 140 100 128 128 115 255"
↓ 解析
[128, 140, 100, 128, 128, 115, 255]
↓ 反离散化
[0.0, 0.1, -0.2, 0.0, 0.0, -0.1, 1.0]def generate_action_rt2(model, tokenizer, image_features, instruction):
"""
RT-2 风格:直接生成动作数字的文本表示,再解析为连续值
"""
# 构建提示词,引导模型输出动作序列
prompt = f"Instruction: {instruction}\nAction:"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
# 自回归生成 7 个离散动作值(数字字符串)
outputs = model.generate(
input_ids,
image_features=image_features,
max_new_tokens=7 * 4, # 每个数字最多 4 个字符(如 "255 ")
num_beams=1, # 贪婪解码,保证动作确定性
)
# 解析输出字符串中的 7 个整数
output_text = tokenizer.decode(outputs[0])
action_str = output_text.split("Action:")[-1].strip()
action_bins = [int(x) for x in action_str.split()[:7]]
# 将离散 bin 反映射为连续动作值
action = undiscretize_action(np.array(action_bins))
return action6.6 OpenVLA 的动作 Token 化方案
OpenVLA 对 RT-2 的改进:为动作 token 分配专门的词表空间
相比 RT-2 复用数字文本 token,专用动作 token 的优势在于:模型可以为动作学到独立的 Embedding 表示,不会与原有词表中数字的语义产生冲突(例如"128"在文本中可能表示一个数量,但在动作中表示某个维度的中间值)。这让模型更容易区分"正在输出动作"和"正在输出文字",训练收敛也更稳定。
class OpenVLAActionTokenizer:
"""
OpenVLA 的动作 token 化:添加 256 个专用特殊 token 代替复用数字文本 token,
避免与原有词表语义冲突,也让模型更容易区分"动作输出"和"文字输出"。
"""
def __init__(self, base_tokenizer, num_bins=256):
# 添加 256 个专属动作 token:<action_0> ~ <action_255>
special_tokens = [f"<action_{i}>" for i in range(num_bins)]
base_tokenizer.add_special_tokens({'additional_special_tokens': special_tokens})
self.tokenizer = base_tokenizer
# 预先查好每个动作 token 对应的 ID,推理时直接使用
self.action_token_ids = [
base_tokenizer.convert_tokens_to_ids(f"<action_{i}>")
for i in range(num_bins)
]
def encode_action(self, action, action_min, action_max):
"""将 7-DoF 连续动作编码为 7 个专属动作 token ID"""
action = np.clip(action, action_min, action_max)
normalized = (action - action_min) / (action_max - action_min) # [0, 1]
bins = (normalized * 255).astype(np.int32) # [0, 255]
# 将每维的 bin 映射到对应的专属 token ID
token_ids = [self.action_token_ids[b] for b in bins]
return token_ids6.7 离散化 vs 连续输出
| 方法 | 代表模型 | 优点 | 缺点 |
|---|---|---|---|
| 离散化(Token) | RT-2, OpenVLA | 复用 LLM 能力,训练简单 | 精度受限于桶数 |
| 连续回归 | Octo, 部分方法 | 精度高 | 需要额外的回归头 |
| Diffusion Head | π0 | 处理多模态分布 | 推理速度慢 |
9. 总结
9.1 核心知识速查
| 概念 | 一句话总结 |
|---|---|
| 自回归生成 | 依次生成 token,每个 token 依赖之前所有 token |
| Causal Mask | 上三角掩码,防止看到未来信息 |
| KV Cache | 缓存历史 K/V,避免重复计算 |
| Tokenization | 文本 → token ID 的转换,主流用 BPE |
| SFT | 用(指令,回复)对监督微调 LLM |
| 动作 Token 化 | 把连续动作离散化为 token,让 LLM 直接预测 |
| 贪婪解码 | VLA 推理中最常用,保证动作确定性 |
| LoRA | 参数高效微调,VLA 训练中常用 |
9.2 VLA 中 LLM 的核心要点
1. 输入构成:[视觉 Token] + [语言 Token]
2. 输出目标:预测 [动作 Token]
3. 训练方式:SFT,只在动作部分计算损失
4. 推理方式:贪婪解码,确保动作确定性
5. 微调方法:LoRA,参数高效10. 参考资料
- Attention Is All You Need - Transformer
- Language Models are Few-Shot Learners - GPT-3
- LLaMA: Open and Efficient Foundation Language Models
- Training language models to follow instructions with human feedback - InstructGPT
- RT-2: Vision-Language-Action Models Transfer Web Knowledge to Robotic Control
- OpenVLA: An Open-Source Vision-Language-Action Model
- LoRA: Low-Rank Adaptation of Large Language Models
- HuggingFace Transformers 文档
- HuggingFace PEFT 文档
LLM 解决了「理解语言」的问题。但要让模型看懂图像,还需要另一块拼图——怎么把一张 224×224 的 RGB 图片变成模型能处理的 token 序列?这就是 ViT 要解决的问题。
ViT:让 Transformer 看懂图像
理解了 Transformer 如何处理序列和生成文本,现在来看同一套架构如何'看'图像。
1. 输入表示与 Patch Embedding
1.1 为什么需要 Patch Embedding?
1.1.1 问题背景
Transformer 原本用于处理一维序列,但图像是二维网格。最直接的办法是把每个像素当成一个 token,但这会带来两个问题:
- 224×224 图像会产生 50,176 个像素 token
- 自注意力复杂度是 O(n²),序列过长时计算量不可接受
1.1.2 ViT 的解决方案
ViT 的核心做法是把图像切成固定大小的 Patch,让每个 Patch 对应一个 token:
| 图像尺寸 | Patch 大小 | Token 数量 |
|---|---|---|
| 224×224 | 16×16 | 196 |
| 224×224 | 14×14 | 256 |
| 384×384 | 16×16 | 576 |
这样既保留了局部结构,又把序列长度压到了可接受范围。
1.2 Patch Embedding 的数学原理
在进入公式之前,先建立一个直觉:每个 16×16 的像素块本身就包含了局部纹理、边缘和颜色分布等信息,只是这些信息散落在 768 个原始像素值里,维度高且冗余。线性投影的作用类似于"压缩+旋转"——它学习一组基向量,把高维像素空间中有意义的模式(如边缘方向、颜色梯度)投影到一个更紧凑的特征空间中,使得语义相近的 Patch 在新空间里彼此靠近。这和 NLP 中词嵌入把离散 token 映射到连续向量空间的思路是一致的。
1.2.1 图像分块
设输入图像为 X∈RH×W×C,其中 H 是高度,W 是宽度,C 是通道数。设 Patch 大小为 P×P,则 Patch 数量为
N=P2H×W=PH×PW
1.2.2 展平与线性投影
每个 Patch 展平为向量:
xp∈RP2⋅C
再映射到 D 维隐空间:
zp=xpE+b
其中:
- E∈R(P2⋅C)×D 是投影矩阵
- b∈RD 是偏置
- D 是 Transformer 的隐藏维度
1.2.3 完整公式
Z0=[zcls;z1;z2;...;zN]+Epos
其中:
- zcls:可学习的分类 token
- Epos∈R(N+1)×D:位置编码
1.3 实现方式
1.3.1 先 Reshape 再线性投影
朴素实现的思路很直接:先将图像张量按 Patch 大小在空间维度上 reshape 为 (B, H/P, W/P, P, P, C) 的块结构,再将每个 Patch 内的像素展平为 P²·C 维向量,最后通过一个 nn.Linear 投影到隐空间维度 D。这种写法逻辑清晰,但涉及多次 reshape 和 permute 操作,实际效率不如下面的卷积实现。
1.3.2 使用卷积(等价且高效)
Patch Embedding 等价于一个 kernel_size = stride = patch_size 的卷积操作。
代码:卷积实现 Patch Embedding
class PatchEmbeddingConv(nn.Module):
"""高效实现:使用卷积完成分块+投影"""
def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
super().__init__()
self.img_size = img_size
self.patch_size = patch_size
self.num_patches = (img_size // patch_size) ** 2
self.proj = nn.Conv2d(
in_channels,
embed_dim,
kernel_size=patch_size,
stride=patch_size,
)
def forward(self, x):
x = self.proj(x) # (B, embed_dim, H/P, W/P)
x = x.flatten(2) # (B, embed_dim, N)
x = x.transpose(1, 2) # (B, N, embed_dim)
return x1.3.3 为什么卷积实现等价?
| 线性投影视角 | 卷积视角 |
|---|---|
| 每个 16×16 Patch 展平为 768 维向量 | 16×16 卷积核,stride=16,输出 768 通道 |
| 投影矩阵 (768, 768) | 卷积权重 (768, 3, 16, 16) |
两者数学上等价,但卷积实现更简洁,也更容易利用 GPU 加速。
1.4 位置编码的添加
1.4.1 可学习位置编码(ViT 默认)
代码:完整 ViT Patch Embedding(含 CLS Token 和位置编码)
class ViTPatchEmbed(nn.Module):
"""完整的 ViT Patch Embedding,包含 CLS Token 和位置编码"""
def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
super().__init__()
self.num_patches = (img_size // patch_size) ** 2
self.patch_embed = nn.Conv2d(
in_channels, embed_dim,
kernel_size=patch_size, stride=patch_size
)
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
self.pos_embed = nn.Parameter(torch.zeros(1, self.num_patches + 1, embed_dim))
nn.init.trunc_normal_(self.cls_token, std=0.02)
nn.init.trunc_normal_(self.pos_embed, std=0.02)
def forward(self, x):
B = x.shape[0]
x = self.patch_embed(x) # (B, D, H/P, W/P) - 卷积分块并投影
x = x.flatten(2).transpose(1, 2) # (B, N, D) - 空间维展平,转为序列格式
# 将形状为 (1, 1, D) 的 CLS token 复制 B 份,扩展到 batch 维
cls_tokens = self.cls_token.expand(B, -1, -1) # (B, 1, D)
# 将 CLS token 拼接到 patch 序列最前面,使其能通过注意力聚合全局信息
x = torch.cat([cls_tokens, x], dim=1) # (B, N+1, D)
x = x + self.pos_embed # (B, N+1, D) - 叠加可学习位置编码
return x1.4.2 2D 位置编码(替代方案)
由于 Patch 具有二维空间结构,也可以使用 2D sin-cos Position Embedding(位置编码)。核心思路:将每个 Patch 的行索引和列索引分别用 1D sin-cos 编码,然后将两者拼接成完整的位置向量。这样位置编码天然具有 2D 结构,比 1D 可学习编码更适合不规则分辨率输入(例如推理时换用更大图像)。
为什么 2D sin-cos 比 1D 可学习编码更适合图像?
| 对比维度 | 1D 可学习编码 | 2D sin-cos 编码 |
|---|---|---|
| 位置信息 | 一维序号,隐式包含 2D 结构 | 显式编码行、列位置 |
| 分辨率迁移 | 需要插值,效果一般 | 可自然推广到新分辨率 |
| 参数量 | 有(随 token 数增加) | 无参数,确定性计算 |
| 适合场景 | 固定分辨率分类任务 | 需要处理变长输入的 VLM/VLA |
1.5 可视化理解
1.5.1 Patch 划分示意
原始图像 (224 × 224 × 3)
┌────┬────┬────┬────┬─...─┬────┐
│ P1 │ P2 │ P3 │ P4 │ │P14 │
├────┼────┼────┼────┼─...─┼────┤
│P15 │P16 │P17 │P18 │ │P28 │
├────┼────┼────┼────┼─...─┼────┤
│ │ │ │ │ │ │
... ... ... ... ... ...
├────┼────┼────┼────┼─...─┼────┤
│ │ │ │ │ │P196│
└────┴────┴────┴────┴─...─┴────┘
每个 Patch: 16 × 16 × 3 = 768 维
共 14 × 14 = 196 个 Patch1.5.2 Embedding 过程
图像 切块展平 线性投影 添加位置编码
224×224×3 → [P1, P2, ..., P196] → [z1, z2, ..., z196] → [z1+PE1, z2+PE2, ...]
每个 768 维 每个 768 维 每个 768 维
↓ 添加 CLS Token
最终: [CLS, z1+PE1, z2+PE2, ..., z196+PE196] 共 197 个 Token1.6 关键设计选择
1.6.1 Patch 大小的权衡
| Patch 大小 | Token 数量 | 计算量 | 细节捕捉 |
|---|---|---|---|
| 8×8 | 784 | 高 | 好 |
| 16×16 | 196 | 中 | 中 |
| 32×32 | 49 | 低 | 差 |
常用配置:
- ViT-B/16:Patch 大小 16,Base 规模
- ViT-L/14:Patch 大小 14,Large 规模
- ViT-H/14:Patch 大小 14,Huge 规模
工程实践
为什么现代 VLM/VLA 偏爱 Patch 14(ViT-L/14)? Patch 14 比 Patch 16 粒度更细,能保留更多空间细节——对机器人操控等需要精确定位的任务尤为重要。代价是 token 数从 196 增加到 256(224×224 输入),对 LLM 的序列处理带来一定压力。当输入分辨率提升到 336×336 或 448×448 时,token 数可达 576~1024,这一权衡在高分辨率场景下尤为突出,也是业界持续探索 token 压缩方案的主要动因。
1.6.2 重叠 Patch(Overlapping Patches)
一些改进方法会让 Patch 之间有重叠:
1.6.2.1 重叠 Patch Embedding
self.proj = nn.Conv2d(
in_channels, embed_dim,
kernel_size=16, stride=12 # stride < kernel_size
)优点是能更好地捕捉边界信息,缺点是 token 数增加、计算量上升。
1.7 小结
| 要点 | 说明 |
|---|---|
| 核心作用 | 将 2D 图像转换为 1D Token 序列 |
| 实现方式 | 使用 stride=patch_size 的卷积最简洁 |
| Patch 大小 | 常用 16×16 或 14×14 |
| 输出维度 | (B, N+1, D),包含 CLS Token |
| 位置编码 | 可学习 1D 编码或 2D sin-cos 编码 |
2. ViT 架构、训练与实现
2.1 ViT 架构概览
2.1.1 整体流程
输入图像 (224×224×3)
↓
┌─────────────────────────┐
│ Patch Embedding │ 图像 → Patch 序列
│ + CLS Token │
│ + Position Embedding │
└─────────────────────────┘
↓
┌─────────────────────────┐
│ Transformer Encoder │ L 层堆叠
│ (Multi-Head Attention │
│ + FFN + LayerNorm) │
└─────────────────────────┘
↓
┌─────────────────────────┐
│ Classification Head │ CLS Token → 类别
└─────────────────────────┘
↓
输出 (num_classes)2.1.2 关键组件
| 组件 | 作用 |
|---|---|
| Patch Embedding | 将图像切分为 Patch 并投影到隐空间 |
| CLS Token | 聚合全局信息,用于分类 |
| Position Embedding | 注入位置信息 |
| Transformer Encoder | 特征提取与建模 |
| MLP Head | 将 CLS Token 映射到类别概率 |
2.2 CLS Token 的设计
2.2.1 什么是 CLS Token?
CLS Token(Classification Token)是一个可学习向量,添加在 Patch 序列最前面:
输入序列: [CLS, P1, P2, P3, ..., P196]
↑
可学习参数2.2.2 为什么需要 CLS Token?
| 问题 | CLS Token 的解决方案 |
|---|---|
| 如何聚合所有 Patch 的信息? | CLS Token 通过注意力机制与所有 Patch 交互 |
| 分类时使用哪个 Token? | 使用 CLS Token 的最终表示 |
| 如何避免位置偏差? | CLS Token 不对应具体 Patch,位置中立 |
2.2.3 CLS Token 的工作原理
Layer 1: [CLS] [P1] [P2] ... [P196]
↓ ↓ ↓ ↓
←─────────────────────────→ (Self-Attention)
↓
Layer 2: [CLS'] [P1'] [P2'] ... [P196']
↓
...
↓
Layer L: [CLS*] [P1*] [P2*] ... [P196*]
↓
MLP Head → 分类结果CLS Token 会在多层注意力中不断聚合全局信息。
2.2.4 代码实现
class ViT(nn.Module):
def __init__(self, ...):
super().__init__()
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
nn.init.trunc_normal_(self.cls_token, std=0.02)
def forward(self, x):
B = x.shape[0]
x = self.patch_embed(x) # (B, N, D)
cls_tokens = self.cls_token.expand(B, -1, -1) # (B, 1, D)
x = torch.cat([cls_tokens, x], dim=1) # (B, N+1, D)
x = x + self.pos_embed # (B, N+1, D)
x = self.transformer(x) # (B, N+1, D)
cls_output = x[:, 0] # (B, D)
return self.head(cls_output) # (B, num_classes)2.3 Transformer Encoder
2.3.1 单层 Encoder Block
代码:Transformer Encoder Block
class TransformerEncoderBlock(nn.Module):
def __init__(self, embed_dim, num_heads, mlp_ratio=4.0, dropout=0.0):
super().__init__()
self.norm1 = nn.LayerNorm(embed_dim)
self.norm2 = nn.LayerNorm(embed_dim)
self.attn = nn.MultiheadAttention(
embed_dim, num_heads,
dropout=dropout, batch_first=True
)
hidden_dim = int(embed_dim * mlp_ratio)
self.mlp = nn.Sequential(
nn.Linear(embed_dim, hidden_dim),
nn.GELU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, embed_dim),
nn.Dropout(dropout)
)
def forward(self, x):
x = x + self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0]
x = x + self.mlp(self.norm2(x))
return x2.3.2 Encoder 结构图
输入: (B, N+1, D)
↓
┌───────────────────────────────────────┐
│ ┌─────────────────────────────────┐ │
│ │ Layer Normalization │ │
│ └─────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ Multi-Head Self-Attention │ │
│ └─────────────────────────────────┘ │
│ ↓ ↑ │
│ + ←──────────┘ (Residual) │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ Layer Normalization │ │
│ └─────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────┐ │
│ │ MLP (FFN + GELU) │ │
│ └─────────────────────────────────┘ │
│ ↓ ↑ │
│ + ←──────────┘ (Residual) │
└───────────────────────────────────────┘
↓
输出: (B, N+1, D)
重复 L 次2.4 分类头
2.4.1 标准 MLP Head
class ClassificationHead(nn.Module):
"""ViT 原始论文的分类头"""
def __init__(self, embed_dim, num_classes):
super().__init__()
self.head = nn.Linear(embed_dim, num_classes)
def forward(self, x):
return self.head(x) # x: CLS token 的输出 (B, D)在预训练后微调阶段,也可以在线性层前加入隐藏层(LayerNorm → Linear → GELU → Linear),以提升分类头的表达能力。
2.4.2 全局平均池化(替代方案)
另一种做法是不使用 CLS Token,而是对所有 patch token 取全局平均池化(GAP)作为图像表示,再送入分类头。这种方式更简单且无需额外参数,但会丢失位置信息。
| 方法 | 优点 | 缺点 |
|---|---|---|
| CLS Token | BERT 预训练兼容、位置中立 | 需要额外参数 |
| GAP | 简单、无额外参数 | 丢失位置信息 |
2.5 完整 ViT 实现
代码:完整 ViT 实现
import torch
import torch.nn as nn
class ViT(nn.Module):
"""Vision Transformer 完整实现"""
def __init__(
self,
img_size=224,
patch_size=16,
in_channels=3,
num_classes=1000,
embed_dim=768,
depth=12,
num_heads=12,
mlp_ratio=4.0,
dropout=0.0,
):
super().__init__()
self.patch_embed = nn.Conv2d(
in_channels, embed_dim,
kernel_size=patch_size, stride=patch_size
)
num_patches = (img_size // patch_size) ** 2
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
self.pos_drop = nn.Dropout(dropout)
self.blocks = nn.Sequential(*[
TransformerEncoderBlock(embed_dim, num_heads, mlp_ratio, dropout)
for _ in range(depth)
])
self.norm = nn.LayerNorm(embed_dim)
self.head = nn.Linear(embed_dim, num_classes)
self._init_weights()
def _init_weights(self):
nn.init.trunc_normal_(self.pos_embed, std=0.02)
nn.init.trunc_normal_(self.cls_token, std=0.02)
for m in self.modules():
if isinstance(m, nn.Linear):
nn.init.trunc_normal_(m.weight, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.LayerNorm):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
def forward(self, x):
B = x.shape[0]
# Step 1: Patch Embedding — 卷积分块,输出空间特征图
x = self.patch_embed(x) # (B, D, H/P, W/P)
# Step 2: 展平空间维度,转为 token 序列格式
x = x.flatten(2).transpose(1, 2) # (B, N, D)
# Step 3: 将 CLS token 扩展到 batch 维并拼接到序列头部
cls_tokens = self.cls_token.expand(B, -1, -1) # (B, 1, D)
x = torch.cat([cls_tokens, x], dim=1) # (B, N+1, D)
# Step 4: 叠加位置编码,注入空间位置信息
x = x + self.pos_embed
x = self.pos_drop(x) # Dropout 正则化
# Step 5: 通过 L 层 Transformer Encoder 提取特征
x = self.blocks(x) # (B, N+1, D)
# Step 6: 最终 LayerNorm
x = self.norm(x)
# Step 7: 取 CLS token 的输出(index=0),通过线性分类头
return self.head(x[:, 0]) # (B, num_classes)2.6 ViT 模型配置
2.6.1 标准配置
| 模型 | Layers | Hidden | Heads | MLP | Params |
|---|---|---|---|---|---|
| ViT-Ti (Tiny) | 12 | 192 | 3 | 768 | 5.7M |
| ViT-S (Small) | 12 | 384 | 6 | 1536 | 22M |
| ViT-B (Base) | 12 | 768 | 12 | 3072 | 86M |
| ViT-L (Large) | 24 | 1024 | 16 | 4096 | 307M |
| ViT-H (Huge) | 32 | 1280 | 16 | 5120 | 632M |
2.6.2 模型命名规则
ViT-B/16
│ │
│ └── Patch 大小 (16×16)
└────── 模型规模 (Base)2.6.3 配置说明
各模型变体的具体参数见上方 2.6.1 配置表格,实际使用时只需按表中数值实例化 ViT 类即可。
2.7 训练与微调
2.7.1 数据增强
ViT 对数据增强很敏感,常见策略包括:
- RandomResizedCrop(随机裁剪缩放到 224×224)
- RandomHorizontalFlip(随机水平翻转)
- AutoAugment / RandAugment(自动增强策略)
- Normalize(ImageNet 均值方差标准化)
2.7.2 正则化技术
| 技术 | 作用 |
|---|---|
| Dropout | 随机丢弃神经元 |
| DropPath | 随机丢弃整个层(Stochastic Depth) |
| Label Smoothing | 软化标签,防止过拟合 |
| Mixup/CutMix | 数据混合增强 |
2.7.3 学习率调度
2.7.3.1 Cosine Annealing with Warmup
ViT 训练通常采用先 warmup 再余弦退火的学习率调度:前 5 个 epoch 线性升温到基础学习率(如 1e-3),之后按余弦曲线衰减至最小值(如 1e-5),总训练轮数一般为 300 epoch。
2.8 小结
| 组件 | 设计选择 |
|---|---|
| Patch Embedding | 16×16 卷积,stride=16 |
| 位置编码 | 可学习的 1D 位置编码 |
| Encoder | Pre-Norm,GELU 激活 |
| 分类 | CLS Token + 线性层 |
3. 关键变体与演进
3.1 变体概览
ViT 的后续工作主要围绕三个方向展开:数据效率、层级结构、自监督预训练。
具体来说:DeiT 解决的是"没有海量标注数据时如何训练 ViT"的问题;Swin Transformer 和 PVT 引入层级特征图和线性复杂度注意力,使 ViT 能胜任检测、分割等密集预测任务;MAE 和 BEiT 则探索无标签自监督预训练,让模型从原始图像中学习通用视觉表征。理解这三条线索,有助于在后续章节中判断不同视觉编码器为何适合不同的下游场景。
3.2 ViT 的局限性
ViT 的已知局限
原始 ViT 的几个关键问题:
| 问题 | 描述 |
|---|---|
| 数据饥渴 | 需要 JFT-300M 等超大数据集才能超越 CNN |
| 计算复杂度 | O(n²) 的注意力机制,高分辨率图像计算量较大 |
| 缺乏层级特征 | 单一分辨率,不适合检测、分割等密集任务 |
| 位置编码限制 | 固定分辨率,难以处理可变尺寸输入 |
3.3 DeiT 的数据高效训练策略
ViT 的数据瓶颈能否不靠海量数据来解决?DeiT 的答案是:用已有的 CNN 模型做"教师",通过知识蒸馏把 CNN 积累的归纳偏置传授给 ViT,在 ImageNet-1K 规模下就能训练出有竞争力的模型。
3.3.1 核心贡献
DeiT 证明了只用 ImageNet-1K 也能训练出有竞争力的 ViT。
关键点:
- 知识蒸馏:用 CNN 教师模型指导 ViT 学生模型
- 强数据增强:RandAugment、Mixup、CutMix
- 正则化:Stochastic Depth、Label Smoothing
3.3.2 蒸馏 Token
输入序列: [CLS, Distill, P1, P2, ..., P196]
↑ ↑
分类 蒸馏
Token Token# DeiT 关键设计:在 CLS token 之外新增一个蒸馏 token
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
self.dist_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
# forward 中的核心逻辑
x = torch.cat([cls_tokens, dist_tokens, x], dim=1) # 拼接 CLS + Distill + Patches
cls_output, dist_output = x[:, 0], x[:, 1] # 分别取两个特殊 token 的输出
# 训练时分别计算分类损失和蒸馏损失;推理时取两者预测的平均3.3.3 训练策略
3.3.3.1 蒸馏损失
蒸馏损失由两部分加权组成:hard loss 是学生预测与真实标签之间的交叉熵;soft loss 是学生与教师的 softmax 输出(经温度 T 软化)之间的 KL 散度,乘以 T² 以补偿梯度缩放。最终损失为 α · hard_loss + (1-α) · soft_loss,其中 α 控制两者的权重比例。
3.3.4 DeiT 成果
| 模型 | ImageNet Top-1 | 参数量 | 训练数据 |
|---|---|---|---|
| DeiT-Ti | 72.2% | 5M | ImageNet-1K |
| DeiT-S | 79.8% | 22M | ImageNet-1K |
| DeiT-B | 81.8% | 86M | ImageNet-1K |
| DeiT-B (蒸馏) | 83.4% | 86M | ImageNet-1K |
3.4 Swin Transformer 的层级化与窗口注意力
3.4.1 核心创新
Swin Transformer 主要解决了 ViT 的两类问题:
- 使用层级特征图,逐层降采样
- 使用窗口注意力,把复杂度从 O(n²) 降到 O(n)
3.4.2 架构对比
ViT 架构:
输入 → [Patch 196个] → Transformer × 12 → 输出
固定分辨率 全局注意力
Swin Transformer 架构:
输入 → Stage 1 (56×56) → Stage 2 (28×28) → Stage 3 (14×14) → Stage 4 (7×7)
窗口注意力 Patch 合并 窗口注意力 窗口注意力3.4.3 窗口注意力
# WindowAttention 伪代码
# 1. 将特征图划分为 M×M 的不重叠窗口
# 2. 在每个窗口内独立计算多头自注意力
Q, K, V = linear(x).chunk(3) # 线性投影得到 Q/K/V
attn = softmax(Q @ K.T / sqrt(d) + rel_pos_bias) # 注意力 + 相对位置偏置
out = attn @ V # 加权聚合
# 3. 相邻层交替使用常规窗口和移动窗口(shifted window)
# 移动窗口使不同窗口之间能够交换信息复杂度对比:
- 全局注意力:O((HW)²)
- 窗口注意力:O(HW × M²),其中 M 为窗口大小
3.4.4 移动窗口
Layer L (常规窗口): Layer L+1 (移动窗口):
┌───┬───┬───┬───┐ ┌─┬───┬───┬───┬─┐
│ 1 │ 1 │ 2 │ 2 │ │ │ │ │ │ │
├───┼───┼───┼───┤ ├─┼───┼───┼───┼─┤
│ 1 │ 1 │ 2 │ 2 │ → │ │ A │ A │ B │ │
├───┼───┼───┼───┤ ├─┼───┼───┼───┼─┤
│ 3 │ 3 │ 4 │ 4 │ │ │ A │ A │ B │ │
├───┼───┼───┼───┤ ├─┼───┼───┼───┼─┤
│ 3 │ 3 │ 4 │ 4 │ │ │ │ │ │ │
└───┴───┴───┴───┘ └─┴───┴───┴───┴─┘
移动 (M/2, M/2)3.4.5 Patch Merging
Patch Merging 的作用类似于 CNN 中的下采样:它将相邻 2×2 的 Patch 在通道维度上拼接(维度变为 4C),再通过一个线性层降维到 2C,从而使空间分辨率减半、通道数翻倍。这一操作在每个 Stage 之间执行,构建出类似 FPN 的多尺度特征金字塔。
3.4.6 Swin 模型配置
| 模型 | 层数 [C, 隐藏维度] | 参数量 | ImageNet |
|---|---|---|---|
| Swin-T | [2,2,6,2], C=96 | 29M | 81.3% |
| Swin-S | [2,2,18,2], C=96 | 50M | 83.0% |
| Swin-B | [2,2,18,2], C=128 | 88M | 83.5% |
| Swin-L | [2,2,18,2], C=192 | 197M | 86.4% |
3.5 MAE 掩码自编码器
ViT 的基本结构已经清楚了,但结构只是一半——另一半是"如何训练"。DeiT 解决的是有标签数据不足的问题,而现实中更大的瓶颈往往是标注本身:标注数百万张图片耗时费力。自监督预训练提供了另一条路:让模型从无标签图像中自己学习结构性知识。预训练方式决定了 ViT 学到什么类型的特征,这直接影响在 VLA 中的适用性——接下来介绍视觉领域最具影响力的自监督预训练方案 MAE,以及它为何在需要空间细节的机器人任务中格外出色。
3.5.1 核心思想
MAE 将 NLP 的掩码预训练迁移到视觉领域:
- 随机遮挡 75% 的 Patch
- Encoder 只处理可见 Patch
- Decoder 重建被遮挡的 Patch
3.5.2 为什么 75% 这么高?
| 模态 | 掩码比例 | 原因 |
|---|---|---|
| 文本 (BERT) | 15% | 语言有强语义依赖 |
| 图像 (MAE) | 75% | 图像冗余高,需要更高难度 |
3.5.3 架构设计
原始图像 (196 Patches)
↓
随机遮挡 75% (保留 49 Patches)
↓
┌─────────────────────┐
│ ViT Encoder │ ← 只处理可见 Patch
│ (大,深) │
└─────────────────────┘
↓
添加 Mask Tokens (回到 196)
↓
┌─────────────────────┐
│ 轻量 Decoder │ ← 重建所有 Patch
│ (小,浅) │
└─────────────────────┘
↓
重建像素值3.5.4 代码实现
# MAE 核心逻辑伪代码
# 1. Random Masking — 随机保留 25% 的 Patch
noise = torch.rand(B, N)
ids_keep = argsort(noise)[:, :N*0.25]
x_visible = gather(patches, ids_keep) # 只保留可见 Patch
# 2. Encoder — 只处理可见 Patch(大幅节省计算)
x_encoded = vit_encoder(x_visible) # 大而深的 ViT Encoder
# 3. Decoder — 补回 mask token,重建所有 Patch
mask_tokens = learnable_mask_token.repeat(N*0.75)
x_full = concat(x_encoded, mask_tokens) # 恢复到完整序列
x_full = reorder(x_full, ids_restore) # 按原始位置排列
x_decoded = lightweight_decoder(x_full) # 小而浅的 Decoder
pixel_pred = linear(x_decoded) # 预测每个 Patch 的像素值3.5.5 损失函数
MAE 的损失函数为均方误差(MSE),仅在被遮挡的 Patch 位置上计算预测像素值与真实像素值之间的重建误差,可见 Patch 不参与损失计算。
3.5.6 MAE 的优势
| 优势 | 说明 |
|---|---|
| 训练效率高 | 只处理 25% 的 Patch |
| 强表示学习 | 高遮挡率迫使模型学习全局语义 |
| 无需标签 | 自监督预训练,数据成本低 |
| 迁移能力强 | 下游任务微调效果优秀 |
3.6 其他重要变体
3.6.1 CvT(Convolutional Vision Transformer)
CvT 的核心思路是用卷积替代 ViT 中的线性投影:Patch Embedding 阶段使用多层卷积(如 7×7 + 3×3)逐步降采样,而非一步到位的大 stride 卷积;在注意力模块内部,Q/K/V 的投影也替换为深度可分离卷积(depthwise conv),从而在不增加太多计算量的前提下引入局部归纳偏置。这种设计让 CvT 在小数据集上的表现优于原始 ViT。
3.6.2 PVT(Pyramid Vision Transformer)
| Stage | 分辨率 | Token 数 | 通道数 |
|---|---|---|---|
| 1 | H/4 × W/4 | 3136 | 64 |
| 2 | H/8 × W/8 | 784 | 128 |
| 3 | H/16 × W/16 | 196 | 320 |
| 4 | H/32 × W/32 | 49 | 512 |
3.6.3 CrossViT
┌─────────────────┐ ┌─────────────────┐
│ 小 Patch 分支 │ │ 大 Patch 分支 │
│ (细粒度特征) │ ←→ │ (粗粒度特征) │
│ Patch=12×12 │ │ Patch=16×16 │
└─────────────────┘ └─────────────────┘
│ │
└──────── CLS 融合 ────┘3.6.4 BEiT(BERT Pre-training of Image Transformers)
1. 使用 dVAE 将图像编码为离散 Token
2. 随机遮挡部分 Patch
3. 预测被遮挡位置的 dVAE Token(而非像素)3.7 变体对比总结
| 模型 | 核心创新 | 解决的问题 | 计算复杂度 |
|---|---|---|---|
| ViT | Patch + Transformer | 开创性工作 | O(n²) |
| DeiT | 知识蒸馏 + 数据增强 | 减少数据依赖 | O(n²) |
| Swin | 窗口注意力 + 层级结构 | 高分辨率 + 密集任务 | O(n) |
| MAE | 高比例掩码 + 自监督 | 无标签预训练 | O(n²) |
| PVT | 金字塔特征 | 密集预测任务 | O(n) |
| CvT | 卷积增强 | 引入归纳偏置 | O(n²) |
3.8 如何选择?
场景选型指南
| 场景 | 推荐模型 |
|---|---|
| ImageNet 分类 | DeiT / ViT |
| 目标检测 / 分割 | Swin Transformer |
| 自监督预训练 | MAE / BEiT |
| 小数据集 | DeiT (蒸馏) / CvT |
| 高分辨率图像 | Swin / PVT |
拼图的最后一块:从 ViT 到 VLM
到这里,我们已经攒齐了 VLM 的两块核心积木:Transformer 提供了通用序列建模能力,LLM 用它学会了理解和生成语言,ViT 则让同一套架构能处理图像。接下来的问题是:怎么把视觉 token 和文本 token 拼在一起,让一个模型同时理解图像和语言?
这就是 VLM(Vision-Language Model) 的核心命题,也是本系列后续章节要解决的问题。但在进入融合模块之前,有几个 ViT 的工程结论值得提前记住——它们直接决定了 VLM 系统的设计上限:
ViT 是 VLA 视觉编码器的基础结构。 当前主流视觉编码器——CLIP、SigLIP、DINOv2——全部基于 ViT,区别在于预训练目标而非网络骨干。
Patch 大小直接影响推理成本。 ViT-L/14 在 224×224 输入下产生 256 个视觉 token;若用 448×448 高分辨率,token 数翻至 1024。在机器人 10Hz 控制频率下,每步决策只有 100ms,token 数量直接决定系统能否实时响应。
全 patch token 优于 CLS token。 分类任务只需 CLS token 做全局判断,但机器人操控需要知道「杯子在图像的哪个位置」。所以 VLA 通常将全部 patch token 送入语言模型,这让视觉 token 数量成为系统瓶颈之一,也是 token 压缩方案(如 Q-Former、Perceiver Resampler)在 VLA 中备受关注的原因。
预训练方式决定特征类型。 MAE 保留局部空间细节,适合精细操控;CLIP/SigLIP 赋予强语义对齐能力,适合指令理解。实践中,VLA 往往在语义编码器上补充空间预训练,以兼顾两类能力。
接下来
理解了 Transformer → LLM → ViT 这条链路,你已经具备了阅读 VLM/VLA 论文的基础。后续的主题——CLIP 的对比学习、LLaVA 的融合架构、RT-2 的动作 token 化——本质上都是在这条链路的某个环节上做增强。
5. 参考资料
5.1 ViT 与实现
5.2 变体论文
- DeiT: Training data-efficient image transformers
- Swin Transformer
- Masked Autoencoders Are Scalable Vision Learners
- CvT: Introducing Convolutions to Vision Transformers
- PVT: Pyramid Vision Transformer
- CrossViT: Cross-Attention Multi-Scale Vision Transformer for Image Classification
- BEiT: BERT Pre-Training of Image Transformers
版权所有
版权归属:SAKE
