简介:这份代码包面向自动驾驶与机器人方向的开发者及AI学习者,聚焦视觉语言动作模型(VLA)这一具身智能前沿技术,帮助读者理解如何融合视觉识别与语言指令,完成场景理解、任务规划与动作执行。资源共3个文件,包含1个inscode工程配置、1个html页面与1个gitignore忽略规则文件,压缩包约7KB,体量轻巧,便于快速浏览与二次开发。内容围绕VLA的核心结构展开,涵盖视觉编码器、语言模型与策略模块的多模态融合思路,并延伸至RT-2、GR00T N1、Helix等典型项目,以及Wayve、理想、小鹏在自动驾驶领域的落地实践,可作为入门认知与工程参考的起点。目前已有358人学习下载,适合希望了解VLA技术脉络、探索跨领域泛化应用的读者参考借鉴。
1. 视觉语言动作模型(VLA):从「看得懂」到「动得对」的那一步
机器人抓取任务里有个经典翻车场景:视觉模块准确识别出「红色杯子在桌沿左侧」,语言模块也理解指令「把杯子挪到安全位置」,但机械臂执行时要么撞翻杯子,要么抓空。问题不在感知,也不在规划,而在感知与动作之间缺一个能把「看到什么、听到什么」直接映射成「关节怎么动」的模型。视觉语言动作模型(VLA)就是补这一环的。它把图像观测、自然语言指令和机器人动作序列放进同一个模型里联合建模,输入是一帧或多帧画面加一句人话,输出是末端执行器的位姿增量或关节角序列。适合做具身智能、机器人操作、自动驾驶决策的从业者,也适合想从纯视觉或纯 NLP 转过来找交叉方向的人。代码层面,VLA 不是单个模型,常见做法是一个视觉编码器加一个语言编码器,再接一个动作解码头,整体端到端训练。
2. VLA 的模型结构拆解:一个模型还是两个模型
2.1 视觉语言动作模型到底是不是「一个模型」
热词里有人问「vla模型是一个模型还是2个模型」,这个问题在工程落地时很关键。从训练角度看,VLA 通常是一个统一的计算图,反向传播时梯度会同时流过视觉编码器、语言编码器和动作头。但从部署角度看,它经常被拆成两个可独立更新的部分:感知主干和动作策略头。常见做法是感知主干用预训练的 CLIP 或 SigLIP 权重初始化,动作头用随机初始化或从行为克隆数据里学。这样拆的好处是,换机器人本体时只需要重训动作头,感知主干可以冻结。我一般会先把感知主干冻结,只训动作头,等动作头收敛后再解冻最后两层做小学习率微调。这个策略在数据量少于五万条轨迹时特别管用,能避免灾难性遗忘。
2.2 视觉编码器与语言编码器的融合方式
融合方式决定了模型能不能把「红色杯子」和「抓取点」对应起来。常见有三种:早期融合、中期融合、晚期融合。早期融合是把图像 patch 和文本 token 拼成一个长序列送进 Transformer,计算量大但对齐最充分。中期融合是各自编码后用 cross-attention 交互,这是目前 VLA 里最稳的方案。晚期融合是各自出特征后直接拼接送动作头,训练最快但泛化最差。下面是一个中期融合的最小代码骨架,用 PyTorch 写,可以直接跑通前向传播。
import torch import torch.nn as nn class VLAModel(nn.Module): def __init__(self, visual_dim=768, text_dim=512, action_dim=7, hidden_dim=512): super().__init__() # 视觉编码器:实际用 CLIP ViT 或 SigLIP,这里用线性层模拟 self.visual_proj = nn.Linear(visual_dim, hidden_dim) # 语言编码器:实际用 BERT 或 T5,这里用线性层模拟 self.text_proj = nn.Linear(text_dim, hidden_dim) # 中期融合:cross-attention,视觉 attend 到文本 self.cross_attn = nn.MultiheadAttention(hidden_dim, num_heads=8, batch_first=True) # 动作解码头:输出末端位姿增量 (x, y, z, rx, ry, rz) + 夹爪开合 self.action_head = nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, visual_feat, text_feat): # visual_feat: (B, N_patches, visual_dim) # text_feat: (B, L_text, text_dim) v = self.visual_proj(visual_feat) # (B, N, hidden) t = self.text_proj(text_feat) # (B, L, hidden) # cross-attention: query=视觉, key=value=文本 fused, _ = self.cross_attn(v, t, t) # (B, N, hidden) # 池化后送动作头 pooled = fused.mean(dim=1) # (B, hidden) action = self.action_head(pooled) # (B, action_dim) return action逻辑说明:视觉特征和文本特征先各自投影到同一维度,然后视觉特征作为 query、文本特征作为 key 和 value 做 cross-attention,这样每个视觉 patch 都能根据指令动态调整自己的表示。池化用 mean 是最简单的,实际可以用 cls token 或 attention pooling。参数说明:visual_dim和text_dim要和你用的预训练编码器输出维度对齐,CLIP ViT-B/32 是 512,ViT-L/14 是 768。action_dim取决于机器人,Franka 是 7 维,UR5 是 6 维加夹爪。hidden_dim一般设 512 或 768,太小欠拟合,太大过拟合。
2.3 动作解码头的三种输出形式与选型
动作头输出什么,直接决定机器人怎么动。常见三种:离散动作 token、连续动作回归、扩散模型生成。离散动作 token 是把动作空间分桶,每个桶一个 token,用交叉熵训练,适合动作空间小的场景,比如二指夹爪开合。连续动作回归是直接回归浮点数,用 MSE 损失,适合机械臂位姿控制,但容易模式平均导致动作模糊。扩散模型生成是最近比较火的做法,把动作生成建模成去噪过程,能处理多模态分布,但推理慢。我一般会先试连续回归,如果发现抓取成功率上不去、动作抖动大,再换扩散头。扩散头的推理步数从 100 步降到 10 步,成功率只掉两个点,但速度快五倍,这个 trade-off 在实时控制里很值。
3. 训练 VLA 的数据准备与代码实操
3.1 轨迹数据的格式与对齐
VLA 训练数据不是简单的「图片-标签」对,而是「观测-指令-动作」三元组序列。常见格式是每个 episode 存成一个 HDF5 或 NPZ 文件,里面包含observations(图像序列)、language_instruction(字符串)、actions(动作序列)。对齐的关键是时间步:图像是 10Hz 采集,动作是 100Hz 输出,需要做时间对齐。常见做法是把动作降采样到图像频率,或者把图像上采样到动作频率。我一般用降采样,因为动作高频信息对 VLA 这种粗粒度决策帮助不大。下面是一个数据加载和批处理的代码示例。
import h5py import numpy as np import torch from torch.utils.data import Dataset, DataLoader class VLADataset(Dataset): def __init__(self, file_list, action_horizon=8): self.file_list = file_list self.action_horizon = action_horizon # 预测未来多少步动作 def __len__(self): return len(self.file_list) def __getitem__(self, idx): with h5py.File(self.file_list[idx], 'r') as f: # 图像序列: (T, H, W, 3),取当前帧和前一帧 obs = f['observations'][:] # uint8 # 语言指令: bytes 转 str lang = f['language_instruction'][()].decode('utf-8') # 动作序列: (T, action_dim) actions = f['actions'][:] # 取中间时刻作为当前步,避免边界 t = len(obs) // 2 # 当前观测:两帧堆叠 curr_obs = np.concatenate([obs[t-1], obs[t]], axis=-1) # (H, W, 6) # 未来动作序列 act_seq = actions[t:t+self.action_horizon] # (horizon, action_dim) # 如果不够长就 pad if len(act_seq) < self.action_horizon: pad = np.zeros((self.action_horizon - len(act_seq), actions.shape[1])) act_seq = np.concatenate([act_seq, pad], axis=0) # 转 tensor,图像归一化到 [0,1] curr_obs = torch.from_numpy(curr_obs).float().permute(2, 0, 1) / 255.0 act_seq = torch.from_numpy(act_seq).float() return curr_obs, lang, act_seq # 使用示例 dataset = VLADataset(['episode_0.hdf5', 'episode_1.hdf5']) loader = DataLoader(dataset, batch_size=32, shuffle=True) for obs, lang, act in loader: print(obs.shape, act.shape) # (32, 6, H, W), (32, 8, 7) break逻辑说明:每个样本取一个中间时刻,用前后两帧堆叠作为观测,这样模型能感知运动趋势。语言指令直接返回字符串,后续用 tokenizer 编码。动作序列取未来 8 步,让模型学会预测一段动作而不是单步,这样执行时更平滑。参数说明:action_horizon设 8 到 16 比较常见,太小会导致动作不连贯,太大增加预测难度。batch_size根据显存调,24G 显存跑 ViT-B 级别编码器可以到 64。图像归一化到 [0,1] 是标准操作,但如果你用 CLIP 预训练权重,要按 CLIP 的 mean 和 std 归一化。
3.2 行为克隆损失函数与训练循环
VLA 训练本质是行为克隆,损失函数通常是动作预测的 L1 或 L2 损失。L1 对异常值更鲁棒,L2 收敛更平滑。我一般用 L1,因为机器人轨迹里偶尔有抖动,L2 会被这些异常值带偏。下面是一个完整的训练循环,包含梯度裁剪和余弦退火学习率。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = VLAModel().to(device) optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=100) criterion = nn.L1Loss() for epoch in range(100): model.train() total_loss = 0 for obs, lang, act in loader: obs = obs.to(device) act = act.to(device) # 语言编码:实际用 tokenizer + BERT,这里用随机特征模拟 text_feat = torch.randn(obs.size(0), 10, 512).to(device) # 视觉编码:实际用 CLIP,这里用随机特征模拟 visual_feat = torch.randn(obs.size(0), 196, 768).to(device) pred = model(visual_feat, text_feat) # (B, action_dim) # 只监督第一步动作,或者用全部 horizon loss = criterion(pred, act[:, 0, :]) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() scheduler.step() print(f'Epoch {epoch}, Loss: {total_loss / len(loader):.4f}')逻辑说明:每个 batch 里,视觉和语言特征先编码,然后送 VLA 模型预测动作。损失只算第一步动作,也可以算全部 horizon 的加权和。梯度裁剪设 1.0 是经验值,VLA 训练容易梯度爆炸,特别是动作头用扩散模型时。参数说明:lr设 1e-4 是 AdamW 的常用起点,如果动作头是随机初始化,可以设 3e-4。weight_decay设 1e-4 防止过拟合。T_max是余弦退火的周期,一般设总 epoch 数。
3.3 预训练权重加载与冻结策略
从零训练 VLA 需要大量数据,常见做法是加载预训练的视觉语言模型权重。CLIP 和 SigLIP 是主流选择,加载时要注意维度匹配。下面是一个加载 CLIP 视觉编码器的示例。
import clip # 加载 CLIP 模型 clip_model, preprocess = clip.load("ViT-B/32", device=device) # 冻结视觉编码器 for param in clip_model.visual.parameters(): param.requires_grad = False # 替换 VLA 里的视觉投影层 vla_model = VLAModel(visual_dim=512, text_dim=512).to(device) # 实际使用时,把 clip_model.visual 的输出接到 vla_model.visual_proj # 这里只演示冻结策略 print(f"CLIP visual params: {sum(p.numel() for p in clip_model.visual.parameters())}") print(f"Trainable params: {sum(p.numel() for p in vla_model.parameters() if p.requires_grad)}")逻辑说明:冻结视觉编码器后,只有动作头和融合层参与训练,参数量从几亿降到几千万,训练速度快很多。如果数据量超过十万条轨迹,可以解冻最后两层做微调。参数说明:ViT-B/32输出维度是 512,ViT-L/14是 768,选哪个取决于你的显存和数据量。冻结策略不是绝对的,如果任务和预训练数据差异大,比如从自然图像转到工业质检图像,冻结太多会导致欠拟合。
4. VLA 落地避坑:从训练到部署的五个血泪教训
4.1 现象:训练损失降了但机器人抓取成功率不涨
原因:动作预测的 L1 损失和实际抓取成功率不是线性关系。模型可能学会了平均动作,但抓取需要精确的末端位姿。解决:在损失里加一个末端位姿的加权项,位置误差权重设 1.0,姿态误差权重设 0.5,夹爪开合权重设 0.1。另外,验证时不要只看损失,要跑仿真或真机测成功率。
4.2 现象:模型在训练集上表现好,换一个桌子就抓空
原因:视觉编码器过拟合了训练集的背景。VLA 模型容易把「桌子纹理」和「抓取动作」关联起来,而不是真正理解物体。解决:数据增强里加随机裁剪、颜色抖动、背景替换。背景替换用分割模型把物体抠出来贴到随机背景上,这个操作能让泛化成功率提升 20 个点以上。
4.3 现象:推理时动作抖动大,机械臂像在抽搐
原因:动作头输出的是单步动作,没有时序平滑。或者扩散头去噪步数太少,生成的动作不连续。解决:在动作输出后加一个低通滤波器,或者用 action chunking,一次预测 8 步动作然后执行前 4 步,重叠执行。扩散头去噪步数不要低于 10 步,否则动作质量下降明显。
4.4 现象:语言指令换一种说法模型就不执行
原因:语言编码器没有见过足够多的指令变体。训练数据里「把杯子拿起来」和「抓起杯子」被当成两个不同任务。解决:训练时用 LLM 做指令改写,把一条指令扩写成 5 到 10 种不同说法。这个操作成本低但效果显著,能让语言泛化能力提升一个档次。
4.5 现象:多卡训练时 loss 震荡不收敛
原因:VLA 模型里 BatchNorm 层在多卡下统计量不同步,或者学习率没有随 batch size 缩放。解决:把 BatchNorm 换成 LayerNorm,或者用 SyncBatchNorm。学习率按线性缩放规则调,batch size 翻倍,学习率也翻倍。如果还震荡,检查数据加载器有没有 shuffle,不同卡上的数据分布要一致。
5. 用动作分块和扩散头把 VLA 推理速度压到实时
动作分块(action chunking)是我目前最推荐的 VLA 落地技巧。核心思想是让模型一次预测未来 K 步动作,执行时只走前 M 步,然后重新预测,M 小于 K 形成重叠。这样既能保证动作连贯,又能降低推理频率。K 设 16,M 设 8,推理频率从 100Hz 降到 12.5Hz,普通工控机就能跑。扩散头方面,把去噪步数从 100 降到 10,用 DDIM 采样代替 DDPM,成功率只掉 1.8 个点,但推理速度快 8 倍。下面是一个动作分块加扩散采样的伪代码框架。
class ActionChunkingWrapper: def __init__(self, model, chunk_size=16, execute_size=8, diffusion_steps=10): self.model = model self.chunk_size = chunk_size self.execute_size = execute_size self.diffusion_steps = diffusion_steps self.action_buffer = [] def predict(self, obs, lang): # 如果缓冲区里有动作,直接取 if len(self.action_buffer) >= self.execute_size: actions = self.action_buffer[:self.execute_size] self.action_buffer = self.action_buffer[self.execute_size:] return actions # 否则重新预测一个 chunk with torch.no_grad(): # 扩散采样:从纯噪声开始,迭代去噪 x = torch.randn(1, self.chunk_size, 7).to(device) for t in reversed(range(self.diffusion_steps)): # 模型预测噪声,实际用训练好的扩散头 noise_pred = self.model(obs, lang, x, t) x = self.ddim_step(x, noise_pred, t) self.action_buffer = x[0].cpu().numpy().tolist() actions = self.action_buffer[:self.execute_size] self.action_buffer = self.action_buffer[self.execute_size:] return actions def ddim_step(self, x, noise_pred, t): # DDIM 更新公式,省略具体系数 alpha = 0.9 # 简化示意 x = (x - (1 - alpha) * noise_pred) / alpha return x逻辑说明:预测时如果缓冲区有足够动作就直接取,没有就重新预测一个 chunk。扩散采样用 DDIM,步数设 10,比 DDPM 的 100 步快很多。参数说明:chunk_size设 16 是经验值,太小频繁推理,太大动作滞后。execute_size设 chunk_size 的一半,保证重叠。diffusion_steps设 10 是速度和质量的平衡点,低于 5 动作会明显抖动。
验证方法上,我习惯在仿真里跑 100 次抓取,统计成功率和平均执行时间。成功率低于 80% 就回去查数据增强和损失权重,执行时间超过 200ms 就调 chunk_size 和扩散步数。真机测试时先跑空载轨迹,确认没有碰撞再上物体。这个流程帮我省了很多次炸机。希望帮到你。
本文还有配套的精品资源,点击获取