前言


文章目录


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∑D​logpθ​(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−βt​ ​at−1​, βt​I)
  • 令 α 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​=αˉt​ ​a0​+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​=αt​ ​1​(at​−1−αˉt​ ​1−αt​​ϵθ​(at​,t,o,l))+σt​z

说人话:扩散模型解动作,像从一块被噪声糊住的石头里"雕"出动作。每雕一下动作清晰一点,雕几十下之后就是一条像样的专家轨迹。因为每一步都在同一个分布上重采样,它天然能表达"同一句指令下有好几种合理走法"。

  • 代表工作:
    • RDT-1B:把扩散头接在十亿参数级的多模态骨干上,做到跨本体双臂控制
    • DexVLA:用扩散专家搭配预训练 VLM,处理灵巧手这类高维动作空间
  • 取舍:
    • 优点:动作不做量化,精度不受格数限制;多模态分布建模能力强,抓取点不唯一、绕行路径不唯一这类任务上表现突出
    • 缺点:推理要迭代几十步,控制频率受限;动作块越长、维度越高,单次去噪的开销越大
1-2-4 路线C:Flow Matching 动作生成
  • 可以参考:【生成式模型入门】(四):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+∫01​vθ​(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 万参数,与前一行表格里的量级一致
  • 整条前向链路如下:

动作生成与解码

多模态融合

视觉编码器

预处理

输入层

RGB 图像

自然语言指令

图像预处理

文本 Tokenization

DINOv2 ViT-L/14

SigLIP ViT-So400M/14

视觉特征融合

MLP Projector
视觉特征映射

视觉 Token

文本 Token

拼接为多模态序列

Llama 2 7B
Transformer Decoder

自回归生成动作 Token

动作反离散化

动作反归一化

机器人动作适配
坐标系 / 关节映射 / 限幅

机器人控制器

机械臂执行

  • 整条链路里值得先记住的一点是:动作不是单独的一个输出头,而是被塞进了语言模型的词表。图里"动作生成与解码"这一段,实际发生的位置就在 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.lvd142mvit_so400m_patch14_siglip_224
主干ViT-L/14SoViT-400M/14
层数2427
隐藏宽度10241152
注意力头16 头,每头 64 维16 头,每头 72 维
MLP 中间维度40964304
patch 边长1414
输入分辨率 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
前缀 token1 个分类 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 塔

输入图像
224 × 224 × 3

Patch Embedding
16 × 16 = 256 个 Patch

添加位置编码
拼接 CLS + 4 个 Register Tokens

ViT-L/14
Transformer Blocks 1–22

倒数第二层特征
取 Block 22 输出

Patch Tokens
256 × 1024

  • 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:
    1. 切 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
    2. 拼前缀 token。先给 256 个 patch 加上位置嵌入,再把 1 个分类 token(class token)与 4 个 register token 拼到序列最前面,此时序列长度是 1 + 4 + 256 = 261 1+4+256=261 1+4+256=261。前缀 token 的位置信息已经并进了它们各自的参数里,不占位置嵌入
    3. 过 24 层 Block。每层是"前置归一化、多头自注意力、残差",后面再跟"前置归一化、MLP、残差"
    4. 取倒数第二层。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​ ; x1​E+e1pos​,…,x256​E+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 能稳训的配方之一
  • 名字里的 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 塔

输入图像
224 × 224 × 3

Patch Embedding
16 × 16 = 256 个 Patch

添加位置编码

SoViT-400M/14
Transformer Blocks 1–25

倒数第二层特征
取 Block 25 输出

Patch Tokens
256 × 1152

  • 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​=−B1​i=1∑B​j=1∑B​logσ(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 特征拼接

输入图像 I

DINOv2 预处理

SigLIP 预处理

DINOv2 ViT-L/14
24 层

SigLIP SoViT-400M/14
27 层

取倒数第二层
Patch 特征

取倒数第二层
Patch 特征

DINOv2 特征
256 × 1024

SigLIP 特征
256 × 1152

沿通道维拼接
dim = 2

融合视觉特征
256 × 2176

下游 Projector

  • 两塔的 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 都不在输出里"那句的出处
  • 论文给的理由是:倒数第二层的特征比最后一层更贴近"视觉表征"本身,最后一层已经开始向各自的预训练任务(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 结构与公式

双塔视觉特征 F
256 × 2176

线性层 W₁ + b₁
2176 到 8704

GELU

线性层 W₂ + b₂
8704 到 4096

GELU

线性层 W₃ + b₃
4096 到 4096

视觉 Token Z
256 × 4096

输入 Llama 2 7B

  • 视觉塔输出的是 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=W3​GELU(W2​GELU(W1​F+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 也被屏蔽掉
  • 后者是关键。训练目标只对动作 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 d4096
注意力头数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×40961.31 亿
单层注意力 4 × 4096 2 4\times 4096^2 4×409620.67 亿
单层前馈 3 × 4096 × 11008 3\times 4096\times 11008 3×4096×110081.35 亿
32 层合计 32 × 2.02 32\times 2.02 32×2.02 亿64.8 亿
输出层 LM Head 32000 × 4096 32000\times 4096 32000×40961.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 层解码器、输出层:

Transformer Decoder × 32 层

自回归循环

输入 Token IDs

Token Embedding
词嵌入

输入隐藏状态
序列特征

RMSNorm

Multi-Head Self-Attention
32 个注意力头

残差连接

RMSNorm

Feed-Forward Network
SwiGLU

残差连接

Final RMSNorm

LM Head
线性映射至词表维度

Logits

Softmax

下一个 Token 的概率分布

采样 / 贪心选择 Token

  • 图中几个部件的职责:
    • 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=1d​xi2​+ϵ ​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(dk​ ​Qh​Kh⊤​​+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−dk​2(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Θ,m​x= ​x1​cosmθ1​−x2​sinmθ1​x1​sinmθ1​+x2​cosmθ1​x3​cosmθ2​−x4​sinmθ2​x3​sinmθ2​+x4​cosmθ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Θ,m​q, RΘ,n​k⟩=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(W1​x)⊙W3​x) 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 ⊙:逐元素相乘,这就是"门"——一路算出内容,另一路算出该放多少过去
  • 中间维度 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=38​d=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​=Wlm​ht​,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 一个未归一化的分数,可正可负
  • 有个容易忽略的实现细节: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=132000​exp(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,也就是"条件"
  • 拿到概率之后有两条取法:
    • 贪心解码:直接取概率最大的那个, 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​=argmaxv​p(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 表示的是一个数值档位,采样会给同一观测引入不同动作,是否让闭环抖动取决于采样策略、动作空间与控制频率,部署时要单独验证
  • 代码上就是一次 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 自回归生成的基本过程

① 前向计算
得到最后一位的 Logits

② 贪心解码
argmax 选出 yₜ

③ 拼接 Token
将 yₜ 接到序列末尾

④ 更新输入序列
再次进行前向计算

  • "自回归"的含义是:把上一步的输出接到输入末尾,再算一次,如此往复。第 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​ (Wlm​ht−1(32)​)v​

  • 完整一轮是四步:
    1. 前向算出最后一位的 logits
    2. 取 argmax 得到 y t y_t yt​
    3. 把 y t y_t yt​ 拼到序列末尾
    4. 回到第 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 章已经拆开讲过,这里把三次变换并排列出,方便对照:
    1. 归一化: 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]
    2. 分箱:把 [ − 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]
    3. 映射词表: 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

DINOv2

SigLIP

256 个 Patch
每个 1024 维

256 个 Patch
每个 1152 维

通道拼接
256 × 2176

Projector
256 × 4096

语言指令
In: ... Out:

Tokenizer + Embedding
L 个 Token

BOS Embedding

拼接输入序列
长度 257 + L

Llama 2 7B
32 层 Decoder

取最后一位 Logits
完整词表 32000

贪心解码
argmax 得到 y₁

已生成 7 个
动作 Token?

将新 Token
追加到序列末尾

得到 7 个动作 Token

动作解码
还原为连续动作

  • 把前面所有模块串起来,一次完整的动作预测是这样走完的:
    1. 图像缩放到 224 × 224 224\times 224 224×224,分别送进 DINOv2 与 SigLIP,各拿到 256 个 patch 特征
    2. 两路特征按通道拼成 2176 维,过 Projector 变成 4096 维的 patch 嵌入
    3. 语言指令套上 In: ...\nOut: 模板,分词成 L L L 个 token 嵌入
    4. 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
    5. 这个序列过 32 层解码器,因果掩码保证每个位置只能看见它前面的位置
    6. 取最后一位的 logits,在全部 32000 个 token 上取最大值, y 1 = arg ⁡ max ⁡ y_1=\arg\max y1​=argmax
    7. 把 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。这个量级不是单卡能承担的任务,本地能实际跑起来的只有后面的微调与量化推理
  • 所以本章按四件事展开:
    1. 预训练用了什么数据、优化目标是什么(4-2)
    2. 拿到预训练好的 OpenVLA 之后,怎么适配到自己的机械臂(4-3)
    3. 怎么把 7B 的模型塞进消费级显存(4-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=−K1​k=1∑K​logpθ​(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 M69.7 %163.1 GB
只调最后一层465.1 M30.3 %51.4 GB
冻结视觉塔6760.4 M47.0 %156.2 GB
Sandwich 微调914.2 M62.1 %64.0 GB
LoRA( r = 32 r=32 r=32)97.6 M68.2 %97.6 GB
LoRA( r = 64 r=64 r=64)195.2 M68.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.5
    • lora_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_size16单卡批大小,脚本注释里 48 GB 的卡能放到 12、80 GB 能放到 24
grad_accumulation_steps1梯度累积步数
learning_rate 5 × 10 − 4 5\times10^{-4} 5×10−4常数学习率,比预训练的 2 × 10 − 5 2\times10^{-5} 2×10−5 高二十多倍
max_steps200000优化器步数上限
save_steps5000每多少个优化步存一次
shuffle_buffer_size100000流式打乱的缓冲区大小
image_augTrue是否对图像做增广
  • 第一步是加载与挂载。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 量化推理与显存需求
4-4-1 bf16 与显存门槛
  • 7B 参数在 bf16 下光权重就要约 14 GB。论文报的推理显存是 15 GB,在 RTX 4090 上大约能跑到 6 Hz
  • 这个数意味着:24 GB 的消费级显卡放得下,但几乎没有余量;16 GB 的卡则必须量化
4-4-2 三种精度的权衡
  • 论文在八个 BridgeData V2 任务上对比了三种精度:
精度成功率显存
bf1671.3 %16.8 GB
int858.1 %10.2 GB
int471.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.pyLlama 2 7B 与双视觉塔LLaVA-1.5、LVIS-4V、LRV 的混合视觉塔冻结,Projector 与 LLM 一起训prism-dinosiglip-224px+7b
阶段二:VLA 预训练vla-scripts/train.py阶段一的 VLMOXE 机器人轨迹全参数解冻openvla-7b
阶段三:下游微调vla-scripts/finetune.pyOpenVLA 权重自己的机器人数据全参数或 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 万条图文样本
epoch2配置里写作 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_size64期望的 GPU 数量
global_batch_size2048全局批大小
per_device_batch_size32单卡批大小,乘以 64 卡正好是全局批
learning_rate 2 × 10 − 5 2\times10^{-5} 2×10−5常数学习率,与 4-2-3 一致
weight_decay0不施加权重衰减
max_grad_norm1.0梯度裁剪阈值
lr_scheduler_typeconstant不加预热也不衰减
train_strategyfsdp-full-shardFSDP 全分片
enable_gradient_checkpointingTrue用重算换显存
save_interval2500每 2500 个优化步存一次
  • 启动后的第一步是校验硬件。配置在 __post_init__ 里断言实际进程数等于 expected_world_size,拿 32 张卡跑 64 卡的配方会直接报错,不会悄悄换成一半的批大小。硬件与配方绑定的好处是结果可复现,代价是配方没法缩水
  • 第二步是加载基础 VLM。load(..., load_for_training=True) 之后会把每一个参数都断言为 FP32:混合精度只在 FSDP 内部生效,进入训练循环前模型始终是全精度
  • 第三步决定"谁在学"。脚本把两个冻结开关组合成四种训练阶段:
Stage视觉塔ProjectorLLM
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 的模型结构、动作离散化方案与训练微调流程
  • 核心要点:
    1. 整体定位:OpenVLA 走的是自回归动作 Token 路线,把机械臂控制改写成"在 256 个动作 token 上做下一 token 预测"。7B 参数,在 BridgeData V2 上的平均成功率 70.6%,高于 55B 的 RT-2-X 的 50.6%
    2. 视觉塔:DINOv2 与 SigLIP 双塔并联,各出 256 个 patch 特征,按通道拼成 2176 维。DINOv2 补空间细节,SigLIP 补语义
    3. Projector:FusedMLPProjector 把 2176 维升到 4096 维,三层线性加两层 GELU,约 7100 万参数。论文正文写作"两层小 MLP",与开源代码的三层实现有出入,本文以代码为准
    4. 多模态融合: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 位置参与注意力,但既不产生损失也不产生动作
    5. 动作离散化:按 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​
    6. Llama 2 7B:32 层、隐藏维 4096、32 头注意力、前馈中间维 11008。RMSNorm 前置归一化配残差连接,RoPE 提供相对位置
    7. 生成与部署:max_new_tokens 等于动作维度(7),全程 do_sample=False 贪心解码。推理显存 15 GB,RTX 4090 上约 6 Hz
    8. 微调:全参数微调效果最好,LoRA 用 1.4% 的参数做到接近的水平,默认 r = 32 r=32 r=32、target_modules="all-linear"。冻结视觉塔会明显掉点,这条与预训练阶段的取舍一致
    9. 量化:int4 在成功率和速度上都优于 int8,显存从 16.8 GB 降到 7.0 GB。掉点与否的关键不是精度,而是推理帧率是否还匹配数据采集时的控制频率
    10. 完整训练流程:阶段一在约 123 万条图文上把视觉接进语言,阶段二在 64 卡、全局批 2048 下全参数预训练,阶段三用 LoRA 或全参数微调到自己的任务;三段之间传递权重、数据集统计量与适配器
  • 论文正文与开源代码之间还有几处措辞层面的出入,比如 Projector 的层数、LoRA 的显存读数。文中都按"以代码和原表为准"的方式标了出来,读论文时留意这类差异
  • 如有错误,欢迎指出!
  • 感谢观看!
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐