2026 具身智能技术实战(一):VLA 到底怎么控制机器人?——用 LeRobot 跑通 SmolVLA 推理

给机器人一张桌面图片,再说一句"把红色方块放到蓝色托盘里",接下来会发生什么?

普通聊天模型通常给你一句回答。机器人需要的却是可以被控制系统使用的动作表示。两者之间,不是把一段自然语言直接发给电机那么简单。

这篇文章不训练模型、不购买机械臂,也不搭建复杂仿真。我们用 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.

为了控制成本,只做三次推理:

  1. 输入固定图像、状态、指令与噪声,生成第一个动作片段。
  2. 使用相同输入和相同噪声重跑,检查结果能否重复。
  3. 保持图像、状态和噪声不变,仅将指令改为"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 的输出不只是聊天文本,也提醒我们:生成动作与安全、正确地执行动作,中间仍然需要数据定义、机器人适配、反馈控制与验证。

第一篇先把这个边界讲清楚,后续才有基础讨论模仿学习、微调与机器人闭环,而不是看见模型输出了几个数,就宣布"机器人听懂人话了"。

相关推荐
鲁邦通物联网3 小时前
图书馆机器人跨楼层运行,机器人梯控低噪设计与架构分析
机器人·巡检机器人·机器人梯控·agv梯控·非侵入式采集·机器人乘梯·机器人自主乘梯
Tancenter4 小时前
sequeeze()和unsequeeze()
pytorch·tensorr
PascalXie6 小时前
人形机器人的“眼睛“:多目视觉方案怎么选?
计算机视觉·机器人
广州虚拟动力-动捕&虚拟主播6 小时前
产教融合,具身智能数据采集实训室打造五大真实场景
机器人
微信开发api6 小时前
微信iPad协议怎么用?基于协议的二次开发实践
微信·机器人·ipad
QYR-分析6 小时前
机器人精密运动升级,机器人关节动态旋转密封件行业全景市场报告
人工智能·机器人
Thomas.Sir6 小时前
第21课:PyTorch|GPU多卡训练与分布式训练基础【让多卡并行成为你的加速引擎】
人工智能·pytorch·分布式
QYRdata7 小时前
年均增速24.2%!机器人数据湖未来六年增长动能强劲
网络·机器人·服务发现
陈天伟教授7 小时前
技能人才评价考评员(高级考评员)备考要点-模块一技能人才评价制度体系与政策法规
人工智能·具身智能