资讯动态

图像与视频如何变成软Token?视觉Token化全流程详解

发布时间:2026/10/26 13:18:24 来源:尧图企业网站定制
你平时调大模型接口输入是一段文字输出也是文字。可一旦牵扯到图片理解、视频分析事情就变得微妙起来——照片明明是一堆像素凭什么能塞进基于语言训练出来的模型里这里头最关键的一道关口就是把图像信号转成模型能吃的“软Token”。我这两年做多模态相关项目几乎天天跟“视觉输入”这个词打交道今天就把照片和视频帧是怎么一步步变成软Token这件事从原理到实操完整捋一遍顺便分享一些不跑一遍绝不会知道的细节。先说一个所有做多模态的人都绕不开的困惑图片分辨率动辄上百万像素模型算力再强也不可能逐个像素去理解。那么问题来了我们到底该把图片“切成什么形状”、把视频“抽成什么节奏”才能让大模型既不丢失关键语义又不至于把算力烧成灰这篇文章就是围绕这个问题展开的。适合正在入门多模态、想做图文理解或视频问答、以及想搞清楚LMM大型多模态模型最底层输入格式的朋友尤其是那些已经在跑CLIP、LLaVA、Qwen-VL这类模型但还不清楚视觉分支“前几层”到底做了什么的人。看了这篇文章你会对从图像到Token的全链路有一个彻底清晰的认知并且可以直接上手验证“我的视频到底会变成多少个Token”这个问题。1. 照片为什么进不了大模型的嘴像素矩阵与Token的本质差异1.1 大模型只会读变长序列不会读“图像”文本大模型的核心输入形式是Token序列——一串带位置信息的离散向量。Transformer的自注意力机制本质上是在一堆向量之间两两计算关联度。不管是GPT还是Llama它们对输入的唯一要求就是给我一个形状为 [seq_len, hidden_size] 的二维矩阵。而图片是什么最常见的表示是一个三维数组 [height, width, channels]通道数通常是3RGB。你不可能直接把三维矩阵塞进一个期望二维输入的模型里就算强行 reshape 成一个长条向量模型也不知道哪里是“上”、哪里是“相邻”更可怕的是计算量完全不可接受。所以问题的本质变成了如何把三维像素信息转化成一个模型能理解、能参与注意力计算的一维Token序列而且这个转化过程要尽量保留视觉语义。1.2 直接拉平像素为什么行不通最粗暴的想法是把每个像素当作一个特征维度拉平成一个超长向量。比如一张 512x512 的RGB图拉平之后是 512x512x3786432 个数字。放在 FFN前馈网络里做一次线性变换参数就是百万级别起步多头注意力更是直接瘫痪。而且更致命的是——像素与像素之间的关系是强局部的这种局部性完全被一维拉平破坏了。业界早期走的是另一条路用CNN做特征提取把图片浓缩成一个小尺寸的特征图再想办法接入Transformer。但在ViT视觉Transformer提出来之后思路变了干脆把图片当成一堆“小图片块”的集合把这个集合按顺序排列成一个Token序列。这也是照片能变成Token的最根本前提——图片本质上也是序列只不过这个序列的元素不是词而是被切出来的图像块。2. 一张图变Token的三道工序Patch切分、线性投影与位置编码2.1 Patch切分把整张图切成小豆腐块以ViT最经典的配置为例输入图片通常是 224x224patch_size 是 16x16。224除以16等于14也就是说图片被切成了 14x14196 个小块每个小块是一个只有3个通道的 16x16 区域。你可以把这件事类比成拼图游戏整幅画太大机器一口吃不下我们就按固定大小切成拼图片把每一片单独编码再按顺序把拼图片排成一串。这些“拼图片”就是视觉Token的前身。这里有一个关键选择patch_size 为什么是16而不是4或者32如果patch太小比如4x4224x224会生成 56x563136 个Token算力直接上升一个量级自注意力的复杂度是Token数量的平方3136的平方比196的平方大了255倍还要多。如果patch太大比如32x32Token数量降到了 7x749算力是省了但每个小块承载的语义过于混杂边缘细节、纹理信息被严重稀释下游任务精度会下滑。所以16x16是在“Token数量可控”和“语义粒度足够细”之间取得的平衡点。现代一些模型改成14x14配合分辨率提升比如把输入改成336x336原因是336除以14正好是24可以多出细节又不会让序列爆炸。2.2 线性投影每一个小豆腐块都会“说人话”切出196个小块之后呢每个小块还是一个 16x16x3 的像素矩阵这一步需要把它压缩成一个向量。具体做法是把每个patch拉平成一维向量维度是 16x16x3768然后过一个可学习的线性层也就是一个矩阵乘法token_vector flatten(patch) W_proj b_projW_proj 的形状一般是 [768, hidden_size]如果你用的是ViT-Basehidden_size768乘出来就是一个768维的向量。这整个操作叫“Patch Embedding”。这个线性层到底在干吗一句话把768维的原始像素信息压缩、重排成768维的语义空间向量。这个过程很像查字典——只不过这个字典是通过训练学出来的。像素值本身是一个绝对物理量模型很难直接拿来做语义对比但经过线性变换之后每个patch向量所处的位置就落在了一个“语义空间”里相近语义的图像块在这个空间里的距离更近。2.3 把位置信息编进去不然零件还是那堆零件光有196个向量还不够。Transformer的注意力机制本身是不感知顺序的它把所有输入向量当成一堆无序的集合。对文本来说我爱你和你爱我完全反义对图像来说左上角的猫和右上角的猫天差地别。所以必须给每个Token补一个位置编码。最常用的方案是可学习位置编码初始化一个 [196, 768] 的可训练矩阵加到patch embedding上。token_sequence patch_embeddings pos_embeddings在图像这个场景里位置编码的意义甚至比文本更复杂。文本的位置是一维的“第几个词”图像的位置是二维的“第几行第几列”。所以有些实现会把位置编码拆成行方向列方向来处理比如Swin Transformer里就用到了相对位置偏移表。但ViT的做法相对简单粗暴直接用二维可学习位置编码效果也足够好。2.4 那个特殊的CLS Token整张图的“代言人”在ViT的标准实现中所有patch token前面还会拼接一个特殊的可学习向量叫CLS token它自己也有一个专属位置编码。这一步很多人不理解为什么要塞一个啥内容都没有的向量进去打个比方CLS token就像班级里选出来的班长他本身不需要交作业但每次老师提问分类任务都让他来回答。通过训练模型学会了把“整张图总结出来的全局信息”浓缩到这个向量里。在预训练阶段这个向量后面接的是对比学习或者分类头在微调阶段它也可以直接作为整个图像的代表向量。但它不是必须的——一些模型比如DeiT变体会用所有token的平均池化来代替CLS也能得到不错的效果只是ViT从架构上沿袭了BERT的习惯保留了CLS token这一路。做完这四步一张 224x224 的照片就变成了一个 1x197x768 的张量其中197是196个patch token加1个CLS token。这就是照片变成Token的完整链路。3. 视频帧的专属难题时间轴、冗余帧与计算量爆破3.1 视频的本质是一堆长得差不多的照片如果说照片是一帧那视频就是一连串帧。一个10秒的视频30fps的话就是300帧。每一帧拿出来都可以单独走一遍第二节的流程。但问题来了——如果简单粗暴地把300帧每帧都切成196个Token那就是58800个Token。就算主流模型的上下文窗口是128K这也占据了小半壁江山自注意力计算量更是呈平方级爆炸。而且别忘了视频相邻帧之间存在大量冗余同一只猫在镜头前待了3秒这期间它可能只是耳朵动了动背景基本没变。如果每一帧都独立切patch、独立编码模型会接收到海量几乎一样的信息纯粹的浪费。3.2 视频Token化的主流思路先抽帧再编码目前实际项目中处理视频输入最主流的做法不是“逐帧全部处理”而是“先采样抽帧再对采样帧做图像Token化”。比如一个5秒的视频我们每秒抽1帧一共5帧每帧得到196个Token合计980个Token。这个数量级就能被绝大多数LLM大语言模型接受了。关键问题在于怎么抽均匀抽帧每隔固定间隔取一帧简单可靠比如fps1就是每秒一帧。关键帧抽帧只取I帧关键帧这些帧是完整编码的信息密度最高B帧和P帧只是差分单独取出来没有意义。这种方式适合做视频摘要。场景切分抽帧先做镜头检测每个镜头取代表性的几帧。复杂一点但效果最好适合长视频。3.3 把时间维写进Token3D Patch和Tube Embedding抽完帧之后每一帧依然是独立的图像。但视频本质上是有时间维度的——同一只猫在第1秒和第5秒出现模型应该知道它的动作轨迹。所以只做纯空间Token化还不够得把时间信息也编进去。学术界有一系列做法最经典的是Tube Embedding把连续几帧里处在同一空间位置的patch堆叠成一个“管道”式的3D块然后对这个3D块整体做embedding相当于一次性把时空信息编码进一个Token。另一个代表是TimeSformer它先把每一帧单独做空间注意力再做跨帧的时间注意力把注意力的计算拆成两个阶段大幅降低了计算复杂度。工业界落地的时候更简洁直接给每一帧的Token序列拼接一个“帧序号”编码或者把帧序号当作一个额外的特殊Token拼在每帧开头。这种方式有点像是给视觉Token加上了“第几集”的标签让模型知道信息在时间轴上的排布。3.4 一个易被忽视的边界视频长并不等于Token越多越好我会在后面的实操部分给出具体计算。但这里先点一句如果你的模型上下文窗口是4096那么Token预算大概只有4000个。按每秒1帧、每帧196个Token来算20秒视频就已经3920个Token几乎占满。所以一旦项目涉及长视频你必须提前设计好Token压缩率而不是任由抽帧数量膨胀。常用的压缩技巧降分辨率把每帧从224x224降到112x112patch不变的情况下Token数从196降到49。增加patch_size把16改成32Token数直接除以4。增加抽帧间隔从fps1改成fps0.5每秒只取半帧即2秒一帧。帧融合把相邻4帧的Token做池化产生一个聚合Token兼顾连续性和压缩率。4. 从ffmpeg查询到逐帧导出亲手把一段视频变成Token矩阵概念讲完了这一节我们上手。我用ffmpeg把一段视频逐帧导出再用Python模拟Patch Embedding整个过程能让你“看见”Token是怎么从视频里长出来的。4.1 ffprobe先摸清视频的底细处理任何视频之前第一步都是搞清楚它有多少帧、什么分辨率、什么帧率。我一般用ffprobe它是ffmpeg家族里的“侦察兵”。命令如下ffprobe -v error -select_streams v:0 \ -show_entries streamwidth,height,r_frame_rate,duration \ -of defaultnoprint_wrappers1 input.mp4输出大概是这样的width1920 height1080 r_frame_rate30/1 duration10.000000这几个参数直接决定了你的Token预算帧率是30fps时长10秒共300帧。如果每帧都转Token以224x224、patch16计算就是300x19658800个Token。这显然不现实所以接下来要按策略抽帧。4.2 ffmpeg按需求抽帧两种最常见方式场景一均匀抽帧每秒取一帧。mkdir -p frames ffmpeg -i input.mp4 -vf fps1,scale224:224 frames/frame_%04d.jpg这条命令做了两件事按每秒1张的频率抽帧同时把每帧缩放到224x224。frame_%04d.jpg的意思是输出文件名按 frame_0001.jpg、frame_0002.jpg 这样递增。如果你的输入是1080p直接缩到224x224会变形所以在生产中通常还会加上crop逻辑先按中心裁剪成正方形再缩放ffmpeg -i input.mp4 -vf cropih:ih:(iw-ih)/2:0,scale224:224,fps1 frames/frame_%04d.jpg这个裁剪表达式解释一下cropih:ih表示裁剪区域的宽和高都等于原视频的高这样就得到一个正方形(iw-ih)/2是居中偏移。生产项目里推荐用这个避免画面被拉伸失真。场景二只取关键帧适合做视频摘要。ffmpeg -i input.mp4 -vf selecteq(pict_type,I),scale224:224 -vsync vfr frames/keyframe_%04d.jpgeq(pict_type,I)是筛选条件只保留I帧-vsync vfr表示不强制补齐缺失的帧数。4.3 Python模拟Patch Embedding把一张帧变成Token矩阵帧导出之后我用Python来模拟ViT的Patch切分和线性投影。先读入一张帧切成16x16的patch拉平后乘一个模拟的投影矩阵。完整代码如下import cv2 import numpy as np img cv2.imread(frames/frame_0001.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # BGR转RGB H, W, C img.shape print(f输入图像尺寸: {H}x{W}x{C}) patch_size 16 num_patches_h H // patch_size num_patches_w W // patch_size num_patches num_patches_h * num_patches_w print(fPatch数量: {num_patches_h}x{num_patches_w}{num_patches}) hidden_size 768 patch_dim patch_size * patch_size * C # 16*16*3768 patches np.zeros((num_patches, patch_dim)) idx 0 for i in range(num_patches_h): for j in range(num_patches_w): patch img[i*patch_size:(i1)*patch_size, j*patch_size:(j1)*patch_size] patches[idx] patch.flatten() idx 1 # 模拟线性投影层 W_proj: [patch_dim, hidden_size] np.random.seed(42) W_proj np.random.randn(patch_dim, hidden_size).astype(np.float32) / np.sqrt(patch_dim) b_proj np.zeros(hidden_size, dtypenp.float32) token_embeddings patches W_proj b_proj # shape: [196, 768] print(fToken矩阵形状: {token_embeddings.shape})这里patch_dim768是个有趣的巧合——16x16x3正好也是768和ViT-Base的hidden_size一样。很多初学者会以为这个数字有某种深意其实纯属维度巧合patch拉平后是768维经过W_proj映射到768维恰好不升不降。如果是ViT-Largehidden_size1024维度就从768升到1024这才是正常情况。4.4 加入位置编码和CLS Token拿到token_embeddings后再接上位置编码和CLS token# 可学习位置编码: [196, 768] pos_embeddings np.random.randn(num_patches, hidden_size).astype(np.float32) * 0.02 # CLS token 对应位置编码 cls_token np.random.randn(1, hidden_size).astype(np.float32) * 0.02 cls_pos np.random.randn(1, hidden_size).astype(np.float32) * 0.02 final_tokens np.concatenate([cls_token cls_pos, token_embeddings pos_embeddings], axis0) print(f最终输入Transformer的序列形状: {final_tokens.shape}) # [197, 768]到这里“照片变成软Token”的最后一环也完成了。如果把整个视频5帧都压进来得到的形状就是[5*197, 768]也就是985个Token。这个序列可以直接拼进任何标准的Transformer Block里面做注意力计算。5. 软Token到底“软”在哪视觉Token与文本Token的本质差异5.1 文本Token是离散的“硬编码”视觉Token是连续的“软向量”为什么我们反复强调“软Token”要理解这件事得先看看文本Token是怎么来的。文本走的是Tokenizer一句话先被切成词或子词然后每个词查词表拿到一个整数ID。举个直观的例子“猫”查表得到ID值是 8934“狗”查表得到 6732。这个ID是离散的、唯一的、不可微的——你不可能给“猫”这个ID加上0.1它就不再是“猫”了中间没有任何可导的梯度通路。这是“硬Token”。视觉Token完全不是这个路数。我们前面做的 Patch Embedding输入是连续的像素值过了一个可学习的线性层输出还是连续向量。这里没有查表、没有离散化、没有argmax每一段都是可导的。所以你在第五章节看到的那个模拟的W_proj矩阵在实际训练中会被梯度持续更新从而不断调整“什么样的像素组合应该落在语义空间的什么位置”。软Token的核心价值就在于可微与可学习。5.2 软Token让视觉和文本落在同一个向量世界里还有一个非常关键的细节做完Patch Embedding之后视觉Token的维度是 hidden_size比如768文本Token的embedding维度也是hidden_size。这两者可以直接拼接到同一个序列里一起进Transformer。这正是LLaVA这类模型的做法图片走CLIP的视觉编码器得到576个Token336x336分辨率patch14文本走语言模型的embedding二者拼成一个混合序列。模型在这个混合序列上做自注意力时视觉Token和文本Token会互相做attention——图片里的“红色圆形”可以和文字里的“苹果”产生关联。跨模态对齐就是在这个“共享向量空间”里完成的。而软Token还有一层更极致的玩法在一些Prompt Tuning方法里甚至可以在输入序列前面加一串纯随机的、没有实际语义的连续向量通过训练让这串向量学会携带特定任务的信息。这种向量也被叫作“软提示”和视觉软Token在形态上是一家人——它们都是连续的、可学习的、作为模型输入前缀参与计算的向量。5.3 视觉Token的“语义密度”远低于文本Token这里要泼一盆冷水虽然照片变成了和文本同构的Token序列但这并不代表197个视觉Token的信息量能抵得上197个文本Token。实际情况是一个文本Token往往携带了高度浓缩的语义比如“苹果”两个字就激活了完整的概念而一个视觉Patch Token通常只描述了画面中很小的一个局部纹理。这就是为什么视觉任务通常需要比文本任务多得多的Token才能get到同样的语义——一张图196个Token很多模型还是需要预先用CLIP做对齐、或者在后端接一个Query Transformer来做注意力池化如Flamingo的做法本质都是在压缩视觉Token的语义冗余。5.4 从软Token到理解视觉Token只是“原材料”最后强调一个常见认知误区视觉Token并不是“模型看懂了图片”的最终形态。它只是把图片切碎、编码、排序后的“原材料”。模型真正理解图片靠的是后面几十层Transformer对这批Token做多轮注意力计算——某一层关注到猫的耳朵和眼睛的Token关系另一层把“毛茸茸”的纹理Token和“猫”的语义Token关联起来。所谓多模态理解底层也就是这些Token之间一层层做attention的结果。6. 做视觉Token化时我踩过的坑和现在的处理习惯6.1 别盲目堆分辨率位置编码会先崩溃有一段时间我为了提升精度直接把输入图片从224x224加到448x448patch_size保持16不变。Token数量直接从196变成了784。模型倒是没报错但效果反而变差了。排查后发现两个问题第一预训练模型的位置编码是196个位置训练出来的你强行塞784个位置新位置没有被训练过模型“不认识”这些位置第二即便用插值强行拉伸位置编码高频细节的位置关系也已经被破坏。后来的处理习惯是如果确实需要高分辨率优先考虑保留局部信息的混合架构比如把高分辨率的小图patch和低分辨率的全局Token同时输入而不是简单放大分辨率。实测下来用多尺度融合的效果比单张高分辨率输入要稳定得多。6.2 抽帧别贪多Token永远比你想象的更容易爆还是那句话10秒30fps的视频逐帧Token化是58800个Token。这在很多模型里已经逼近或超过输入上限了。我做视频理解项目时每段视频统一用每秒1帧、224x224、patch16的标准配置Token数大概980个/10秒视频。如果需要更高时间精度我会把帧率提到2fps但同步降低分辨率到168x168会得到 10x10100 个patch/frame总Token数控制在 2fps x 10秒 x 100patch 2000左右。先算后跑养成习惯。6.3 ffmpeg输出格式的坑千万别忽略位深和色彩空间这个坑我印象太深了。ffmpeg默认导出jpg是8bit RGB但如果你的源视频是10bit HDR导出的画面色调会发灰、发暗。模型喂这种图embedding出的Token和训练分布偏差极大精度会掉得莫名其妙。现在的处理习惯是在ffmpeg命令里显式加色彩空间转换ffmpeg -i input.mp4 -vf cropih:ih:(iw-ih)/2:0,scale224:224,fps1,formatyuv420p,eqgamma1.0 frames/frame_%04d.jpgformatyuv420p是确保输出格式统一eqgamma1.0是我常用的色彩校正保护。这种问题在纯看视频时发现不了但一旦做视觉Token化所有像素差异都会通过Embedding被放大进语义空间不可不防。6.4 生产环境的建议直接把Token化搬进Dataloader最后一个经验送给准备做工程的同学千万别把所有帧先导成jpg存盘再训练的时候读盘转Token。这么做的IO开销和时间成本完全是浪费的。正确做法是在Dataloader里用PyAV或OpenCV直接读视频流在内存里完成抽帧、缩放、Patch化CPU算子解包直接出Token。我这边生产环境用的是一套基于PyAV的读取管线4秒内可以完成一段10秒视频的全部Token化比“ffmpeg导图OpenCV读图”的方案快了一个数量级。这样做还有一个额外好处抽帧参数和图像增强可以直接联动比如每帧做随机裁剪和色彩抖动时Token的自然分布也会跟着变化模型泛化性会好不少。视觉Token化是一条不复杂但细节极多的链路。从照片的Patch切分到视频帧的抽帧策略再到软Token如何在神经网络的梯度中“呼吸”每一步都决定了最后模型能否真的“看懂”画面。我自己最大的体会是动手跑一遍哪怕只是把几行模拟代码执行一次对这套机制的理解会完全不一样。尤其是你自己亲手把一段视频变成了一堆数字矩阵之后再去读各种多模态模型的代码会顺畅很多——因为它们绝大多数工作都在处理你看过的那种Token序列。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑