给机器人一张桌面图片,再说一句"把红色方块放到蓝色托盘里",接下来会发生什么?
普通聊天模型通常给你一句回答。机器人需要的却是可以被控制系统使用的动作表示。两者之间,不是把一段自然语言直接发给电机那么简单。
这篇文章不训练模型、不购买机械臂,也不搭建复杂仿真。我们用 LeRobot 加载 SmolVLA 预训练策略,在本地 GPU 上跑一个最小离线实验,观察图像、语言和状态怎样进入模型,以及模型到底输出什么。
一、VLA 的 A,不是一段聊天回答
VLA 是 Vision-Language-Action:视觉、语言、动作。
可以把本文的计算流程概括为:
text
三路 RGB 图像 ─┐
语言指令 ─────┼─→ SmolVLA 策略 → 一个动作片段 [1, 50, 6]
状态向量 ─────┘ ↓
本文只检查并保存,不执行
SmolVLA 的公开设计把视觉语言模型与动作专家结合起来,使用 flow matching 生成动作。它不是先让聊天模型写出一段控制代码,再执行那段代码。具体设计可参考 SmolVLA 官方介绍 和 论文。
这个区别很重要。语言指令是模型的条件输入,动作则有自己的表示、维度与训练分布。即使指令一样,换了机器人、相机位置或动作定义,也不能默认同一份输出仍然可用。
二、这次实验究竟测什么?
我们把场景固定为桌面上的红色方块和蓝色区域,但这只是三张程序生成的输入图,不包含物理世界。

主指令是:
Pick up the red block and place it in the blue tray.
为了控制成本,只做三次推理:
- 输入固定图像、状态、指令与噪声,生成第一个动作片段。
- 使用相同输入和相同噪声重跑,检查结果能否重复。
- 保持图像、状态和噪声不变,仅将指令改为"Leave the red block on the table.",观察数值差异。
前两次可以检查输出形状、数值是否有限,以及本次环境中的重复性。第三次只是输入条件变化的检查:即便输出有差异,也不能凭此证明模型正确理解了任务,更不能说明机器人会抓对目标。
我们不下载训练数据集,不微调,不搜索超参数,不做几十轮性能评测。
三、先读配置,不要猜输入和输出
使用的模型仓库是 lerobot/smolvla_base。
本次读取的配置规定:
| 项目 | 配置 |
|---|---|
| observation.state | 6 维 |
| camera1、camera2、camera3 | 每路 3×256×256 |
| chunk_size | 50 |
| action | 每步 6 维 |
| max_state_dim / max_action_dim | 32 / 32 |
| tokenizer_max_length | 48 |
| num_steps | 10 |
注意三个容易混淆的数字:
- 50 是动作片段长度,不是 50 次语言模型回答。
- 6 是这份配置的动作维度,不能脱离数据定义,直接标成通用的 xyz 和姿态。
- 32 是内部最大维度设置,不代表这个模型的最终输出有 32 个关节。
在固定版本源码中,predict_action_chunk() 会把动作裁剪回配置的实际动作维度。本文因此检查输出是否为 1, 50, 6。对应源码
另外,输入图尺寸不等于全部内部计算分辨率:本次配置的 resize_imgs_with_padding 是 512×512。显存不能只按输入图片大小估算。
四、为什么不直接把输出当作关节角?
配置中的状态与动作使用 MEAN_STD 归一化。正常接入机器人时,需要与训练匹配的数据统计和预处理、后处理步骤,不能把一串输出浮点数直接解释成角度。
为了只验证网络推理,我们在模型输入空间里构造零状态,图像缩放到 0,1,语言经过 tokenizer。这里不引入某个具体任务的数据统计,也不执行动作反归一化。
因此,本文保存的 CSV 是模型空间中的动作输出,不是电机指令。
这不是漏写一个后处理函数,而是明确收窄实验目标。没有真机标定、任务数据与控制接口时,擅自补上"关节角度"解释,反而会误导读者。
五、准备环境:独立安装,不动 ROS 工程
建议使用 Python 3.12 的独立虚拟环境。本文没有要求在 ROS 的 Python 环境里安装 PyTorch,也没有更改已有机器人工程的依赖。
将配套 run_inference.py 放在工作目录,然后执行:
bash
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python "lerobot[smolvla]==0.4.4"
.venv/bin/python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
本文固定 LeRobot 0.4.4,是为了明确复现基线,不是声称它是最新版本。第一次安装 PyTorch 及 CUDA 依赖可能下载数 GB 文件;最小推理实验不等于首次安装包也很小。
本次使用清华 PyPI 镜像完成依赖安装。如默认源过慢,可以给安装命令增加:
bash
--index-url https://pypi.tuna.tsinghua.edu.cn/simple
配套 outputs/requirements-tested.txt 记录完整实际依赖版本。希望尽量复现同一环境时,可在新环境中使用:
bash
uv pip install --python .venv/bin/python -r outputs/requirements-tested.txt
模型仍从 Hugging Face 获取。首次需要可访问的网络与足够磁盘空间;不要把下载超时当作模型推理失败。
六、完整实验脚本
保存为 run_inference.py。脚本会记录模型精确 revision,严格加载预训练权重,并保存输入图、动作 CSV 与 results.json。
有一项节省下载的设置值得说明:load_vlm_weights=False 仅跳过初始化时单独下载 VLM 权重;随后仍通过 strict=True 加载完整 SmolVLA 策略 checkpoint。它不是让随机初始化的网络冒充预训练模型。若 checkpoint 不匹配,严格加载应当报错,而不是静默继续。
python
"""Synthetic-input smoke test. No simulator, robot, or task-success claim."""
import argparse
import csv
import importlib.metadata as md
import json
from pathlib import Path
import time
import numpy as np
from PIL import Image
import torch
from huggingface_hub import HfApi, snapshot_download
from lerobot.configs.policies import PreTrainedConfig
from lerobot.policies.smolvla.configuration_smolvla import SmolVLAConfig
from lerobot.policies.smolvla.modeling_smolvla import SmolVLAPolicy
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--output', default='outputs')
parser.add_argument('--model-revision', default='main')
parser.add_argument('--vlm-revision', default='main')
args = parser.parse_args()
output = Path(args.output)
output.mkdir(parents=True, exist_ok=True)
assert torch.cuda.is_available(), 'This measured experiment requires CUDA'
torch.set_num_threads(4)
torch.manual_seed(2026)
api = HfApi()
model_id = 'lerobot/smolvla_base'
model_sha = api.model_info(model_id, revision=args.model_revision).sha
print(f'Downloading policy revision {model_sha}', flush=True)
model_path = snapshot_download(model_id, revision=model_sha,
allow_patterns=['config.json', 'model.safetensors'])
config = PreTrainedConfig.from_pretrained(model_path)
assert isinstance(config, SmolVLAConfig)
vlm_id = config.vlm_model_name
vlm_sha = api.model_info(vlm_id, revision=args.vlm_revision).sha
vlm_path = snapshot_download(vlm_id, revision=vlm_sha,
allow_patterns=['*.json', '*.txt', '*.model', '*.jinja'])
# The full policy checkpoint supplies the weights. Do not download another VLM checkpoint.
config.vlm_model_name = vlm_path
config.load_vlm_weights = False
config.device = 'cuda'
print('Loading policy with strict=True', flush=True)
start = time.perf_counter()
policy = SmolVLAPolicy.from_pretrained(model_path, config=config, strict=True)
policy.eval()
torch.cuda.synchronize()
load_seconds = time.perf_counter() - start
batch = {'observation.state': torch.zeros(1, config.input_features['observation.state'].shape[0], device='cuda')}
# Procedural test fixtures, not photographs or physical camera observations.
for i, (key, feature) in enumerate(config.image_features.items()):
c, h, w = feature.shape
pixels = np.full((h, w, c), 190, dtype=np.uint8)
offset = i * 10
pixels[80:125, 45 + offset:90 + offset] = (210, 35, 35)
pixels[150:205, 150:215] = (35, 65, 210)
Image.fromarray(pixels).save(output / f'camera{i + 1}.png')
batch[key] = torch.from_numpy(pixels.copy()).permute(2, 0, 1).unsqueeze(0).float().div(255).cuda()
tokenizer = policy.model.vlm_with_expert.processor.tokenizer
generator = torch.Generator(device='cuda').manual_seed(2026)
noise = torch.randn((1, config.chunk_size, config.max_action_dim), generator=generator, device='cuda')
torch.cuda.reset_peak_memory_stats()
def infer(task):
# Same basic tokenization settings as the pinned SmolVLA processor.
tokens = tokenizer([task.rstrip('\n') + '\n'], padding='max_length', max_length=config.tokenizer_max_length,
truncation=True, return_tensors='pt', padding_side='right')
data = dict(batch)
data['observation.language.tokens'] = tokens['input_ids'].cuda()
data['observation.language.attention_mask'] = tokens['attention_mask'].bool().cuda()
policy.reset()
torch.cuda.synchronize()
begin = time.perf_counter()
with torch.inference_mode():
chunk = policy.predict_action_chunk(data, noise=noise.clone())
torch.cuda.synchronize()
seconds = time.perf_counter() - begin
assert tuple(chunk.shape) == (1, config.chunk_size, config.action_feature.shape[0])
assert torch.isfinite(chunk).all().item()
return chunk.detach().cpu(), seconds, int(tokens['attention_mask'].sum())
task_a = 'Pick up the red block and place it in the blue tray.'
task_b = 'Leave the red block on the table.'
a, cold_seconds, tokens_a = infer(task_a)
replay, warm_seconds, _ = infer(task_a)
b, changed_seconds, tokens_b = infer(task_b)
repeat_diff = float((a - replay).abs().max())
assert torch.allclose(a, replay, atol=1e-5, rtol=1e-5), 'Fixed-input repeatability check failed'
for name, actions in [('task_a', a), ('task_b', b)]:
with (output / f'{name}_actions.csv').open('w', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(['step'] + [f'action_{i}' for i in range(actions.shape[-1])])
for step, row in enumerate(actions[0].tolist()):
writer.writerow([step] + row)
result = {
'experiment': 'synthetic-input normalized-space offline inference; NOT robot control',
'model_id': model_id, 'model_revision': model_sha,
'vlm_id': vlm_id, 'vlm_revision': vlm_sha,
'gpu': torch.cuda.get_device_name(),
'versions': {name: md.version(name) for name in ['lerobot', 'torch', 'transformers', 'safetensors']},
'input_shapes': {key: list(value.shape) for key, value in batch.items()},
'state': 'six zeros in model-input space; no robot calibration or dataset statistics',
'action_shape': list(a.shape), 'denoising_steps': config.num_steps,
'load_seconds_excluding_download': load_seconds,
'cold_inference_seconds': cold_seconds, 'warm_inference_seconds': warm_seconds,
'changed_prompt_seconds': changed_seconds,
'peak_allocated_mib': torch.cuda.max_memory_allocated() / 1024**2,
'peak_reserved_mib': torch.cuda.max_memory_reserved() / 1024**2,
'task_a': task_a, 'task_b': task_b, 'language_tokens': [tokens_a, tokens_b],
'repeat_max_abs_diff': repeat_diff,
'changed_prompt_mean_abs_diff': float((a - b).abs().mean()),
'all_finite': True, 'first_action_model_space': a[0, 0].tolist(),
'warning': 'No action denormalization, actuator command, simulation rollout, or success-rate evaluation.',
}
(output / 'results.json').write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
if __name__ == '__main__':
main()
七、运行命令与输出文件
在保存脚本的目录执行:
bash
export TOKENIZERS_PARALLELISM=false
.venv/bin/python run_inference.py --output outputs
默认会解析模型仓库当前版本,并把实际提交号写入结果。要复现本文使用的权重与 VLM 配置,请显式固定:
bash
.venv/bin/python run_inference.py --output outputs \
--model-revision d9f33c94a60fb382c90dea2164c96845bd955e28 \
--vlm-revision 7b375e1b73b11138ff12fe22c8f2822d8fe03467
本次首次下载约 907 MB 的策略权重,另外只下载了 VLM 配置和 tokenizer 文件,没有单独下载其权重。下载在模型加载计时之前完成,不计入下面的推理耗时。
程序生成的文件如下:
text
outputs/
├── camera1.png
├── camera2.png
├── camera3.png
├── task_a_actions.csv
├── task_b_actions.csv
└── results.json
配套压缩包还提供本次运行日志 run.log 与实际环境依赖 requirements-tested.txt。CSV 每份有 50 行动作记录,每行包含步号与 6 个数值,不是 50 次独立观测的闭环结果。
八、这次真的跑出了什么?
本次在 WSL2、Ubuntu 26.04、Python 3.12.13、RTX 4080 Laptop GPU(12 GB)上完成测试。使用 LeRobot 0.4.4、PyTorch 2.10.0、Transformers 4.57.6。没有训练,也没有调用云端语言模型推理 API。
| 检查项 | 实测结果 |
|---|---|
| 输出形状 | 1, 50, 6 |
| 采样迭代数 | 10 |
| 完整策略加载耗时,不含下载 | 18.89 秒 |
| 第一次动作片段推理 | 2.065 秒 |
| 同输入第二次推理 | 0.513 秒 |
| 更换指令后推理 | 0.939 秒 |
| 三次推理阶段 PyTorch 峰值已分配显存 | 1215.7 MiB |
| 同阶段 PyTorch 峰值保留显存 | 1266.0 MiB |
| 输出是否全部有限 | 是,无 NaN / Inf |
| 同输入、同噪声两次结果最大绝对差 | 0 |
| 只更换指令后的平均绝对差 | 0.147492 |
第一步的六维输出为:
text
0.010021, -0.187044, -0.182985, -0.046191, 0.127235, 0.078823
这六个数字是网络实际生成的,不是手写的示例输出。但它们仍然只是模型空间的动作表示,不能直接命名为某六个电机的安全目标位置。
计时在 GPU 同步后开始和结束,包含该次 predict_action_chunk() 调用,不包含下载、分词和文件写入。只有三次运行,没有做统计基准;第二次比首次快,不代表所有后续任务都保证这个延迟。
显存指标来自 PyTorch,在模型加载完成后重置峰值计数再测量,因此包含推理阶段仍驻留的模型与张量,但不反映加载阶段可能出现的更高峰值,也不等于 nvidia-smi 的整个进程显存。不能据此直接承诺"2 GB 显卡就能运行"。
更换指令后输出发生变化,说明本次计算结果对这项输入变化有响应;但它不能证明任务理解正确。对于合成图和虚构状态,没有真实动作目标、环境反馈和成功判据,我们不能计算抓取成功率。
九、从动作片段到机器人控制,还隔着什么?
看到 1, 50, 6,很容易产生一个误解:每次推理生成 50 步,直接播放就能工作。
真正部署时还要回答:
这些维度对应什么? 是哪些关节、何种控制量、什么单位,绝对值还是增量?要以对应任务数据与机器人接口为准,不能仅凭数组长度判断。
怎样还原物理量? 归一化动作需要与训练匹配的后处理和统计量。本实验为了检查网络没有执行这一步。
执行多久再观察? 动作片段长度、实际执行步数、控制频率与重新观察的时机,是不同参数。50 步不能直接换算成固定秒数。
偏差出现后怎么办? 相机新观测、机器人状态与接触变化要进入后续决策。只把一整段动作开环播放,没有验证过的反馈与停止策略,就谈不上可靠控制。
模型是否适配你的机器人和任务? 预训练基础策略不是任意机械臂的通用即插即用控制器。场景、视角、状态与动作分布都需要匹配,并通过离线评估、仿真或受控真机测试逐级验证。
官方硬件示例也把观测构建、预处理、策略、后处理和发送动作分开,而不是将模型输出直接当成任何机器人的指令。LeRobot 官方 SmolVLA 示例
本文刻意停在发送动作之前。任何来自这个实验的 CSV,都不应直接写入执行器。
十、这次验证中实际修正的一个问题
第一次读取配置时,我们调用了:
python
SmolVLAConfig.from_pretrained(model_path)
在本次固定版本中,配置反序列化报错:
text
DecodingError: The fields `type` are not valid for SmolVLAConfig
最终改为通过注册配置的基类解析,再检查具体类型:
python
config = PreTrainedConfig.from_pretrained(model_path)
assert isinstance(config, SmolVLAConfig)
完整脚本已经包含修复。这个错误发生在加载配置阶段,不是显卡显存不足,也不是 checkpoint 损坏,不需要重新下载 907 MB 权重。
依赖安装与模型下载曾占用明显的等待时间,但也不能混同为推理耗时。判断"跑不动"时,先区分卡在下载、配置、加载,还是模型计算。
十一、这套最小实验节省了什么?
它省掉的是训练、任务数据集下载、复杂仿真、硬件接入和大量重复评测。
它没有省掉必要的验证:真实加载预训练策略、严格检查参数匹配、实际 GPU 计算、固定噪声复测,以及输出检查。
本地推理不按聊天 API 的 token 收费。指令仍会被 tokenizer 编码成本地模型输入,但这与写文章时助手消耗的对话 token 是两种成本。首次安装和下载主要消耗网络、磁盘与等待时间,三次推理则消耗本地算力。
以后可以复用这个环境与缓存,再考虑用真实数据验证动作质量。那时应增加任务适配、正确的归一化统计、闭环执行和成功判据,而不是仅重复本篇的数值检查。
总结:先看懂模型输出,再讨论机器人能做什么
这个实验没有让机械臂抓起方块,却把一条关键链路实际跑通了:
图像、语言和状态进入预训练策略,经过动作采样,输出带有明确形状的连续动作片段。
它说明 VLA 的输出不只是聊天文本,也提醒我们:生成动作与安全、正确地执行动作,中间仍然需要数据定义、机器人适配、反馈控制与验证。
第一篇先把这个边界讲清楚,后续才有基础讨论模仿学习、微调与机器人闭环,而不是看见模型输出了几个数,就宣布"机器人听懂人话了"。