LeRobot 全链路原理:用一个“1+1=2“的例子讲透机械臂与模型如何接入
本文用一个贯穿到底的例子,讲清楚 LeRobot 是什么、它定义了哪些"协议"、一台新机械臂和一个新模型分别要怎么接入,以及 6 个舵机和 12 个舵机的机器人如何被同一种模型适配。文中结论对照过 LeRobot 源码(基于 LeRobot 0.6.x 的分支阅读);为了讲清楚,例子里虚构了一个新模型 “MiniVLA” 和一台新机械臂 “双臂 12 舵机”。代码是简化版,但类名、方法名与真实 LeRobot 一致。
一、前世今生:LeRobot 是什么
LeRobot 出现之前(2023 年及以前):每个实验室、每家公司都自己写一套数据格式、训练脚本和机械臂驱动。A 实验室发了个好模型,B 公司想在自家机械臂上用,几乎要重写一遍;你采的数据别人也用不了。
图 1:没有统一接口时,每个模型和每台机械臂都要两两适配;有了 LeRobot,各自只需对接一次中间层。
2024 年:Hugging Face 发布了 LeRobot,目标是做机器人领域的 “transformers 库”:统一数据格式、统一训练流程、统一硬件接口。再配上一台低成本、可 3D 打印的 SO-100 机械臂,让学生也玩得起。
现在:它成了模仿学习领域事实上的标准。仓库里已经接入的机器人包括:
| 类型 | 例子 |
|---|---|
| 单臂 | SO-101、Koch、NexArm、OpenArm、OMX |
| 双臂 | 双 SO 臂、双 OpenArm |
| 移动底盘加机械臂 | LeKiwi |
| 人形或半人形 | 宇树 G1、Reachy2、HopeJR |
| 移动小车 | EarthRover |
所以它不只管机械臂,而是"任何能读出状态、能接收动作的机器人"都能接。
二、LeRobot 到底定义了什么
它定义了 5 类插口和 1 种数据格式,再加上 3 个现成的流程脚本:
图 2:LeRobot 的整体架构。五类插口和统一数据格式被三个现成脚本串起来:机器人、遥操作、相机供采集使用,数据集和模型供训练使用,机器人和模型再一起用于推理。脚本通过 --robot.type、–policy.type 这类名字在登记表里找到具体实现。
所谓"协议",在这里就是三样具体的东西:
- 一组 Python 抽象类:规定"你必须实现哪几个方法、返回什么格式"。比如所有机器人都必须继承
Robot类,实现get_observation()和send_action()。 - 一种数据格式:规定采集下来的数据怎么存、叫什么名字。
- 一个登记机制:每个机器人、每个模型用一个名字登记,脚本里用
--robot.type=名字、--policy.type=名字来调用。
这和 USB 很像:USB 规定了接口形状和通信规则,键盘厂商按规则做键盘,电脑厂商按规则做接口,双方不需要认识对方。
三、接入一台新机械臂,要做什么
例子:某厂商出了一台"双臂 12 舵机"的机械臂,左右各 6 个舵机。要接进 LeRobot,只需写一个类,回答"我有什么、怎么读、怎么写":
@RobotConfig.register_subclass("dual12") # ① 登记名字
class Dual12Config(RobotConfig):
port: str
cameras: dict = {}
class Dual12(Robot):
config_class = Dual12Config
name = "dual12"
JOINTS = ["left_shoulder_pan", "left_shoulder_lift", "left_elbow", "left_wrist_flex",
"left_wrist_roll", "left_gripper",
"right_shoulder_pan", "right_shoulder_lift", "right_elbow", "right_wrist_flex",
"right_wrist_roll", "right_gripper"]
@property
def observation_features(self): # ② 声明"我能提供什么"
return {f"{j}.pos": float for j in self.JOINTS} | {"front": (480, 640, 3)}
@property
def action_features(self): # ③ 声明"我能接收什么"
return {f"{j}.pos": float for j in self.JOINTS}
def get_observation(self): # ④ 读:调用厂商的舵机协议
pos = self.bus.read_all_positions() # 这一行是厂商私有的部分
return {f"{j}.pos": pos[i] for i, j in enumerate(self.JOINTS)} | {"front": self.cam.read()}
def send_action(self, action): # ⑤ 写:把 12 个目标值发给舵机
self.bus.write_all_positions([action[f"{j}.pos"] for j in self.JOINTS])
return action
# 另有 connect / disconnect / calibrate 等,略
机械臂这边只做三件事:声明有几个关节和相机,实现怎么读,实现怎么写。真正跟硬件相关的,只有 self.bus 背后那段舵机通信代码。比如幻尔为 NexArm 写的适配代码约 300 行,做的就是这些事。
一个现成的真实例子:LeRobot 里的"双 SO 臂"就是这么做的。它不重新写驱动,而是把两条 6 舵机的 SO 臂拼在一起,给左臂每个关节名加前缀 left_,右臂加 right_,对外就变成一台 12 个关节的机器人。
图 3:接入新机械臂时,左边是 LeRobot 规定要实现的接口,右边才是和具体硬件相关的代码。
四、12 个舵机和 6 个舵机,怎么"适配"
这是理解全局的关键一步。采集数据时,LeRobot 会把机器人声明的关节自动拼成一个向量:有几个关节,向量就有多长。
6 舵机的机械臂采集后,数据集的说明文件里会写成这样:
"observation.state": { "shape": [6], "names": ["shoulder_pan.pos", "shoulder_lift.pos", "elbow_flex.pos",
"wrist_flex.pos", "wrist_roll.pos", "gripper.pos"] },
"action": { "shape": [6], "names": [ 同上 ] },
"observation.images.front": { "shape": [480, 640, 3] },
"observation.images.wrist": { "shape": [480, 640, 3] }
双臂 12 舵机采集后,同一位置就变成 "shape": [12],名字是 left_... 和 right_... 共 12 个。
模型怎么"理解"6 和 12? 说实话,模型不理解,它只认数字的个数和位置:
- 它看到的不是"肩关节转 30 度",而是"一个长度为 6 的数字列表,第 1 个数是 2048"。
- 关节名字是给人看的,也是为了保证数据和硬件对得上号,模型本身不读名字。
- 每个数代表什么,是模型从示范数据里"统计出来"的:它发现"每次夹东西前,第 6 个数都会变小",于是学会在该夹的时候把第 6 个数调小。
模型的网络大小怎么跟着变? 分两种:
| 模型类型 | 怎么处理 6 和 12 | 源码依据 |
|---|---|---|
| 从头训练的小模型,如 ACT | 训练开始时读数据集说明文件,看到 shape: [6] 就把输入输出层建成 6,看到 12 就建成 12 | LeRobot 有专门的函数把数据集特征转成模型的输入输出形状 |
| 有预训练基座的大模型,如 SmolVLA | 基座尺寸固定,统一把向量补零到 32 位:6 个关节就是 6 个真实数加 26 个 0,12 个关节就是 12 个真实数加 20 个 0 | SmolVLA 配置里最大状态维度和最大动作维度都是 32,模型代码里有补零函数 |
所以,同一种模型可以用在 6 舵机或 12 舵机上,但训练出来的那份权重只属于那一台机器:在 6 舵机数据上训练的,第 6 位是夹爪;在双臂上训练的,第 6 位是左夹爪、第 12 位是右夹爪,含义完全不同,不能混用。
图 4:预训练大模型的输入输出尺寸固定为 32 维,不同关节数的机器人通过补零接入,输出时只取前 N 个。ACT 这类从头训练的小模型没有这一步,直接按关节数建网络。
五、接入一个新模型,要做什么
例子:某实验室发布了 “MiniVLA”,效果很好。要接入 LeRobot,需要写三个文件,每个文件只关心数据,不关心机器人是谁:
# 文件 1:configuration_minivla.py —— 登记名字、声明超参数
@PreTrainedConfig.register_subclass("minivla")
class MiniVLAConfig(PreTrainedConfig):
chunk_size: int = 50 # 一次输出多少步动作
max_action_dim: int = 32 # 像 SmolVLA 一样,统一补到 32 位
# 文件 2:modeling_minivla.py —— 实现协议规定的方法
class MiniVLAPolicy(PreTrainedPolicy):
config_class = MiniVLAConfig
name = "minivla"
def __init__(self, config):
# 从 config 里读到"动作有几维"(6 或 12),据此搭网络
action_dim = config.output_features["action"].shape[0]
self.net = 实验室原来的网络代码(...)
def forward(self, batch): # 训练用:给一批数据,返回误差
pred = self.net(batch["observation.images.front"], batch["observation.state"])
return loss(pred, batch["action"]), {}
def select_action(self, batch): # 推理用:给当前观测,返回下一步动作
return self.net(batch["observation.images.front"], batch["observation.state"])[:, 0]
def reset(self): ... # 每轮开始清空缓存
def get_optim_params(self): return self.parameters()
# 文件 3:processor_minivla.py —— 前后处理(归一化、反归一化、图片缩放)
def make_minivla_pre_post_processors(config, dataset_stats): ...
模型这边只做三件事:登记名字;把作者原来的网络包进 forward 和 select_action 两个方法;定义前后处理。它只认 observation.state、observation.images.xxx、action 这些标准名字,不管背后是哪台机械臂。
写好后有两条路发布:合并进官方 LeRobot;或者打包成一个名字以 lerobot_policy_ 开头的插件(比如 lerobot_policy_minivla),LeRobot 启动时会自动发现并加载。
图 5:接入新模型时,模型只认标准名字的输入输出,不关心背后是哪台机械臂。
六、1 + 1 = 2:把它们拼起来
已知:
- 机械臂 = NexArm(6 舵机),已实现机器人插口,登记名
nexarm_follower - 模型 = MiniVLA,已实现模型插口,登记名
minivla
第 1 步:采集。用主动臂示范"抓小件挂到钩上"100 次:
图 6:从采集到推理的完整链路。
lerobot-record --robot.type=nexarm_follower --robot.port=/dev/ttyUSB0 \
--robot.cameras="{front: {...}, wrist: {...}}" \
--teleop.type=nexarm_leader --teleop.port=/dev/ttyUSB1 \
--dataset.repo_id=yourname/hang_v1 --dataset.num_episodes=100 \
--dataset.single_task="hang the part on the hook"
脚本每秒 30 次调用 NexArm 的 get_observation(),得到 6 个数和 2 张图,再记录主动臂的 6 个数作为"正确动作"。最终存成标准数据集,说明文件写明 state: [6]、action: [6],并算好每个关节的均值和方差。
第 2 步:训练。
lerobot-train --dataset.repo_id=yourname/hang_v1 --policy.type=minivla \
--output_dir=outputs/hang_minivla
脚本读数据集说明文件,发现动作是 6 维,于是告诉 MiniVLA “输出 6 维”(MiniVLA 内部再补零到 32 位)。然后反复调用 forward:喂进图片和 6 个关节数,让它预测 6 个动作数,和人的示范对比,算误差、调参数。结束后保存权重、配置和归一化统计值。
第 3 步:推理。
lerobot-rollout --robot.type=nexarm_follower --robot.port=/dev/ttyUSB0 \
--robot.cameras="{front: {...}, wrist: {...}}" \
--policy.path=outputs/hang_minivla/checkpoints/last/pretrained_model
每秒 30 次循环:
图 7:推理时的实时控制循环。
NexArm.get_observation() → [2048, 1530, 2900, 1800, 2048, 3100] + 两张图
前处理:按训练时的统计值归一化 → [0.01, -0.83, 1.21, ...]
MiniVLA.select_action() → [0.02, -0.80, 1.18, ..., -1.40] (第 6 位变小:要夹了)
后处理:反归一化 → [2052, 1545, 2880, 1810, 2048, 2200]
NexArm.send_action() → 6 个舵机转到这些位置,夹爪闭合
结果:NexArm 夹住小件、挂上钩子。整个过程里,机械臂厂商没写过一行 MiniVLA 的代码,MiniVLA 的作者也没见过 NexArm。这就是 1 + 1 = 2。
换成双臂 12 舵机,会怎样? 把三条命令里的 nexarm_follower 换成 dual12,重新采一批双臂数据,说明文件就会变成 [12],训练出的 MiniVLA 自动输出 12 维。代码一行都不用改,但必须重新采数据、重新训练,因为 6 维那份权重学的是另一副身体。
七、总结:谁负责什么
| 角色 | 负责写什么 | 不需要关心什么 |
|---|---|---|
| 机械臂厂商 | 机器人插口:声明关节和相机,实现读、写 | 用什么模型 |
| 模型作者 | 模型插口:配置、forward、select_action、前后处理 | 用什么机械臂 |
| Hugging Face / LeRobot | 数据格式、采集训练推理脚本、登记机制 | 具体的硬件和模型 |
| 落地团队 | 选机械臂和模型;采高质量的任务数据;训练;做评测;放到真实工位 | 底层接口 |
接口已经被别人铺好了,落地团队真正的价值在于任务数据、评测方法和现场落地。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)