【玩转VLA具身智能机械臂】(八):OpenVLA——从视觉语言理解到机器人动作生成
前言
- 最近 VLA(Vision-Language-Action,视觉-语言-动作)具身智能发展迅速,从谷歌的
RT-2、OpenVLA到π0,大模型开始直接输出机器人动作,而这一切的物理载体正是机械臂 - 回顾往期内容,我们已经比较完整地完成了传统机械臂相关内容的学习。从机械臂本体建模、运动学与动力学,到运动规划、视觉避障,再到点云分割、抓取与仿真环境搭建,基本覆盖了一套传统机械臂系统从建模、感知、规划到执行的完整链路。
- 第一期:
- 第二期: 【玩转VLA具身智能机械臂】(二):MoveIt2 运动规划——架构、配置与 C++ 上手
- 第三期:【玩转VLA具身智能机械臂】(三):视觉避障——从 RGB-D 点云到 MoveIt octomap 的完整落地
- 第四期:【玩转VLA具身智能机械臂】(四):点云分割与 OBB 夹取——从 PCA 失效到主动扫描
- 第五期:【玩转VLA具身智能机械臂】(五):GraspNet 6-DoF 抓取——从网络提议到筛选执行
- 第六期:【玩转VLA具身智能机械臂】(六):MuJoCo 与 Pinocchio 双引擎——从底层动力学对表到末端画圆
- 第七期:【玩转VLA具身智能机械臂】(七):ACT——从单步动作到 Action Chunking 的机器人模仿学习
- 同时我们的另一个实战部署系列也在同步进行:
- 自此,我们已经完成了从传统机械臂建模、运动规划、视觉感知,到模仿学习与真机部署的前置学习。
- 接下来,我们正式进入 VLA(Vision-Language-Action,视觉-语言-动作)模型。本期将先介绍 VLA 的基本概念与主流技术路线,再以开源模型 OpenVLA 为例,深入分析其模型架构、动作表示、自回归生成机制与训练流程,并为后续的模型微调和实机部署打下基础。

文章目录
- 前言
- 1 VLA
- 2 OpenVLA
- 3 Llama 2 7B
- 4 训练与微调
- 总结
1 VLA
1-1 介绍

- VLA(Vision-Language-Action,视觉-语言-动作)模型,指的是以视觉观测与自然语言指令为输入、直接输出机器人动作的一类端到端策略模型
- 用策略的语言写出来,它就是把一个"看图 + 读指令"的函数,学习成"出动作"的函数
π
θ
:
(
I
,
L
)
⟶
A
,
a
t
:
t
+
H
=
π
θ
(
o
t
,
l
)
\pi_\theta:\ \left(\mathcal{I},\ \mathcal{L}\right)\ \longrightarrow\ \mathcal{A},\qquad a_{t:t+H}=\pi_\theta\left(o_t,\ l\right)
πθ: (I, L) ⟶ A,at:t+H=πθ(ot, l)其中:
- I \mathcal{I} I:视觉观测空间, o t ∈ I o_t\in\mathcal{I} ot∈I 是第 t t t 帧图像,通常是 RGB,也可以是多路
-
L
\mathcal{L}
L:自然语言指令空间,
l
l
l 是形如
"put the eggplant in the bowl"的字符串 - A \mathcal{A} A:机器人动作空间,既可以是末端执行器增量,也可以是关节目标
- H H H:一次前向预测的动作步数(horizon), H = 1 H=1 H=1 是单步策略, H > 1 H>1 H>1 是动作块策略
- θ \theta θ:策略参数,在 VLA 里通常就是"视觉编码器 + 投影层 + 大语言模型"的全部权重
- 那它要解决的核心问题是什么?是泛化
- 传统模仿学习策略在训练分布内很准,换一个没见过的背景、换一只没见过的杯子、换一句没听过的说法,成功率就掉下来
- VLA 的思路是把互联网规模预训练得到的视觉与语言先验整体搬进策略,让策略在训练数据之外也有可用的先验
- 这里要强调一点:VLA 是一种模型形态,不是一种学习范式
- 训练 VLA 可以用行为克隆,可以用强化学习,可以先在大规模机器人数据上预训练再在目标任务上微调,也可以让语言数据与机器人数据联合微调
- 所以"VLA 与模仿学习哪个更好"这个问题本身不成立,二者不在同一个分类维度上
说人话:模仿学习、强化学习回答的是"数据怎么用";VLA 回答的是"模型长什么样"。同一个 VLA 骨架,换一个损失函数就是另一种学习范式。
1-2 当今主流方向
1-2-1 概述
- 各家 VLA 在骨架上大同小异,真正的分歧集中在动作这一端怎么表示、怎么生成
- 原因也很直接:视觉与语言那一端已经有现成的大模型可以借用,动作那一端没有对应的预训练模型,只能自己设计
- 按这个分歧点,当前常见四条技术路线。前三者是三种动作生成方式,路线 D 描述的是环境动力学,属于另一层级的建模,可以和前三者组合:
| 路线 | 动作表示 | 生成方式 | 代表工作 |
|---|---|---|---|
| A 自回归动作 Token | 离散 token | 逐 token 自回归 | RT-1、RT-2、OpenVLA |
| B 扩散模型 | 连续向量 | 迭代去噪 | Diffusion Policy、RDT-1B、DexVLA |
| C 流匹配 | 连续向量 | 回归速度场后积分 | π0、π0.5 |
| D 世界模型 | 隐状态或未来观测 | 先预测再规划 | 见 1-2-5 |
- 四条路线并不互斥:A、B、C 是三种动作生成方式,D 世界模型负责预测"如果我这么做会怎样",可以接在任意一种动作生成器前面做规划或优化。实际系统里也经常混用,例如用自回归模型做高层子任务分解,用扩散或流匹配模型做低层动作生成
- 本期的主角 OpenVLA 走的是路线 A,所以后面三条只讲清楚机制与取舍,不展开推导
1-2-2 路线A:自回归动作 Token
- 核心思路:把连续动作切成一格一格、每格起个名字,动作就变成了词表里的 token,语言模型那套"预测下一个词"的训练与推理流程可以原样复用
- 每维动作先归一化到 [ − 1 , 1 ] [-1,1] [−1,1],再均分成 N N N 个区间,于是第 i i i 维动作 a ( i ) a^{(i)} a(i) 落进第 k k k 格: k = d i g i t i z e ( a ( i ) ) , b j = − 1 + 2 j N , j = 0 , 1 , … , N k=\mathrm{digitize}\left(a^{(i)}\right),\qquad b_j=-1+\frac{2j}{N},\quad j=0,1,\dots,N k=digitize(a(i)),bj=−1+N2j,j=0,1,…,N
- 于是 D D D 维动作变成 D D D 个 token,整条轨迹的训练目标就是标准的下一 token 预测: L ( θ ) = − ∑ j = 1 D log p θ ( t j ∣ t < j , o t , l ) \mathcal{L}(\theta)=-\sum_{j=1}^{D}\log p_\theta\left(t_j \mid t_{<j},\ o_t,\ l\right) L(θ)=−j=1∑Dlogpθ(tj∣t<j, ot, l)
说人话:机器人动作本来是一串小数,比如
[0.013, -0.204, 0.001, ...]。这条路线把它翻译成"第 137 格、第 42 格、第 129 格…",格子编号去词表里占一个词的位置。模型干的活,就和写文章时"猜下一个字"完全一样。
- 代表工作:
- RT-1:用 EfficientNet 提视觉特征、Transformer 输出离散动作,验证了"动作当 token"在多任务上可行
- RT-2:把动作 token 直接并进视觉语言模型的词表一起微调,第一次展示出互联网预训练先验能迁移到控制上
- OpenVLA:用开源 VLM(Vision-Language Model,视觉语言模型)做骨架、把训练数据扩到 97 万条轨迹,是这条路线当前最有代表性的开源实现,也是本期要拆开看的模型
- 取舍:
- 优点:完全复用语言模型的训练与推理基础设施,实现代价最低;天然支持"动作与语言混在一条序列里",便于做思维链式的中间推理
- 缺点:动作被量化,精度上限受格数限制;自回归逐维串行生成, D D D 维动作就要跑 D D D 步解码;单步策略不天然支持一次吐出一整块动作
1-2-3 路线B:扩散模型连续动作生成
- 可以参考另一个系列:
- 核心思路:不量化动作,直接对连续动作的分布建模,用一个去噪网络把高斯噪声逐步还原成一条专家动作
- 前向过程按方差表 β 1 , … , β T \beta_1,\dots,\beta_T β1,…,βT 逐步加噪: q ( a t ∣ a t − 1 ) = N ( a t ; 1 − β t a t − 1 , β t I ) q\left(a_t \mid a_{t-1}\right)=\mathcal{N}\left(a_t;\ \sqrt{1-\beta_t}\,a_{t-1},\ \beta_t \mathbf{I}\right) q(at∣at−1)=N(at; 1−βtat−1, βtI)
- 令 α t = 1 − β t \alpha_t=1-\beta_t αt=1−βt 且 α ˉ t = ∏ s = 1 t α s \bar{\alpha}_t=\prod_{s=1}^{t}\alpha_s αˉt=∏s=1tαs,就可以一步跳到任意时刻: a t = α ˉ t a 0 + 1 − α ˉ t ϵ , ϵ ∼ N ( 0 , I ) a_t=\sqrt{\bar{\alpha}_t}\,a_0+\sqrt{1-\bar{\alpha}_t}\,\epsilon,\qquad \epsilon\sim\mathcal{N}\left(\mathbf{0},\mathbf{I}\right) at=αˉta0+1−αˉtϵ,ϵ∼N(0,I)
- 训练目标就是让网络从带噪动作里把噪声猜回来,条件是观测 o o o 与指令 l l l: L ( θ ) = E t , a 0 , ϵ [ ∥ ϵ − ϵ θ ( a t , t , o , l ) ∥ 2 2 ] \mathcal{L}(\theta)=\mathbb{E}_{t,\,a_0,\,\epsilon}\left[\left\|\epsilon-\epsilon_\theta\left(a_t,\ t,\ o,\ l\right)\right\|_2^2\right] L(θ)=Et,a0,ϵ[∥ϵ−ϵθ(at, t, o, l)∥22]
- 推理时从纯噪声出发,沿反向过程迭代 T T T 次得到动作: a t − 1 = 1 α t ( a t − 1 − α t 1 − α ˉ t ϵ θ ( a t , t , o , l ) ) + σ t z a_{t-1}=\frac{1}{\sqrt{\alpha_t}}\left(a_t-\frac{1-\alpha_t}{\sqrt{1-\bar{\alpha}_t}}\,\epsilon_\theta\left(a_t,t,o,l\right)\right)+\sigma_t z at−1=αt1(at−1−αˉt1−αtϵθ(at,t,o,l))+σtz
说人话:扩散模型解动作,像从一块被噪声糊住的石头里"雕"出动作。每雕一下动作清晰一点,雕几十下之后就是一条像样的专家轨迹。因为每一步都在同一个分布上重采样,它天然能表达"同一句指令下有好几种合理走法"。
- 代表工作:
- RDT-1B:把扩散头接在十亿参数级的多模态骨干上,做到跨本体双臂控制
- DexVLA:用扩散专家搭配预训练 VLM,处理灵巧手这类高维动作空间
- 取舍:
- 优点:动作不做量化,精度不受格数限制;多模态分布建模能力强,抓取点不唯一、绕行路径不唯一这类任务上表现突出
- 缺点:推理要迭代几十步,控制频率受限;动作块越长、维度越高,单次去噪的开销越大
1-2-4 路线C:Flow Matching 动作生成
-
核心思路:扩散是"一小步一小步跳、跳几十次",流匹配改成直接回归一条从噪声到动作的连续路径的速度场,再沿这条路径积分过去
-
最简单也最常用的路径是直线插值。取噪声 z ∼ N ( 0 , I ) z\sim\mathcal{N}(\mathbf{0},\mathbf{I}) z∼N(0,I) 与专家动作 a a a,定义中间状态: a τ = ( 1 − τ ) z + τ a , τ ∈ [ 0 , 1 ] a_\tau=(1-\tau)\,z+\tau\,a,\qquad \tau\in[0,1] aτ=(1−τ)z+τa,τ∈[0,1]
-
沿这条直线速度是常量 a − z a-z a−z,于是回归目标就是让网络预测这个速度: L ( θ ) = E τ , z , a [ ∥ v θ ( a τ , τ , o , l ) − ( a − z ) ∥ 2 2 ] \mathcal{L}(\theta)=\mathbb{E}_{\tau,\,z,\,a}\left[\left\|v_\theta\left(a_\tau,\ \tau,\ o,\ l\right)-(a-z)\right\|_2^2\right] L(θ)=Eτ,z,a[∥vθ(aτ, τ, o, l)−(a−z)∥22]
-
推理时从噪声出发解一个常微分方程, τ \tau τ 从 0 0 0 积到 1 1 1: a ( 0 ) = z , a ( 1 ) = z + ∫ 0 1 v θ ( a τ , τ , o , l ) d τ a(0)=z,\qquad a(1)=z+\int_0^1 v_\theta\left(a_\tau,\ \tau,\ o,\ l\right)\mathrm{d}\tau a(0)=z,a(1)=z+∫01vθ(aτ, τ, o, l)dτ
说人话:扩散是摸着石头过河,一步一步挪;流匹配是先定一条从噪声到动作的直线,再学一个"该往哪走"的箭头场,推理时顺着箭头积分过去。路径越直,数值积分越容易迈大步,但实际能用几步取决于模型、求解器和精度要求,不能因为训练路径是直线就认定一步就能生成。
- 代表工作:
- π0:用预训练 VLM 骨干加一个流匹配动作专家,主打高频、多任务、跨本体
- π0.5:在 π0 基础上引入异构机器人数据联合训练,进一步做开放世界泛化
- SmolVLA:轻量级 VLA,同样使用 Flow Matching,但更强调低成本硬件上的训练与部署。
- 取舍:
- 优点:通常可以用较少的积分步数完成生成,适合高频控制;训练目标比扩散更直接,不需要噪声调度表
- 缺点:直线路径是人为指定的,路径与真实分布之间的偏差要由网络承担;对数据量与骨干质量更敏感
1-2-5 路线D:世界模型与预测式控制
- 关于世界模型的内容,我们后续会开展文章说明
- 核心思路:不直接输出动作,而是先预测"如果我这么做,未来会怎样",再在这份预测上做规划或优化
- 形式上,世界模型学的是状态转移与观测生成:
s
^
t
+
1
=
f
θ
(
s
t
,
a
t
)
,
o
^
t
+
1
=
g
θ
(
s
^
t
+
1
)
\hat{s}_{t+1}=f_\theta\left(s_t,\ a_t\right),\qquad \hat{o}_{t+1}=g_\theta\left(\hat{s}_{t+1}\right)
s^t+1=fθ(st, at),o^t+1=gθ(s^t+1)
- 配上奖励模型 r ^ θ \hat{r}_\theta r^θ,就可以在隐空间里"想象"若干步,挑一条最划算的动作序列再执行
- 这条路线相对前三者还比较早期,原因有两条:
- 世界模型自身的误差会随预测步长累积,长时程预测容易发散
- 真机上采到的高质量交互数据量,远小于互联网图文数据的规模,动力学部分拿不到同等的先验
- 当前的探索方向主要有两支:
- 一支用视频生成模型直接预测未来若干帧(把动作序列作为条件),再从预测视频反解动作;
- 另一支在隐状态空间里学动力学,先在仿真里训好再迁移到真机
- 需要说明的是,这条路线与前三者并不冲突:世界模型可以作为"先想清楚"的高层模块,动作仍由自回归头或流匹配头生成
1-3 VLA 前景与当前落地方向

- 先给结论:VLA 目前能落地的场景,仍然是"固定工位、物体种类有限、指令有限"的重复性操作
- 已经看到成效的方向:
- 分拣与上下料:物体种类固定而位姿变化大,正好落在视觉泛化的强项上
- 柔性抓取:靠视觉先验判断可抓部位,不必为每种物体单独建模
- 长指令拆解:先把"把桌上的东西收拾好"拆成若干子任务,再逐步执行
- 制约落地的三件事:
- 控制频率:单步自回归一次前向只出一个动作,实测通常落在几赫兹的量级,做不了高频力控任务。动作块(action chunking)、流匹配少步采样、推理加速,都是在补这一块
- 数据与本体:跨本体动作空间不统一,A 臂上采的数据在 B 臂上未必能用;双臂、灵巧手、触觉这类输入目前还没有统一的表示
- 可靠性:成功率上不到 90% 以上的任务,在产线上就没有意义。VLA 现在更接近"能干活但需要人盯着"的水平
2 OpenVLA
2-1 介绍

- OpenVLA 是 2024 年 CoRL 上开源的一个 7B 参数 VLA,来自斯坦福、UC Berkeley、丰田研究院、Google DeepMind、Physical Intelligence 与 MIT 的联合工作,走的是 1-2-2 节那条"自回归动作 Token"路线
- 论文给出的几个关键数字:
- 训练数据是从 OXE(Open X-Embodiment) 数据集里筛出来的 97 万条真机演示轨迹,覆盖多种机械臂本体与任务
- 在 29 个评测任务上,绝对成功率比同时期的 RT-2-X(55B) 高出 16.5%,而参数量只有它的约七分之一
- 相比在同样的数据上从零训练的 Diffusion Policy,绝对成功率高 20.4%
- 预训练用了 64 张 A100 跑 14 天,合计约 21500 A100 小时(累计 GPU 小时),批大小 2048
- 推理时 bf16 精度约占 15 GB 显存,在单张 RTX 4090 上约 6 Hz
- 这里"开源"的含义要说清楚:模型权重、训练数据配方、微调与部署代码全部放出,是把一整条链路都交出来,而不是只给一个 demo。这一点对我们是决定性的——正因为权重和代码都在,本期才能把模型逐层拆开看
2-2 模型整体架构
- OpenVLA 的结构可以一句话概括:把 Prismatic-7B 这个视觉语言模型,微调成一个输出动作 token 的策略
- Prismatic-7B 是一种采用模块化架构的视觉语言模型(Vision-Language Model,VLM),其核心思路是将预训练视觉编码器与大型语言模型结合,通过投影层将视觉特征映射到语言模型的输入空间,使模型能够联合处理图像与文本信息。OpenVLA 以该模型为基础,进一步使用机器人操作数据进行训练,将视觉语言理解能力扩展到机器人动作预测。
- 它由三个部件串起来:
| 部件 | 具体实现 | 规模(论文口径) |
|---|---|---|
| 视觉编码器 | DINOv2 ViT-L/14 与 SigLIP ViT-So400M/14 双塔,特征按通道拼接 | 约 6 亿参数 |
| 投影层 | 把视觉特征映射到语言模型嵌入空间的多层 MLP(Multi-Layer Perceptron,多层感知机) | 约 7000 万参数 |
| 语言模型 | Llama 2 7B,自回归输出动作 token | 约 67 亿参数 |
- 论文正文把投影层记作"一个小型两层 MLP",而放出的代码在双塔配置下走的是
FusedMLPProjector,是三层线性层加两次 GELU。按视觉特征维度 2176、语言嵌入维度 4096 代入,三层分别是"2176 维到 8704 维"、“8704 维到 4096 维”、“4096 维到 4096 维”,算下来约 7100 万参数,与前一行表格里的量级一致 - 整条前向链路如下:
- 整条链路里值得先记住的一点是:动作不是单独的一个输出头,而是被塞进了语言模型的词表。图里"动作生成与解码"这一段,实际发生的位置就在 Llama 2 的输出层,与预测下一个词用的是同一套机制
2-3 输入与输出:从视觉观测到机器人动作

2-3-1 观测输入
- 输入只有两样:一张 RGB 图像,一句自然语言指令
- 论文明确说明,当前版本的 OpenVLA 只支持单目、单帧的观测,既不接收本体感知(关节角、末端位姿),也不接收历史帧
- 图像预处理沿用双塔各自的标配变换,论文选的是
224
×
224
224\times 224
224×224 分辨率:
- 先按
letterbox策略缩放到边长 224,不足的部分用该塔的归一化均值填充成正方形,而不是中心裁剪,避免裁掉画面边缘的物体 - 再各自做标准化,DINOv2 走 ImageNet 统计量,SigLIP 走 0.5 / 0.5 0.5/0.5 0.5/0.5 的对称归一化
- 先按
- 分辨率为什么停在 224:论文做过 224 224 224 与 384 384 384 的对照,两者在评测上没有差别,而 384 384 384 的训练时间约是 3 倍。224 的 patch 数是 16 × 16 = 256 16\times 16=256 16×16=256, 384 384 384 则是 27 × 27 = 729 27\times 27=729 27×27=729,注意力开销按序列长度平方增长,这笔账很容易算
2-3-2 动作输出
- 输出是一个 7 维连续向量,一次前向只出一步(
H
=
1
H=1
H=1,单步策略,与 ACT 的动作块策略正相反):
a
=
[
Δ
x
,
Δ
y
,
Δ
z
,
Δ
θ
x
,
Δ
θ
y
,
Δ
θ
z
,
g
]
⊤
∈
R
7
a=\left[\Delta x,\ \Delta y,\ \Delta z,\ \Delta\theta_x,\ \Delta\theta_y,\ \Delta\theta_z,\ g\right]^{\top}\in\mathbb{R}^{7}
a=[Δx, Δy, Δz, Δθx, Δθy, Δθz, g]⊤∈R7其中:
- 前三项是末端执行器在基坐标系下的平移增量
- 中间三项是末端执行器的姿态增量,论文图示记作 Δ θ \Delta\theta Δθ
- 最后一项 g g g 是夹爪开合,一维标量
- 关键词是"增量":OpenVLA 输出的是相对量而不是绝对位姿。相对量对坐标系原点不敏感,换一台臂、换一个安装高度都不必重新标定,这也是跨本体能成立的前提之一
- 这 7 维是按维独立离散化的,不是把 7 维动作当成一个整体去分箱。也就是说每一维各切 256 格,维度之间不做联合建模
需要注意的是,OpenVLA 输出的是机械臂末端位姿增量,而非关节角度。因此,实际部署时还需要通过逆运动学或底层控制器,将其转换为机械臂可执行的控制指令。
2-3-3 Prompt 构造
- 文本侧的输入不是裸指令,而是套进一段固定模板的对话。代码里由
PurePromptBuilder拼出来:
# prismatic/models/backbones/llm/prompting/base_prompter.py
self.wrap_human = lambda msg: f"In: {msg}\nOut: "
self.wrap_gpt = lambda msg: f"{msg if msg != '' else ' '}{self.eos}"
- 训练样本的两个回合分别是:
# prismatic/vla/datasets/datasets.py
prompt_builder = self.prompt_builder_fn("openvla")
conversation = [
{"from": "human", "value": f"What action should the robot take to {lang}?"},
{"from": "gpt", "value": self.action_tokenizer(action)}, # 动作已经变成字符串
]
- 所以模型真正看到的序列是:
In: What action should the robot take to put the eggplant in the bowl?\nOut:后面紧跟 7 个动作 token 与一个结束符 - 指令在拼接前会做一次
.lower(),这是训练与推理两侧都要遵守的约定,少做或漏做都会造成分布偏移 - 有两处容易踩的细节:
Out:后面紧跟的那一个空格,在 Llama 分词器里是一个独立的 token(id 为 29871)。推理时如果输入序列的最后一个 token 不是它,代码会显式补一个上去,以对齐训练时看到的形状- OpenVLA v0.1 用的是 Vicuna(小羊驼,是一个基于 LLaMA 微调得到的开源聊天大语言模型) 的对话格式,带一段 system prompt 并以
USER:、ASSISTANT:分隔;v1 之后的版本换成上面这种In:、Out:的纯文本模板。加载权重时必须与它训练时的模板配套,混用会让成功率明显下降
2-4 视觉编码器:DINOv2 + SigLIP
2-4-1 为什么用两个编码器
- 单个视觉编码器也能跑,但 OpenVLA 选了双塔,理由是两类特征各有短板、又恰好互补:
- DINOv2 是自监督训练出来的,训练信号只有图像自身,因此它保留的是空间与纹理层面的细节——边界在哪、表面朝向如何,这些恰恰是抓取时最需要的
- SigLIP 是视觉语言对比学习训练出来的,训练信号是图文对,因此它带的是语义层面的概念——画面里那个紫色的长条物是"茄子"而不是"紫色圆柱",这一层信息决定了语言指令能不能对上目标
- 两塔的完整尺寸对照如下,2-4-2 与 2-4-3 会分别展开两个塔的内部结构:
| 项目 | DINOv2 塔 | SigLIP 塔 |
|---|---|---|
| 权重标识 | vit_large_patch14_reg4_dinov2.lvd142m | vit_so400m_patch14_siglip_224 |
| 主干 | ViT-L/14 | SoViT-400M/14 |
| 层数 | 24 | 27 |
| 隐藏宽度 | 1024 | 1152 |
| 注意力头 | 16 头,每头 64 维 | 16 头,每头 72 维 |
| MLP 中间维度 | 4096 | 4304 |
| patch 边长 | 14 | 14 |
| 输入分辨率 | 224 × 224 224\times224 224×224 | 224 × 224 224\times224 224×224 |
| patch 数 | 16 × 16 = 256 16\times16=256 16×16=256 | 16 × 16 = 256 16\times16=256 16×16=256 |
| 前缀 token | 1 个分类 token 与 4 个 register token | 无 |
| 预训练信号 | 图像自身的增广视图 | 图文对 |
| 预训练数据 | LVD-142M,1.42 亿张无标注图像 | WebLI 图文对 |
| 取用的层 | 倒数第二层(block 22) | 倒数第二层(block 25) |
| 单塔输出 | 256 × 1024 256\times1024 256×1024 | 256 × 1152 256\times1152 256×1152 |
- 这张表里哪些必须一致、哪些可以不同,决定了选型的空间有多大:
- 必须一致的是输入分辨率与 patch 边长,因为这两项一起决定了 patch 数。两个塔的输出要按通道拼在一起,前提是两个序列的第 i i i 个 token 落在画面上的同一块区域里。两塔都是 patch 边长 14、输入 224 × 224 224\times224 224×224, 16 × 16 = 256 16\times16=256 16×16=256 才对得上
- 可以不同的是层数、宽度、头数、MLP 中间维度。拼的是特征向量本身,不是网络结构,24 层配 27 层、1024 维配 1152 维都没有问题
- 这个约束也决定了两塔的权重都得选 patch 14 的版本。仓库里同样支持 patch 16 的主干,但
224
/
16
=
14
224/16=14
224/16=14,patch 数会变成
14
×
14
=
196
14\times14=196
14×14=196,与另一塔的 256 对不上,拼接这一步直接做不了
- 论文的消融结果支持这个判断:换成单一 SigLIP 编码器后,模型在"指令指定多个物体中的一个"这类任务上明显变差
说人话:DINOv2 管"长什么样、在哪",SigLIP 管"是什么"。前者的信息来自像素本身,后者的信息来自别人怎么描述它。控制一个机械臂,两样都得有。
2-4-2 DINOv2 塔
- DINOv2 的全称是Self-Distillation with NO Labels, Version 2
- DINOv2 的主干是 ViT(Vision Transformer,视觉 Transformer):把图像切成固定大小的 patch,每个 patch 映射成一个向量(Token),再交给多层 Transformer Block 提取特征
- 具体权重是
vit_large_patch14_reg4_dinov2.lvd142m,也就是 ViT-L/14。它的处理流程可以按数据流走一遍,设输入图像尺寸为 3 × 224 × 224 3\times224\times224 3×224×224:- 切 patch 并映射。按 14 × 14 14\times14 14×14 的窗口划过去(窗口大小与步长都取 14),得到 16 × 16 = 256 16\times16=256 16×16=256 个 patch。每个 patch 展平后是 3 × 14 × 14 = 588 3\times14\times14=588 3×14×14=588 维,再线性映射成 1024 维。实现上这两步合并成了一次卷积,卷积核大小与步长都取 14,输出通道数取 1024
- 拼前缀 token。先给 256 个 patch 加上位置嵌入,再把 1 个分类 token(class token)与 4 个 register token 拼到序列最前面,此时序列长度是 1 + 4 + 256 = 261 1+4+256=261 1+4+256=261。前缀 token 的位置信息已经并进了它们各自的参数里,不占位置嵌入
- 过 24 层 Block。每层是"前置归一化、多头自注意力、残差",后面再跟"前置归一化、MLP、残差"
- 取倒数第二层。24 个 Block 从 0 编号到 23,取第 22 层的输出,并且只保留 256 个 patch 对应的那部分
- 前两步写成公式,就是 ViT 标准的输入形式:
Z
(
0
)
=
[
z
cls
;
z
reg
1
,
…
,
z
reg
4
;
x
1
E
+
e
1
pos
,
…
,
x
256
E
+
e
256
pos
]
∈
R
261
×
1024
\mathbf{Z}^{(0)}=\left[\ \mathbf{z}_{\text{cls}}\ ;\ \mathbf{z}_{\text{reg}}^{1},\dots,\mathbf{z}_{\text{reg}}^{4}\ ;\ \mathbf{x}_{1}\mathbf{E}+\mathbf{e}_{1}^{\text{pos}},\dots,\mathbf{x}_{256}\mathbf{E}+\mathbf{e}_{256}^{\text{pos}}\ \right]\in\mathbb{R}^{261\times1024}
Z(0)=[ zcls ; zreg1,…,zreg4 ; x1E+e1pos,…,x256E+e256pos ]∈R261×1024其中:
- x i \mathbf{x}_i xi:第 i i i 个 patch 展平后的向量,588 维
- E \mathbf{E} E:patch 嵌入矩阵,把 588 维映射到 1024 维
- z cls \mathbf{z}_{\text{cls}} zcls:分类 token; z reg 1 \mathbf{z}_{\text{reg}}^{1} zreg1 到 z reg 4 \mathbf{z}_{\text{reg}}^{4} zreg4:4 个 register token
- E pos = [ e 1 pos , … , e 256 pos ] ∈ R 256 × 1024 \mathbf{E}_{\text{pos}}=\left[\mathbf{e}_{1}^{\text{pos}},\dots,\mathbf{e}_{256}^{\text{pos}}\right]\in\mathbb{R}^{256\times1024} Epos=[e1pos,…,e256pos]∈R256×1024:位置嵌入,只对应 256 个 patch token
- Z ( 0 ) \mathbf{Z}^{(0)} Z(0):第 0 层 Block 的输入
- 有四处与"教科书版 ViT"不完全一样的地方,值得单独说清楚:
- 位置嵌入只加在 patch token 上。timm 里对应
no_embed_class这个开关: E pos \mathbf{E}_{\text{pos}} Epos 的形状是 256 × 1024 256\times1024 256×1024,只作用于 256 个 patch。分类 token 的位置向量在加载官方权重时被并进了cls_token参数(timm 的转换逻辑是cls_token + pos_embed[:, 0]),4 个 register token 也是独立可学习参数,同样不占位置嵌入。上面的式子把前缀 token 与位置嵌入分开写,正是为了对应这一实现 - 多了 4 个 register token,也就是名字里的
reg4。它是 DINOv2 后期加入的设计:给模型几个"杂物抽屉",让它在需要暂存全局信息时有地方放,不至于污染真正表示图像内容的 patch 特征 - 位置嵌入要适配分辨率。官方权重是在 518 × 518 518\times518 518×518 上预训练的, 518 / 14 = 37 518/14=37 518/14=37,位置嵌入对应的是 37 × 37 37\times37 37×37 的网格;换成 224 224 224 之后要按 16 × 16 16\times16 16×16 重新插值,这一步由加载权重的流程自动完成
- 每个 Block 的残差分支带一个很小的缩放。这一支的
init_values取 10 − 5 10^{-5} 10−5,也就是残差分支前乘一个初值极小的可学习系数(LayerScale),目的是让深层 ViT 在训练初期接近恒等映射。这是 DINOv2 能稳训的配方之一
- 位置嵌入只加在 patch token 上。timm 里对应
- 名字里的
lvd142m指 LVD-142M 数据集,也就是自监督预训练用的那 1.42 亿张无标注图像。训练目标里没有语言,只有图像自身的增广视图,这解释了它为什么擅长空间细节 - 构造函数只有几行,但每一行都在决定 2-4-4 那一步拼接能不能成立:
# prismatic/models/backbones/vision/dinosiglip_vit.py
# 权重标识取自注册表,这里直接写出来
self.dino_featurizer = timm.create_model(
"vit_large_patch14_reg4_dinov2.lvd142m",
pretrained=True, # 加载 LVD-142M 自监督预训练权重
num_classes=0, # 去掉顶上的分类头,只要特征
img_size=224, # 把 patch 网格钉在 16 x 16
)
self.dino_featurizer.eval()
- 逐条看这几个参数的作用:
pretrained=True取的是 DINOv2 官方权重,塔的"性格"在这一步就定下了num_classes=0表示不要分类头。ViT 默认会带一个线性分类头,这里用不上,去掉可以少一层无关参数img_size=224是把输入尺寸钉死成 224。不覆盖的话 patch 网格会与 16 × 16 16\times16 16×16 不一致,与 SigLIP 塔的形状也对不上.eval()把塔置为推理模式。这与"冻结参数"是两件事:论文在微调时没有冻结视觉编码器,参数是否更新由训练侧决定,4-3 里冻结与不冻结的对照也能看出差别
- 最后核对一遍输出形状,前三条正是拼接能成立的原因:
- 输入是 [ B , 3 , 224 , 224 ] [B,3,224,224] [B,3,224,224]
- 输出是 [ B , 256 , 1024 ] [B,256,1024] [B,256,1024]。第二个维度 256 是 patch 数,第三个维度 1024 是隐藏宽度
- 序列里只有 patch token。分类 token 与 4 个 register token 都被丢掉了,否则第二个维度会是 261 而不是 256
- 默认不做 reshape,输出是一串扁平序列,而不是 16 × 16 16\times16 16×16 的网格。对下游的 Projector 来说,它要的正是一串 token
2-4-3 SigLIP 塔
- SigLIP 全称是 Sigmoid Loss for Language Image Pre-training
- 具体权重是
vit_so400m_patch14_siglip_224,即 SoViT-400M/14(shape-optimized ViT,指在给定算力下重新配比宽度、深度与分辨率的 ViT 系列),27 层,隐藏宽度 1152,patch 边长同样为 14 - 内部结构与 DINOv2 塔同构,数据流可以照 2-4-2 那样走一遍,差别只落在几个数字和前缀 token 上:
- 同样按 14 × 14 14\times14 14×14 的窗口切出 256 个 patch,但每个 patch 映射到 1152 维
- 没有前缀 token。timm 里这个权重配的是
class_token=False,池化交给一个单独的探针去做,所以主干里的序列从头到尾就是 256 个 patch token,位置嵌入的形状也是 256 × 1152 256\times1152 256×1152 - 27 个 Block 从 0 编号到 26,取第 25 层的输出,仍然是倒数第二层
- 注意力同样切成 16 个头,但每头 72 维( 16 × 72 = 1152 16\times72=1152 16×72=1152),比 DINOv2 塔每头 64 维稍宽;MLP 的中间维度取 4304 4304 4304,约是隐藏宽度的 3.74 倍,这个不那么整的数字来自 SoViT 的形状优化
- 也就是说,两塔在数据流上可以画成同一张图,真正把它们区分开的是预训练方式
- SigLIP 相对 CLIP(Contrastive Language-Image Pre-training,对比语言图像预训练)的改动在损失函数上:CLIP 在全 batch 上做 softmax 对比,需要跨卡同步大批次;SigLIP 换成逐对的 sigmoid 损失,每一对图文独立判断"配不配",不再需要全局归一化
L
SigLIP
=
−
1
B
∑
i
=
1
B
∑
j
=
1
B
log
σ
(
z
i
j
(
s
u
i
⊤
v
j
+
b
)
)
,
z
i
j
=
+
1
若
i
=
j
,
否则
−
1
\mathcal{L}_{\text{SigLIP}}=-\frac{1}{B}\sum_{i=1}^{B}\sum_{j=1}^{B}\log\sigma\left(z_{ij}\left(s\,\mathbf{u}_i^{\top}\mathbf{v}_j+b\right)\right),\qquad z_{ij}=+1\ \text{若}\ i=j,\ \text{否则}\ -1
LSigLIP=−B1i=1∑Bj=1∑Blogσ(zij(sui⊤vj+b)),zij=+1 若 i=j, 否则 −1其中:
- B B B:一个批次(Batch)中的图文配对数量
- i , j i,j i,j:分别表示第 i i i 张图像和第 j j j 条文本
- u i \mathbf{u}_i ui:第 i i i 张图像经过视觉编码器得到的特征向量
- v j \mathbf{v}_j vj:第 j j j 条文本经过文本编码器得到的特征向量
- u i ⊤ v j \mathbf{u}_i^\top\mathbf{v}_j ui⊤vj:图像特征与文本特征的点积,用于衡量二者的相似程度(若特征已归一化,则等价于余弦相似度)
- s s s:缩放系数,用于调整相似度的数值范围
- b b b:可学习的偏置项,用于调整匹配判断的阈值
- z i j z_{ij} zij:匹配标签。若图像和文本属于正确配对,即 i = j i=j i=j,则 z i j = + 1 z_{ij}=+1 zij=+1;否则 z i j = − 1 z_{ij}=-1 zij=−1
- σ ( x ) = 1 1 + e − x \sigma(x)=\frac{1}{1+e^{-x}} σ(x)=1+e−x1:Sigmoid 函数,将输入映射到 0 0 0 到 1 1 1 之间
- log \log log:自然对数;外面的负号使模型通过最小化损失来优化匹配结果
- 这一改动的直接好处是小批次也能训,工程上更容易扩展。对 OpenVLA 而言,关键是它带来的是语言对齐过的语义特征
- 构造代码与 DINOv2 塔完全对称,只有权重标识不同:
# prismatic/models/backbones/vision/dinosiglip_vit.py
self.siglip_featurizer = timm.create_model(
"vit_so400m_patch14_siglip_224",
pretrained=True, # 加载 WebLI 图文对预训练权重
num_classes=0,
img_size=224, # 同样钉在 16 x 16 的 patch 网格
)
self.siglip_featurizer.eval()
- 有一处工程细节值得记一笔:SigLIP 上游默认的图像变换是"先把图放大到 224 224 224 以上再中心裁剪",仓库把它改成了直接缩放到 224 224 224,后面那一步裁剪随之变成空操作。原因是 OpenVLA 走的是 letterbox 补边(见 2-3-1),如果这里再裁一刀,画面边缘的物体会被切掉,而操作对象不一定落在画面正中
2-4-4 特征拼接
- 两塔的 patch 数完全一致(都是 224 / 14 = 16 224/14=16 224/14=16,共 16 × 16 = 256 16\times 16=256 16×16=256 个 patch),维度不同(1024 与 1152),因此拼接方式只有一个选择:按通道维拼接,让每个空间位置上的两段特征并成一段 2176 维的向量
- 这两塔的配对在一张注册表里成对绑定,选哪个分辨率就用哪一对:
# prismatic/models/backbones/vision/dinosiglip_vit.py
DINOSigLIP_VISION_BACKBONES = {
"dinosiglip-vit-so-224px": {
"dino": "vit_large_patch14_reg4_dinov2.lvd142m",
"siglip": "vit_so400m_patch14_siglip_224",
},
"dinosiglip-vit-so-384px": {
"dino": "vit_large_patch14_reg4_dinov2.lvd142m",
"siglip": "vit_so400m_patch14_siglip_384",
},
}
-
OpenVLA 用的是
dinosiglip-vit-so-224px这一对。另一对是 384 384 384 分辨率版本:DINOv2 那一侧复用同一份权重,SigLIP 换成 384 的变体。至于为什么不用更高分辨率,2-3-1 已经算过这笔账 -
编码过程写作: F = [ D I N O v 2 ( I ) patch ∥ S i g L I P ( I ) patch ] ∈ R 256 × 2176 \mathbf{F}=\left[\ \mathrm{DINOv2}(\mathbf{I})_{\text{patch}}\ \middle\|\ \mathrm{SigLIP}(\mathbf{I})_{\text{patch}}\ \right]\in\mathbb{R}^{256\times 2176} F=[ DINOv2(I)patch ∥ SigLIP(I)patch ]∈R256×2176
-
代码实现非常直接。注意两处:一是取的是倒数第二层的特征而不是最后一层;二是
dim=2就是通道维
# prismatic/models/backbones/vision/dinosiglip_vit.py
def forward(self, pixel_values: Dict[str, torch.Tensor]) -> torch.Tensor:
dino_patches = self.dino_featurizer(pixel_values["dino"])
siglip_patches = self.siglip_featurizer(pixel_values["siglip"])
return torch.cat([dino_patches, siglip_patches], dim=2) # [B, 256, 2176]
- 取倒数第二层的写法在构造函数里通过猴子补丁(Monkey Patching)完成(即在程序运行时,直接替换或修改一个已有对象的属性或方法,而不去修改它原本所在的类的源码):
# 把 featurizer.forward 替换成"取第 len(blocks) - 2 层的中间层输出"
self.dino_featurizer.forward = unpack_tuple(
partial(self.dino_featurizer.get_intermediate_layers, n={len(self.dino_featurizer.blocks) - 2})
)
- 两个塔各打一次,写法完全一样。三处值得留意:
- 原注释写明这么做是为了 FSDP(Fully Sharded Data Parallel,完全分片数据并行)兼容。
get_intermediate_layers返回的是元组,而forward需要返回张量,unpack_tuple做的就是取元组的第一个元素 n传的不是"第几层"这种写死的常数,而是len(blocks) - 2。24 层代入是 22,27 层代入是 25,两塔因此都落在倒数第二层上,换主干也不用改这一行- 取中间层时默认不返回前缀 token,这就是 2-4-2 里"分类 token 与 register token 都不在输出里"那句的出处
- 原注释写明这么做是为了 FSDP(Fully Sharded Data Parallel,完全分片数据并行)兼容。
- 论文给的理由是:倒数第二层的特征比最后一层更贴近"视觉表征"本身,最后一层已经开始向各自的预训练任务(DINOv2 的自监督目标、SigLIP 的图文匹配目标)过拟合。Prismatic 沿用了这一设定,OpenVLA 再沿用 Prismatic 的设定
- 再把这个
pixel_values字典的来历补上。一张图进去、出来两份张量,这是由预处理决定的:
@dataclass
class DinoSigLIPImageTransform:
dino_image_transform: ImageTransform
siglip_image_transform: ImageTransform
def __call__(self, img: Image, **kwargs: str) -> Dict[str, torch.Tensor]:
return {
"dino": self.dino_image_transform(img, **kwargs),
"siglip": self.siglip_image_transform(img, **kwargs),
}
- 两塔各自解析自己的数据配置(含归一化均值与标准差),所以连 letterbox 的补边颜色都是分开算的,代码里分别叫
dino_fill与siglip_fill- 拼接顺序也有约定:DINOv2 在前,SigLIP 在后。这条顺序一旦定下就不能改,因为下游 Projector 的第一层线性权重是照着这 2176 维的排布学出来的,前 1024 维来自 DINOv2,后 1152 维来自 SigLIP。换视觉塔等于换输入排布,Projector 得跟着重训
2-5 Projector:连接视觉和语言
2-5-1 结构与公式
- 视觉塔输出的是 2176 维特征,而 Llama 2 的嵌入空间是 4096 维,中间必须有一层翻译。OpenVLA 用的是多层 MLP:
Z
=
W
3
G
E
L
U
(
W
2
G
E
L
U
(
W
1
F
+
b
1
)
+
b
2
)
+
b
3
\mathbf{Z}=W_3\,\mathrm{GELU}\!\left(W_2\,\mathrm{GELU}\!\left(W_1\mathbf{F}+b_1\right)+b_2\right)+b_3
Z=W3GELU(W2GELU(W1F+b1)+b2)+b3其中:
- F ∈ R 256 × 2176 \mathbf{F}\in\mathbb{R}^{256\times 2176} F∈R256×2176:双塔拼接后的 patch 特征
- W 1 ∈ R 8704 × 2176 W_1\in\mathbb{R}^{8704\times 2176} W1∈R8704×2176:先把维度升到 4 × 2176 = 8704 4\times 2176=8704 4×2176=8704,给非线性留出余量
- W 2 ∈ R 4096 × 8704 W_2\in\mathbb{R}^{4096\times 8704} W2∈R4096×8704:压回语言模型的嵌入维度
- W 3 ∈ R 4096 × 4096 W_3\in\mathbb{R}^{4096\times 4096} W3∈R4096×4096:在语言空间内部再做一次变换
- G E L U \mathrm{GELU} GELU:激活函数,两次都加在两层线性之间
-
Z
∈
R
256
×
4096
\mathbf{Z}\in\mathbb{R}^{256\times 4096}
Z∈R256×4096:送进语言模型的视觉 token

说人话:视觉塔提取图像特征,语言模型处理词向量,两者的特征空间不同,因此需要投影层进行映射。投影层先升维、经过激活函数,再压回语言模型的隐藏维度,让视觉特征能够更好地融合。
2-5-2 代码实现
# prismatic/util/nn_utils.py(训练侧)/prismatic/extern/hf/modeling_prismatic.py(推理侧)
class FusedMLPProjector(nn.Module):
def __init__(self, fused_vision_dim, llm_dim, mlp_type="fused-gelu-mlp"):
super().__init__()
self.initial_projection_dim = fused_vision_dim * 4 # 2176 * 4 = 8704
self.projector = nn.Sequential(
nn.Linear(fused_vision_dim, self.initial_projection_dim, bias=True),
nn.GELU(),
nn.Linear(self.initial_projection_dim, llm_dim, bias=True),
nn.GELU(),
nn.Linear(llm_dim, llm_dim, bias=True),
)
- 投影层与视觉编码器在训练策略上不同:视觉编码器在预训练里被冻结,投影层则始终是可训练的。Prismatic 的预训练分两阶段,
align阶段只训投影层,finetune阶段连语言模型一起放开,视觉塔一直冻着
2-6 多模态融合
2-6-1 patch 即 token
- 融合方式沿用 LLaVA(Large Language and Vision Assistant)一路确立的
patch-as-token约定:把每一个图像 patch 当成一个 token,直接插进文本 token 序列里 - LLaVA(Large Language and Vision Assistant) 是一个开源视觉语言模型项目,核心思路是将预训练视觉编码器提取的图像特征,通过一个投影层映射到大型语言模型的嵌入空间,再与文本 Token 一起输入语言模型,实现图像与文本的联合理解。
- 插入位置是 BOS(Beginning of Sequence,序列起始符)之后、正文之前:
X
=
[
e
BOS
,
Z
(
1
)
,
…
,
Z
(
256
)
,
e
1
,
…
,
e
L
]
X=\left[\ e_{\text{BOS}},\ \mathbf{Z}^{(1)},\ \dots,\ \mathbf{Z}^{(256)},\ e_{1},\ \dots,\ e_{L}\ \right]
X=[ eBOS, Z(1), …, Z(256), e1, …, eL ]其中:
- e BOS e_{\text{BOS}} eBOS:序列起始 token 的嵌入
- Z ( k ) \mathbf{Z}^{(k)} Z(k):第 k k k 个 patch 经投影层之后的 4096 维向量
- e i e_{i} ei:第 i i i 个文本 token 的嵌入
- L L L:文本序列长度,于是序列总长度为 257 + L 257+L 257+L
- 视觉 token 与文本 token 在语言模型内部没有任何区别,走的是同一套注意力。所谓"多模态融合"就是让它们在自注意力里互相看见
说人话:图像被切成 256 块,每块压缩成一个 4096 维的向量。这 256 个向量被当成 256 个"词",和"把茄子放进碗里"这句话的词排在一起,交给语言模型一起读。对模型来说,看图就是读一段特殊的词。
2-6-2 注意力掩码与损失掩码
-
两处掩码必须区分清楚,它们作用完全不同:
- 注意力掩码:256 个 patch 位置全部置为有效(
True),不屏蔽任何一个。它只区分"有效输入"与"填充位",不改变因果性——Llama 2 的因果掩码依然生效,靠前的视觉 token 看不到它后面的位置;后续文本与动作 token 则能看见全部视觉 token - 损失掩码:256 个 patch 位置全部置为
IGNORE_INDEX(值为 − 100 -100 −100),不参与损失计算。同时,指令文本部分的 token 也被屏蔽掉
- 注意力掩码:256 个 patch 位置全部置为有效(
-
后者是关键。训练目标只对动作 token 计算损失:
# prismatic/vla/datasets/datasets.py
# [CRITICAL] We do not want to take the loss for anything but the predicted action tokens!
labels[: -(len(action) + 1)] = IGNORE_INDEX
if not self.predict_stop_token:
labels[-1] = IGNORE_INDEX
len(action) + 1里的那个+1是序列末尾的结束符。因此默认情况下,模型要学的是"出 7 个动作 token,然后收尾"- 拼接逻辑对应下面的代码,注意
input_embeddings[:, :1, :]就是 BOS 那一位:
# prismatic/extern/hf/modeling_prismatic.py
multimodal_embeddings = torch.cat(
[input_embeddings[:, :1, :], projected_patch_embeddings, input_embeddings[:, 1:, :]], dim=1
)
multimodal_labels = torch.cat([labels[:, :1], projected_patch_labels, labels[:, 1:]], dim=1)
说人话:只对动作 token 计损失,这一条决定了整个模型的训练信号极其稀疏——一条样本几百个 token,真正产生梯度的只有最后 8 个。这也是后面 4-2 节里"要跑 27 个 epoch"的直接原因。
2-7 Llama 2 7B:预测动作 Token
- 语言模型这一层是 Llama 2 7B,负责在"看到了 256 个视觉 token + 读到了指令 token"之后,自回归地把动作 token 一个个吐出来
- 对 OpenVLA 而言,Llama 2 有两个特性被直接利用:
- 它看不到特殊标记:动作 token 占据的是词表末尾被覆写过的 256 个位置,对模型来说它们与普通词没有区别,输出层不需要任何改动
- 它是自回归的:7 维动作天然对应 7 步解码,不需要额外的动作头结构
- 具体的内部计算(归一化、自注意力、位置编码、前馈网络)与 KV 缓存(Key-Value Cache,键值缓存),还有"如何从隐藏状态解出下一个 token",都放到第三章单独展开,这里先记结论:
- 模型输出的不是动作数值,而是 7 个词表下标
- 这 7 个下标要经过反离散化与反归一化,才变回可以下发的物理量
这部分我们第三章详细说明
2-8 动作离散化:连续动作如何转化为 Token
2-8-1 第一步:归一化
- 原始动作的每一维取值范围差异很大,直接分箱会让量程小的一维精度浪费。因此先按数据集的分位数归一化到 [ − 1 , 1 ] [-1,1] [−1,1]
- 论文用的是第 1 与第 99 百分位,而不是最大最小值,即
BOUNDS_Q99: a ~ = c l i p ( 2 ( a − q 01 ) q 99 − q 01 + ϵ − 1 , − 1 , 1 ) \tilde{a}=\mathrm{clip}\left(\frac{2\left(a-q_{01}\right)}{q_{99}-q_{01}+\epsilon}-1,\ -1,\ 1\right) a~=clip(q99−q01+ϵ2(a−q01)−1, −1, 1)其中:- q 01 q_{01} q01、 q 99 q_{99} q99:该维动作在训练数据上的第 1 与第 99 百分位,逐维统计,随模型一起保存
- ϵ \epsilon ϵ:防止分母为零的小量,代码里取 10 − 8 10^{-8} 10−8
- c l i p \mathrm{clip} clip:把越界值截回 [ − 1 , 1 ] [-1,1] [−1,1]
- 用分位数而不是极值,是为了不被离群动作带偏:某一帧因为通信抖动记下了一个异常大的位移,若用它当最大值,正常动作会被压到很窄的一段区间里,离散化精度随之崩掉
- 代码里还有一条约定:某维动作如果最大值等于最小值(也就是这一维在数据里从没动过),直接把该维全部置为 0 0 0
# prismatic/vla/datasets/rlds/utils/data_utils.py
traj = dl.transforms.selective_tree_map(
traj, match=lambda k, _: k == traj_key,
map_fn=lambda x: tf.where(
mask,
tf.clip_by_value(2 * (x - low) / (high - low + 1e-8) - 1, -1, 1),
x,
),
)
# 未使用的动作维度(min == max)统一映射为 0
zeros_mask = metadata[key]["min"] == metadata[key]["max"]
2-8-2 第二步:均匀分箱
-
归一化之后,每一维在 [ − 1 , 1 ] [-1,1] [−1,1] 上均匀切 256 个端点,得到 255 个区间: b j = − 1 + 2 j 255 , j = 0 , 1 , … , 255 b_j=-1+\frac{2j}{255},\qquad j=0,1,\dots,255 bj=−1+2552j,j=0,1,…,255
-
每个区间的用它的中心来代表: c k = b k + b k + 1 2 , k = 0 , 1 , … , 254 c_k=\frac{b_k+b_{k+1}}{2},\qquad k=0,1,\dots,254 ck=2bk+bk+1,k=0,1,…,254
-
对应代码只有两行,注意
n_bins=256指的是端点数而不是区间数:
# prismatic/vla/action_tokenizer.py
self.bins = np.linspace(min_action, max_action, self.n_bins) # 256 个端点
self.bin_centers = (self.bins[:-1] + self.bins[1:]) / 2.0 # 255 个中心
说人话:把 [ − 1 , 1 ] [-1,1] [−1,1] 这条尺子刻上 256 道线,就得到 255 个小格子。动作落在哪个格子里,就报哪个格子的编号,小数位被抹掉——这就是"量化",而 256 格意味着单维精度约为 2 / 255 ≈ 0.0078 2/255\approx 0.0078 2/255≈0.0078。
2-8-3 第三步:映射到词表末尾
-
词表(Vocabulary),可以理解为模型认识的所有 Token 及其编号组成的列表
-
分箱得到的是格子下标,还要把它变成一个合法的 token id,模型才能输出它
-
做法是从词表末尾倒着数:第 d d d 号格子对应词表下标 V − d V-d V−d,其中 V V V 是词表大小 token_id = V − d , d ∈ [ 1 , 256 ] \text{token\_id}=V-d,\qquad d\in[1,\ 256] token_id=V−d,d∈[1, 256]
-
这里的 d d d 直接取代码里
np.digitize的返回值:动作先裁剪到 [ − 1 , 1 ] [-1,1] [−1,1],下端点 − 1 -1 −1 返回 1 1 1,上端点 + 1 +1 +1 返回 256 256 256,中间取值落在 1 1 1 到 255 255 255。代进去得到 token_id ∈ [ 31744 , 31999 ] \text{token\_id}\in[31744,\ 31999] token_id∈[31744, 31999],正好是词表最后 256 个位置。论文把档位编号写作 0 0 0 到 255 255 255,与代码的 1-based 下标差一个偏移,这里统一按代码来 -
为什么不新开 256 个特殊 token?论文给了答案:
- Llama 2 的分词器只为微调阶段新引入的 token 预留了 100 个空位,而动作需要 256 个,不够用
- 于是改用 Brohan 等人在 RT 系列里提出的办法:把词表里使用频率最低的最后 256 个 token 覆写掉。词表大小不变,模型结构不变,只是这 256 个位置的含义被重新定义了
说人话:为了避免修改词表的格式、扩展模型的输出空间,OpenVLA 直接复用了原本很少用到的 256 个 Token ID,把它们重新定义为动作编码。这样既能让模型输出动作,又不需要改动 Llama 2 的词表大小。
- 代码把这条边界记成
action_token_begin_idx,用来区分"普通词"与"动作词":
# prismatic/vla/action_tokenizer.py
self.action_token_begin_idx: int = int(self.tokenizer.vocab_size - (self.n_bins + 1))
def __call__(self, action: np.ndarray):
action = np.clip(action, a_min=float(self.min_action), a_max=float(self.max_action))
discretized_action = np.digitize(action, self.bins)
return self.tokenizer.decode(list(self.tokenizer.vocab_size - discretized_action))
- 这一行
self.tokenizer.vocab_size - discretized_action就是上面公式的实现,它是向量化的:一个 7 维动作一次性变成 7 个 token id,再解码成字符串,直接拼进训练样本的gpt回合里 - 需要留意的是,被覆写的这 256 个 token 在原始 Llama 2 里本来是低频词。用它们当动作槽位,等于主动牺牲了这 256 个词的表达能力。对机器人控制任务来说这笔交易划算,因为微调阶段本来也不指望模型继续写文章
2-8-4 训练标签的形状
- 到这里,一条训练样本的完整形状就清楚了: <BOS> ⏟ 起始符 v 1 , … , v 256 ⏟ 视觉 Patch 嵌入,不计损失 t 1 , … , t n ⏟ 指令 Token,不计损失 a 1 , … , a 7 , <EOS> ⏟ 动作 Token 与结束符,计损失 \underbrace{\texttt{<BOS>}}_{\text{起始符}} \quad \underbrace{v_1,\ldots,v_{256}}_{\text{视觉 Patch 嵌入,不计损失}} \quad \underbrace{t_1,\ldots,t_n}_{\text{指令 Token,不计损失}} \quad \underbrace{a_1,\ldots,a_7,\texttt{<EOS>}}_{\text{动作 Token 与结束符,计损失}} 起始符 <BOS>视觉 Patch 嵌入,不计损失 v1,…,v256指令 Token,不计损失 t1,…,tn动作 Token 与结束符,计损失 a1,…,a7,<EOS>
| 位置 | 内容 | 是否计损失 |
|---|---|---|
| 0 | <BOS> | 否 |
| 1 到 256 | 视觉 patch 嵌入 | 否 |
| 257 起 | In: What action should ...? 指令 token | 否 |
| 末尾 8 个 | 7 个动作 token + 1 个结束符 | 是 |
- 也就是说,一条序列三四百个 token,真正产生梯度的只有最后 8 个
2-9 动作解码与反归一化
2-9-1 反离散化
- 推理时模型吐出的是 token id,第一步要还原成格子下标,再查出格子中心
- 这里有一处必须按代码来理解的处理:解码时先做一次减一,再把结果截断到 [ 0 , 254 ] [0,254] [0,254]
# prismatic/vla/action_tokenizer.py
discretized_actions = self.tokenizer.vocab_size - action_token_ids
discretized_actions = np.clip(discretized_actions - 1, a_min=0, a_max=self.bin_centers.shape[0] - 1)
return self.bin_centers[discretized_actions]
- 所以完整映射是
a
^
=
c
c
l
i
p
(
d
−
1
,
0
,
254
)
\hat{a}=c_{\mathrm{clip}(d-1,\ 0,\ 254)}
a^=cclip(d−1, 0, 254)。为什么减一:下标是从 1 开始数的,减去 1 才对应到
bin_centers的零基下标。为什么要截断:动作恰好取到上边界 + 1 +1 +1 时np.digitize返回 256 256 256,减一得到 255 255 255,超出 255 个区间中心的上界,截断把它归到最后一个中心;下界不会出问题,动作已先裁剪到 [ − 1 , 1 ] [-1,1] [−1,1], − 1 -1 −1 返回的是 1 1 1- 这个往返在下标层面是准的: a ~ \tilde a a~ 落在第 k k k 个区间,编码得到 d = k + 1 d=k+1 d=k+1,解码得到 c k c_k ck。但动作值本身已经被量化到区间中心,格子内部的小数位回不来
说人话:编码时"看动作在第几格",解码时"报第几格的中心值"。中间丢掉的只是格子内部的小数位,格子定位本身是准的。
- 一次前向输出的 token 个数由动作维度决定,代码直接从数据集统计量里取:
# prismatic/models/vlas/openvla.py
generated_ids = super(PrismaticVLM, self).generate(
input_ids=input_ids,
pixel_values=pixel_values,
max_new_tokens=self.get_action_dim(unnorm_key), # 7
)
predicted_action_token_ids = generated_ids[0, -self.get_action_dim(unnorm_key):]
normalized_actions = self.action_tokenizer.decode_token_ids_to_actions(
predicted_action_token_ids.cpu().numpy()
)
2-9-2 反归一化
- 模型输出的动作还在 [ − 1 , 1 ] [-1,1] [−1,1] 的归一化空间里,要按训练时保存的统计量还原回物理量:
a = 1 2 ( a ^ + 1 ) ( q 99 − q 01 ) + q 01 a=\frac{1}{2}\left(\hat{a}+1\right)\left(q_{99}-q_{01}\right)+q_{01} a=21(a^+1)(q99−q01)+q01
- 这正是 2-8-1 节那个式子的逆变换。注意式中用的
q
01
q_{01}
q01、
q
99
q_{99}
q99 必须与训练时一致,它们随权重一起保存在
dataset_statistics.json里,加载模型时要一并载入 - 代码里还带着一个
mask,未使用的动作维度不参与反归一化,直接保留归一化值:
# prismatic/models/vlas/openvla.py
action_norm_stats = self.get_action_stats(unnorm_key)
mask = action_norm_stats.get("mask", np.ones_like(action_norm_stats["q01"], dtype=bool))
action_high, action_low = np.array(action_norm_stats["q99"]), np.array(action_norm_stats["q01"])
actions = np.where(
mask,
0.5 * (normalized_actions + 1) * (action_high - action_low) + action_low,
normalized_actions,
)
注意:
unnorm_key是必须显式关心的参数。一个权重如果在多个数据集上训练过,norm_stats里就存着多套统计量,推理时选错unnorm_key,动作数值会整体错位,而这一步不会报错。
2-10 从模型输出到机械臂控制
2-10-1 闭环执行
- OpenVLA 是单步策略,部署方式是标准的"预测一步、执行一步、重新观测"的闭环:
# experiments/robot/openvla_utils.py
action = vla.predict_action(**inputs, unnorm_key=unnorm_key, do_sample=False)
do_sample=False是贪心解码(Greedy Decoding,就是模型每次预测下一个 Token 时,直接选择概率最大的那个),不是采样。原始评测用它来保证同一个观测下输出确定、结果可复现;采样会让同一观测每次输出不同的动作,是否表现为闭环抖动取决于采样策略、动作空间与控制频率,部署时应谨慎评估- 控制频率建议落在 5 到 10 Hz。论文的评测就是在 5 Hz 的非阻塞控制器上做的,README 里也明确写了:控制器频率远高于这个区间时应当先把动作降采样,再验证降采样后的动作序列仍能完成任务
2-10-2 动作适配
- 模型输出的 7 维增量是在训练数据的坐标系约定下的,落到自己的机械臂上至少还要走三步:
- 坐标系映射:模型给出的增量是在其训练时的基坐标系下表达的,要按自己机械臂的安装方式做旋转变换
- 控制模式选择:增量式末端位姿适合笛卡尔空间伺服;若控制器只接受关节目标,需要先做一次逆运动学,再交给位置控制器
- 夹爪二值化: g g g 是连续标量,实际下发时通常按阈值判成"张开"或"闭合"
- 论文还提到一点常被忽略的差别:Diffusion Policy 等方法里既有绝对位置控制也有相对位置控制,而 OpenVLA 用的是相对位置控制。相对量天然抗标定误差,但也意味着模型必须靠闭环把累积漂移拉回来,一次预测偏一点,下一步还有机会修
2-10-3 微调数据采集上的几条经验
- README 里给了一份微调后的排查清单,其中几条属于"模型结构决定的、绕不过去"的约束,值得提前记住:
- 控制频率 5 到 10 Hz:OpenVLA 没有动作块,高频数据会让它的单步预测跟不上,数据频率高于此区间时应先降采样
- 避免停顿与微小动作:训练数据里大量"几乎不动"的帧,会让模型在推理时卡在这些帧对应的状态上出不来
- 覆盖要够:打算测试的初始位置变化,采集时就得覆盖到,模型不会凭空外推
- 演示策略保持一致:同一任务尽量用同一种方式完成,多模态的微调数据会让建模难度上升
- 另外两条是排查顺序上的建议:先用演示数据回放确认采集链路没坏,再把数据集里的图像喂进推理管线,确认 token 准确率能复现训练时的读数。两步都过了,问题才轮到模型本身
3 Llama 2 7B
3-1 介绍

- OpenVLA 的动作头不是新设计的网络,而是 Llama 2 7B 本体。这一章把它的内部结构拆开,回答两个问题:动作 token 是怎么被算出来的,以及它和生成自然语言 token 有什么区别
- Llama 2 7B 是一个纯解码器(decoder-only)的 Transformer,超参数如下:
| 超参数 | 取值 |
|---|---|
| 解码层数 | 32 |
| 隐藏维度 d d d | 4096 |
| 注意力头数 | 32 |
| 每头维度 d k d_k dk | 128 |
| 前馈中间维度 | 11008 |
| 归一化方式 | RMSNorm(Root Mean Square Normalization,均方根归一化),前置(pre-norm) |
| 位置编码 | RoPE(Rotary Position Embedding,旋转位置编码) |
| 前馈激活 | SwiGLU(Swish Gated Linear Unit,门控线性单元) |
| 词表大小 | 32000 |
| 上下文长度 | 4096 |
| 词嵌入与输出层 | 不共享权重 |
- 参数量的分布大致是这样:
| 部件 | 计算方式 | 参数量 |
|---|---|---|
| 词嵌入 | 32000 × 4096 32000\times 4096 32000×4096 | 1.31 亿 |
| 单层注意力 | 4 × 4096 2 4\times 4096^2 4×40962 | 0.67 亿 |
| 单层前馈 | 3 × 4096 × 11008 3\times 4096\times 11008 3×4096×11008 | 1.35 亿 |
| 32 层合计 | 32 × 2.02 32\times 2.02 32×2.02 亿 | 64.8 亿 |
| 输出层 LM Head | 32000 × 4096 32000\times 4096 32000×4096 | 1.31 亿 |
| 总计 | 约 67 亿 |
- 表里有一条容易看漏:前馈层占了单层参数的三分之二,32 层里几乎一半以上的参数都在前馈层。这也是后面讲 LoRA(Low-Rank Adaptation,低秩适配)与量化时,
target_modules="all-linear"会命中最多的那一类模块 - 为什么选 Llama 2 7B 作为骨架:
- 权重完全开放,且是当时同规模里被验证最充分的开源语言模型
- 纯解码器结构,训练目标就是下一 token 预测,与"动作即 token"的思路天然吻合
- 7B 这个规模在单卡 24 GB 上能推理、在单卡 48 GB 上能微调,是对个人研究者最友好的一档
3-2 Llama 2 整体架构
- Llama 2 7B 是 Decoder-only Transformer,经过最后的输出层后,会得到词表中每个 Token 的分数,再通过 Softmax 转换为概率分布。
- 整体数据流如下,从下到上依次是嵌入、32 层解码器、输出层:
- 图中几个部件的职责:
- Token Embedding:把 token id 查表变成 4096 维向量。动作 token 走的是同一张嵌入表,没有单独的嵌入
- Transformer Decoder × 32 层:每层依次做"归一化、自注意力、残差"与"归一化、前馈、残差"两组运算,逐层细化表示
- Final RMSNorm:32 层之后再做一次归一化,把激活值的尺度拉回稳定区间,供输出层使用
- LM Head:一个 4096 × 32000 4096\times 32000 4096×32000 的线性层,把 4096 维隐藏状态映射成词表维度上的 logits
- Softmax:把 logits 变成词表上的概率分布
- 采样或贪心选择:按概率分布取出下一个 token;OpenVLA 走的是贪心
- 自回归循环:取出的 token 拼回输入序列,再跑一次前向,如此往复
- 有一处结构细节值得单独指出:归一化放在子层之前而不是之后。因此进入自注意力与前馈层的是归一化过的激活值,而残差通路传递的是未归一化的原值。图里那两条虚线标的就是这个关系
- 32 层里真正决定"动作 token 怎么被算出来"的,是下面这几个计算模块
3-3 Transformer Decoder:核心计算模块
3-3-1 RMSNorm:归一化
- Llama 2 每个子层之前都放一个 RMSNorm。它只做一件事:把这一层激活值的整体尺度拉回单位量级
R
M
S
N
o
r
m
(
x
)
=
x
1
d
∑
i
=
1
d
x
i
2
+
ϵ
⊙
γ
\mathrm{RMSNorm}(\mathbf{x})=\frac{\mathbf{x}}{\sqrt{\frac{1}{d}\sum_{i=1}^{d}x_i^{2}+\epsilon}}\odot\boldsymbol{\gamma}
RMSNorm(x)=d1∑i=1dxi2+ϵx⊙γ其中:
- d d d:隐藏维度,这里取 4096
-
ϵ
\epsilon
ϵ:防止分母为零的小量。Llama 2 的
config.json里rms_norm_eps取 10 − 5 10^{-5} 10−5;下面代码里的eps=1e-6只是这个类的默认值,建立模型时会被配置覆盖 - γ ∈ R d \boldsymbol{\gamma}\in\mathbb{R}^{d} γ∈Rd:可学习的缩放向量,逐维不同
- ⊙ \odot ⊙:逐元素相乘
- 与 LayerNorm 相比,RMSNorm 不减均值、不加偏置、不做平移,只保留缩放。实现上也因此少几行:
# transformers/models/llama/modeling_llama.py
class LlamaRMSNorm(nn.Module):
def __init__(self, hidden_size, eps=1e-6):
super().__init__()
self.weight = nn.Parameter(torch.ones(hidden_size))
self.variance_epsilon = eps
def forward(self, hidden_states):
input_dtype = hidden_states.dtype
hidden_states = hidden_states.to(torch.float32) # 方差用 fp32 算,避免 bf16 下溢
variance = hidden_states.pow(2).mean(-1, keepdim=True)
hidden_states = hidden_states * torch.rsqrt(variance + self.variance_epsilon)
return self.weight * hidden_states.to(input_dtype)
说人话:LayerNorm 是把激活值"减去平均值再除以标准差",RMSNorm 只做后半句——除以均方根。它只盯住尺度有没有失控,不减均值也足以稳住训练;这是实践中验证有效的简化,不能理解成"均值偏移会被后面的线性层吸收"——Llama 2 的线性层本来就不带偏置项。少算一步,省一点显存和带宽。
3-3-2 Multi-Head Self-Attention:自注意力机制
-
注意力是这一层真正"让不同位置互相看见"的模块。它先把输入投影成三组向量: Q = X W Q , K = X W K , V = X W V \mathbf{Q}=\mathbf{X}W_Q,\qquad \mathbf{K}=\mathbf{X}W_K,\qquad \mathbf{V}=\mathbf{X}W_V Q=XWQ,K=XWK,V=XWV其中:
- X ∈ R n × 4096 \mathbf{X}\in\mathbb{R}^{n\times 4096} X∈Rn×4096:当前层的输入序列, n n n 是序列长度
- W Q , W K , W V ∈ R 4096 × 4096 W_Q,W_K,W_V\in\mathbb{R}^{4096\times 4096} WQ,WK,WV∈R4096×4096:三组投影矩阵,参数量占了单层注意力的四分之三
- Q \mathbf{Q} Q、 K \mathbf{K} K、 V \mathbf{V} V:查询、键、值,名字对应"我在找什么"、“我能提供什么”、“我实际带的信息”
-
这里值得一提的是KV Cache(Key-Value Cache,键值缓存):
- Transformer 大模型推理时的一种加速技术。它会缓存之前生成的 Token 在注意力机制中计算得到的 Key 和 Value,避免每生成一个新 Token,就把之前所有 Token 的 K、V 重新计算一遍。
-
三组向量各自拆成 32 个头,每头 128 维,每个头独立算一次注意力: h e a d h = s o f t m a x ( Q h K h ⊤ d k + M ) V h , d k = 128 \mathrm{head}_h=\mathrm{softmax}\left(\frac{\mathbf{Q}_h\mathbf{K}_h^{\top}}{\sqrt{d_k}}+\mathbf{M}\right)\mathbf{V}_h,\qquad d_k=128 headh=softmax(dkQhKh⊤+M)Vh,dk=128
-
式中的 M \mathbf{M} M 是因果掩码,它保证第 i i i 个位置只能看见 j ≤ i j\le i j≤i 的位置: M i j = { 0 , j ≤ i − ∞ , j > i M_{ij}=\begin{cases}0, & j\le i\\ -\infty, & j>i\end{cases} Mij={0,−∞,j≤ij>i
-
32 个头的结果拼回 4096 维,再过一次输出投影: M H A ( X ) = [ h e a d 1 , h e a d 2 , … , h e a d 32 ] W O \mathrm{MHA}(\mathbf{X})=\left[\ \mathrm{head}_1,\ \mathrm{head}_2,\ \dots,\ \mathrm{head}_{32}\ \right]W_O MHA(X)=[ head1, head2, …, head32 ]WO
-
三处设计意图:
- 除以 d k \sqrt{d_k} dk: d k = 128 d_k=128 dk=128 时点积的方差随维度线性增长,不缩放会让 softmax 落在饱和区,梯度极小。除以 128 ≈ 11.3 \sqrt{128}\approx 11.3 128≈11.3 把方差拉回 1
- 加 − ∞ -\infty −∞:softmax 之后该位置权重为 0,等价于"看不见"。这是自回归生成能成立的前提——预测第 t t t 个 token 时不能偷看 t t t 之后的答案
- 分多头:单个注意力分布只能表达一种"关注模式",32 个头让模型同时关注"目标物体的位置"、“夹爪与物体的相对关系”、"指令里提到的颜色"等不同关系
-
代码里的关键几行,注意 Q、K、V 的拆头、位置编码的施加位置、以及 KV 缓存的拼接:
# transformers/models/llama/modeling_llama.py(要点摘录)
class LlamaAttention(nn.Module):
def forward(self, hidden_states, attention_mask=None, position_ids=None, past_key_value=None, **kwargs):
bsz, q_len, _ = hidden_states.size()
query_states = self.q_proj(hidden_states)
key_states = self.k_proj(hidden_states)
value_states = self.v_proj(hidden_states)
# 拆成 32 个头,每头 128 维;transpose 之后形状是 [B, heads, n, 128]
query_states = query_states.view(bsz, q_len, self.num_heads, self.head_dim).transpose(1, 2)
key_states = key_states.view(bsz, q_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)
value_states = value_states.view(bsz, q_len, self.num_key_value_heads, self.head_dim).transpose(1, 2)
# 位置编码只施加在 Q、K 上,V 不参与
cos, sin = self.rotary_emb(value_states, position_ids)
query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
# KV 缓存:把历史 K、V 拼到当前步前面,历史的 Q 不需要保留
if past_key_value is not None:
key_states, value_states = past_key_value.update(key_states, value_states, self.layer_idx)
# 掩码已经由上层拼好并传入,此处统一交给 SDPA(scaled_dot_product_attention)处理
attn_output = torch.nn.functional.scaled_dot_product_attention(
query_states, key_states, value_states,
attn_mask=attention_mask, dropout_p=0.0, is_causal=False,
)
attn_output = attn_output.transpose(1, 2).contiguous().view(bsz, q_len, self.hidden_size)
return self.o_proj(attn_output), None, past_key_value
- 关于多头还有一点要说明:Llama 2 7B 用的是标准 MHA(Multi-Head Self-Attention,多头自注意力),32 个查询头对应 32 个键值头;GQA(Grouped Query Attention,分组查询注意力)只在 34B 与 70B 上启用。所以 7B 的 KV 缓存没有做键值头共享,缓存体积按 32 个头算
3-3-3 RoPE:旋转位置编码
-
自注意力本身对位置是无序的:打乱输入序列的顺序,输出的集合不变。位置信息必须显式注入,Llama 2 用的是 RoPE
-
它把隐藏维度两两配对,每一对看成平面上的一个点,按位置乘一个旋转角: θ i = 10000 − 2 ( i − 1 ) d k , i = 1 , 2 , … , d k 2 \theta_i=10000^{-\frac{2(i-1)}{d_k}},\qquad i=1,2,\dots,\frac{d_k}{2} θi=10000−dk2(i−1),i=1,2,…,2dk R Θ , m x = ( x 1 cos m θ 1 − x 2 sin m θ 1 x 1 sin m θ 1 + x 2 cos m θ 1 x 3 cos m θ 2 − x 4 sin m θ 2 x 3 sin m θ 2 + x 4 cos m θ 2 ⋮ ) R_{\Theta,m}\mathbf{x}=\begin{pmatrix}x_1\cos m\theta_1-x_2\sin m\theta_1\\ x_1\sin m\theta_1+x_2\cos m\theta_1\\ x_3\cos m\theta_2-x_4\sin m\theta_2\\ x_3\sin m\theta_2+x_4\cos m\theta_2\\ \vdots\end{pmatrix} RΘ,mx= x1cosmθ1−x2sinmθ1x1sinmθ1+x2cosmθ1x3cosmθ2−x4sinmθ2x3sinmθ2+x4cosmθ2⋮ 其中:
- m m m:token 在序列中的位置
- θ i \theta_i θi:第 i i i 对维度的旋转基频,频率按几何级数递减, i i i 越大转得越慢
- R Θ , m R_{\Theta,m} RΘ,m:位置 m m m 对应的旋转矩阵,不含任何可学习参数
-
之所以有效,靠的是旋转的一个性质:两个位置的内积只取决于它们的相对距离 ⟨ R Θ , m q , R Θ , n k ⟩ = g ( q , k , n − m ) \left\langle R_{\Theta,m}\mathbf{q},\ R_{\Theta,n}\mathbf{k}\right\rangle=g\left(\mathbf{q},\ \mathbf{k},\ n-m\right) ⟨RΘ,mq, RΘ,nk⟩=g(q, k, n−m)
-
这意味着注意力打分的"位置敏感度"是相对量,相对距离的语义不随序列总长改变,这是 RoPE 比可学习位置嵌入更容易外推的原因。但"相对性"不等于"多长都能用":超出训练长度后模型仍可能退化,实际部署往往要配合位置插值或 RoPE scaling 才能拉到更长的上下文
-
实现上用的是"前后半段配对"而不是"相邻两维配对",这是 HuggingFace 与 Meta 原始实现的约定。两种配对只要 Q、K 用同一套,效果一致:
# transformers/models/llama/modeling_llama.py
def rotate_half(x):
x1 = x[..., : x.shape[-1] // 2] # 前半段
x2 = x[..., x.shape[-1] // 2 :] # 后半段
return torch.cat((-x2, x1), dim=-1) # 交换并取负,就是旋转 90 度
def apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
cos = cos.unsqueeze(unsqueeze_dim)
sin = sin.unsqueeze(unsqueeze_dim)
q_embed = (q * cos) + (rotate_half(q) * sin)
k_embed = (k * cos) + (rotate_half(k) * sin)
return q_embed, k_embed
说人话:给每个词的位置,配一个"旋转角度"。位置越靠后,转得越多。两个词做内积的时候,各自转过的角度自动相减,剩下的正好是它们之间的距离。位置信息就这么"转"进了注意力里,全程不需要额外参数。
3-3-4 SwiGLU:前馈神经网络

-
注意力负责在位置之间搬信息,前馈层负责对每个位置单独做一次非线性变换。Llama 2 用的是 SwiGLU: F F N ( x ) = W 2 ( S i L U ( W 1 x ) ⊙ W 3 x ) \mathrm{FFN}(\mathbf{x})=W_2\left(\mathrm{SiLU}\!\left(W_1\mathbf{x}\right)\odot W_3\mathbf{x}\right) FFN(x)=W2(SiLU(W1x)⊙W3x) S i L U ( z ) = z ⋅ σ ( z ) = z 1 + e − z \mathrm{SiLU}(z)=z\cdot\sigma(z)=\frac{z}{1+e^{-z}} SiLU(z)=z⋅σ(z)=1+e−zz其中:
-
W
1
∈
R
11008
×
4096
W_1\in\mathbb{R}^{11008\times 4096}
W1∈R11008×4096:门控投影,HuggingFace 里叫
gate_proj -
W
3
∈
R
11008
×
4096
W_3\in\mathbb{R}^{11008\times 4096}
W3∈R11008×4096:上投影,叫
up_proj -
W
2
∈
R
4096
×
11008
W_2\in\mathbb{R}^{4096\times 11008}
W2∈R4096×11008:下投影,叫
down_proj - σ \sigma σ:Sigmoid 函数
- ⊙ \odot ⊙:逐元素相乘,这就是"门"——一路算出内容,另一路算出该放多少过去
-
W
1
∈
R
11008
×
4096
W_1\in\mathbb{R}^{11008\times 4096}
W1∈R11008×4096:门控投影,HuggingFace 里叫
-
中间维度 11008 不是随手取的值。普通 FFN 有两个矩阵、隐藏维取 4 d 4d 4d,参数量是 8 d 2 8d^2 8d2;SwiGLU 有三个矩阵,要让参数量与它持平,就取 3 d h = 8 d 2 3dh=8d^2 3dh=8d2,即 h = 8 3 d = 8 3 × 4096 ≈ 10923 h=\frac{8}{3}d=\frac{8}{3}\times 4096\approx 10923 h=38d=38×4096≈10923这只是 Llama 2 让两种 FFN 参数量对齐的取法,不是 SwiGLU 的硬性规定;换一种骨干或参数预算, h h h 完全可以取别的比例
-
实际取 11008,是往上取到 256 的整数倍,方便硬件对齐。参数分布的结论也在这里:单层前馈 3 × 4096 × 11008 ≈ 1.35 3\times 4096\times 11008\approx 1.35 3×4096×11008≈1.35 亿,比单层注意力的 0.67 亿多一倍
-
代码只有一行:
# transformers/models/llama/modeling_llama.py
class LlamaMLP(nn.Module):
def forward(self, x):
# 一路过激活当门,一路原样通过,逐元素相乘后再降维
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
说人话:普通前馈层是"升维、激活、降维"。SwiGLU 多了一路:一路算内容,一路算"这个内容该放行多少",两路相乘再降维。多出来的那个门,让网络可以对不同输入选择性地放大或抑制某些通道。
3-3-5 残差连接与前置归一化
-
把上面三个模块按顺序拼起来,一层解码器的计算是: h = x + M H A ( R M S N o r m ( x ) ) \mathbf{h}=\mathbf{x}+\mathrm{MHA}\left(\mathrm{RMSNorm}(\mathbf{x})\right) h=x+MHA(RMSNorm(x)) y = h + F F N ( R M S N o r m ( h ) ) \mathbf{y}=\mathbf{h}+\mathrm{FFN}\left(\mathrm{RMSNorm}(\mathbf{h})\right) y=h+FFN(RMSNorm(h))
-
两处设计各自解决一个问题:
- 前置归一化:归一化加在子层输入上,残差通路本身不含归一化,梯度可以从输出端一路恒等地传回输入端。32 层堆叠时这一点很关键,换成后置归一化(原始 Transformer 的写法)就需要精心设计学习率预热才能训稳
- 残差连接:每个子层的输出都是"原值 + 增量",子层只需要学"该改多少",而不是"该变成什么"。这把恒等映射变成了默认解
-
对应代码的结构非常直白,两个
residual变量就是两条残差通路:
# transformers/models/llama/modeling_llama.py(要点摘录)
class LlamaDecoderLayer(nn.Module):
def forward(self, hidden_states, attention_mask=None, position_ids=None, past_key_value=None, **kwargs):
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states) # 前置 RMSNorm
hidden_states, self_attn_weights, present_key_value = self.self_attn(
hidden_states=hidden_states, attention_mask=attention_mask,
position_ids=position_ids, past_key_value=past_key_value, **kwargs,
)
hidden_states = residual + hidden_states # 第一条残差
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states) # 又一个前置 RMSNorm
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states # 第二条残差
return hidden_states, self_attn_weights, present_key_value
- 到这里,一层解码器就讲完了。32 层结构完全相同、权重各自独立,堆叠起来就是 Llama 2 的骨干
3-4 从隐藏状态到下一个 Token
3-4-1 LM Head 与 Logits
- 32 层解码器走完之后,最后一个位置的隐藏状态是一个 4096 维向量。它还不是"下一个词",只是一段高维表示。把它变成词表上的打分,靠的是 LM Head:
z
t
=
W
l
m
h
t
,
W
l
m
∈
R
32000
×
4096
\mathbf{z}_t=W_{\mathrm{lm}}\,\mathbf{h}_t,\qquad W_{\mathrm{lm}}\in\mathbb{R}^{32000\times 4096}
zt=Wlmht,Wlm∈R32000×4096其中:
-
h
t
∈
R
4096
\mathbf{h}_t\in\mathbb{R}^{4096}
ht∈R4096:第
t
t
t 个位置经过最后一道
Final RMSNorm之后的隐藏状态 - W l m W_{\mathrm{lm}} Wlm:输出投影矩阵,行数等于词表大小
- z t ∈ R 32000 \mathbf{z}_t\in\mathbb{R}^{32000} zt∈R32000:logits,词表里每个 token 一个未归一化的分数,可正可负
-
h
t
∈
R
4096
\mathbf{h}_t\in\mathbb{R}^{4096}
ht∈R4096:第
t
t
t 个位置经过最后一道
- 有个容易忽略的实现细节:Llama 2 的输入嵌入和输出投影不共享权重(
tie_word_embeddings=False)。这两个 32000 × 4096 32000\times 4096 32000×4096 的矩阵各占 1.31 亿参数,加起来 2.62 亿,接近总参数的 4% - 代码就一行:
# transformers/models/llama/modeling_llama.py(要点摘录)
class LlamaForCausalLM(LlamaPreTrainedModel):
def forward(self, input_ids, attention_mask=None, position_ids=None,
past_key_values=None, labels=None, **kwargs):
outputs = self.model( # 32 层解码器
input_ids=input_ids, attention_mask=attention_mask,
position_ids=position_ids, past_key_values=past_key_values,
)
hidden_states = outputs.last_hidden_state # [B, n, 4096]
logits = self.lm_head(hidden_states) # [B, n, 32000]
return CausalLMOutputWithPast(logits=logits, past_key_values=outputs.past_key_values)
3-4-2 Softmax 与解码策略
- 把 logits 变成概率,用带温度
T
T
T 的 softmax:
p
(
y
t
=
v
∣
y
<
t
)
=
exp
(
z
t
,
v
/
T
)
∑
u
=
1
32000
exp
(
z
t
,
u
/
T
)
p\left(y_t=v \mid y_{<t}\right)=\frac{\exp\left(z_{t,v}/T\right)}{\sum_{u=1}^{32000}\exp\left(z_{t,u}/T\right)}
p(yt=v∣y<t)=∑u=132000exp(zt,u/T)exp(zt,v/T)其中:
-
T
T
T:温度。
T
=
1
T=1
T=1 是原始分布
- T T T 较低:概率分布更尖锐,采样结果更倾向于高概率 Token。
- T T T 较高:概率分布更平坦,低概率 Token 也更有机会被选中
- y < t y_{<t} y<t:已经生成的全部 token,也就是"条件"
-
T
T
T:温度。
T
=
1
T=1
T=1 是原始分布
- 拿到概率之后有两条取法:
- 贪心解码:直接取概率最大的那个, y t = arg max v p ( y t = v ∣ y < t ) y_t=\arg\max_{v}p\left(y_t=v\mid y_{<t}\right) yt=argmaxvp(yt=v∣y<t),也就是温度趋于 0 0 0 的极限情形
- 采样:按概率抽一个,通常还会先用 top-k 或 top-p 把长尾截掉
说人话:模型每次都选择概率最大的 Token。对于 OpenVLA,这意味着每一步都优先选择概率最高的动作 Token,最后将生成的 7 个动作 Token 解码为连续动作值。
- 文本生成里两条路各有各的用处:写代码要确定性就贪心,写文案要多样性就采样
- OpenVLA 的评测全程用贪心,推理时
do_sample=False,同一个观测下结果确定、实验可复现。接口本身没有锁死,predict_action的**kwargs会一路传给generate;但动作 token 表示的是一个数值档位,采样会给同一观测引入不同动作,是否让闭环抖动取决于采样策略、动作空间与控制频率,部署时要单独验证
- OpenVLA 的评测全程用贪心,推理时
- 代码上就是一次
argmax:
# transformers/generation/utils.py(要点摘录)
next_token_logits = outputs.logits[:, -1, :]
next_token_scores = logits_warper(input_ids, next_token_logits) # 温度、top-k、top-p 都在这里施加
if do_sample:
probs = nn.functional.softmax(next_token_scores, dim=-1)
next_tokens = torch.multinomial(probs, num_samples=1).squeeze(1)
else:
next_tokens = torch.argmax(next_token_scores, dim=-1) # 贪心,OpenVLA 走这一支
3-4-3 自回归生成的基本过程
- "自回归"的含义是:把上一步的输出接到输入末尾,再算一次,如此往复。第 t t t 个 token 由前 t − 1 t-1 t−1 个位置最后一层的隐藏状态决定:
y t = arg max v ( W l m h t − 1 ( 32 ) ) v y_t=\arg\max_{v}\ \left(W_{\mathrm{lm}}\,\mathbf{h}_{t-1}^{(32)}\right)_{v} yt=argvmax (Wlmht−1(32))v
- 完整一轮是四步:
- 前向算出最后一位的 logits
- 取 argmax 得到 y t y_t yt
- 把 y t y_t yt 拼到序列末尾
- 回到第 1 步
- 文本生成里,终止条件是遇到 EOS(End of Sequence,序列结束符)或达到长度上限;OpenVLA 里是固定跑满
action_dim步,7 个动作维度对应 7 个 token - 这里有个工程上的关键点:第 3 步之后,前面的 token 并没有变,它们的 K、V 完全可以复用。这就是 KV 缓存:
- 没有缓存时,每生成一个 token 都要把整段序列重算一遍,单步的注意力代价随序列长度按平方增长
- 有缓存时,每步只算新 token 的 Q,再和缓存里现成的 K、V 做一次注意力,单步代价降为线性
- 代码上,"只送最后一个 token"这件事写在生成循环的输入准备里:
# transformers/models/llama/modeling_llama.py
def prepare_inputs_for_generation(self, input_ids, past_key_values=None, attention_mask=None, **kwargs):
if past_key_values is not None:
# 已有缓存:只把最后一个 token 送进来,历史的 K、V 直接复用
input_ids = input_ids[:, -1:]
return {
"input_ids": input_ids,
"past_key_values": past_key_values,
"attention_mask": attention_mask,
...
}
- OpenVLA 这一侧只关心"跑满 7 步就停":
# openvla.py :: predict_action(要点摘录)
generated_ids = super(PrismaticVLM, self).generate(
input_ids=input_ids, # [1, seq]
pixel_values=pixel_values, # [1, 3, 224, 224] 或 dict
max_new_tokens=self.get_action_dim(unnorm_key), # 7 步就停
**kwargs, # 调用方传 do_sample=False
)
predicted_action_token_ids = generated_ids[0, -self.get_action_dim(unnorm_key) :]
说人话:语言模型生成不是一次吐出整句,而是一个词一个词往外蹦。每蹦一个,就把它接到输入后面再算一次。之所以不慢,是因为前面的计算结果被缓存下来了(KV 缓存),新的一步只需要算最后一个 token 的注意力。OpenVLA 借的正是这个循环,只不过它只蹦 7 个词就停下。
3-5 Llama 2 如何生成机器人动作
3-5-1 自然语言 Token 与动作 Token 的区别
- 讲完 Llama 2 的机制,回到 OpenVLA 最核心的那次改写:把机械臂控制问题变成下一 token 预测问题。但这个"变成"并不等价,动作 token 和语言 token 有几处本质差别:
| 对比项 | 自然语言 Token | 动作 Token |
|---|---|---|
| 含义 | 一个语义单元,有语法、有上下文 | 某个动作维度上的一个数值档位 |
| 来源 | 预训练语料学到的 32000 个词 | 借用词表末尾的 256 个低频 token |
| 相邻性 | 语义相近的词,嵌入也相近 | 档位相邻不代表嵌入相邻,需要从数据里学出来 |
| 顺序 | 换个顺序,含义就变了 | 7 个 token 的顺序固定对应 7 个动作维度 |
| 解码 | 采样可以增加多样性 | 评测用贪心保证确定性,采样会引入随机性 |
- 第二行最容易被忽略:动作 token 没有语言学意义上的语义。词表末尾那 256 个 token 原本就是 Llama 2 词表里的普通词元,带着预训练参数,只是这些参数不是为机器人动作学的;OpenVLA 既没有扩充词表,也没有新建随机初始化的动作 token,只是把这 256 个低频位置重新解释为动作档位。模型要学会的是把"画面 + 指令"映射到"第 3 维该落在第几档",这个映射完全由机器人数据训练出来
- 第三行同样重要。token id 与档位之间是一条单调的数轴,但嵌入空间里并没有"相邻"这个先验,模型需要自己学出这种单调结构。也就是说它做的不是回归,而是 256 类的分类。这解释了为什么动作精度天然受限于分箱粒度 2 255 \frac{2}{255} 2552,也解释了为什么分箱数不是越大越好:类别变多,每一类分到的样本变少,边界更难学
3-5-2 动作如何表示为离散 Token
- 这条链路在第 2 章已经拆开讲过,这里把三次变换并排列出,方便对照:
- 归一化: a ^ = 2 a − q 01 q 99 − q 01 + 10 − 8 − 1 \hat{a}=2\,\dfrac{a-q_{01}}{q_{99}-q_{01}+10^{-8}}-1 a^=2q99−q01+10−8a−q01−1,再用 c l i p \mathrm{clip} clip 压到 [ − 1 , 1 ] [-1,1] [−1,1]
- 分箱:把 [ − 1 , 1 ] [-1,1] [−1,1] 均匀切成 256 段,取档位索引 d = d i g i t i z e ( a ^ , l i n s p a c e ( − 1 , 1 , 256 ) ) d=\mathrm{digitize}\left(\hat{a},\ \mathrm{linspace}(-1,1,256)\right) d=digitize(a^, linspace(−1,1,256)),取值范围是 [ 1 , 256 ] [1,256] [1,256]
- 映射词表: t = V − d t=V-d t=V−d,其中 V = 32000 V=32000 V=32000,档位因此落在词表最后 256 个 token 上
- 三步合起来完成了从连续动作到离散 token 的编码,但不是严格可逆的:分箱会丢掉格子内部的小数位,解码只能回到所在区间的中心值,回不到量化前的精确动作
- 之所以能这么做,靠的是分词器里一个现成的空档:Llama 词表预留的特殊 token 只有 100 个,装不下 256 个动作 token。于是 OpenVLA 直接覆盖词表里最不常用的 256 个 token。这在工程上省掉了一次词表扩展,而扩词表意味着嵌入矩阵要变形,LM Head 也得同步改
3-5-3 从视觉观测和语言指令到动作 Token
- 把前面所有模块串起来,一次完整的动作预测是这样走完的:
- 图像缩放到 224 × 224 224\times 224 224×224,分别送进 DINOv2 与 SigLIP,各拿到 256 个 patch 特征
- 两路特征按通道拼成 2176 维,过 Projector 变成 4096 维的 patch 嵌入
- 语言指令套上
In: ...\nOut:模板,分词成 L L L 个 token 嵌入 - patch 嵌入整段插在 BOS 之后: [ e B O S , Z 1 , … , Z 256 , e 1 , … , e L ] \left[e_{\mathrm{BOS}},\,Z_1,\dots,Z_{256},\,e_1,\dots,e_L\right] [eBOS,Z1,…,Z256,e1,…,eL],总长 257 + L 257+L 257+L
- 这个序列过 32 层解码器,因果掩码保证每个位置只能看见它前面的位置
- 取最后一位的 logits,在全部 32000 个 token 上取最大值, y 1 = arg max y_1=\arg\max y1=argmax
- 把 y 1 y_1 y1 接到序列末尾,重复第 6 步,直到拿到 7 个动作 token
- 第 6 步有个容易想当然的地方:代码里并没有"只在 256 个动作 token 里比较"这一步。
predict_action调的是标准的generate,do_sample=False即贪心, arg max \arg\max argmax 在整个词表上做,找不到任何屏蔽候选或改写 logits 的处理 - 模型之所以总吐出动作 token,靠的是训练侧的约束:损失只在动作 token 上计算(见 4-2-2),词表最后这 256 个 token 因此成了唯一被监督过的输出。推理端不做限制,是"学会了"而不是"被拦住"
- 万一真生成出别的 token,解码也不会报错。
decode_token_ids_to_actions把 V − t i − 1 V-t_i-1 V−ti−1 直接 clip 到 [ 0 , 254 ] [0,254] [0,254],越界的 id 会一律折到最大档,属于静默兜底 - 第 4 步和第 6 步的关系值得单独说一句:patch 位置虽然参与注意力,但它们既不产生损失也不产生动作。它们扮演的是条件,负责把视觉信息灌进后续 token 的表示里
- 整条流程在代码里就是一次
generate调用:
# openvla.py :: predict_action(要点摘录)
# 1. 语言指令 -> prompt -> token,并按训练时的模板补一个空 token 占位
prompt_builder.add_turn(role="human", message=f"What action should the robot take to {instruction.lower()}?")
input_ids = tokenizer(prompt_builder.get_prompt(), truncation=True, return_tensors="pt").input_ids.to(self.device)
if not torch.all(input_ids[:, -1] == 29871):
input_ids = torch.cat((input_ids, torch.tensor([[29871]], device=input_ids.device)), dim=1)
# 2. 图像 -> pixel_values,一路给 DINOv2,一路给 SigLIP
pixel_values = image_transform(image)
pixel_values = {k: v[None, ...].to(self.device) for k, v in pixel_values.items()}
# 3. 自回归生成 7 个动作 token
generated_ids = super(PrismaticVLM, self).generate(
input_ids=input_ids, pixel_values=pixel_values,
max_new_tokens=self.get_action_dim(unnorm_key), **kwargs,
)
predicted_action_token_ids = generated_ids[0, -self.get_action_dim(unnorm_key) :]
- 代码里那个 29871 是 Llama 分词器里的"空 token"。训练时的 prompt 末尾带着它,推理时如果分词结果没带上,就得手动补,否则模型见到的输入和训练时不一致
3-5-4 从 Token 序列到连续机器人动作
- 拿到 7 个 token 之后,逆向走一遍编码链路:
a
^
i
=
b
i
n
_
c
e
n
t
e
r
s
[
c
l
i
p
(
V
−
t
i
−
1
,
0
,
254
)
]
\hat{a}_i=\mathrm{bin\_centers}\left[\mathrm{clip}\left(V-t_i-1,\ 0,\ 254\right)\right]
a^i=bin_centers[clip(V−ti−1, 0, 254)]
a
i
=
0.5
(
a
^
i
+
1
)
(
q
99
,
i
−
q
01
,
i
)
+
q
01
,
i
a_i=0.5\left(\hat{a}_i+1\right)\left(q_{99,i}-q_{01,i}\right)+q_{01,i}
ai=0.5(a^i+1)(q99,i−q01,i)+q01,i其中:
- t i t_i ti:第 i i i 个动作 token 的 id
- V − t i V-t_i V−ti:还原出的档位索引,取值范围是 [ 1 , 256 ] [1,256] [1,256]
- 减 1 再 clip 到 [ 0 , 254 ] [0,254] [0,254]:把索引对齐到 255 个 bin 中心上;上端的 255 255 255 折回 254 254 254,下端的 0 0 0 不用动( t i t_i ti 落在动作区间内时, d d d 最小为 1 1 1)
- a i a_i ai:最终发给机械臂的连续动作,通常前 6 维是末端位姿增量,第 7 维是夹爪开合
- 代码:
# action_tokenizer.py
def decode_token_ids_to_actions(self, action_token_ids):
discretized_actions = self.tokenizer.vocab_size - action_token_ids
discretized_actions = np.clip(discretized_actions - 1, a_min=0, a_max=self.bin_centers.shape[0] - 1)
return self.bin_centers[discretized_actions]
- 到这里,OpenVLA 的全貌就拼完了。一句话概括它的方法:用 970k 条机器人轨迹,把一个 7B 的视觉语言模型改造成"在 256 个动作 token 上做下一 token 预测"的策略网络
说人话:OpenVLA 没有为机器人控制发明任何新结构,它把"该往哪动"翻译成"下一个词该是什么",然后原封不动地借用语言模型的生成能力。第 2 章的视觉编码负责看懂画面,第 3 章的 Llama 机制负责把画面和指令变成一串词,两半拼起来才是完整的方法。
4 训练与微调
4-1 介绍

- 前 3 章讲的是 OpenVLA “长什么样”,这一章讲它"怎么练出来"。先说明本章范围:受限与硬件限制,我们无法完成 7B 规模的预训练,因此本章不做复现,只做原理与数据层面的梳理
- 论文的预训练用了 64 张 A100、batch size 2048、跑 27 个 epoch,累计约 21,500 A100-hours。这个量级不是单卡能承担的任务,本地能实际跑起来的只有后面的微调与量化推理
- 所以本章按四件事展开:
- 预训练用了什么数据、优化目标是什么(4-2)
- 拿到预训练好的 OpenVLA 之后,怎么适配到自己的机械臂(4-3)
- 怎么把 7B 的模型塞进消费级显存(4-4)
- 预训练脚本按什么顺序执行、三段训练之间交接什么(4-5)
4-2 OpenVLA 的预训练数据与训练目标
4-2-1 数据从哪来
- OpenVLA 以 Open X-Embodiment(OXE)为主要训练数据来源。OXE 整合了多个机构、机器人平台及任务场景的操作数据,并通过统一格式促进异构数据的复用。OpenVLA 从中筛选约 97 万条轨迹进行训练,学习视觉观测、语言指令与机器人动作之间的映射关系。
- 清洗与配比遵循两条原则:
- 统一输入输出空间:只保留含至少一个第三人称相机、且是单臂末端执行器控制的数据集,多视角、双臂、异构传感器一律排除。这一条是硬约束——数据异构会直接破坏"同一套提示词、同一套动作空间"这个前提
- 平衡任务与场景:沿用 Octo 的数据集权重,下调多样性差的数据集、上调任务和场景更丰富的数据集
- 论文还试过把 DROID(Distributed Robot Interaction Dataset) 数据集以 10% 的权重混进来。结果是训练全程 DROID 上的动作 token 准确率都上不去,所以在最后三分之一的训练里把 DROID 移出了数据混合
- 补一个规模上的对比:RT-2-X 用了 350k 条轨迹,OpenVLA 用了 970k 条,接近三倍
4-2-2 训练目标:只在动作 token 上算损失
- 训练目标是标准的下一 token 预测,但损失只落在动作 token 上:
L
=
−
1
K
∑
k
=
1
K
log
p
θ
(
y
k
∣
y
<
k
,
i
m
g
,
i
n
s
t
r
)
\mathcal{L}=-\frac{1}{K}\sum_{k=1}^{K}\log p_\theta\left(y_k \mid y_{<k},\ \mathrm{img},\ \mathrm{instr}\right)
L=−K1k=1∑Klogpθ(yk∣y<k, img, instr)其中:
- i m g \mathrm{img} img、 i n s t r \mathrm{instr} instr:视觉观测与语言指令,作为条件
- y k y_k yk:参与计算的第 k k k 个目标 token
- K K K:参与计算的位置数,等于 7 个动作 token 加 1 个终止 token,也就是 8
- θ \theta θ:全部可训练参数
- 图像 patch 位置与 prompt 位置都打了
IGNORE_INDEX = -100。这带来一个容易被忽略的后果:梯度信号只来自机器人动作那一小段,视觉语言能力在微调阶段基本被冻结。这也解释了一个现象——OpenVLA 在语言理解类任务上不如 RT-2-X,因为后者在机器人数据之外还混了互联网视觉语言数据一起训 - 损失掩码在代码里只有一行:
# datasets.py :: RLDSBatchTransform.__call__
# 除末尾 len(action) + 1 个位置外,其余标签全部置为 IGNORE_INDEX
labels[: -(len(action) + 1)] = IGNORE_INDEX
if not self.predict_stop_token: # 默认 True,即终止 token 也参与损失
labels[-1] = IGNORE_INDEX
- 说明一下形状:
labels与input_ids等长,保留下来的是 7 个动作 token 加上紧跟其后的终止 token。HF 的forward内部会自动做一位错位,所以数据集这一侧保持与输入对齐,不自己 shift
4-2-3 训练配置与几条设计取舍
- 配置层面:
- batch size 2048,学习率固定 2 × 10 − 5 2\times10^{-5} 2×10−5,不加预热。论文扫过多个数量级,预热没有带来收益
- 训练 27 个 epoch。这一点与常规 VLM 训练差别很大——VLM 通常只跑一到两个 epoch,而 VLA 需要反复过同一批数据,直到动作 token 的准确率超过 95%
- 视觉编码器不冻结,与主干一起训
- 几条关键取舍,都是先在小规模实验里筛出来的:
- 视觉编码器要一起微调。此前 VLM 的经验是冻结视觉塔效果更好,但在 VLA 上冻结视觉塔的成功率明显更低(4-3 节的表里有实测读数)。推断是互联网数据学到的特征缺少精细空间信息,而抓取恰恰依赖这些信息
- 分辨率取 224 × 224 224\times 224 224×224。 384 × 384 384\times 384 384×384 的输入在评测中没有带来提升,训练时间却变成 3 倍。原因是分辨率翻倍后 patch 数按平方增长,序列一变长,注意力开销就跟着涨
- backbone 选 Prismatic-7B。论文早期在 BridgeData V2 上做过横向对比:IDEFICS-1 与 LLaVA 在单物体场景下表现相当,一旦涉及多物体和语言定位,LLaVA 明显更好;Prismatic-7B 又比 LLaVA 高约 10 个百分点,论文把这个差距归因于融合了 SigLIP 与 DINOv2 的视觉塔
4-3 全参数微调与 LoRA
4-3-1 为什么微调才是主要用法
- 微调(Fine-tuning) 是指在预训练模型的基础上,使用特定任务的数据继续训练,调整模型参数,使其更适应目标任务。
- 预训练模型是通用的,但落到具体机械臂、具体任务上,必须再做一次适配。论文对这个模型的定位说得很直接:把 OpenVLA 当作一个强有力的初始化,而不是开箱即用的成品
- 全参数微调的效果最好,代价是显存。实验设置是每个任务给 10 到 150 条示范,测试场景是 Franka 机械臂的桌面操作任务
4-3-2 五种微调方案对比
- 论文比较了五种方案,其中 LoRA 另测了两个秩。成功率在七个 Franka 任务上取平均,结果如下:

| 微调方案 | 可训练参数 | 成功率 | 显存(batch 16) |
|---|---|---|---|
| 全参数微调 | 7188.1 M | 69.7 % | 163.1 GB |
| 只调最后一层 | 465.1 M | 30.3 % | 51.4 GB |
| 冻结视觉塔 | 6760.4 M | 47.0 % | 156.2 GB |
| Sandwich 微调 | 914.2 M | 62.1 % | 64.0 GB |
| LoRA( r = 32 r=32 r=32) | 97.6 M | 68.2 % | 97.6 GB |
| LoRA( r = 64 r=64 r=64) | 195.2 M | 68.2 % | 60.5 GB |
- 几个读法:
- 只调最后一层不行(30.3%)。只换输出层,模型内部的表示完全没动,学不会新任务
- 冻结视觉塔也不行(47.0%)。这条与 4-2-3 的结论互相印证:视觉塔必须一起调
- Sandwich 微调是一个折中方案,只解冻视觉塔、输入嵌入和解码器最后几层,参数降到 914.2 M,成功率 62.1%,显存也同步降下来
- LoRA 的性价比最高。只用 1.4% 的参数就拿到 68.2%,与全参数微调的 69.7% 基本持平,显存需求低得多
- 表里有两处需要额外说明:
- 全参数微调那一行的 163.1 GB 是论文标注的"在 2 张 GPU 上用 FSDP 分摊"之后的合计值。单张 80 GB 的卡放不下,必须两卡
- LoRA 的 r = 32 r=32 r=32 与 r = 64 r=64 r=64 两行,参数量翻倍,显存读数反而下降。论文没有额外解释这个差异,这里按原表列出
- 论文的结论是 LoRA 的秩对策略性能的影响可以忽略,因此默认取 r = 32 r=32 r=32
4-3-3 LoRA 的原理与配置

- LoRA(Low-Rank Adaptation,低秩适配) 是一种参数高效微调(PEFT)方法。它的核心思想是:冻结预训练模型的原始权重,只训练少量额外参数,就能让模型适应新的任务。
- 在 LoRA 中,低秩支路(Low-Rank Branch) 指的就是由两个小矩阵 A A A 和 B B B 组成的额外计算分支。它不直接修改原始权重 W W W,而是学习一个权重增量,再将这个增量的计算结果加到原始输出上。
说人话:LoRA 训练的是一小部分额外的权重参数,用来修正原模型的权重,而不是直接训练原模型的全部权重。
- LoRA 的做法是:冻结原权重
W
0
W_0
W0,在它旁边挂一条低秩支路,只训这条支路:
W
=
W
0
+
Δ
W
=
W
0
+
α
r
B
A
,
B
∈
R
d
o
u
t
×
r
,
A
∈
R
r
×
d
i
n
W=W_0+\Delta W=W_0+\frac{\alpha}{r}\,BA,\qquad B\in\mathbb{R}^{d_{\mathrm{out}}\times r},\quad A\in\mathbb{R}^{r\times d_{\mathrm{in}}}
W=W0+ΔW=W0+rαBA,B∈Rdout×r,A∈Rr×din其中:
- r r r:秩,控制新增参数量的规模
- α \alpha α:缩放系数,用来让不同秩下的有效学习率大致对齐
- A A A、 B B B:两个低秩矩阵, A A A 用高斯初始化, B B B 初始化为零
- LoRA 是先降维,再升维,最终用较少的参数来修正原模型的权重。其中:
- A 负责降维:将原始特征从高维空间映射到低维空间
- B 负责升维:将低维特征重新映射回原始维度,得到对原模型权重的修正量
- 最终将 LoRA 支路的输出与原模型的输出相加,实现对模型行为的调整,而原始权重保持冻结
- 因为
B
B
B 从零开始,训练第一步
Δ
W
=
0
\Delta W=0
ΔW=0,模型行为与预训练模型完全一致。这也是 LoRA 比直接全量微调更"安全"的地方——它不会一上来就把预训练权重带偏
- 算一下参数量。以 4096 × 11008 4096\times 11008 4096×11008 的前馈矩阵为例:全量更新需要 4500 万参数, r = 32 r=32 r=32 时低秩支路只要 32 × ( 4096 + 11008 ) ≈ 48 32\times(4096+11008)\approx 48 32×(4096+11008)≈48 万参数,差两个数量级
- 配置上有三个点值得记下来:
target_modules="all-linear":挂载对象是所有权重矩阵,注意力与 MLP 都算,而不是只挂注意力的 Q、V。这和"前馈层占了单层参数的三分之二"是同一件事的两面,只挂 Q、V 会漏掉大部分容量lora_alpha=min(r, 16): α \alpha α 取秩与 16 中的较小值。 r = 32 r=32 r=32 时 α = 16 \alpha=16 α=16,缩放系数 α r = 0.5 \frac{\alpha}{r}=0.5 rα=0.5lora_dropout=0.0:默认不加 dropout,秩本身已经是一种强约束
- 代码:
# finetune.py
lora_config = LoraConfig(
r=cfg.lora_rank, # 默认 32
lora_alpha=min(cfg.lora_rank, 16), # r=32 时取 16
lora_dropout=cfg.lora_dropout, # 默认 0.0
target_modules="all-linear", # 所有线性层
init_lora_weights="gaussian",
)
vla = get_peft_model(vla, lora_config)
vla.print_trainable_parameters()
- 训练里还有一个工程细节值得说:LoRA 权重是先存适配器、再合并回主干。合并之后推理就没有额外开销,但合并这一步要重新加载一份基础模型,所以代码把它放在存 checkpoint 的间隙做:
# finetune.py(要点摘录)
vla.module.save_pretrained(adapter_dir) # 先存适配器权重
# 再把适配器并回基础模型,得到可直接推理的完整权重
base_vla = AutoModelForVision2Seq.from_pretrained(cfg.vla_path, torch_dtype=torch.bfloat16, ...)
merged_vla = PeftModel.from_pretrained(base_vla, adapter_dir).merge_and_unload()
merged_vla.save_pretrained(run_dir)
- 另一个细节:训练时统计的"动作准确率"必须先筛出动作 token 位置再算,否则会被 prompt 里的普通 token 稀释掉:
# finetune.py(要点摘录)
mask = action_gt > action_tokenizer.action_token_begin_idx # 只保留动作 token 位置
correct_preds = (action_preds == action_gt) & mask
action_accuracy = correct_preds.sum().float() / mask.sum().float()
- 除了 token 准确率,代码里还统计了一个 L1 损失:把预测的动作 token 与真值动作 token 都解码回连续值,再算平均绝对误差。这个指标比准确率更贴近"机械臂实际偏了多少"
4-3-4 微调的训练流程
- 4-3-3 讲的是 LoRA 怎么挂、权重怎么合并,这一节把整条链路按执行顺序串一遍:加载与挂载、数据管道、训练循环、保存与退出。先看默认配置:
| 参数 | 默认值 | 含义 |
|---|---|---|
batch_size | 16 | 单卡批大小,脚本注释里 48 GB 的卡能放到 12、80 GB 能放到 24 |
grad_accumulation_steps | 1 | 梯度累积步数 |
learning_rate | 5 × 10 − 4 5\times10^{-4} 5×10−4 | 常数学习率,比预训练的 2 × 10 − 5 2\times10^{-5} 2×10−5 高二十多倍 |
max_steps | 200000 | 优化器步数上限 |
save_steps | 5000 | 每多少个优化步存一次 |
shuffle_buffer_size | 100000 | 流式打乱的缓冲区大小 |
image_aug | True | 是否对图像做增广 |
- 第一步是加载与挂载。OpenVLA 的模型类不在 transformers 仓库里,是跟权重一起分发的自定义实现,所以要先把它们注册进 HF 的 AutoClass:
# finetune.py(要点摘录)
AutoConfig.register("openvla", OpenVLAConfig)
AutoImageProcessor.register(OpenVLAConfig, PrismaticImageProcessor)
AutoProcessor.register(OpenVLAConfig, PrismaticProcessor)
AutoModelForVision2Seq.register(OpenVLAConfig, OpenVLAForActionPrediction)
vla = AutoModelForVision2Seq.from_pretrained(
cfg.vla_path,
torch_dtype=torch.bfloat16, # 权重直接以 bf16 载入
low_cpu_mem_usage=True,
trust_remote_code=True,
)
vla = DDP(vla, device_ids=[device_id], find_unused_parameters=True, gradient_as_bucket_view=True)
- 三个地方值得留意:
- 不注册,
from_pretrained就认不出这个架构 torch_dtype=torch.bfloat16让权重一载入就是 bf16,省一半显存- DDP 的两个开关分别管容错与显存:
find_unused_parameters允许某些可训练参数在某一步没参与计算,gradient_as_bucket_view让梯度桶与通信缓冲共用一块内存
- 不注册,
- 第二步是优化器。只有一行,而且没有学习率调度器:
# finetune.py(要点摘录)
trainable_params = [param for param in vla.parameters() if param.requires_grad]
optimizer = AdamW(trainable_params, lr=cfg.learning_rate) # 常数学习率,无预热
- 只把
requires_grad为真的参数交给优化器,冻结的部分因此不产生优化器状态。学习率全程恒定,源码注释写得很直白:“we default to a simple constant learning rate”,这与预训练用固定学习率是同一个思路 - 第三步是数据管道,有三处与常规 PyTorch 训练不同:
RLDSDataset是IterableDataset,没有 epoch 的概念。它是一条可以一直取下去的流,所以训练循环靠max_steps截断,而不是靠遍历完数据集- 打乱靠
shuffle_buffer_size的缓冲区做流式随机化,不是对索引做随机置换 - 开启
image_aug后,每帧先做固定长宽比的随机裁剪(面积取原图的 0.9),再随机调整亮度、对比度、饱和度与色调;语言指令与动作标签不受影响
- collator 负责把不等长的样本拼成批:输入按
pad_token_id右填充,标签按IGNORE_INDEX右填充,注意力掩码由"哪些位置等于pad_token_id"倒推 - 图像在这里的传法值得单独说一句。2-4-4 里双塔是各吃一张图,但微调走的是 HF 那条路径,
pixel_values是一张 6 通道的张量:前 3 通道是给 DINOv2 的预处理结果,后 3 通道是给 SigLIP 的,模型内部再用torch.split(pixel_values, [3, 3], dim=1)拆回两张图分别送塔 - 第四步是训练循环,核心只有这几行:
# finetune.py(要点摘录)
with torch.autocast("cuda", dtype=torch.bfloat16):
output = vla(
input_ids=batch["input_ids"].to(device_id),
attention_mask=batch["attention_mask"].to(device_id),
pixel_values=batch["pixel_values"].to(torch.bfloat16).to(device_id),
labels=batch["labels"], # 掩码已经在数据集侧打好
)
loss = output.loss
(loss / cfg.grad_accumulation_steps).backward()
if (batch_idx + 1) % cfg.grad_accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
- 循环里没有任何自定义损失项:
loss直接取自前向输出,而"只算动作 token"是靠数据集侧把其余位置填成IGNORE_INDEX实现的(4-2-2)。也就是说,掩码就是损失函数,训练脚本不必知道哪些位置该不该算 - 循环之外还有三件外围的事:每 10 个优化步往 W&B 推一次损失、动作准确率与 L1 损失;每 5000 步存一次 checkpoint;优化步数到达
max_steps就退出
4-4 量化推理与显存需求
- 我们在【模型量化基础】从 FP16 到 NVFP4:你需要知道的量化知识提到量化的基础知识,这里就不过多展开
4-4-1 bf16 与显存门槛
- 7B 参数在 bf16 下光权重就要约 14 GB。论文报的推理显存是 15 GB,在 RTX 4090 上大约能跑到 6 Hz
- 这个数意味着:24 GB 的消费级显卡放得下,但几乎没有余量;16 GB 的卡则必须量化
4-4-2 三种精度的权衡
- 论文在八个 BridgeData V2 任务上对比了三种精度:
| 精度 | 成功率 | 显存 |
|---|---|---|
| bf16 | 71.3 % | 16.8 GB |
| int8 | 58.1 % | 10.2 GB |
| int4 | 71.9 % | 7.0 GB |
- 三行读数里有一个反直觉的点:int4 的成功率与 bf16 基本持平,int8 反而明显更差。原因不在精度本身,而在速度。int8 量化引入了额外的反量化开销,在 A5000 上只能跑到约 1.2 Hz,而训练数据是由 5 Hz 的非阻塞控制器采集的。控制频率掉到 1.2 Hz,闭环里的系统动态就变了
- int4 反而因为显存带宽压力小,跑到约 3 Hz,更接近采集时的动态,因此没有掉点。论文的建议是把 int4 当作默认的低显存方案
- 需要说明的是,4-3 与 4-4 两张表出自论文的另一组实验:用的是训练数据较少、视觉塔只有 SigLIP 的精简版 OpenVLA,不是完整的 7B 融合版本。所以表里的显存读数反映的是量级关系,不能直接照搬到 7B 上
4-4-3 量化在代码里是什么
- 量化推理不需要改模型结构,只要在加载时挂一个量化配置:
# openvla_utils.py(要点摘录)
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # 4 比特正态浮点,对权重分布拟合得更好
bnb_4bit_compute_dtype=torch.bfloat16, # 计算仍然用 bf16
)
vla = AutoModelForVision2Seq.from_pretrained(
"openvla/openvla-7b",
quantization_config=bnb_config,
torch_dtype=torch.bfloat16,
low_cpu_mem_usage=True,
trust_remote_code=True,
)
- 关键在"权重低精度存储、计算高精度进行":权重存成 4 比特,前向时反量化回 bf16 再参与矩阵乘。省下的是显存,付出的是每次前向多一次反量化
4-4-4 与 LoRA 组合时的注意事项
- 4 比特加载与 LoRA 可以叠在一起用,这也是微调 7B 的常见做法。但顺序不能反:先量化加载,再包 LoRA,并且在量化训练前要调用
prepare_model_for_kbit_training把一部分层转成 fp32,否则梯度更新会被量化误差吃掉 - 论文对量化微调的态度比较克制:能省显存,但会掉性能,所以默认的微调配置不开量化。如果只是推理,4 比特是划算的;如果要微调,还是优先考虑不量化的 LoRA
4-5 完整训练流程
- 4-2 讲的是预训练用什么数据、优化什么目标,4-3 讲的是拿到权重之后怎么微调。这一节把三段训练按时间顺序接起来,重点补上前两节没有展开的一段:OpenVLA 自己的预训练脚本是怎么启动的,训练循环又与微调有哪些不同
- 从随机初始化的 Projector 到一条能上机器人的策略,中间经过三次训练,每次的起点、数据与冻结策略都不一样:
| 阶段 | 脚本 | 起点 | 数据 | 冻结策略 | 产出 |
|---|---|---|---|---|---|
| 阶段一:视觉语言对齐 | scripts/pretrain.py | Llama 2 7B 与双视觉塔 | LLaVA-1.5、LVIS-4V、LRV 的混合 | 视觉塔冻结,Projector 与 LLM 一起训 | prism-dinosiglip-224px+7b |
| 阶段二:VLA 预训练 | vla-scripts/train.py | 阶段一的 VLM | OXE 机器人轨迹 | 全参数解冻 | openvla-7b |
| 阶段三:下游微调 | vla-scripts/finetune.py | OpenVLA 权重 | 自己的机器人数据 | 全参数或 LoRA | 可部署的策略 |
- 三段的差别不只是数据量,更关键的是"谁在学":阶段一学的是怎么把视觉特征接进语言空间,阶段二学的是动作,阶段三只学自己的任务。阶段三的循环在 4-3-4 已经逐行讲过,下面只展开前两段
4-5-1 阶段一:把三个组件接成 VLM
- 阶段一把三个各自预训练过的组件拼起来:Llama 2 7B、DINOv2 塔、SigLIP 塔。三个主干都带着权重进来,只有中间的 Projector 是随机初始化的
- 发布模型的配置在注册表里归在 one-stage 一组,按
pretrain.py默认的stage="finetune"执行,关键参数如下:
| 参数 | 值 | 说明 |
|---|---|---|
| 冻结策略 | 视觉塔冻结 | Projector 与 Llama 2 7B 一起训 |
| 数据 | LLaVA-1.5、LVIS-4V、LRV 混合 | 约 123 万条图文样本 |
| epoch | 2 | 配置里写作 finetune_epochs=2 |
| 全局批大小 | 128 | 单卡 16,128 正好由 8 卡拼成 |
| 学习率 | 2 × 10 − 5 2\times10^{-5} 2×10−5 | 线性预热加余弦衰减,预热占 3% |
| 权重衰减 | 0.1 | 只作用于二维以上且不是偏置的权重 |
| 训练策略 | fsdp-full-shard | 与阶段二同一个策略 |
- 代码里还保留了一条 align 分支:冻结视觉塔与 LLM,只训 Projector,学习率
10
−
3
10^{-3}
10−3,批大小 256。这条分支用于多阶段配方;发布模型的架构声明是
no-align,走的是单阶段- 视觉塔在阶段一冻结,与阶段二相反。这不矛盾:阶段一的任务是让语言模型熟悉视觉特征,视觉塔在互联网图文上已经学得足够好;阶段二要预测动作,更依赖精细的空间关系,才把视觉塔一起放开(4-2-3 有实测读数)
- 这一步的产出是
prism-dinosiglip-224px+7b,阶段二从它出发
4-5-2 阶段二:预训练脚本怎么启动
train.py是一条命令加一份配置,超参数全部写在VLAConfig里,命令行只负责选择用哪一份。OpenVLA 用的是prism-dinosiglip-224px+mx-oxe-magic-soup-plus:
| 配置项 | 值 | 说明 |
|---|---|---|
expected_world_size | 64 | 期望的 GPU 数量 |
global_batch_size | 2048 | 全局批大小 |
per_device_batch_size | 32 | 单卡批大小,乘以 64 卡正好是全局批 |
learning_rate | 2 × 10 − 5 2\times10^{-5} 2×10−5 | 常数学习率,与 4-2-3 一致 |
weight_decay | 0 | 不施加权重衰减 |
max_grad_norm | 1.0 | 梯度裁剪阈值 |
lr_scheduler_type | constant | 不加预热也不衰减 |
train_strategy | fsdp-full-shard | FSDP 全分片 |
enable_gradient_checkpointing | True | 用重算换显存 |
save_interval | 2500 | 每 2500 个优化步存一次 |
- 启动后的第一步是校验硬件。配置在
__post_init__里断言实际进程数等于expected_world_size,拿 32 张卡跑 64 卡的配方会直接报错,不会悄悄换成一半的批大小。硬件与配方绑定的好处是结果可复现,代价是配方没法缩水 - 第二步是加载基础 VLM。
load(..., load_for_training=True)之后会把每一个参数都断言为 FP32:混合精度只在 FSDP 内部生效,进入训练循环前模型始终是全精度 - 第三步决定"谁在学"。脚本把两个冻结开关组合成四种训练阶段:
| Stage | 视觉塔 | Projector | LLM |
|---|---|---|---|
vla-full-train | 解冻 | 解冻 | 全部解冻 |
vla-train | 冻结 | 解冻 | 全部解冻 |
vla-sandwich-train | 解冻 | 解冻 | 仅最后一层等指定部分训练 |
vla-last-layer-train | 冻结 | 冻结 | 仅输入嵌入、最后一层与输出头等指定部分训练 |
# train.py(要点摘录)
if not cfg.vla.freeze_vision_backbone and not cfg.vla.freeze_llm_backbone:
stage = "vla-full-train" # 视觉塔、Projector 与 LLM 全部解冻
elif cfg.vla.freeze_vision_backbone and not cfg.vla.freeze_llm_backbone:
stage = "vla-train" # 冻结视觉塔,其余解冻
elif not cfg.vla.freeze_vision_backbone and cfg.vla.freeze_llm_backbone:
stage = "vla-sandwich-train" # 视觉塔、Projector 与 LLM 最后一层
else:
stage = "vla-last-layer-train" # 只解冻 LLM 的输入嵌入、最后一层与输出头
vlm.freeze_backbones(stage) # 按阶段冻结,并打印可训练参数量
- 这四档与 4-3-2 表里的微调方案是同一套划分,只是命名不同。OpenVLA 走
vla-full-train,与 4-2-3 "视觉编码器不冻结"的结论一致 - 第四步是数据。
get_vla_dataset_and_collator一次装好三样东西:动作分词器、RLDS 批变换、右填充的 collator。图像在这一路是"一塔一份"的字典,键是dino与siglip;4-3-4 里那个 6 通道张量只出现在 HF 通路,原生prismatic通路不需要拼- 配置里的
data_mix指向oxe_magic_soup_plus_minus。类注释写明了原因:完整配方先用在含 DROID 的混合上训到 70% 的进度,再切到去掉 DROID 的版本。这与 4-2-1 里"DROID 上的动作准确率上不去"是同一件事
- 配置里的
- 第五步是 FSDP 与优化器。模型按模块切成若干分片,LLM 的每个 Transformer 层再套一层非重入的激活检查点;前向计算用 bf16,梯度归约保留 FP32。优化器把参数分成两组:一维参数与偏置不做权重衰减,其余权重按配置衰减
4-5-3 训练循环:与微调流程的不同
- 循环骨架与 4-3-4 几乎一致:bf16 前向、损失由模型内部算、反向传播、裁剪梯度、更新参数。差别集中在这几处:
| 差异 | 预训练(train.py) | 微调(finetune.py) |
|---|---|---|
| 梯度累积 | 断言必须为 1 | 可配置,默认 1 |
| 终止条件 | max_steps,为空时不自动停 | max_steps,默认 20 万步 |
| 存盘间隔 | 每 2500 步 | 每 5000 步 |
| 指标上报 | 每个优化步 | 每 10 个优化步 |
| 分布式 | FSDP 全分片 | DDP |
- 注:FSDP(全分片数据并行)和 DDP(分布式数据并行)是两种 PyTorch 分布式训练方式
- 梯度累积被断言禁止,是因为 64 卡乘以单卡 32 已经等于全局批 2048,不需要再靠累积凑批。批大小固定,显存预算也就固定
- 预训练同样没有 epoch 循环。RLDS 数据流会无限重复,脚本里的 epoch 是由全局步数反推出来的:
# base_strategy.py :: run_vla_training(要点摘录)
assert self.grad_accumulation_steps == 1, "VLA training does not support gradient accumulation!"
...
epoch = (metrics.global_step + 1) // (len(vla_dataset) // self.global_batch_size)
- 由此带出一个容易忽略的点:
epochs只参与总步数计算与进度条显示,不构成退出条件。配置里填的 1000 只是上界,论文报告的实际长度见 4-2-3;max_steps为空时,循环不会自己停下来- 存盘时 FSDP 先把分片汇总成全量 state dict,只在主进程、先搬到 CPU 再写盘,文件名带步数、epoch 与当前损失
- 还有一个微调用不上的监控:一个批次里可能混着多个数据集,主进程会按
dataset_names把批次拆开,分别算每个数据集的动作准确率与 L1 损失
4-5-4 三段之间交接了什么
- 每一段训练结束都会留下几样东西,下一段要用到它们:
| 交接物 | 谁产生 | 谁消费 | 用途 |
|---|---|---|---|
| 全量权重 | 每一段训练 | 下一段训练 | 作为初始化 |
| 数据集统计量 | 阶段二与阶段三 | 推理 | q 01 q_{01} q01 与 q 99 q_{99} q99 用于反归一化(2-9) |
| LoRA 适配器 | 阶段三(可选) | 合并步骤 | 用 merge_and_unload() 并回基础模型(4-3-3) |
- 数据集统计量最容易被漏掉。它不参与训练,却决定了推理时怎么把归一化后的动作还原回米与弧度;没跟着权重一起走,动作幅度会整体偏移
- 到这里流程就闭环了:阶段一让模型看得懂,阶段二让它会做动作,阶段三让它会做你的任务,推理时再按 4-4 的办法决定用不用量化
总结
- 本期从 VLA 的四条技术路线出发,完整走通了 OpenVLA 的模型结构、动作离散化方案与训练微调流程
- 核心要点:
- 整体定位:OpenVLA 走的是自回归动作 Token 路线,把机械臂控制改写成"在 256 个动作 token 上做下一 token 预测"。7B 参数,在 BridgeData V2 上的平均成功率 70.6%,高于 55B 的 RT-2-X 的 50.6%
- 视觉塔:DINOv2 与 SigLIP 双塔并联,各出 256 个 patch 特征,按通道拼成 2176 维。DINOv2 补空间细节,SigLIP 补语义
- Projector:
FusedMLPProjector把 2176 维升到 4096 维,三层线性加两层 GELU,约 7100 万参数。论文正文写作"两层小 MLP",与开源代码的三层实现有出入,本文以代码为准 - 多模态融合:patch 直接当 token,整段插在 BOS 之后,序列形如 [ e B O S , Z 1 , … , Z 256 , e 1 , … , e L ] \left[e_{\mathrm{BOS}},\,Z_1,\dots,Z_{256},\,e_1,\dots,e_L\right] [eBOS,Z1,…,Z256,e1,…,eL]。patch 位置参与注意力,但既不产生损失也不产生动作
- 动作离散化:按 q 01 q_{01} q01、 q 99 q_{99} q99 归一化到 [ − 1 , 1 ] [-1,1] [−1,1],均匀分 256 箱,再映射到词表末尾 256 个低频 token。分箱步长是 2 255 \frac{2}{255} 2552
- Llama 2 7B:32 层、隐藏维 4096、32 头注意力、前馈中间维 11008。RMSNorm 前置归一化配残差连接,RoPE 提供相对位置
- 生成与部署:
max_new_tokens等于动作维度(7),全程do_sample=False贪心解码。推理显存 15 GB,RTX 4090 上约 6 Hz - 微调:全参数微调效果最好,LoRA 用 1.4% 的参数做到接近的水平,默认
r
=
32
r=32
r=32、
target_modules="all-linear"。冻结视觉塔会明显掉点,这条与预训练阶段的取舍一致 - 量化:int4 在成功率和速度上都优于 int8,显存从 16.8 GB 降到 7.0 GB。掉点与否的关键不是精度,而是推理帧率是否还匹配数据采集时的控制频率
- 完整训练流程:阶段一在约 123 万条图文上把视觉接进语言,阶段二在 64 卡、全局批 2048 下全参数预训练,阶段三用 LoRA 或全参数微调到自己的任务;三段之间传递权重、数据集统计量与适配器
- 论文正文与开源代码之间还有几处措辞层面的出入,比如 Projector 的层数、LoRA 的显存读数。文中都按"以代码和原表为准"的方式标了出来,读论文时留意这类差异
- 如有错误,欢迎指出!
- 感谢观看!
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)