OpenVLA 核心原理深度解析

OpenVLA 核心原理深度解析

这是一份"脱离实现细节也能读懂"的原理说明。

它不按工程结构讲,而按一个 Vision-Language-Action 系统为什么这样设计、数据如何流动、模型到底学习什么、推理时如何把 token 变回机器人动作来讲。

目录

  1. 先用一句话建立直觉
  2. [OpenVLA 到底在解决什么问题](#OpenVLA 到底在解决什么问题)
  3. [核心思想:把机器人控制改写成 token 生成](#核心思想:把机器人控制改写成 token 生成)
  4. [从机器人动作到语言模型 token](#从机器人动作到语言模型 token)
  5. [从图像到语言模型可理解的 embedding](#从图像到语言模型可理解的 embedding)
  6. 训练样本如何构造
  7. 模型训练时到底在优化什么
  8. 推理时如何从图片和指令得到真实动作
  9. 多机器人数据集为什么需要标准化
  10. 两种训练范式:全量训练与参数高效微调
  11. 部署服务的抽象链路
  12. 核心伪代码
  13. 关键设计取舍
  14. 常见误解
  15. 如果要改造系统,应优先理解哪些抽象接口
  16. 最终总结

先用一句话建立直觉

OpenVLA 的本质是:

text 复制代码
把"机器人看到什么、听到什么指令、下一步怎么动"这个控制问题,
改写成"多模态语言模型在图像条件下生成一串动作 token"的问题。

普通视觉语言模型通常是:

text 复制代码
图片 + 问题 -> 文本回答

OpenVLA 是:

text 复制代码
图片 + 任务指令 -> 动作 token -> 连续机器人动作

类比一下:

一个传统机器人策略像"直接输出方向盘角度和油门大小"的驾驶控制器。

OpenVLA 更像是先发明一套"动作密码本",把方向盘角度和油门大小编码成若干特殊词,然后让一个会看图的语言模型根据路况和指令生成这些特殊词。最后系统再把特殊词翻译回真实控制量。

这个思路看起来绕,但它把机器人动作预测接入了语言模型最擅长的能力:

text 复制代码
根据上下文生成下一个 token。

OpenVLA 到底在解决什么问题

机器人模仿学习里,一条训练数据大致包含三类信息:

text 复制代码
1. 观测
   - 相机图像
   - 可选的本体状态,例如关节角、末端位置、夹爪状态

2. 任务
   - 人类语言指令,例如"把胡萝卜放进碗里"

3. 动作
   - 机器人专家在这个状态下执行的动作
   - 常见形式是连续向量,例如:
     [x方向移动, y方向移动, z方向移动, 旋转1, 旋转2, 旋转3, 夹爪开合]

模型要学的是一个条件策略:

text 复制代码
policy(image, instruction) -> action

也就是:

text 复制代码
当前画面 + 当前任务 -> 下一步机器人动作

传统做法可能直接训练一个神经网络回归动作:

text 复制代码
图片特征 + 语言特征 -> MLP -> 连续动作向量

OpenVLA 选择了另一条路:

text 复制代码
图片特征 + 语言 token -> LLM -> 动作 token -> 连续动作向量

这样做的关键收益是:

  1. 可以复用大型语言模型的自回归生成框架。
  2. 可以复用视觉语言模型的图文对齐能力。
  3. 可以把"动作预测"纳入通用 token 预测范式。
  4. 可以用语言指令自然地控制机器人行为。

核心思想:把机器人控制改写成 token 生成

语言模型最擅长的问题是:

text 复制代码
给定前文,预测下一个 token。

例如:

text 复制代码
输入: The robot should pick up the
输出: cup

OpenVLA 想让语言模型做:

text 复制代码
输入: 图像 + "机器人应该如何执行:把杯子拿起来?"
输出: 动作 token 1, 动作 token 2, ..., 动作 token N

这里的动作 token 不是普通词语,而是人为约定的"动作编码符号"。

如果动作是 7 维:

text 复制代码
action = [a1, a2, a3, a4, a5, a6, a7]

模型就可以生成 7 个动作 token:

text 复制代码
tokens = [t1, t2, t3, t4, t5, t6, t7]

每个 token 对应一个动作维度的离散值。

于是原问题:

text 复制代码
回归一个连续向量

被改写成:

text 复制代码
生成一串离散 token

这一步是 OpenVLA 的思想核心。


从机器人动作到语言模型 token

机器人动作通常是连续数值。比如机械臂末端移动动作:

text 复制代码
dx = 0.021
dy = -0.014
dz = 0.007

语言模型不能天然生成"高精度浮点控制量"。它生成的是 token。因此需要一个动作编码器。

第一步:归一化动作

不同机器人、不同数据集的动作尺度不一样。

例如:

text 复制代码
机器人 A:
  dx 的合理范围可能是 [-0.05, 0.05]

机器人 B:
  dx 的合理范围可能是 [-0.5, 0.5]

如果直接混在一起训练,模型会很混乱。

所以先把动作统一映射到一个标准区间,通常是:

text 复制代码
[-1, 1]

一种常见的分位数归一化方式是:

text 复制代码
normalized = 2 * (raw - q01) / (q99 - q01) - 1

其中:

text 复制代码
q01 = 这个动作维度在数据集中的 1% 分位数
q99 = 这个动作维度在数据集中的 99% 分位数

使用分位数而不是最小值/最大值,是为了降低异常动作值的影响。

q01 和 q99 到底是什么意思

分位数可以理解成:

text 复制代码
把一堆数从小到大排序后,位于某个百分比位置的值。

假设某个数据集里,dx 这个动作维度一共有 10000 个样本。把所有 dx 从小到大排序:

text 复制代码
dx_sorted = [
  最小值,
  第二小,
  第三小,
  ...
  最大值
]

那么:

text 复制代码
q01 = 排在 1% 位置附近的值
q99 = 排在 99% 位置附近的值

更白话地说:

text 复制代码
q01 ≈ "正常小值边界"
q99 ≈ "正常大值边界"

它们不是最小值和最大值,而是把最极端的少量异常值排除掉之后的范围。

举个具体例子。假设某个机器人数据集里,dx 的一批值从小到大大概是:

text 复制代码
[-10.0, -0.06, -0.05, -0.04, -0.03,
 -0.02, -0.01,  0.00,  0.01,  0.02,
  0.03,  0.04,  0.05,  0.06,  0.07,
  0.08,  0.09,  0.10,  0.11,  8.0]

这里 -10.08.0 很可能是异常值,例如传感器错误、数据记录异常、碰撞瞬间动作等。

如果用最小值/最大值归一化:

text 复制代码
min = -10.0
max = 8.0

那么正常动作值,比如 0.05,会被压到非常靠近中间的位置。模型会把大量正常动作都挤在很窄的归一化区间里,难以区分细微差别。

如果用分位数,系统会更关注大多数正常数据。例如可以近似认为:

text 复制代码
q01 ≈ -0.06
q99 ≈ 0.11

这表示:

text 复制代码
绝大多数正常 dx 动作大概落在 [-0.06, 0.11] 之间。

然后再把这个正常范围映射到 [-1, 1]

例如:

text 复制代码
q01 = -0.06
q99 =  0.11
raw =  0.025

代入公式:

text 复制代码
normalized = 2 * (raw - q01) / (q99 - q01) - 1
           = 2 * (0.025 - (-0.06)) / (0.11 - (-0.06)) - 1
           = 2 * 0.085 / 0.17 - 1
           = 0

所以 raw = 0.025 位于正常范围中间,会被映射成 0

边界也很好理解:

text 复制代码
raw = -0.06 -> normalized = -1
raw =  0.11 -> normalized =  1

因此,使用 q01/q99 的含义是:

text 复制代码
不要让最极端的 1% 小值和最极端的 1% 大值决定整个动作范围;
用中间 98% 的主要数据范围来定义"正常动作尺度"。

这能让模型更关注真实控制中常见的动作变化,而不是被少量异常值带偏。

第二步:离散化

归一化后的动作在 [-1, 1] 之间。

OpenVLA 风格的离散化会把这个区间切成固定数量的桶,例如 256 个桶。

text 复制代码
[-1, 1] -> 256 个离散区间

于是:

text 复制代码
-1.0 附近 -> 第 1 桶
 0.0 附近 -> 中间桶
 1.0 附近 -> 第 256 桶

类比:

连续动作像一把可以无级调节的旋钮;

离散化像把旋钮改成 256 档。

精度少了一点,但模型从"预测小数"变成了"选择档位"。

第三步:把桶映射到 token

离散桶还不是语言模型 token。

还需要一个映射:

text 复制代码
桶编号 -> token id

一种简单策略是使用语言模型词表中很少用于自然语言的末尾 token。

假设词表大小是:

text 复制代码
vocab_size = 32000

动作桶数量是:

text 复制代码
n_bins = 256

那么可以约定:

text 复制代码
词表最后 256 个 token 专门表示动作桶

所以:

text 复制代码
第 1 桶   -> 某个靠近词表末尾的 token
第 128 桶 -> 另一个动作 token
第 256 桶 -> 另一个动作 token

这样,语言模型不需要新增一个动作输出头。它仍然输出整个词表上的 logits,只不过训练会让它在动作位置生成这些动作 token。


从图像到语言模型可理解的 embedding

语言模型原本只理解 token embedding,不理解图片像素。

OpenVLA 需要把图片变成可以插入语言模型上下文的向量。

整体过程是:

text 复制代码
图片
  -> 视觉编码器
  -> 一串图像 patch 特征
  -> 投影器
  -> 与 LLM hidden size 匹配的图像 embedding
  -> 插入语言模型输入序列

视觉编码器

视觉编码器通常是 ViT 类模型。

它会把图片切成 patch,每个 patch 变成一个向量。

text 复制代码
图片 -> [patch_1, patch_2, ..., patch_M]

每个 patch 向量表示图片某个区域的视觉语义。

可以类比成:

人看一张桌面图片,会注意到"左边有杯子""中间有碗""右侧有夹爪"。

ViT patch 特征就是让模型用一堆局部视觉向量表达这些区域信息。

投影器

视觉编码器输出的向量维度通常和语言模型 hidden size 不一致。

例如:

text 复制代码
视觉特征维度 = 1024
语言模型 hidden size = 4096

不能直接拼接。

因此需要一个投影器:

text 复制代码
projector: vision_dim -> llm_hidden_dim

投影器可以是线性层,也可以是小 MLP。

它的角色像翻译器:

text 复制代码
视觉编码器说"视觉向量语言"
语言模型说"token embedding 语言"
projector 负责把前者翻译成后者

插入语言模型上下文

处理文本时,语言模型本来会得到:

text 复制代码
[BOS], [In], [:], [What], [action], ...

每个 token 会变成 embedding:

text 复制代码
[text_emb_0, text_emb_1, text_emb_2, ...]

OpenVLA 会把图片 patch embedding 插进去:

text 复制代码
[BOS],
[image_emb_1], [image_emb_2], ..., [image_emb_M],
[In], [:], [What], [action], ...

注意:图片没有被转换成自然语言描述。

它是以 embedding 形式进入语言模型上下文的。


训练样本如何构造

一条机器人数据:

text 复制代码
图像: 当前相机画面
指令: "put the carrot in the bowl"
动作: [0.1, -0.2, 0.0, ...]

会被改写成类似对话的格式:

text 复制代码
输入部分:
  In: What action should the robot take to put the carrot in the bowl?
  Out:

输出部分:
  <动作token1><动作token2>...<动作tokenN>

拼起来是:

text 复制代码
In: What action should the robot take to put the carrot in the bowl?
Out: <动作token1><动作token2>...<动作tokenN>

然后整段文本被 tokenizer 编码成:

text 复制代码
input_ids = [prompt tokens..., action tokens...]

训练时,labels 通常从 input_ids 复制而来,但 prompt 部分会被屏蔽:

text 复制代码
labels = [-100, -100, ..., -100, action_token_1, action_token_2, ...]

其中 -100 是常见的 ignore index,表示这些位置不参与 loss。

这非常重要。

模型不是被训练去复述:

text 复制代码
In: What action should...

模型只在动作 token 位置被监督。

也就是说,训练目标是:

text 复制代码
看到图像和指令后,把动作 token 预测对。

模型训练时到底在优化什么

虽然 OpenVLA 是机器人模型,但它的训练目标本质上仍是语言模型的 next-token prediction。

只不过预测目标换成了动作 token。

训练时模型看到:

text 复制代码
图像 embedding + prompt token

它要生成:

text 复制代码
动作 token 1
动作 token 2
...
动作 token N

损失函数

语言模型通常输出:

text 复制代码
每个位置对整个词表的 logits

在动作位置上,它会对所有 token 给出概率。

训练目标要求:

text 复制代码
正确动作 token 的概率尽可能高。

所以 loss 是交叉熵:

text 复制代码
loss = cross_entropy(predicted_token_distribution, target_action_token)

因为 prompt 和图片 patch 的 label 都被忽略,所以 loss 主要来自:

text 复制代码
动作 token 位置

训练指标

除了 loss,常见还会看两个动作相关指标。

动作 token accuracy

看模型生成的动作 token 是否和真实动作 token 完全一致。

text 复制代码
预测 token == 真实 token

这个指标衡量的是离散动作桶分类是否准确。

连续动作 L1 误差

把预测 token 和真实 token 都解码回连续动作,然后计算绝对误差。

text 复制代码
L1 = mean(abs(predicted_action - target_action))

这个指标更接近机器人控制意义上的误差。

举例:

text 复制代码
真实动作桶 = 128
预测动作桶 = 129

token accuracy 会认为错了,但连续值可能只差一点点。

所以 L1 误差可以补充 token accuracy 的局限。


推理时如何从图片和指令得到真实动作

推理时没有真实动作,只有:

text 复制代码
当前图片
任务指令

系统要输出:

text 复制代码
真实机器人动作向量

完整链路如下:

text 复制代码
1. 构造 prompt
2. 处理图片和文本
3. 图片变成视觉 embedding
4. 文本变成 token embedding
5. 图像 embedding 插入语言模型上下文
6. 语言模型 generate 动作 token
7. 动作 token 解码成 [-1, 1] 范围内的归一化动作
8. 用数据集统计量反归一化
9. 输出机器人真实动作

为什么需要反归一化

模型内部动作空间是统一的:

text 复制代码
[-1, 1]

但真实机器人控制空间不是。

所以需要把:

text 复制代码
normalized_action

恢复成:

text 复制代码
real_action

常见反归一化公式:

text 复制代码
real = 0.5 * (normalized + 1) * (q99 - q01) + q01

其中 q01q99 来自训练数据统计。

如果模型混合训练了多个机器人数据集,就必须知道当前要使用哪套统计量。

可以类比成:

模型说的是"标准动作语言":向右移动 0.3 个标准单位。

但不同机器人 0.3 个标准单位对应的真实距离不同。

反归一化就是把标准单位换算成当前机器人实际控制单位。


多机器人数据集为什么需要标准化

OpenVLA 通常希望利用大量机器人数据。

问题是,不同数据集差异很大:

text 复制代码
数据集 A:
  图片字段叫 camera_image
  指令字段叫 instruction
  动作是 7 维

数据集 B:
  图片字段叫 image_primary
  指令字段叫 language_instruction
  动作是 10 维

数据集 C:
  有腕部相机
  有深度图
  gripper 表示方式也不同

如果训练代码直接适配每个数据集,会变得不可维护。

所以系统需要一个统一数据协议。

抽象后的样本应该长这样:

text 复制代码
sample:
  image_primary:
    主视角 RGB 图像

  language_instruction:
    自然语言任务

  action:
    统一语义下的动作向量

  dataset_name:
    样本来自哪个数据集

  statistics:
    动作归一化和反归一化需要的统计量

数据标准化做什么

数据标准化主要解决四类问题。

字段名统一

把不同数据集中的图像、语言、动作字段映射到统一名字。

text 复制代码
workspace_image / camera_rgb / image -> image_primary
task_text / instruction / language -> language_instruction

动作语义统一

不同机器人动作可能含义不同。

例如:

text 复制代码
前 3 维是末端平移
中间 3 维是末端旋转
最后 1 维是夹爪

系统需要明确每个维度的语义,尤其是:

text 复制代码
哪些维度是相对动作
哪些维度是绝对动作
哪些维度需要归一化
哪些维度不该归一化

夹爪维度经常比较特殊,因为它可能是二值开合,不适合像连续位移那样归一化。

数值尺度统一

动作要归一化到模型内部空间。

这一步让多机器人数据能共享一个动作 token 体系。

数据混合采样

多数据集训练时,需要决定每个数据集被采样的比例。

抽象地说:

text 复制代码
mixed_dataset = sample_from([
  dataset_A with weight_A,
  dataset_B with weight_B,
  dataset_C with weight_C,
])

这样模型训练时会看到来自多个机器人和任务的数据。


两种训练范式:全量训练与参数高效微调

OpenVLA 类系统一般支持两类训练。

全量训练

全量训练会更新大量参数,可能包括:

text 复制代码
视觉编码器
投影器
语言模型

优点:

text 复制代码
表达能力强
适合大规模预训练
能深度适配机器人动作生成

缺点:

text 复制代码
显存需求高
训练成本高
分布式工程复杂

常见策略还会选择冻结一部分模块:

text 复制代码
冻结视觉编码器,只训练投影器和语言模型
冻结大部分语言模型,只训练最后几层
全部解冻,端到端训练

参数高效微调

参数高效微调不直接更新全部大模型参数,而是在模型中插入少量可训练参数。

常见方式是 LoRA。

它的直觉是:

不改整本书,只在关键页贴便签;

便签数量很少,但足以让模型适配新任务。

优点:

text 复制代码
显存需求低
训练更快
适合新任务、新机器人快速适配

缺点:

text 复制代码
适配能力可能不如全量训练
如果新机器人分布差异太大,可能不够

两种训练范式的共同核心不变:

text 复制代码
图像 + 指令 -> 动作 token

部署服务的抽象链路

部署时,OpenVLA 通常作为一个远程策略服务。

机器人控制端发送:

text 复制代码
当前相机图片
当前任务指令
可选的动作反归一化配置名

模型服务返回:

text 复制代码
动作向量

抽象服务流程:

text 复制代码
1. 接收请求
2. 解码图片
3. 构造 prompt
4. 图片和文本预处理
5. 模型生成动作 token
6. token 解码成动作
7. 动作反归一化
8. 返回动作

这个服务层本身很薄。

真正重要的是:

text 复制代码
processor + multimodal model + action decoder + unnormalizer

核心伪代码

下面伪代码只表达 OpenVLA 的核心机制,不绑定任何具体工程结构。每个关键步骤都包含中文注释。

伪代码 1:动作归一化

python 复制代码
def normalize_action(raw_action, statistics, mask):
    # raw_action:
    #   真实机器人动作,例如机械臂末端位移、旋转、夹爪开合。
    #
    # statistics:
    #   从训练数据中统计出的每个动作维度范围。
    #   这里用 q01/q99 表示 1% 和 99% 分位数,用于减少异常值影响。
    #
    # mask:
    #   指示哪些动作维度需要归一化。
    #   例如夹爪维度可能已经是二值信号,不一定适合连续归一化。

    low = statistics.q01
    high = statistics.q99

    # 将动作线性映射到 [-1, 1]。
    # 当 raw_action == low 时,normalized 约等于 -1;
    # 当 raw_action == high 时,normalized 约等于 1。
    normalized = 2 * (raw_action - low) / (high - low + 1e-8) - 1

    # 将极端值裁剪到 [-1, 1],避免离散化时越界。
    normalized = clip(normalized, -1, 1)

    # 对不需要归一化的维度,保留原始值。
    # 这样可以避免破坏夹爪等特殊动作维度的语义。
    normalized = where(mask, normalized, raw_action)

    return normalized

伪代码 2:连续动作编码为动作 token

python 复制代码
def encode_action_as_tokens(normalized_action, tokenizer, n_bins=256):
    # normalized_action:
    #   已归一化到 [-1, 1] 附近的动作向量。
    #
    # tokenizer:
    #   语言模型使用的 tokenizer。
    #
    # n_bins:
    #   每个动作维度被切成多少个离散桶。

    # 创建离散桶边界。
    # 例如 n_bins=256 时,[-1, 1] 会被切成 256 个刻度。
    bin_edges = linspace(-1, 1, n_bins)

    # 先裁剪,确保所有动作值都在可表示范围内。
    clipped_action = clip(normalized_action, -1, 1)

    # 找到每个动作维度落在哪个桶中。
    # 这一步把连续控制值变成离散类别编号。
    bin_ids = digitize(clipped_action, bin_edges)

    # 将离散桶编号映射到语言模型词表中的动作 token id。
    # 一种常见做法是使用词表尾部 token 作为动作 token。
    action_token_ids = tokenizer.vocab_size - bin_ids

    # 返回 token id 序列。
    # 每个动作维度对应一个 token。
    return action_token_ids

伪代码 3:构造训练样本

python 复制代码
def build_training_example(image, instruction, raw_action, statistics, tokenizer):
    # image:
    #   当前机器人相机图像。
    #
    # instruction:
    #   人类语言任务,例如"把杯子放到盘子上"。
    #
    # raw_action:
    #   专家示范中的真实动作。
    #
    # statistics:
    #   用于动作归一化的数据集统计量。

    # 1. 将真实动作归一化到标准动作空间。
    #    这样不同机器人或不同数据集的动作可以共用一套离散 token 体系。
    normalized_action = normalize_action(
        raw_action,
        statistics=statistics.action,
        mask=statistics.action_mask,
    )

    # 2. 将归一化动作编码成动作 token。
    #    从这里开始,机器人动作就被转成语言模型可以预测的 token 序列。
    action_token_ids = encode_action_as_tokens(
        normalized_action,
        tokenizer=tokenizer,
    )

    # 3. 构造自然语言 prompt。
    #    这一步把机器人控制任务包装成语言模型熟悉的问答格式。
    prompt = "In: What action should the robot take to " + instruction.lower() + "?\nOut:"

    # 4. 将 prompt 编码为文本 token。
    #    这些 token 提供任务语义,但不作为训练目标。
    prompt_token_ids = tokenizer.encode(prompt)

    # 5. 拼接完整输入。
    #    语言模型看到 prompt,并在后续位置学习生成动作 token。
    input_ids = concat(prompt_token_ids, action_token_ids, [EOS_TOKEN])

    # 6. 构造 labels。
    #    labels 与 input_ids 对齐,但 prompt 部分设置为 IGNORE_INDEX。
    labels = copy(input_ids)

    # 7. 屏蔽 prompt 部分。
    #    这表示模型不需要学习复述问题,只需要学习预测动作。
    labels[0 : len(prompt_token_ids)] = IGNORE_INDEX

    # 8. 图像预处理。
    #    将原始 RGB 图像 resize、归一化,并变成视觉编码器可接收的张量。
    pixel_values = preprocess_image(image)

    # 9. 返回一个训练样本。
    #    后续 batch collator 会负责 padding 和堆叠。
    return {
        "pixel_values": pixel_values,
        "input_ids": input_ids,
        "labels": labels,
    }

伪代码 4:多模态前向传播

python 复制代码
def multimodal_forward(model, pixel_values, input_ids, attention_mask, labels):
    # pixel_values:
    #   已预处理的图片张量。
    #
    # input_ids:
    #   prompt token + 动作 token 的输入序列。
    #
    # labels:
    #   prompt 部分被 IGNORE_INDEX 屏蔽,只监督动作 token。

    # 1. 图片进入视觉编码器,得到 patch 级视觉特征。
    #    每个 patch 表示图片中的一个局部区域。
    image_patch_features = model.vision_encoder(pixel_values)

    # 2. 用投影器把视觉特征转换到语言模型 hidden size。
    #    只有维度一致,图像 embedding 才能和文本 embedding 拼接。
    image_embeddings = model.projector(image_patch_features)

    # 3. 文本 token id 进入语言模型 embedding table。
    #    这得到 prompt 和动作 token 的文本 embedding。
    text_embeddings = model.language_model.embed_tokens(input_ids)

    # 4. 将图像 embedding 插入文本 embedding 序列。
    #    常见方式是插到 BOS 之后。
    multimodal_embeddings = concat(
        text_embeddings[:, :1, :],
        image_embeddings,
        text_embeddings[:, 1:, :],
        dim=1,
    )

    # 5. 为图像 patch 构造 attention mask。
    #    图像 patch 是有效上下文,所以 mask 为 1。
    image_attention_mask = ones(batch_size, num_image_patches)

    # 6. 拼接新的 attention mask。
    #    新 mask 必须和 multimodal_embeddings 的序列长度一致。
    multimodal_attention_mask = concat(
        attention_mask[:, :1],
        image_attention_mask,
        attention_mask[:, 1:],
        dim=1,
    )

    # 7. 为图像 patch 构造 label。
    #    图像 patch 不是要生成的 token,所以它们的 label 必须忽略。
    image_labels = full(
        shape=(batch_size, num_image_patches),
        value=IGNORE_INDEX,
    )

    # 8. 拼接新的 labels。
    #    最终 loss 只会在动作 token 位置产生。
    multimodal_labels = concat(
        labels[:, :1],
        image_labels,
        labels[:, 1:],
        dim=1,
    )

    # 9. 调用语言模型。
    #    因为序列中包含图像 embedding,它们没有 token id,
    #    所以这里传入 inputs_embeds,而不是只传 input_ids。
    output = model.language_model(
        inputs_embeds=multimodal_embeddings,
        attention_mask=multimodal_attention_mask,
        labels=multimodal_labels,
    )

    # 10. 输出包含 loss 和 logits。
    #     loss 用于训练,logits 可用于计算动作 token accuracy。
    return output

伪代码 5:训练循环

python 复制代码
def train_openvla(model, dataset, optimizer):
    # model:
    #   由视觉编码器、投影器、语言模型组成的多模态动作生成模型。
    #
    # dataset:
    #   已标准化的机器人数据流,每条样本包含图片、指令和专家动作。

    for raw_batch in dataset:
        # 1. 将原始机器人数据转成模型训练 batch。
        #    包括动作归一化、动作 token 化、prompt 构造、图片预处理和 label mask。
        batch = build_batch(raw_batch)

        # 2. 多模态前向传播。
        #    模型会把图片 embedding 插入语言模型上下文,并预测动作 token。
        output = multimodal_forward(
            model=model,
            pixel_values=batch.pixel_values,
            input_ids=batch.input_ids,
            attention_mask=batch.attention_mask,
            labels=batch.labels,
        )

        # 3. 读取训练 loss。
        #    因为 prompt 和图像 patch 都被忽略,所以这个 loss 主要来自动作 token。
        loss = output.loss

        # 4. 反向传播。
        #    梯度会更新所有被设置为可训练的参数。
        loss.backward()

        # 5. 可选梯度裁剪。
        #    大模型训练中,梯度裁剪能减少不稳定更新。
        clip_grad_norm(model.trainable_parameters)

        # 6. 参数更新。
        optimizer.step()

        # 7. 清空梯度,准备下一步训练。
        optimizer.zero_grad()

        # 8. 计算动作 token 准确率。
        #    这是离散 token 层面的监控指标。
        token_accuracy = compute_action_token_accuracy(
            logits=output.logits,
            labels=batch.labels,
        )

        # 9. 计算连续动作误差。
        #    把预测 token 解码回连续动作,再和真实动作比较。
        action_l1 = compute_continuous_action_l1(
            logits=output.logits,
            labels=batch.labels,
        )

        # 10. 记录指标。
        log({
            "loss": loss,
            "token_accuracy": token_accuracy,
            "action_l1": action_l1,
        })

伪代码 6:动作 token 解码为归一化动作

python 复制代码
def decode_tokens_to_normalized_action(action_token_ids, tokenizer, n_bins=256):
    # action_token_ids:
    #   模型生成的动作 token id 序列。
    #
    # tokenizer:
    #   用于知道词表大小,从而反推出桶编号。

    # 1. 重建桶中心。
    #    每个桶中心代表该离散 token 对应的连续动作近似值。
    bin_edges = linspace(-1, 1, n_bins)
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

    # 2. 根据 token id 反推桶编号。
    #    编码时使用词表尾部 token,因此解码时反向计算。
    bin_ids = tokenizer.vocab_size - action_token_ids

    # 3. 将桶编号转换为数组索引。
    #    通常 digitize 的桶编号从 1 开始,而数组索引从 0 开始。
    bin_indices = bin_ids - 1

    # 4. 裁剪索引,避免边界情况下越界。
    bin_indices = clip(bin_indices, 0, len(bin_centers) - 1)

    # 5. 查表得到归一化动作。
    #    此时动作仍在模型内部标准空间,不可直接发给机器人。
    normalized_action = bin_centers[bin_indices]

    return normalized_action

伪代码 7:动作反归一化

python 复制代码
def unnormalize_action(normalized_action, statistics, mask):
    # normalized_action:
    #   模型输出并解码后的 [-1, 1] 动作。
    #
    # statistics:
    #   当前机器人或当前数据集对应的动作统计量。
    #
    # mask:
    #   指示哪些维度需要从标准空间还原到真实动作空间。

    low = statistics.q01
    high = statistics.q99

    # 将 [-1, 1] 线性映射回真实动作范围。
    real_action = 0.5 * (normalized_action + 1) * (high - low) + low

    # 对不需要反归一化的维度,保留 normalized_action。
    # 这通常用于夹爪等特殊维度。
    real_action = where(mask, real_action, normalized_action)

    return real_action

伪代码 8:推理流程

python 复制代码
def predict_action(model, image, instruction, statistics_key):
    # model:
    #   已训练好的 OpenVLA 风格模型。
    #
    # image:
    #   当前相机图像。
    #
    # instruction:
    #   当前任务语言。
    #
    # statistics_key:
    #   指定当前应使用哪套动作统计量。
    #   多机器人或多数据集模型必须知道这一点。

    # 1. 构造 prompt。
    #    使用和训练时一致的问答格式,避免训练/推理分布不一致。
    prompt = "In: What action should the robot take to " + instruction.lower() + "?\nOut:"

    # 2. 编码文本。
    #    得到 prompt token ids 和 attention mask。
    text_inputs = tokenize(prompt)

    # 3. 预处理图片。
    #    得到视觉编码器需要的 pixel_values。
    pixel_values = preprocess_image(image)

    # 4. 根据当前动作空间维度决定生成多少个 token。
    #    7 维动作生成 7 个 token,10 维动作生成 10 个 token。
    action_dim = model.get_action_dim(statistics_key)

    # 5. 自回归生成动作 token。
    #    这里通常使用 deterministic decoding,例如 do_sample=False。
    generated_token_ids = model.generate(
        input_ids=text_inputs.input_ids,
        attention_mask=text_inputs.attention_mask,
        pixel_values=pixel_values,
        max_new_tokens=action_dim,
    )

    # 6. 取最后 action_dim 个 token。
    #    这些就是模型为每个动作维度生成的动作 token。
    action_token_ids = generated_token_ids[-action_dim:]

    # 7. 将动作 token 解码为归一化动作。
    normalized_action = decode_tokens_to_normalized_action(
        action_token_ids,
        tokenizer=model.tokenizer,
    )

    # 8. 读取当前机器人或数据集的统计量。
    statistics = model.action_statistics[statistics_key]

    # 9. 将归一化动作还原为真实机器人动作。
    real_action = unnormalize_action(
        normalized_action,
        statistics=statistics,
        mask=statistics.action_mask,
    )

    # 10. 返回动作给机器人控制器。
    return real_action

伪代码 9:多数据集混合训练数据流

python 复制代码
def build_mixed_robot_dataset(datasets, sampling_weights):
    # datasets:
    #   多个机器人数据集。
    #
    # sampling_weights:
    #   每个数据集被采样的相对概率。

    standardized_datasets = []

    for dataset in datasets:
        # 1. 字段标准化。
        #    将不同数据集的图片、语言、动作字段映射到统一协议。
        dataset = standardize_fields(dataset)

        # 2. 动作语义标准化。
        #    确保动作维度含义一致,例如前几维是位移、后几维是旋转和夹爪。
        dataset = standardize_action_semantics(dataset)

        # 3. 计算动作统计量。
        #    这些统计量用于训练时归一化,以及推理时反归一化。
        dataset.statistics = compute_action_statistics(dataset)

        # 4. 过滤无效样本。
        #    例如没有语言指令、动作异常、图片缺失的样本。
        dataset = filter_invalid_examples(dataset)

        # 5. 将轨迹展开成训练帧。
        #    如果模型预测单步动作,每个时间步都可以变成一条训练样本。
        dataset = flatten_trajectories_to_frames(dataset)

        standardized_datasets.append(dataset)

    # 6. 按权重混合采样。
    #    训练时模型会以指定比例看到不同机器人和任务的数据。
    mixed_dataset = weighted_sample(
        standardized_datasets,
        weights=sampling_weights,
    )

    # 7. 打乱样本。
    #    避免训练连续看到同一轨迹或同一数据集的局部相关样本。
    mixed_dataset = shuffle(mixed_dataset)

    return mixed_dataset

关键设计取舍

为什么不直接回归动作

直接回归动作当然可以,但 OpenVLA 选择 token 化动作有几个好处:

  1. 复用语言模型原生输出头。
  2. 复用自回归生成机制。
  3. 统一文本生成和动作生成形式。
  4. 让动作预测变成分类问题,训练稳定性往往更好。

代价是:

  1. 连续动作会有量化误差。
  2. 推理需要 token 解码和反归一化。
  3. 动作维度越多,需要生成的 token 越多。

为什么图片要作为 embedding 插入,而不是生成图像描述

图像描述会丢失细节。

机器人控制需要非常具体的空间信息:

text 复制代码
杯子离夹爪多远
物体在图像哪个区域
夹爪是否已经对齐
目标物是否被遮挡

这些信息很难通过一句 caption 完整表达。

直接插入视觉 embedding 可以保留更多空间和视觉细节。

为什么只监督动作 token

如果 prompt 也参与 loss,模型会花能力学习复述固定模板。

而机器人控制真正需要的是:

text 复制代码
根据图像和任务预测动作

所以 prompt 和图像 patch 位置都应该被忽略,loss 集中到动作 token。

为什么需要数据集统计量

模型内部只能统一学习标准动作空间。

但真实机器人动作尺度因机器人和数据集而异。

数据集统计量承担了单位换算功能:

text 复制代码
标准动作单位 -> 当前机器人真实动作单位

没有这一步,模型输出的动作 token 即使语义正确,也可能无法驱动真实机器人。


常见误解

误解一:OpenVLA 是普通动作回归模型

不是。

它的输出首先是 token。连续动作是 token 解码和反归一化之后得到的。

误解二:动作 token 是自然语言词语

不是。

动作 token 通常不可读。它们只是借用了语言模型词表中的 token id 来表示动作桶。

误解三:图片先被描述成文字再给语言模型

不是。

图片通过视觉编码器变成 embedding,然后直接插入语言模型上下文。

误解四:只要 token accuracy 高,机器人效果就一定好

不一定。

机器人效果还取决于:

text 复制代码
动作反归一化是否正确
图像预处理是否和训练一致
控制频率是否匹配
机器人硬件误差
任务是否需要长期闭环纠错

误解五:多数据集训练只需要把数据拼起来

不够。

必须统一:

text 复制代码
字段格式
动作语义
动作尺度
采样权重
统计量保存

否则模型学到的是混乱的动作语言。


如果要改造系统应优先理解哪些抽象接口

新增机器人数据集

你需要定义:

text 复制代码
原始图像字段 -> 标准图像字段
原始语言字段 -> 标准语言字段
原始动作字段 -> 标准动作向量
动作维度语义
哪些维度需要归一化
数据集统计量如何计算

改动作表示

你需要同时考虑:

text 复制代码
动作归一化方式
离散桶数量
桶到 token 的映射
token 到连续动作的解码
反归一化公式
训练指标如何计算
推理生成 token 数量

这些必须保持一致。

只改编码不改解码,系统会失效。

改视觉编码器

你需要保证:

text 复制代码
图片预处理匹配视觉编码器
视觉输出是 patch 级特征
视觉特征维度已知
投影器能映射到语言模型 hidden size
图像 patch 数量和 attention mask / label mask 对齐

改语言模型

你需要注意:

text 复制代码
tokenizer 词表大小
特殊 token 规则
prompt 格式
上下文长度
动作 token 是否仍能映射到词表尾部
generate 行为是否和训练格式一致

语言模型变了,动作 token 的含义也可能要重新校准。

支持动作 chunk

如果想一次生成多个未来动作,而不是单步动作,需要重新定义:

text 复制代码
动作序列如何展开成 token
max_new_tokens 应该是多少
解码后如何 reshape 成 [chunk_len, action_dim]
训练 labels 如何屏蔽
机器人控制器如何执行动作 chunk

最终总结

OpenVLA 的核心不是某个具体工程实现,而是一套建模思想:

text 复制代码
1. 把不同机器人动作统一到标准动作空间
2. 把连续动作离散成动作桶
3. 把动作桶映射成语言模型 token
4. 把图片变成视觉 embedding
5. 把视觉 embedding 插入语言模型上下文
6. 让语言模型在图像和指令条件下生成动作 token
7. 把动作 token 解码成归一化动作
8. 用数据集统计量反归一化成真实机器人动作

用最短的话说:

OpenVLA 把机器人控制变成了多模态 token 生成。

它的强大之处在于,机器人动作不再被看作一个孤立的回归头输出,而是被纳入了语言模型统一的生成范式。

它的关键工程闭环是:

text 复制代码
机器人数据
  -> 标准化
  -> 动作归一化
  -> 动作 token 化
  -> 图像 embedding 注入
  -> 语言模型生成
  -> token 解码
  -> 动作反归一化
  -> 机器人执行