引言
人类理解世界从来不是单通道的:看到一张图,我们能读出物体、关系、文字和隐含意图。多模态模型要做的,就是让机器也具备这种「图 + 文」联合理解能力——既能给图配文、又能看图答题、还能读懂一张发票或一份 PDF。
这条技术路线的转折点是 CLIP:它用四亿对图文数据、以对比学习的方式,把图像和文本映射到同一个语义空间,第一次让「用文字检索图像」变得可靠。此后的 LLaVA、BLIP-2、Qwen-VL 等模型,都是把 CLIP 式的视觉编码器接上大语言模型,让 LLM 获得「看」的能力。本文按「对齐 → 架构 → 训练 → 评测 → 落地」的顺序,把这条技术栈讲透。
前置:卷积网络与图像基础见 https://plumephp.com/ml-cv-basics/;LLM 微调与指令数据构造见 https://plumephp.com/ml-llm-finetuning-practice/;迁移学习与特征复用思想见 https://plumephp.com/ml-transfer-learning/;图文检索的向量索引见 https://plumephp.com/ml-rag-vector-search/。
目录
- 1. 多模态模型解决什么问题
- 2. CLIP:对比学习与图文对齐
- 3. ViT:把图像变成 patch 序列
- 4. VLM 三段式架构
- 5. 三阶段训练流程
- 6. 视觉编码器与分辨率策略
- 7. 多模态评测
- 8. 落地场景与工程实践
- 9. 总结
- 延伸阅读
1. 多模态模型解决什么问题
1.1 从单模态到跨模态
单模态模型各管一摊:CV 模型输出类别,NLP 模型处理文本,但它们之间没有共同语言。多模态模型要建立共享语义空间,让「一张猫的图片」和「一只猫」这句话在向量空间里靠得很近。
| 任务类型 | 输入 | 输出 | 代表 |
|---|---|---|---|
| 图文检索 | 图 或 文 | 匹配的另一模态 | CLIP |
| 图像描述 | 图 | 文本 | BLIP |
| 视觉问答 | 图 + 问题 | 答案文本 | LLaVA |
| 文档理解 | 文档图 | 结构化文本 | Qwen-VL |
| 文生图 | 文本 | 图像 | Stable Diffusion |
1.2 三条技术路线
- 双塔对比(CLIP):图、文各一个编码器,靠对比学习对齐,适合检索与零样本分类;
- 融合编码(ViLBERT 类):图与文早融合,交互充分但推理慢;
- 编码器 + LLM(LLaVA 类):视觉编码器把图转成 token,喂给 LLM,是当前主流。
一句话:多模态的核心问题是「如何让图像和文本说同一种语言」——双塔对齐解决检索,编码器接 LLM 解决生成与推理,后者是当下的主战场。
1.3 一个贯穿全文的例子
后面所有概念都围绕同一个例子展开:给模型一张「红色杯子放在木桌上」的照片,问它「杯子是什么颜色?」。CLIP 负责让它「看懂」图文对应关系,ViT 负责把像素变成 token,投影层负责翻译成 LLM 能读的向量,指令微调负责让它学会回答。
2. CLIP:对比学习与图文对齐
2.1 对比学习的目标
CLIP 用一批图文对训练:一个 batch 里 N 张图和 N 条文本,模型要让配对的图文相似度最高、不配对的相似度最低。这是一个 N×N 的分类问题——对角线是正样本,其余都是负样本。
文本1 文本2 文本3
图像1 [ 高 低 低 ] ← 对角线上是正样本
图像2 [ 低 高 低 ]
图像3 [ 低 低 高 ]
损失是双向的 InfoNCE:图像→文本方向 + 文本→图像方向,各算一次交叉熵再平均。
2.2 代码:CLIP 的零样本分类
CLIP 最实用的能力是零样本分类——不用训练,直接拿文本当分类器:
import torch
from PIL import Image
from transformers import CLIPProcessor, CLIPModel
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
image = Image.open("cup.jpg")
texts = ["a photo of a red cup", "a photo of a blue cup", "a photo of a cat"]
inputs = processor(text=texts, images=image, return_tensors="pt", padding=True)
with torch.no_grad():
outputs = model(**inputs)
# logits_per_image: 图像与每条文本的相似度
probs = outputs.logits_per_image.softmax(dim=1)
for t, p in zip(texts, probs[0]):
print(f"{t}: {p:.3f}")
这就是「用自然语言当分类标签」的威力:新增类别不用重训,改文本即可。
2.3 温度系数与训练技巧
CLIP 使用可学习的温度系数 τ 控制 softmax 的锐度,初始值 0.07。τ 越小分布越尖锐,梯度越集中。另一个关键是大 batch(CLIP 用了 32768),因为负样本越多,对比信号越强——这也是为什么小规模复现 CLIP 效果总差一截。
| 要素 | CLIP 做法 | 影响 |
|---|---|---|
| Batch size | 32768 | 负样本多,对齐更准 |
| 温度 τ | 可学习,初值 0.07 | 控制分布锐度 |
| 数据量 | 4 亿图文对 | 决定泛化上限 |
| 对称损失 | 图→文 + 文→图 | 双向对齐 |
一句话:CLIP 用对比学习把图文压进同一空间,靠海量负样本和对称 InfoNCE 逼出对齐能力;它的产物——对齐好的图像编码器——正是后续所有 VLM 的视觉前端。
3. ViT:把图像变成 patch 序列
3.1 图像即序列
ViT(Vision Transformer)的核心洞察是:图像不需要卷积,切成 patch 就能当 token 喂给 Transformer。一张 224×224 的图切成 16×16 的 patch,得到 196 个 patch,每个 patch 展平后线性投影成向量,加上位置编码,就变成了一条 197 长的序列(含 CLS token)。
图像 224×224×3
→ 切 16×16 patch → 196 个 patch
→ 每个 patch 展平(768维) → 线性投影 → 196 个 token
→ 加位置编码 + [CLS] → Transformer → 分类
3.2 ViT 的 PyTorch 实现骨架
import torch
import torch.nn as nn
class PatchEmbed(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_ch=3, dim=768):
super().__init__()
self.num_patches = (img_size // patch_size) ** 2
# 用 stride=patch_size 的卷积等价于切 patch + 线性投影
self.proj = nn.Conv2d(in_ch, dim, kernel_size=patch_size, stride=patch_size)
def forward(self, x):
x = self.proj(x) # (B, dim, H/P, W/P)
x = x.flatten(2).transpose(1, 2) # (B, num_patches, dim)
return x
class ViT(nn.Module):
def __init__(self, dim=768, depth=12, heads=12, num_classes=1000):
super().__init__()
self.patch_embed = PatchEmbed(dim=dim)
self.cls_token = nn.Parameter(torch.zeros(1, 1, dim))
self.pos_embed = nn.Parameter(torch.zeros(1, 197, dim))
encoder_layer = nn.TransformerEncoderLayer(dim, heads, batch_first=True)
self.encoder = nn.TransformerEncoder(encoder_layer, depth)
self.head = nn.Linear(dim, num_classes)
def forward(self, x):
x = self.patch_embed(x)
cls = self.cls_token.expand(x.size(0), -1, -1)
x = torch.cat([cls, x], dim=1) + self.pos_embed
x = self.encoder(x)
return self.head(x[:, 0]) # 取 CLS token 做分类
3.3 ViT vs CNN
| 维度 | CNN | ViT |
|---|---|---|
| 归纳偏置 | 强(局部性、平移不变) | 弱(需大量数据学) |
| 数据需求 | 小数据即可 | 需大规模预训练 |
| 全局建模 | 靠堆层扩大感受野 | 注意力天然全局 |
| 与 LLM 衔接 | 需适配层 | 天然是 token 序列 |
ViT 的弱归纳偏置是双刃剑:小数据下不如 CNN,但大数据下上限更高,且序列形式与 LLM 天然兼容——这正是 VLM 选它当视觉前端的原因。
一句话:ViT 把图像切成 patch 序列用 Transformer 处理,牺牲了 CNN 的归纳偏置换取规模上限,同时天然对齐了 LLM 的 token 范式。
4. VLM 三段式架构
4.1 视觉编码器 + 投影层 + LLM
主流 VLM(LLaVA、Qwen-VL、InternVL)的结构高度一致:
图像 → [视觉编码器 ViT/CLIP] → 视觉特征
→ [投影层 Projector] → 视觉 token(对齐到 LLM 词向量空间)
→ [拼接文本 token] → [LLM] → 文本输出
投影层是关键:视觉编码器输出的特征维度和语义空间都与 LLM 的词向量不同,投影层负责「翻译」。最简单的是一层线性层(LLaVA-1.0),进阶的用 MLP(LLaVA-1.5)或 Q-Former(BLIP-2)。
4.2 两种主流投影方案
| 方案 | 结构 | 视觉 token 数 | 代表 |
|---|---|---|---|
| 线性/MLP 投影 | 直接映射每个 patch | 多(256-576) | LLaVA |
| Q-Former | 用 query 压缩 | 少(32-64) | BLIP-2 |
| Perceiver Resampler | 注意力池化 | 少 | Flamingo |
Q-Former 用一组可学习的 query 去「查询」视觉特征,把上百个 patch 压成 32 个 token,大幅降低 LLM 的输入长度,代价是细节信息损失,对 OCR、小目标任务不利。
4.3 LLaVA 的组装代码
import torch
import torch.nn as nn
from transformers import CLIPVisionModel, LlavaForConditionalGeneration
class LlavaProjector(nn.Module):
"""两层 MLP 投影:视觉特征 → LLM 词向量空间"""
def __init__(self, vision_dim=1024, llm_dim=4096):
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(vision_dim, llm_dim),
nn.GELU(),
nn.Linear(llm_dim, llm_dim),
)
def forward(self, vision_features):
return self.mlp(vision_features)
# 实际推理直接用 transformers 的整合模型
model = LlavaForConditionalGeneration.from_pretrained(
"llava-hf/llava-1.5-7b-hf", torch_dtype=torch.float16, device_map="auto"
)
from transformers import AutoProcessor
processor = AutoProcessor.from_pretrained("llava-hf/llava-1.5-7b-hf")
conversation = [
{"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": "图中杯子是什么颜色?"},
]},
]
prompt = processor.apply_chat_template(conversation, add_generation_prompt=True)
inputs = processor(images=image, text=prompt, return_tensors="pt").to("cuda")
out = model.generate(**inputs, max_new_tokens=64)
print(processor.decode(out[0], skip_special_tokens=True))
一句话:VLM = 视觉编码器(看懂)+ 投影层(翻译)+ LLM(推理),投影层决定视觉信息以多少 token、什么粒度进入 LLM,是精度与成本的核心旋钮。
5. 三阶段训练流程
5.1 阶段一:对齐预训练
只训练投影层,冻结视觉编码器和 LLM。数据是海量图文对(CC3M、LAION 子集),目标是让投影后的视觉 token 能被 LLM「读懂」。这一阶段便宜、快,通常几小时到一天。
冻结:视觉编码器 ❄️ + LLM ❄️
训练:投影层 🔥
数据:图文对(caption 任务)
5.2 阶段二:指令微调
解冻 LLM(或加 LoRA),用多模态指令数据训练,让模型学会按指令回答问题。数据是关键瓶颈:LLaVA 用 GPT-4 把图像描述扩写成多轮问答(158K 条),开创了「用强模型造多模态指令数据」的范式。
| 数据类型 | 示例 | 作用 |
|---|---|---|
| 对话 | 多轮关于图的问答 | 交互能力 |
| 细节描述 | 详细描述图中每个物体 | 感知能力 |
| 复杂推理 | 需要多步推理的问题 | 推理能力 |
| OCR/文档 | 读发票、表格 | 文字识别 |
5.3 阶段三:任务特化
最后按业务做领域微调(医疗影像、工业质检、票据识别)。这一步和普通 LLM 微调无异,用 LoRA 就能跑:
from peft import LoraConfig, get_peft_model
lora_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "v_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters() # 通常 < 1%
一句话:VLM 训练遵循「先对齐、后指令、再特化」的三段式——对齐阶段只训投影层成本极低,指令阶段决定通用能力,特化阶段才谈业务精度。
6. 视觉编码器与分辨率策略
6.1 分辨率的困境
CLIP 原版固定 224×224 输入,但真实文档、表格、截图分辨率远高于此。直接下采样会丢失小字,导致 OCR 全错。解决思路是切图(tiling):把高分辨率图切成多个 224×224 的 tile,各自编码后拼接。
| 策略 | 视觉 token 数 | 适用 |
|---|---|---|
| 固定低分辨率 | 256 | 通用场景 |
| 动态分辨率 | 可变 | 文档/OCR |
| 切图 + 缩略图 | 多 tile + 1 全局 | 高分辨率(Qwen-VL) |
| AnyRes | 自适应 | 复杂版面 |
6.2 视觉 token 压缩
视觉 token 越多,LLM 的注意力开销越大(平方级)。压缩手段包括:池化(相邻 patch 平均)、重采样(Q-Former)、token merging(相似 token 合并)。目标是在保留关键信息的前提下,把 576 个视觉 token 压到 64-128 个。
6.3 训练中的图像增强
多模态训练的数据增强要小心——水平翻转会破坏文字方向,随机裁剪可能切掉关键物体。安全增强包括:轻微色彩抖动、小幅度缩放、JPEG 压缩模拟。OCR 任务几乎不做几何增强。
一句话:分辨率决定 VLM 能看清多细的东西,切图是提升分辨率的主流方案,但视觉 token 数会随之膨胀,必须配合压缩手段控制 LLM 的开销。
7. 多模态评测
7.1 主流基准
| 基准 | 考察能力 | 形式 |
|---|---|---|
| VQAv2 | 通用视觉问答 | 选择题/开放题 |
| GQA | 组合推理 | 结构化问答 |
| TextVQA | 图中文字理解 | 开放题 |
| MMBench | 综合能力 | 选择题(中英) |
| MMMU | 大学级学科推理 | 多学科选择题 |
| POPE | 幻觉检测 | 是/否(物体是否存在) |
7.2 幻觉:VLM 的头号问题
VLM 常「一本正经地胡说」:图里没有的东西它说有。POPE 专门测这个——问模型图中是否存在某物体,统计 yes/no 的准确率与偏向。幻觉来源有二:视觉编码器没提取到该信息、LLM 语言先验压过了视觉证据。
缓解手段:
1. 提高分辨率 / 切图,让模型真的看见
2. 指令微调数据里加入"图中没有X"的负样本
3. 解码时用视觉对比(VCD):对比有图/无图时的输出差异
4. 用更强视觉编码器 + 更多视觉 token
7.3 评测的坑
- 选择题基准会被「猜」影响:随机猜对率 25%,要报告相对提升;
- 训练数据泄漏:很多 VLM 在评测集上训过,数字虚高;
- prompt 敏感:换个提问方式分数波动大,要固定模板。
一句话:多模态评测不能只看综合分,要拆到 OCR、推理、幻觉各维度;幻觉是 VLM 最致命的缺陷,靠分辨率、负样本和视觉对比解码三管齐下缓解。
8. 落地场景与工程实践
8.1 场景与模型选型
| 场景 | 推荐模型规模 | 关键能力 |
|---|---|---|
| 通用图文问答 | 7B | 指令跟随 |
| 文档/票据理解 | 7B + 高分辨率 | OCR |
| 工业质检 | 2-7B 特化微调 | 缺陷识别 |
| 图文检索 | CLIP 类双塔 | 向量对齐 |
| 端侧视觉助手 | 2B 量化 | 低延迟 |
8.2 与 RAG 结合
VLM 也可以接检索增强:把图片描述、文档页存进向量库,检索后再让 VLM 基于证据回答,减少幻觉。
# 图文检索的向量化(复用 CLIP)
import torch
from transformers import CLIPModel, CLIPProcessor
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
proc = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
def embed_image(img):
inputs = proc(images=img, return_tensors="pt")
with torch.no_grad():
return model.get_image_features(**inputs)
def embed_text(text):
inputs = proc(text=[text], return_tensors="pt", padding=True)
with torch.no_grad():
return model.get_text_features(**inputs)
8.3 部署成本控制
VLM 的推理成本 = 视觉编码 + LLM 生成。视觉部分通常是一次前向(非自回归),开销固定;瓶颈仍在 LLM 生成。优化手段:压缩视觉 token 数、用 4bit 量化、把视觉特征缓存(同一张图反复提问时复用)。
# 用 vLLM 部署 VLM(支持多模态输入)
python -m vllm.entrypoints.openai.api_server \
--model llava-hf/llava-1.5-7b-hf \
--dtype float16 --max-model-len 4096 --gpu-memory-utilization 0.9
一句话:VLM 落地先按场景选规模,文档类必须上高分辨率,工业类必须做特化微调;工程上视觉编码是固定开销,控制视觉 token 数与量化是降本主线。
9. 总结
9.1 技术栈全景
对齐层:CLIP 对比学习 → 共享语义空间
视觉层:ViT → patch 序列(可换 SigLIP、DINOv2)
桥接层:投影层 MLP / Q-Former → 视觉 token
语言层:LLM → 推理与生成
训练层:对齐 → 指令微调 → 特化
9.2 关键决策点
| 问题 | 选择 |
|---|---|
| 只做图文检索 | CLIP 双塔,别上生成模型 |
| 需要问答与推理 | 7B VLM(LLaVA/Qwen-VL) |
| 文档 OCR 要求高 | 高分辨率切图 + 大视觉 token 预算 |
| 视觉 token 太多、太慢 | Q-Former / 池化压缩 |
| 幻觉严重 | 提高分辨率 + 负样本 + VCD |
| 端侧部署 | 2B 量化模型 |
9.3 一句话心法
多模态的本质是「把图像翻译成 LLM 能读的语言」——CLIP 解决了对齐、ViT 解决了表示、投影层解决了翻译,剩下的精度问题几乎都落在「视觉信息够不够细」和「训练数据够不够好」这两件事上。
延伸阅读
- https://plumephp.com/ml-cv-basics/ — 卷积网络与图像任务的通用基础
- https://plumephp.com/ml-llm-finetuning-practice/ — LoRA/QLoRA 与指令数据构造
- https://plumephp.com/ml-transfer-learning/ — 预训练特征复用与微调范式
- https://plumephp.com/ml-rag-vector-search/ — 图文向量检索与索引构建
- AI/ML 专题 — 多模态与视觉算法深度文章
- LLaVA 项目主页
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。