本文面向具备计算机基础、但从未接触过机器人领域的读者。假定读者熟悉编程、数据结构、模型训练的基本概念,但不了解电机、CAN 总线、运动学与机器人数据采集,笔者采用真机部署踩坑遇到的问题记录如下,希望能帮到入门的小伙伴。
本文的目标是:读完能独立完成一次完整的模型训练-部署闭环,并且理解每个环节的技术依据,而非机械照抄命令。
写在前面
把一台 π0.5 模型部署到真实机械臂上,涉及四个彼此独立的领域:
| 领域 | 典型问题 | 对纯计算机背景的人的门槛 |
|---|---|---|
| 嵌入式通信 | CAN 总线、串口、波特率、SocketCAN | 高------不是常规网络编程 |
| 机械与控制 | 关节空间、减速比、反向驱动、校零 | 高------需要物理直觉 |
| 深度学习 | VLA 模型、LoRA、流匹配、分位数归一化 | 中------概念可迁移 |
| 系统集成 | 分布式部署、SSH 隧道、接口对齐 | 低------与后端系统类似 |
绝大多数失败不是出在深度学习上,而是出在嵌入式通信和机械控制上。 因为纯软件背景的人对这些领域缺乏直觉。例如下面这个报错:
ini
RuntimeError: Servo not found for shoulder_pan (id=0)
直觉会把注意力引向「舵机(硬件)坏了」。实际根因是信号链路的物理断开 ------从臂通过 USB 拓展坞连接时信号无法通到第一颗舵机,而舵机总线是菊花链结构,首端断开即全链失联。问题在接口,不在零件。
这个例子说明了本文的核心方法论:系统的可靠性由接口定义,而非由部件定义。
本文的组织逻辑
全文按「从系统到部件、从原理到操作」展开,共十二个部分:
| 部分 | 主题 | 性质 |
|---|---|---|
| 一 | 系统全局:闭环、子系统、接口、反馈回路 | 建立框架 |
| 二 | 硬件与四条物理链路 | 硬件基础 |
| 三 | 通信层细节:connect() 流程、校零机制、关节约定 |
硬件深入 |
| 四 | 遥操作与数据采集 | 数据生产 |
| 五 | LoRA 微调的机制与工程 | 模型训练 |
| 六 | 推理部署:六步推理链、方向约定、时序 | 系统集成 |
| 七 | 深入原理:动作块、流匹配、实时性权衡 | 理论 |
| 八 | 评估方法论与统计显著性 | 实验方法 |
| 九 | 首次跑通全流程(可执行清单) | 操作 |
| 十 | 故障定位:接口清单与症状表 | 排障 |
| 十一 | 项目现状与路线图 | 进度 |
| 十二 | 速查手册 | 查阅 |
贯穿全文的方法论
本文用系统论的视角组织所有技术细节。一句话概括整个系统:
这是一个由「边界 + 六个功能子系统」组成、通过「12 个精确接口」耦合、由「内外两层反馈回路」驱动的闭环系统。
后续每一处技术细节,都归属于这个框架中的某个位置。掌握框架的价值在于:排障时,搜索空间从"哪个零件坏了"缩小到"哪个接口断了"。 前者是无穷的,后者是有限的十二个。
第一部分 · 系统全局:闭环、子系统与接口
1.1 四个基础概念
在展开系统结构之前,需要先固定四个贯穿全文的技术概念。
概念一:π0.5 是 VLA 策略
π0.5 属于 VLA(Vision-Language-Action)模型------输入视觉与语言,输出动作:
| 维度 | 内容 |
|---|---|
| V(视觉) | 输入:俯视机位与腕部机位的 RGB 图像 |
| L(语言) | 输入:自然语言任务描述 |
| A(动作) | 输出:7 关节的目标角度序列 |
在机器人学中,这类模型称为策略(policy) :给定状态 s 与观测 o,输出动作 a。代码中 --policy.type=pi05 即指定使用该策略。
本项目的输入输出规格:
arduino
输入:
7 维关节角向量 [shoulder_pan, ..., gripper](单位:度)
2 张 RGB 图像 俯视 + 腕部
1 条文本指令 "place two test tubes into the orange rack"
输出:
50 × 7 矩阵 未来 50 个控制周期的关节目标角度
模型内部结构:
ini
π0.5 = PaliGemma 2B(视觉-语言骨干,约 20 亿参数)
+ gemma_300m(动作专家,约 3 亿参数)
+ SigLIP(视觉编码器)
这种拆分是有意的。视觉-语言骨干通过海量互联网图文数据预训练获得通用世界知识;动作专家则通过机器人数据微调获得动作生成能力。前者参数多、更新慢、可跨任务复用;后者参数少、任务专一、是微调的主要目标。推理使用 bf16 半精度以降低显存占用与延迟。
概念二:VLA 与传统机器人编程的范式差异
| 维度 | 传统方法 | VLA 方法 |
|---|---|---|
| 决策来源 | 人工编写规则(if-then / 状态机 / 运动规划) | 从示范数据中学习 |
| 泛化能力 | 弱:物体位姿一变即失效,需重新标定 | 较强:学到的是分布性的"操作策略" |
| 数据需求 | 无(但需要精确的场景建模) | 大量高质量示范 |
| 失败模式 | 规则未覆盖的状态 | 数据未覆盖的状态 / 分布外输入 |
| 调试方式 | 查规则、查标定、查运动学 | 查数据、查接口、查分布偏移 |
这一范式差异决定了工作量的分布:数据采集 > 训练调参 > 代码编写。系统集成代码本身并不复杂,难点在于数据与接口的正确性。
概念三:训练与推理
| 维度 | 训练(training) | 推理(inference) |
|---|---|---|
| 权重 | 被优化器持续修改 | 冻结不变 |
| 时机 | 数据采集完成后,批量进行 | 机械臂运行时,周期性调用 |
| 显存占用 | ≈ 4× 模型大小(权重 + 梯度 + 优化器状态) | ≈ 1× 模型大小(仅前向激活) |
| 计算特征 | 前向 + 反向传播 | 仅前向传播 |
梯度(gradient) :损失函数对每个参数的偏导数,指示"参数调整方向"。
反向传播(backpropagation) :从输出层向输入层逐层计算梯度的算法。
训练显存开销为 4× 的构成:模型权重(1×)、梯度(1×)、Adam 优化器的一阶与二阶动量(2×)。这是训练与推理显存需求差异的根本原因。
概念四:DGX Spark 的统一内存架构
本项目服务器为 NVIDIA DGX Spark ,其关键架构特征是统一内存(unified memory) :
| 维度 | 常规 GPU 服务器 | DGX Spark |
|---|---|---|
| CPU 内存 | 独立 DRAM | 与 GPU 共享物理内存 |
| GPU 显存 | 独立 HBM/GDDR | 同上 |
| 数据搬运 | 需要显式 CPU↔GPU 拷贝 | 无拷贝开销 |
| 资源隔离 | 相互独立 | CPU 与 GPU 竞争同一物理内存 |
工程后果:free -h 报告的"可用内存"不等于可用于加载模型的显存,因为 GPU 计算同样消耗这块内存。
因此训练前必须检查资源占用:
bash
nvidia-smi # GPU 计算占用与显存
free -h # 系统内存(关注 available)
pgrep -af 'vllm|omni' # 是否存在其他推理服务
# 训练脚本中的保护性检查
if pgrep -afi 'vllm|vllm-omni' >/tmp/pi05_gpu_blocked.txt; then
echo "检测到现有 vLLM 服务,为避免影响其他用户,本次不启动训练。"
cat /tmp/pi05_gpu_blocked.txt
exit 2
fi
本服务器上曾长期驻留其他用户的 vLLM-Omni 推理服务。在此情况下启动训练会挤占统一内存,影响他方任务。
1.2 系统总功能
总功能:使 reBot B601-DM 机械臂稳定执行"将两根试管放入橙色试管架"任务。
该任务对机器人而言并不简单,它要求同时具备:
- 感知:从图像中辨识试管与架孔;
- 语言理解:解析"放入"这一空间语义;
- 运动协调:7 关节协同完成抓取-移动-放置的多阶段操作;
- 鲁棒性:在试管位姿存在微小变化时仍能完成。
传统方法需要人工建模场景坐标为规则。本系统采用学习范式:通过人类示范数据训练模型习得该技能。
1.3 系统边界
系统论视角下,系统通过边界与环境交换信息而存在。划定边界即区分可控部分与外部输入。
| 边界 | 构成 | 属性 |
|---|---|---|
| 系统内部 | 机械臂 ×2、相机 ×2、控制主机、GPU 服务器 | 可控(可改代码、接线、配置) |
| 外部环境 | 物理世界(试管、架、桌面)+ 操作者 | 不可控输入 / 评价者 |
跨边界传递的信息仅有五类:
- 图像------相机输出
- 关节角------机械臂状态
- 动作------机械臂指令
- 语言指令------任务描述
- 成功率------评估结果
整个系统的全部行为,都是这五类信息的变换与传递。
1.4 六个功能子系统
系统的划分方式有两种:按物理部件(主臂、从臂、相机、服务器)或按功能(感知、决策、执行、示范、学习、评估)。后者更贴近系统实际运行方式,也更容易定位故障。
① 感知子系统
| 项 | 内容 |
|---|---|
| 物理构成 | 相机 ×2 + 主臂编码器 |
| 输入 | 物理世界(光强、关节位置) |
| 输出 | 2 张 RGB 图像 + 7 维关节角(度) |
| 关键接口 | 接口 I1(状态)、I2(图像) |
故障密度最高的子系统,细节见第二、三部分。
② 决策子系统
| 项 | 内容 |
|---|---|
| 物理构成 | GPU 服务器上的 π0.5 策略 |
| 输入 | 7 维状态 + 2 张图像 + 语言指令 |
| 输出 | 50×7 动作块(度) |
| 关键接口 | 接口 I3(语言 prompt)、I8(权重加载) |
③ 执行子系统
| 项 | 内容 |
|---|---|
| 物理构成 | 主臂 follower 的 7 电机(6× dm4340p 关节 + 1× dm4310 夹爪) |
| 输入 | 7 维动作(度,逻辑量) |
| 输出 | 物理运动 |
| 关键接口 | 接口 I4(动作方向)、I10(CAN 物理层) |
④ 示范子系统
| 项 | 内容 |
|---|---|
| 物理构成 | 从臂 leader(reBot Arm 102,FashionStar 总线舵机 ×7) |
| 输入 | 人工演示 |
| 输出 | 示范轨迹 |
| 关键接口 | 接口 I11(串口物理层) |
学习信号的来源。没有示范数据,模型无从学起。
⑤ 学习子系统
| 项 | 内容 |
|---|---|
| 物理构成 | GPU 服务器上的 lerobot-train |
| 输入 | 遥操作数据集 |
| 输出 | LoRA adapter |
| 关键接口 | 接口 I6(录制格式)、I7(统计量) |
⑥ 评估子系统
| 项 | 内容 |
|---|---|
| 物理构成 | 控制主机上的评估脚本 + 人工判定 |
| 输入 | 每条 episode 的执行结果 |
| 输出 | 成功率(二项分布估计) |
| 关键接口 | 接口 I5(物理反馈) |
成功率作为反馈信号,决定是否进入下一轮数据采集。
1.5 两层反馈回路
系统的核心结构是两层嵌套的闭环。
内层:运行时闭环(秒级)
感知 ──→ 决策 ──→ 执行 ──→ 环境变化 ──→ 再感知 ──→ ...
(读状态+拍图)(推理 50 帧)(30fps 发动作)
这是推理部署阶段运行的环路。当前实现为分块闭环(chunk-level closed loop) :推理出 50 帧动作 → 顺序执行 → 重新推理。非流水线结构(详见 7.3 节)。
外层:学习闭环(天级)
markdown
示范(采数据)→ 学习(微调)→ 决策(部署)→ 执行 → 评估(成功率)
↑__________________________________________________|
成功率不达标 → 回到示范节点
外层闭环才是系统的主干。 项目中的全部工作都归属于其中某个节点:
| 工作内容 | 所属节点 |
|---|---|
| 遥操作数据采集 | 示范 |
| LoRA 微调、切换 checkpoint | 学习 |
| 推理部署、评估脚本 | 决策 + 执行 + 评估 |
常见认知误区:将"训练完成"视为项目终点。实际上训练仅是闭环中的一个节点,且通常不是最耗时的节点。系统的真实循环是:
采数据 → 训练 → 评估 → 成功率不达标 → 采集更多/更好数据 → 再训练 → ...
这个循环的转数由任务难度和成功率目标共同决定。
第二部分 · 硬件与连接:四条互相独立的链路
好,骨架立好了,现在开始动手。
但在动手之前,我得先补上最基础的一课------因为「机械臂」这个词,不同的人脑子里想的是完全不同的东西。
2.1 先认识硬件:我们到底在操作什么
机械臂并不是「一个东西」,它是「很多关节串起来」
先破除一个误解:机械臂不是一根会动的杆子。
它更像人的手臂 :肩、肘、腕,一节一节串起来。每一节之间有一个关节(joint) ,每个关节里有一个电机(motor) 。电机转多少度,这一节就弯多少度。
我们的机械臂有 7 个关节:
shoulder_pan 肩部左右转(像你抬肩膀前后摆)
shoulder_lift 肩部上下抬
elbow_flex 肘部弯曲
wrist_flex 腕部上下
wrist_yaw 腕部左右
wrist_roll 腕部自转
gripper 夹爪(开合)
这个顺序非常重要,后面会反复出现。 因为:
- 录数据时按这个顺序存;
- 训练时按这个顺序学;
- 推理时按这个顺序读和发。
任何一处顺序不一致,整个系统就废了。 这就是为什么它被称为「单一事实来源」。
「关节角」是什么、为什么全程用「度」
每个关节的当前位置,用一个角度来表示,单位是度(°) 。
比如:
shoulder_pan: 15.0表示肩部左右转了 15 度;gripper: -120.0表示夹爪开合到 -120 度。
为什么不用「厘米」「毫米」? 因为机械臂的运动本质是旋转 。电机转动的量就是角度。至于「末端在空间的哪个位置」,那是通过 7 个角度算出来的(这叫「正运动学」),不是直接测的。
所以你以后看到的所有数值------状态、动作、限位------全都是角度。 全程用「度」。
主臂(follower)和从臂(leader):为什么需要两只手臂
这是新手最容易懵的地方。我们的系统里有两只机械臂 ,它们长得可能很像,但职责完全不同:
| 主臂 follower | 从臂 leader | |
|---|---|---|
| 型号 | Seeed reBot B601-DM | reBot Arm 102 |
| 电机类型 | 达妙(Damiao)电机 | FashionStar 总线舵机 |
| 通信方式 | CAN 总线 (can0) |
USB 串口 (/dev/ttyUSB0) |
| 谁控制它 | 模型(或你的脚本) | 你的手 |
| 职责 | 执行动作------真正去抓试管的那只 | 做示范------你掰它,它记录你的动作 |
| 有没有相机 | 有(相机装在它附近/腕部) | 没有 |
用一个人话讲:
从臂(leader)是你手里的「教学棒」,主臂(follower)是「学生的手」。
你掰着教学棒做一遍正确动作,学生的手同步跟着做一遍。这个「同步跟着做」的录像,就是给模型看的示范。
为什么要两只手,而不是直接在真臂上手把手教?
因为力矩问题。主臂的达妙电机有很强的力矩(它要能抓东西),你用手掰它会很费劲,甚至可能掰坏或伤人。而从臂用的是「总线舵机」,力矩小、可以随便掰------它就是专门被设计成「给人掰」的。
⚠️ 记住这句话,它在第七部分会回来:主臂走 CAN,从臂走串口,两条完全独立的链路。
完整硬件清单(照着对)
| 角色 | 具体型号 | 接口 | 供电 |
|---|---|---|---|
| 主臂 follower | Seeed reBot B601-DM(6× 达妙 dm4340p 关节 + 1× dm4310 夹爪) |
CAN(PCAN-USB 转接头 → can0)@ 1 Mbps |
24V / 15A 电源 |
| 从臂 leader | reBot Arm 102(FashionStar 总线舵机 ×7,id 0~6) | USB 串口 /dev/ttyUSB0 @ 1 Mbps |
12V / 2A 电源 |
| 相机 ×2 | USB HD Camera(可用海康 USB 相机 或 罗技 C270) | /dev/video6(front 俯瞰)、/dev/video4(wrist 腕部) |
USB 供电 |
| 本机 | Ubuntu 虚拟机(带桌面) | ------ | ------ |
| GPU 服务器 | NVIDIA DGX Spark(root@gx10-a9b2) |
SSH(端口 6022) | ------ |
关于相机的一个实用细节(来自官方文档):
官方推荐两种相机组合方式,因为不同相机的帧率能力不一样:
| 组合 | 稳定性 | 帧率 |
|---|---|---|
| 1 个海康 + 1 个罗技 | 最稳定,支持 120 FPS 采集 | 高 |
| 2 个罗技 | 稳定 | 所有帧率都行 |
| 2 个海康 | 可用 | 帧率必须 ≤ 60 FPS |
我们这里用 30 FPS,任何组合都够用。
实物的接线结构(这是官方文档里的真实结构)
主臂(follower)的线比较多,因为它的信号和电源要分开走:
objectivec
信号路径:
机械臂 → 信号/电源分离板 → USB-CAN 驱动板 → 电脑
(用 XT30 2+2 线、2-pin JST 线、USB-C 线连)
电源路径:
机械臂 → 分离板 → 24V 电源 → 插座
从臂(leader)就简单多了:
信号:机械臂 → USB-C 线 → 电脑
电源:机械臂 → 12V/2A 电源 → 插排
⚠️ 官方文档里的一条重要安全提示 : 在插拔 JST、XT30 或其他线缆之前,先断开电源。 带电插拔可能烧坏驱动板,这不是危言耸听。
线缆清单:
- USB-C 数据线 ×2(两只臂各一根)
- 主臂额外需要:USB-CAN 驱动板 ×1、信号/电源分离板 ×1
- 线材:2-pin JST ×1、3-pin JST ×1、XT30 2+2 ×1
- 固定件:G 型夹 ×2(每只臂一个,用来把底座夹在桌面上)
那个 3-pin JST 是调试线,可以一直不接。
关于固定 :官方文档特别强调,装好之后要确认底座不会移动。因为你录数据的时候,如果底座动了,那录出来的数据就全错了------模型会以为「机械臂自己漂移了」。
一个容易买错东西的坑
官方文档有一句提醒,我觉得非常值得单独拿出来说:
B601 RS 和 B601 DM 看起来很像,一定要分清楚。
为什么这句话重要?因为这两个型号的:
- 关节方向可能不同;
- 零点不同;
- 夹爪范围不同;
- 运动学响应不同。
这就意味着:用 RS 的数据训练出来的模型,不能直接拿来控制 DM。 这也是后面第九部分「为什么必须采集自己的数据」的根本原因。
2.2 先记住这句话
你的系统里有四条互相独立的连接。出问题时,第一件事是分清是哪一条断了。
| 链路 | 物理连接 | 软件接口 | 作用 |
|---|---|---|---|
| ① 主臂 follower | 达妙电机 ←→ PCAN-USB 转接头 ←→ 电脑 USB | SocketCAN can0 @ 1 Mbps |
发指令让主臂动、读主臂关节角 |
| ② 从臂 leader | FashionStar 总线舵机 ←→ USB 串口线 ←→ 电脑 | 串口 /dev/ttyUSB0 @ 1 Mbps |
你手掰从臂,电脑读它的角度作为示范 |
| ③ 相机 ×2 | USB HD Camera ←→ 电脑 USB | /dev/video6(front)、/dev/video4(wrist) |
拍图给模型看 |
| ④ 服务器 | 电脑 ←→ 网络 ←→ GPU 服务器 | SSH 隧道 localhost:8080 |
把图 + 状态发给服务器,拿回动作 |
记忆口诀:CAN 管主臂,串口管从臂,video 管相机,隧道管服务器。四条互不干扰。
我把它做成了一个更直观的图:
bash
┌──────────── 本机(控制电脑)────────────┐ ┌────── GPU 服务器 ──────┐
│ │ │ │
│ 相机 video6/video4 ──┐ │ │ predict_server.py │
│ 主臂 can0 (7关节) ──┼→ 控制脚本 │ │ ├ 加载 base + LoRA │
│ │ run_pi05_robot │ HTTP │ ├ 归一化 + 拼 prompt │
│ └─→ POST /predict ┼─隧道───┼→ ├ 模型推理 │
│ ←── 50×7 动作 ────┼────────┼── └ 反归一化 → JSON │
│ 从臂 ttyUSB0 ────────┘(录数据时用) │ │ │
└────────────────────────────────────────┘ └────────────────────────┘
为什么要有服务器? 因为模型推理要 GPU。你的控制电脑负责机械臂和相机(它离硬件近),服务器负责算(它有显卡)。这是很常见的分布式机器人架构。
2.3 术语约定:先统一叫法
| 称呼 | 实际 | 用途 |
|---|---|---|
| 本机 | Ubuntu 虚拟机(带桌面) | 机械臂走 CAN、相机接这里;跑控制脚本、开 SSH 隧道 |
| 服务器 | 43.155.204.215:6022(root@gx10-a9b2,NVIDIA DGX Spark) |
GPU 跑模型推理 |
2.4 链路①:主臂 follower 的 CAN 连接
先说清楚:CAN 到底是什么
CAN 是「Controller Area Network(控制器局域网络)」的缩写。
它是 1980 年代德国 Bosch 公司为汽车发明的通信协议。为什么汽车需要它?
因为汽车里有一堆电子设备要互相说话:发动机、刹车、车门、仪表盘、雨刷......如果每两个设备之间都拉一根专用线,那线会多到爆炸。
CAN 的核心思路是:所有设备挂在同一根线上,靠「报文 ID」区分谁在说话。
css
┌───────┬───────┬───────┬───────┐
│ │ │ │ │
[发动机] [刹车] [车门] [仪表] [机械臂电机]
│ │ │ │ │
└───────┴───────┴───────┴───────┘
一根总线(两根线:CAN_H / CAN_L)
每个设备发消息时,消息里带一个「ID」标明「这条消息是给谁的 / 谁发的」。
在我们的机械臂里:
- 7 个达妙电机都挂在同一根 CAN 总线上;
- 每个电机有自己的 CAN ID(比如 0x01、0x02......);
- 电脑发「ID=0x01 的消息」时,只有 1 号电机理会。
CAN 的几个特点(这些特点解释了它为什么适合机器人):
| 特点 | 为什么重要 |
|---|---|
| 抗干扰 | 差分信号(两根线),电机、电机驱动器这些强干扰源旁边也能可靠工作 |
| 多设备共享总线 | 一根线挂很多设备,布线简单 |
| 有优先级 | 紧急消息(比如急停)可以优先发送 |
| 有错误检测 | 内置校验,出错能发现 |
为什么机器人用 CAN 而不是 USB?
| CAN | USB | |
|---|---|---|
| 设计目标 | 工业/汽车,实时、可靠 | 消费电子,方便、通用 |
| 多设备 | 天生支持(挂一根线) | 要 hub,而且是星型拓扑 |
| 实时性 | 强 | 弱(有轮询、调度) |
| 抗干扰 | 强(差分信号) | 弱 |
机器人要「实时」------你发一个动作,希望电机立刻响应,不能等。CAN 更适合这个需求。
物理上是什么
主臂(reBot B601-DM)里的 7 个达妙电机,用的就是 CAN 总线通信。
你的电脑没有原生的 CAN 口,所以需要一个 PCAN-USB 转接头,把 CAN 信号转成 USB 插到电脑上。
objectivec
机械臂的 CAN 口
↓(CAN_H / CAN_L 两根线)
信号/电源分离板
↓
PCAN-USB 驱动板
↓(USB-C 线)
电脑
为什么要「信号/电源分离板」? 因为机械臂的电机需要大电流(24V/15A),而 CAN 信号是弱电信号。两者混在一起会互相干扰。分离板把它们分开走。
软件上怎么建立
Linux 把 CAN 转接头抽象成一个网络接口 can0------就像网卡 eth0 一样。
这个设计很妙:它意味着你可以用「配置网卡的方式」配置 CAN,用「看网卡统计的方式」看 CAN 状态。
建立连接的步骤:
bash
# 1. 先关掉(如果已经存在)
sudo ip link set can0 down
# 2. 设成 CAN 接口,波特率 1 Mbps
sudo ip link set can0 type can bitrate 1000000
# 3. 打开
sudo ip link set can0 up
逐行解释:
| 命令 | 作用 |
|---|---|
ip link set can0 down |
先关闭。为什么?因为正在用的接口不能改配置,就像你不能在车开着的时候换轮胎 |
ip link set can0 type can bitrate 1000000 |
设定类型是 CAN,波特率 1 Mbps |
ip link set can0 up |
打开接口 |
什么是「波特率(bitrate)」?
波特率 = 每秒传输多少个比特。
1 Mbps = 每秒 100 万比特。
⚠️ 最常见的坑就在这里:波特率。 电机和 can0 必须都是 1 Mbps。一个对不上,你就什么都收不到。
为什么对不上就完全收不到? 因为两个设备要靠「时间节拍」同步。发送方按 1 Mbps 的速度发送,接收方按 500 kbps 的速度去采样------采到的全是错位的数据,根本无法解析。
这不是「收到一点错的数据」,而是「完全收不到」。 所以这个错误的表现是「完全没有反应」,很容易被误判成「设备坏了」。
达妙电机是 1 Mbps,所以固定写 bitrate 1000000。
怎么验证连上了
bash
ip -details -statistics link show can0
输出长这样(关键部分):
perl
5: can0: <NOARP,UP,LOWER_UP,ECHO> mtu 16 qdisc pfifo_fast state UP ...
can <BERR-REPORTING> state ERROR-ACTIVE (berr-counter tx 0 rx 0)
bitrate 1000000
RX: bytes packets errors dropped TX: bytes packets errors dropped
...
看三个关键点:
| 关键点 | 期望值 | 含义 |
|---|---|---|
state UP |
UP | 接口起来了 |
state ERROR-ACTIVE |
ERROR-ACTIVE | 总线无错误,正在正常收发 |
bitrate 1000000 |
1000000 | 波特率正确 |
再看统计里的 tx/rx(发送/接收包数)是否在涨。
一个重要的诊断技巧:
如果
tx一直涨、rx一直是 0,说明「发出去没人应」。这时候不要怀疑软件------去查物理接线(没接电机?接线错?)。
为什么这个诊断逻辑成立? 因为:
tx涨 → 说明你的电脑成功往总线上发出去了(软件层 OK);rx是 0 → 说明没有任何设备回应(物理层问题)。
这个「区分软件层和物理层」的思路,在整个项目的排障里会反复用到。
ERROR-ACTIVE 是什么意思?
CAN 总线有三种状态:
| 状态 | 含义 |
|---|---|
ERROR-ACTIVE |
正常,可以自由收发 |
ERROR-PASSIVE |
有错误,进入「只收不发」的保守模式 |
BUS-OFF |
错误太多,完全脱离总线 |
看到 ERROR-ACTIVE 就是好的。 如果看到其他两个,说明总线上有干扰或者接线问题。
2.5 链路②:从臂 leader 的串口连接
先说清楚:串口到底是什么
「串口(serial port)」是计算机最早的通信方式之一,比 USB 早几十年。
它的名字很直白:「串行」的意思是数据一位一位地按顺序传。
对比一下「并行」:
erlang
串行(serial):一条线,数据一位一位排队过
1 → 0 → 1 → 1 → 0 → 0 → 1 → 0 ...
并行(parallel):8 条线,8 位同时过
1 ┐
0 ├─ 同时
1 ┘
为什么现在都用串行? 因为并行线多了会互相干扰,而且高速时同步困难。所以现代接口(USB、PCIe、SATA)全是串行。
「USB 串口」是什么?
现代电脑基本没有原生串口了。所以有「USB 转串口」芯片,插上 USB 后,系统里会生成一个虚拟的串口设备。
bash
从臂的串口线
↓(USB 插到电脑)
电脑里生成 /dev/ttyUSB0
↓
软件读写这个文件 = 和舵机通信
/dev/ttyUSB0 是什么?
Linux 里「一切皆文件」。硬件设备也是文件。
| 名字 | 含义 |
|---|---|
/dev/ |
设备文件目录 |
tty |
teletype(电传打字机),串口设备的传统叫法 |
USB |
通过 USB 转接的 |
0 |
第 0 个(如果有第二个就是 /dev/ttyUSB1) |
所以「读写 /dev/ttyUSB0」= 「和第一个 USB 串口设备说话」。
为什么要有「波特率」? 和 CAN 一样,串口也要双方约定传输速度。不匹配就收不到。我们从臂用 1 Mbps。
FashionStar 舵机是什么?
FashionStar 是一个舵机品牌。这里的「总线舵机」意思是:多个舵机串在一条总线上(daisy-chain),而不是每个舵机单独一根线。
scss
电脑 ──[串口线]── 舵机0 ── 舵机1 ── 舵机2 ── ... ── 舵机6
(id=0) (id=1) (id=2) (id=6)
每个舵机有一个 id(0~6),电脑靠 id 找对应舵机。
为什么这个「串联」很重要?
因为它意味着:上游断一个,下游全部失联。
电脑 ──✂── 舵机0 ── 舵机1 ── ... ── 舵机6
↑
这里断了
结果:7 个舵机全部 ping 不到
(因为信号根本传不过去)
这就是我卡最久的那个坑的物理原理------我不是「7 个舵机都坏了」,而是「信号在第一段就断了」。
物理上是什么
从臂(reBot Arm 102)里有 7 个 FashionStar 总线舵机,用一条串口总线串联(daisy-chain),再通过一根 USB 串口线连到电脑。
供电是单独的:12V/2A 电源适配器,不走数据线。
软件上怎么建立
串口线插上后,Linux 会自动生成设备文件 /dev/ttyUSB0。软件通过读写这个文件跟舵机对话。
怎么验证连上了
「ping」是什么?
ping 是「发一个很小的测试消息,看对方回不回」的通用做法。
在网络里,ping google.com 是发包测试连通性。在串口里,ping(id) 是「问 id 号舵机:你在吗?」。
我们写了一个诊断脚本 ping_leader.py:
python
from motorbridge_smart_servo.fashionstar import FashionStarServo
PORT = '/dev/ttyUSB0'
for br in [1000000, 115200, 921600, 460800]:
try:
b = FashionStarServo(PORT, baudrate=br)
res = [b.ping(i) for i in range(7)]
alive = [i for i, ok in enumerate(res) if ok]
print(f'{br}: {res} 通 {len(alive)} 个 (id={alive})')
b.close()
except Exception as e:
print(f'{br}: ERR {e!r}')
逐行解释这个脚本:
| 行 | 作用 |
|---|---|
for br in [...] |
依次试 4 个波特率------万一不是我预期的 1 Mbps 呢? |
FashionStarServo(PORT, baudrate=br) |
用这个波特率打开串口 |
[b.ping(i) for i in range(7)] |
对 id 0~6 逐个 ping |
alive = [i for i, ok in enumerate(res) if ok] |
统计哪些 id 回应了 |
print(...) |
打印结果 |
输出长这样(正常情况):
python
1000000: [True, True, True, True, True, True, True] 通 7 个 (id=[0, 1, 2, 3, 4, 5, 6])
115200: [False, False, False, False, False, False, False] 通 0 个 (id=[])
...
True = 那个舵机在线。
两个高频坑
坑 1:权限。
报错:
vbnet
ServoBusError: serial error: Permission denied
原因 :你没有读 /dev/ttyUSB0 的权限。
为什么会有权限问题? 因为 Linux 设备文件有「所有者」和「权限位」。串口设备默认属于 dialout 组,而普通用户不在这个组里。
解决:
bash
# 临时(重启失效)
sudo chmod 666 /dev/ttyUSB0
# 永久(推荐,一次搞定)
sudo usermod -aG dialout $USER
# 然后重新登录!
解释这两个方案:
| 方案 | 做了什么 | 缺点 |
|---|---|---|
chmod 666 |
把设备文件权限改成「所有人可读写」 | 重启后失效(因为设备文件重新创建了) |
usermod -aG dialout $USER |
把你加入 dialout 组 |
需要重新登录才生效 |
usermod -aG dialout $USER 拆解:
| 部分 | 含义 |
|---|---|
usermod |
修改用户 |
-aG dialout |
追加 (-a)到组(-G)dialout |
$USER |
当前用户($ 是取环境变量) |
⚠️ -a 绝对不能省! 不加 -a 的话,-G 会替换你的所有组,而不是追加。那会把你从 sudo 组踢出去,后果很严重。
坑 2:7 个全 ping 不到。(我卡最久的问题)
现象:
ini
RuntimeError: Servo not found for shoulder_pan (id=0)
然后测试发现 7 个舵机全部 ping 返回 False。
我当时的第一反应:舵机坏了。
但这是错的。正确的排查逻辑是这样的:
| 检查 | 结果 | 结论 |
|---|---|---|
| 电源灯亮吗? | 亮 | 有电 ✓ |
| 串口能打开吗? | 能(无权限错误) | 软件层没问题 ✓ |
| 试 4 个波特率? | 全 False | 排除波特率问题 ✓ |
三条都排除了,那剩下什么可能?
答案:数据线(信号线)物理没通。
为什么一个不通就全不通? 因为 7 个舵机是一条总线串联的------上游断一个,下游全部失联(见上面的图)。
最终的根因 :从臂通过拓展坞(USB docking station) 连电脑。
为什么拓展坞会出问题?
| 问题类型 | 说明 |
|---|---|
| 供电不足 | 拓展坞分到的电流有限,可能不够设备用 |
| 时序问题 | 拓展坞内部有额外的 USB hub 芯片,可能引入延迟/抖动 |
| 带宽共享 | 拓展坞上所有设备共享带宽,互相影响 |
| 芯片兼容性 | 便宜的拓展坞芯片质量参差 |
改成直插主板 USB 口就好了。
教训:关键设备(机械臂、相机)永远直连主板 USB,不要走拓展坞。
我把这条写进了 checklist,因为它太容易犯、又太难查。
如果直插还不行,继续排查:
bash
# 1. 确认从臂到底在哪个串口
ls -l /dev/ttyUSB*
# 如果出现 ttyUSB0 和 ttyUSB1,说明有多个串口设备
# 从臂可能在 ttyUSB1,而不是 ttyUSB0(拓展坞可能多生成了串口)
# 2. 看内核日志,什么时候插上的
dmesg | grep -i tty | tail -20
# 3. 检查总线插头、数据线两头是否松动
# (物理检查,总线串联,任何一处接触不良都会导致全断)
2.6 链路③:相机连接
物理上是什么
两个 USB 相机:一个架在俯瞰位 拍整个工作区(front),一个装在腕部拍近景(wrist)。
软件上怎么建立
每个 USB 相机对应一个 /dev/videoN 设备。
⚠️ 编号不固定 ------USB 重新插拔、重启之后可能变。所以每次开机第一件事:
css
v4l2-ctl --list-devices
找到两个相机的编号。我这里是 video6=front、video4=wrist。
两个关键参数
| 参数 | 为什么要设 | 我的值 |
|---|---|---|
| 分辨率 width×height | 相机在非标准分辨率下帧率会掉 | 640×480(不是 848×480) |
| fourcc | 指定视频编码格式 | MJPG |
为什么不能用 848×480? 因为 front 相机在这个分辨率下只有 15fps,而我们需要 30fps。
为什么要 MJPG? 不设 fourcc 时,OpenCV 自动探测可能选错格式,导致 select() 超时读不到帧。报错长这样:
scss
OpenCVCamera(4) read failed (status=False)
Timed out waiting for frame from camera OpenCVCamera(4) after 1000 ms
强制 MJPG 就解决了。
注意:模型反正会把图缩到 224×224 ,所以原始分辨率 640 还是 848 对模型输入没区别------帧率 30 才重要。
2.7 链路④:服务器连接(SSH 隧道)
为什么需要隧道
模型推理要 GPU,跑在服务器上。你的控制脚本要调服务器的推理接口。但服务器只监听自己的 localhost:8080,你的本机够不着。
SSH 隧道 就是把服务器的 localhost:8080 映射到你本机的 localhost:8080:
ini
ssh -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
-L 8080:localhost:8080 -p 6022 root@43.155.204.215
之后本机脚本 POST http://localhost:8080/predict,数据会自动通过 SSH 加密隧道转发到服务器------就像服务器在你本机上一样。
三个参数在做什么
-L 8080:localhost:8080:本机 8080 → 转发到服务器的 localhost:8080。-p 6022:SSH 端口是 6022(不是默认的 22)。ServerAliveInterval=30/ServerAliveCountMax=3:每 30 秒发心跳、连续 3 次失败就断开。
为什么要加心跳参数? 因为我踩过一个坑:网络波动导致隧道"假死"------连接看起来还在,但实际上数据已经不通了。加了心跳,隧道挂了会立刻断开,你就能发现。
一个写法上的坑
我一开始写 -L 8080:8080,报错:
bash
Bad local forwarding specification
正确的写法是四段式 :-L 8080:localhost:8080。这个坑很隐蔽,因为报错信息完全没提示格式问题。
怎么验证
json
curl http://localhost:8080/health
# 返回 {"ok":true} 就说明隧道通、服务器在跑
2.8 完整连接 checklist
每次开工前按这个顺序过一遍:
bash
# ① CAN(主臂)
ip -details -statistics link show can0 # 看 state UP + ERROR-ACTIVE
# ② 串口(从臂)
python ping_leader.py # 7 个全 True
# ③ 相机
v4l2-ctl --list-devices # 确认 video6=front, video4=wrist
# ④ 隧道(服务器)
curl http://localhost:8080/health # {"ok":true}
四条全绿,才能开始干活。
第三部分 · 深入理解连接:connect() 和校零
这部分看起来是细节,但它是新手最容易卡住的地方------因为报错信息不会告诉你「这是校准问题」。
3.1 connect(calibrate=True) 内部到底做了什么
每次脚本调用 robot.connect(calibrate=True),内部是按固定顺序执行这些事(以主臂 follower 为例):
scss
┌────────────────────────────────────────────────────────────┐
│ robot.connect(calibrate=True) 的 6 个内部步骤 │
├────────────────────────────────────────────────────────────┤
│ │
│ ① MotorBridgeController(channel=port) │
│ └─ 建立 CAN 通信层(打开 can0) │
│ ↓ │
│ ② _add_motors_to_bus() │
│ └─ 把 7 个电机按 motor_can_ids 挂到总线 │
│ ↓ │
│ ③ calibrate() ← 条件触发(只在「还没校零」时执行) │
│ └─ 需要则先校零 │
│ ↓ │
│ ④ cam.connect() (对每个相机) │
│ └─ 打开相机、设分辨率/fourcc、预热 │
│ ↓ │
│ ⑤ configure() │
│ └─ 给电机写初始配置(PID、限位、方向) │
│ ↓ │
│ ⑥ detect_gripper_zero() ← 条件触发 │
│ └─ 夹爪归零检测 │
│ │
└────────────────────────────────────────────────────────────┘
为什么这个顺序很重要?
因为它决定了报错时你该怎么定位。
connect 失败时,报错会告诉你卡在哪一步,你就能据此判断是哪条链路的问题:
| 卡在哪一步 | 哪条链路 | 该查什么 |
|---|---|---|
| ①②(CAN 层) | 链路① | CAN 线、波特率、电机接线 |
| ③(校零) | 链路① | 电机零点、校准文件 |
| ④(相机) | 链路③ | USB、/dev/video* 编号、fourcc |
| ⑤(配置) | 链路① | 电机固件、寄存器握手 |
| ⑥(夹爪检测) | 链路① | 夹爪零点漂移 |
💡 这就是「六步顺序」最大的价值------它把「一个模糊的报错」变成了「一个明确的排查方向」。
第 ③ 步的校零是「条件触发」的
只有检测到「还没校零」才做。
这解释了一个常见困惑:
「为什么我第一次跑要求校零,第二次就不要求了?」
因为第一次跑的时候,系统里没有校准文件(或者检测到电机还没校过零)。校过一次之后,校准信息存下来了,后面就不会重复要求。
反过来,如果你遇到「突然又要求校零了」,说明:
- 你换了台电脑(校准文件在旧的机器上);
- 或者你删了校准文件;
- 或者
--robot.id变了(校准文件名和 id 绑定)。
⚠️ 这就带出一个很隐蔽的坑 :如果你录制时加了
--robot.id=xxx,那校准文件名就变成xxx.json。下次不加id时,默认找None.json------找不到,于是要求重新校零。所以:录制时不要加
--robot.id,保持默认。
3.2 校零(Calibration)是什么
电机需要一个「零位参考」
先理解一个核心事实:电机不知道「哪里是 0 度」。
达妙电机的编码器读的是相对角度 ------从某个零点开始算。但这个零点在哪,电机自己不知道,它需要一个基准。
用一个比喻:
想象你在一间没有窗户的房间里,我让你「往前走 3 米」。
你问:从哪儿开始算? 从门口?从墙角?从我现在站的地方?
如果你不知道「起点在哪」,那「往前走 3 米」这个指令毫无意义。
机械臂的电机也是一样。
markdown
问题:电机读到「当前角度 = 120 度」
但这个 120 度是相对于哪里的 120 度?
答案:相对于「零位(零点)」
而这个零位,需要有人告诉它
这个「零位」存在哪里? 存在电机的 EEPROM 里。
EEPROM 是什么? 一种「掉电不丢」的存储。跟你手机的存储一样------关机了数据还在。
arduino
┌─────────────────────────────┐
│ 达妙电机内部 │
│ │
│ ┌──────────────┐ │
│ │ EEPROM │ ← 零位存在这里 │
│ │ 零位 = ? │ (掉电不丢) │
│ └──────────────┘ │
│ │
│ 编码器 → 读到 120 度 │
│ → 减去零位 │
│ → 得到「相对角度」 │
└─────────────────────────────┘
校零(calibrate)就是:给电机定这个零位
具体操作:
- 手动 把机械臂摆到一个已知的姿势(通常是伸直零位);
- 告诉电机:「你现在就是 0 度」;
- 系统把这个参考写进电机的 EEPROM。
arduino
校零前:
电机读到 120 度 → 但不知道相对于哪
电机读到 45 度 → 也不知道相对于哪
校零时(假设你把它摆在某个姿势):
「你现在这个姿势 = 0 度」→ 写进 EEPROM
校零后:
电机读到 120 度 → 相对于零位,是 120 度 ✓
电机读到 45 度 → 相对于零位,是 45 度 ✓
什么时候需要校零
| 情况 | 为什么需要 |
|---|---|
| 新机器第一次用 | EEPROM 里还没有零位 |
| 电机重新上电后零点漂了 | 某些情况(比如突然断电)EEPROM 可能丢/漂 |
| 换了校准文件 | 比如换了台电脑,新电脑上没有校准记录 |
⚠️ 重要提醒:每次机械臂异常断电后,第一件事就是校零。
因为异常断电最可能导致 EEPROM 里的零点丢失。而零点丢了之后,机械臂会以为自己在错误的位置------如果你这时候发动作,它可能撞到东西。
校零命令
css
lerobot-calibrate --robot.type seeed_b601_dm_follower --robot.port can0
逐参数:
| 参数 | 含义 |
|---|---|
lerobot-calibrate |
校准工具 |
--robot.type seeed_b601_dm_follower |
校准的是哪款机械臂 |
--robot.port can0 |
通过哪个接口(CAN) |
执行流程:
markdown
1. 提示:请把整臂摆到零位、夹爪闭合
↓(你手动摆好)
2. 按回车
↓
3. 它逐个电机写入零位
↓
4. 完成
💡 官方文档里的一条重要提醒 : 「在完成校准之前,不要进行遥操作或数据采集。」
因为如果零位是错的,你录的数据也是错的------模型会学到「错误的姿势对应正确的动作」,整个数据集就废了。
3.3 夹爪归零检测:那个 353.96°
我遇到过一个报错,第一次看完全懵:
lua
gripper zero-detect: gripper not homed, offset 353.96 deg
这个报错在说什么?
拆开看:
| 部分 | 含义 |
|---|---|
gripper zero-detect |
夹爪归零检测(connect 的第 ⑥ 步) |
gripper not homed |
夹爪没有「归位」(零点不对) |
offset 353.96 deg |
偏差 353.96 度 |
检测机制是这样的:
ini
① 夹爪校零后,脚本让它轻轻闭合
↓
② 读它当前的角度
↓
③ 和「零位」比较
↓
④ 如果差异 > 15 度(容差 tolerance_deg=15)
→ 报错「夹爪没有归位」
353.96° 意味着什么?
它约等于一整圈。
353.96 度 ≈ 360 度 = 一整圈
这说明:夹爪的零点参考,漂了几乎一整个圆。
为什么是「一整圈」这个数字? 因为这类漂移通常是「圈数计数错了」------多圈计数 ±1 就是 360 度。
这不是夹爪坏了------是零点丢了
重要:不要因为这个报错去换夹爪。
它只是说「零位参考漂了」。典型原因是重新上电导致 EEPROM 零位漂移。
解决:重新校零。
css
lerobot-calibrate --robot.type seeed_b601_dm_follower --robot.port can0
💡 记住这个排查思路:看到「offset 是 360 的倍数附近」,基本可以直接判定是零点问题,而不是硬件故障。
为什么容差是 15 度?
容差 tolerance_deg=15 不是随便定的------它考虑了回程间隙(backlash) 。
什么是回程间隙? 机械传动(齿轮、连杆)总有一点点「空转」。
你让夹爪「闭合」
↓
电机转了,但齿轮要先「咬合」才带动夹爪
↓
所以夹爪的实际位置,会比电机「应该」到的位置差一点
这个臂的实测回程间隙约 9.5 度。
所以容差设 15 度(> 9.5),是为了「不把正常的机械误差误报成故障」。
3.4 一个重要的澄清:校零不改限位和方向
这是我一开始很担心的问题:重新校零会不会把之前配好的限位、方向搞坏?
答案是:不会。
原因(这是关键):
arduino
校零改的是: 电机的「零位参考」(存在 EEPROM)
↓
这只是一个「起点」
限位/方向/尺度来自: 代码里的 config(joint_limits、joint_directions)
↓
这是「规则」
send_action 用的是: config,不是校准文件
用比喻讲:
校零 = 重新校准「尺子的零刻度」; 限位/方向 = 「这把尺子最长量到多少、正着量还是反着量」的规则。
重新调零刻度,不会改变尺子的长度和方向。
arduino
┌──────────────────────────────────────────┐
│ send_action 内部 │
│ │
│ 输入:逻辑角度(你想让它去哪) │
│ ↓ │
│ × joint_directions ← 来自 config ✓ │
│ ↓ │
│ clip 到 joint_limits ← 来自 config ✓ │
│ ↓ │
│ 转弧度 → 发 CAN │
│ │
│ (全程没有用到校准文件) │
└──────────────────────────────────────────┘
所以:重新校零是安全的,放心校。
3.5 关节约定:单一事实来源
这是全项目最需要「钉死」的东西。
7 个关节的:顺序、方向、限位 ------ 数据集、模型、驱动三者必须完全一致。
为什么说它是「单一事实来源(single source of truth)」?
因为它是一个必须处处一致的约定。你改了任何一处,所有相关的地方都必须同步改,否则------
整个系统会在一个你完全看不见的地方默默地错掉。
关节顺序
shoulder_pan, shoulder_lift, elbow_flex, wrist_flex, wrist_yaw, wrist_roll, gripper
这个顺序在以下所有地方必须一致:
objectivec
┌───────────────────────────────────────────────┐
│ ① 录制时 按这个顺序存进数据集 │
│ ② 数据集 按这个顺序组织 observation │
│ ③ 训练时 按这个顺序学 │
│ ④ 推理读状态 按这个顺序抽 7 个数 │
│ ⑤ 推理发动作 按这个顺序发 │
│ ⑥ 驱动内部 按这个顺序映射到 CAN ID │
└───────────────────────────────────────────────┘
如果顺序错了一处 ------比如训练数据是 肩, 肘, ...,而你读的时候是 肘, 肩, ...------模型会把肩的角度当成肘的角度,输出的动作完全乱。
而且不会有任何报错(因为 7 个数字看起来都是正常的),只会表现为「机械臂乱动」。
⚠️ 这是最难查的一类 bug:不报错、不崩溃,只是动作不对。
方向约定(joint_directions)
yaml
{
shoulder_pan: -1.0,
shoulder_lift: -1.0,
elbow_flex: 1.0,
wrist_flex: 1.0,
wrist_yaw: 1.0,
wrist_roll: -1.0,
gripper: -6.0
}
解释见 6.5 节。
限位(joint_limits,单位:度)
yaml
{
shoulder_pan: (-145, 145),
shoulder_lift: (-170, 0),
elbow_flex: (-200, 0),
wrist_flex: ( -80, 90),
wrist_yaw: ( -90, 90),
wrist_roll: (-130, 130),
gripper: (-270, 0)
}
什么是「限位(limits)」?
它规定每个关节允许的角度范围。
makefile
shoulder_pan: (-145, 145)
→ 允许 -145 度到 +145 度
→ 超出这个范围的指令会被 clip(截断)
elbow_flex: (-200, 0)
→ 只允许负角度到 0
→ 因为肘部物理上只能往一个方向弯
为什么需要限位?
因为机械臂有物理限制,超出会撞坏。
想象肘关节------它只能往一个方向弯(像人的肘不能反着弯)。如果你发一个「反向弯 90 度」的指令,机械臂会试图撞自己。
所以 send_action 会先把动作 clip 到限位内:
ini
action = max(limit_min, min(action, limit_max)) # 概念代码
用一个数字例子:
ini
模型输出 elbow_flex = 50.0(在训练数据里可能是正常的)
但我们的 elbow_flex 限位是 (-200, 0)
↓ clip
变成 0.0
💡 这解释了一个常见现象:「模型输出的动作好像被『压平』了,全在边界上」。
这通常不是 bug,而是模型输出的值超出了本机的物理限位------被 clip 了。
单位与「原始量 vs 逻辑量」
全程用「度」。 但有两个不同的概念(这是理解方向约定的关键,见 6.5):
| 概念 | 谁产生/消费 | 含义 |
|---|---|---|
| 原始编码器角度 | get_observation() 返回 |
物理量,未套方向 |
| 逻辑量 | send_action() 接收 |
语义量,内部会 × joint_directions |
scss
get_observation() ──→ 原始角度(物理量)
↓ 要 × STATE_FLIP 才能喂模型(见 6.5)
send_action(逻辑角度) ──→ 内部 × joint_directions → clip → 发 CAN
记住这张图,6.5 节会用到。
第四部分 · 遥操作采集:让模型看到「正确答案」
硬件通了,接下来是整个学习闭环的起点------录数据。
4.1 遥操作(teleoperation):leader-follower 双机械臂架构
数据采集的方式是遥操作:你手动操纵从臂(leader),主臂(follower)同步跟随,整个过程被录制为训练数据。
ini
人的操作
│
▼
┌─────────────────┐
│ leader(从臂) │ reBot Arm 102,FashionStar 总线舵机
│ /dev/ttyUSB0 │ 可反向驱动(backdrivable)
└────────┬────────┘
│ 读 leader 关节角
│ ──→ 作为 action(训练目标)
▼
┌─────────────────┐
│ follower(主臂) │ reBot B601-DM,达妙电机
│ can0 │ 高减速比、大扭矩
└────────┬────────┘
│ 读 follower 状态 + 相机图
│ ──→ 作为 observation(模型输入)
▼
┌──────────────────────────────────┐
│ 每帧记录: │
│ observation = follower 状态 + 图 │
│ action = leader 关节角 │
└──────────────────────────────────┘
为什么用 leader-follower 而不是直接操纵主臂
关键在于反向驱动能力(backdrivability) ,这是机器人硬件的一个核心概念:
| follower(达妙电机) | leader(总线舵机) | |
|---|---|---|
| 减速比 | 高(约 1:50~1:100) | 低 |
| 扭矩 | 大(能扛负载、抓取) | 小 |
| 反向驱动 | 困难(高减速比 + 摩擦,通常不可逆驱动) | 容易(可手推) |
| 力矩放大 | 电机侧小力矩 → 关节侧大力矩 | 无放大 |
高减速比的电机在物理上不可反向驱动,或者需要极大的外力------强行反向驱动会损坏齿轮系(谐波减速器/行星减速器在反向大扭矩下易损)。所以你不能用手去掰主臂做示范。
leader 用的是低减速比总线舵机,可以自由反向驱动,人推它就能记录角度。这就是它存在的工程理由。
官方 wiki 提到一个可选项:重力补偿(gravity compensation) 。开启后 leader 会主动补偿自身重力,使其在工作空间内任意位置悬停,方便采集需要停顿的轨迹。但官方明确要求 在遥操作方向验证时先关闭它 ------ 因为补偿力矩会干扰人对"方向是否正确"的判断。
关节级同步的语义
「同步跟随」的本质是:把 leader 的关节角直接作为指令下发给 follower,而不是 follower 自己计算运动学。
前提是两个臂在关节层面对齐:7 个关节顺序一致、语义一一对应。至于连杆长度、末端位姿是否完全一致,不影响训练------因为模型学的是关节空间(joint space)的角度映射,不是笛卡尔空间(Cartesian space)的末端轨迹。
这带来一个重要的工程好处:跨型号数据复用是可能的(关节空间同构),但需要处理方向、零点、尺度差异(详见 4.8 节 RS/DM 型号差异)。
4.2 关键语义:action 和 observation 来自不同的臂
这是整个数据采集最重要的一句话:
| 字段 | 是什么 | 从哪来 |
|---|---|---|
| action(动作) | 从臂 leader 的关节角 | 你手掰的位置 = 模型要学的「正确答案」 |
| observation(观测) | 主臂 follower 的实际状态 + 相机图像 | 模型推理时能看到的「世界状态」 |
训练时模型学的是:
「给定 follower 当前状态和相机图像,预测 leader 示范的动作」
为什么这个设计是对的:一张表看透
推理时(模型真正工作时)会发生什么?
推理时:
┌──────────────────────────────────┐
│ 读主臂 follower 的状态 ← observation │
│ 读相机图 ← observation │
│ ↓ │
│ 模型推理 │
│ ↓ │
│ 输出动作 → 发给主臂 follower │
└──────────────────────────────────┘
看出关键了吗? 推理时,没有从臂!
训练时:observation 来自主臂,action 来自从臂
推理时:observation 来自主臂,action 发给主臂
↑ 只有 observation 的来源必须一致(都是主臂)
↑ action 的「来源」和「去向」不重要,重要的是「语义一致」
所以训练和推理是这样对齐的:
| 训练 | 推理 | |
|---|---|---|
| observation 来源 | 主臂(follower) | 主臂(follower) |
| 图像来源 | 相机 | 相机 |
| action | 从臂的角度(示范) | 模型输出的角度 |
这就是为什么「动作和观测来自不同臂」不影响训练------它们只是同一帧里两个字段。训练时模型拿 observation 预测 action,比对的就是你示范的从臂角度。
💡 一句话理解 : observation 是「模型看到的」,训练和推理必须一致(都是主臂); action 是「模型该做的」,训练时是示范(从臂),推理时是输出。
4.3 从臂驱动的技术细节
驱动文件是 rebot_arm_102_leader.py,物理上是 FashionStar 总线舵机 ×7,串口 /dev/ttyUSB0 @ 1 Mbps。
关节 id 映射
yaml
joint_ids = {shoulder_pan: 0, shoulder_lift: 1, elbow_flex: 2,
wrist_flex: 3, wrist_yaw: 4, wrist_roll: 5, gripper: 6}
7 个舵机在一条串口总线上串联,靠 id(0~6)区分。id 和关节名的对应关系,必须和主臂、模型一致。
connect() 里的 ping 机制
scss
bus = FashionStarServo(port, baudrate=1_000_000)
for motor_name, motor_id in joint_ids.items():
if not bus.ping(motor_id):
raise RuntimeError(f"Servo not found for {motor_name} (id={motor_id})")
connect 会对 7 个舵机逐个 ping,任何一个不通就立刻抛异常。所以你现在应该能理解那个报错了:
Servo not found for shoulder_pan (id=0)= 第 0 号舵机没回应;- 7 个全 ping 不到 = 串口数据线物理没通(总线串联,上游断全断)。
多圈角度处理
FashionStar 舵机可以连续多圈转动,角度会一直累加(转两圈就是 720°)。驱动里有:
ruby
def configure(self):
for motor_id in joint_ids.values():
bus.unlock(motor_id)
bus.reset_multi_turn(motor_id) # 复位多圈计数器
def _round_to_valid_range(value, min_value, max_value):
# 把多圈累计的角度「展开」回 ±180° 窗口
这两个函数把多圈累计值规整回有效窗口,避免读到「转了三圈」这种离谱角度。
高效读取
ini
result = bus.sync_monitor(list(joint_ids.values())) # 一次同步读 7 个舵机
用 sync_monitor 一次性广播读 7 个舵机,而不是逐个读------降低延迟。
4.4 录制命令逐参数
ini
lerobot-record \
--robot.type=seeed_b601_dm_follower \
--robot.port=can0 \
--robot.cameras="{front: {type: opencv, index_or_path: 6, width: 640, height: 480, fps: 30, fourcc: MJPG}, wrist: {type: opencv, index_or_path: 4, width: 640, height: 480, fps: 30, fourcc: MJPG}}" \
--teleop.type=rebot_arm_102_leader \
--teleop.port=/dev/ttyUSB0 \
--dataset.repo_id=my_b601/place_tubes \
--dataset.num_episodes=10 \
--dataset.single_task="place two test tubes into the orange rack" \
--dataset.push_to_hub=false
| 参数 | 技术含义 |
|---|---|
--robot.type=seeed_b601_dm_follower |
主臂用自定义驱动(走 CAN) |
--robot.port=can0 |
CAN 接口名(不是 damiao/ttyACM0) |
--robot.cameras=... |
双相机:front=video6、wrist=video4,640×480 + MJPG + 30fps |
--teleop.type=rebot_arm_102_leader |
从臂驱动名 |
--teleop.port=/dev/ttyUSB0 |
从臂串口 |
--dataset.repo_id=my_b601/place_tubes |
数据集仓库名(本地缓存键) |
--dataset.num_episodes=10 |
录几段 |
--dataset.single_task=... |
任务文字,每帧都会写进数据 |
--dataset.push_to_hub=false |
只存本地,不传 HuggingFace |
两个容易忽略的技术约束
约束 1:不要加 --robot.id。
lerobot 用 id 决定校准文件名(calibration_dir/{id}.json)。默认 id=None → 校准文件是 None.json。加了别的 id 就找不到之前的校准,connect 会强制重新校零。
约束 2:dataset.fps 必须和相机 fps 一致。
录制循环里有:
yaml
if dataset.fps != fps:
raise ValueError
所以相机设 30fps、数据集也按 30fps 录。
一个隐蔽的坑:驱动名的选择
从臂驱动应该用 rebot_arm_102_leader,不是 内置的 rebot_102_leader。
为什么?因为后者的 joint_directions 带了 -1 翻转,会导致主从臂动作不一致。
这个坑很阴------两个名字看起来几乎一样,但行为完全不同。记住:用
rebot_arm_102_leader。
4.5 录制循环的内部机制
lerobot 的录制核心是一个按固定帧率的循环。
一张图看懂整个录制循环
ini
┌─────────────────────────────────────────────────────────────┐
│ 开始录制 │
└─────────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 【每个 episode 开始前】 │
│ reset 阶段:回到 rest position │
│ (把机械臂摆回起始位姿) │
└─────────────────┬───────────────────┘
↓
┌─────────────────────────────────────┐
│ 【按 30fps 循环,每一帧做:】 │
│ │
│ ① 读从臂 leader 的角度 │
│ ↓ 当作指令 │
│ ② 发给主臂 follower(它跟着动) │
│ ↓ │
│ ③ 读主臂状态 + 拍两张相机图 │
│ ↓ │
│ ④ 组装成一帧数据: │
│ observation = 主臂状态 + 图 │
│ action = 从臂角度 │
│ task = 任务文字 │
│ ↓ │
│ ⑤ 存进数据集 │
│ ↓ │
│ CycleTimer 保证严格 30fps │
└─────────────────┬───────────────────┘
↓
按下 n(结束这一段)
↓
┌─────────────────────────────────────┐
│ 又一次 reset(回到起始位姿) │
│ 再按 n 确认 │
└─────────────────┬───────────────────┘
↓
录满 num_episodes?
↙ ↘
否 是
↓ ↓
回到上面循环 保存并结束
代码层面的样子:
ini
timer = CycleTimer(fps, records_data=dataset is not None) # 帧率节拍器
while recorded_episodes < num_episodes and not stop_recording:
# 每个 episode 内:
# 1. reset 阶段:回到 rest position(起始位姿)
# 2. 循环:读 leader → 驱动 follower → 记录一帧
frame = {**observation_frame, **action_frame, "task": single_task}
一帧数据里有什么
bash
{
# ─── observation(观测)------ 推理时能看到的 ───
"observation.state": [7 关节角], # follower 的实际状态
"observation.images.front": <图像>, # 俯瞰相机
"observation.images.wrist": <图像>, # 腕部相机
# ─── action(动作)------ 模型要学的「正确答案」───
"action": [7 关节角], # leader 的示范角度
# ─── 任务标签 ───
"task": "place two test tubes into the orange rack",
}
用表格看清楚每一帧的构成:
| 字段 | 来源 | 训练时的角色 |
|---|---|---|
observation.state |
主臂 follower | 题目的一部分(当前状态) |
observation.images.front |
相机 6 | 题目的一部分(世界长什么样) |
observation.images.wrist |
相机 4 | 题目的一部分 |
action |
从臂 leader | 答案(该做什么) |
task |
你输入的文字 | 题目的一部分(任务是什么) |
所以一帧就是一道「(题:状态+图+任务)→(答案:动作)」的配对。
CycleTimer 帧率节拍器:为什么它很重要
CycleTimer(fps) 保证循环严格按 30fps 采帧。
它具体做什么?
bash
每一帧开始:
↓
记下「这一帧应该是什么时刻」(按 fps 算)
↓
做完这一帧的活(读状态、拍照、存数据)
↓
看现在到「下一帧应该的时刻」还有多久:
- 还有时间 → sleep 等一下
- 已经超时 → 立刻开始下一帧(跳过等待)
为什么这个「严格 30fps」这么重要?
因为模型的「动作块」隐含了时间信息。
arduino
模型学到的:
"从 t=0 到 t=1.65s,机械臂应该这样动"
如果采帧不均匀:
实际录到的是 "从 t=0 到 t=1.65s" 但里面有卡顿
↓
模型会以为「这个动作花了 0.1 秒」,实际是 0.05 秒
↓
模型学到的「时间轨迹」是错的
用人话讲:
如果你录视频的时候卡顿,那么模型学到的动作会「忽快忽慢」。它在真机上执行时,动作也会忽快忽慢。
所以 CycleTimer 是保证数据质量的关键。
episode 的 reset 阶段:为什么必须复位
每个 episode 开始前有一个复位阶段:把机械臂回到起始位姿(rest position)。
为什么?
yaml
如果每条 episode 的起点不同:
episode 1: 从「臂在左」开始
episode 2: 从「臂在右」开始
episode 3: 从「臂在中间」开始
↓
模型看到「三种不同的起点」,学起来更难
(它得同时学会「从左怎么走」和「从右怎么走」)
yaml
如果每条 episode 的起点相同:
episode 1: 从「标准起点」开始
episode 2: 从「标准起点」开始
episode 3: 从「标准起点」开始
↓
模型只需要学会「从这个标准起点怎么走」
学得更快、更准
这就是「数据要干净」的体现。 每条 episode 从相同初始状态开始,训练数据质量更高。
对应录制按键的第二次 n:
perl
第一次按 n → 结束「动作部分」(这一段你演示完了)
↓
自动进入 reset 阶段(你手动把臂和场景复位)
↓
第二次按 n → 结束 reset,进入下一段 episode
兼容性检查:防止录出「混搭」数据
scss
sanity_check_dataset_robot_compatibility(dataset, robot, fps, dataset_features)
录制前会检查:相机数量/名称、关节数量/名称、fps 是否和已有数据一致。
为什么需要这个检查?
因为一个数据集可能分多次录。如果你:
- 第一次录的时候用 2 个相机;
- 第二次录的时候改成了 1 个相机;
那这个数据集里就有「有的 episode 有 2 个相机、有的 1 个」------混搭了,训练时会出错。
这个检查会在录制前拦住你,防止你录出「混搭」的数据集。
4.6 录制按键状态机
| 键 | 动作 | 技术含义 |
|---|---|---|
n / → |
保存当前 episode,进入下一个 | 第一次 n 结束动作、第二次 n 结束复位 |
r / ← |
丢弃当前 episode 重录 | 不写入磁盘 |
q / Esc |
退出录制 | 已完成的部分保留 |
录制节奏:每段按两次 n(动作 + 复位),录满 num_episodes 段自动结束。
⚠️ 别按
r! 它会丢弃你刚录的那一段。我第一次录的时候手滑按了r,白录一段。
4.7 数据落盘结构
bash
~/.cache/huggingface/lerobot/my_b601/place_tubes_<时间戳>/
├── videos/
│ ├── observation.images.front/ # 俯瞰视频(按 episode 分片)
│ └── observation.images.wrist/ # 腕部视频
├── data/
│ └── chunk-000/xxx.parquet # 每帧状态+动作(表格,分块存)
└── meta/
├── stats.json # 归一化统计(训练必读)
└── info.json # 元信息(关节名、指令、fps、episode 数)
三件套:videos(图)、data(数)、meta(元信息 + 统计)。
每一层到底是什么(拆开看)
videos/ ------ 存的是视频,不是图片。
这是一个容易被忽略但很重要的设计。你录了 10 条 episode,如果每帧存成一张 PNG,那可能有几万张图,文件系统会被拖垮。所以 lerobot 把它们编码成视频文件(每个 episode 一个视频),按 episode 分片。
而且两个相机的视频分开存 ------observation.images.front/ 和 observation.images.wrist/ 是两个目录。因为它们本来就是两个独立的视频流。
data/ ------ 存的是数字,格式是 parquet。
.parquet 是一种表格文件格式(类似 CSV,但更快、更省空间、能存嵌套结构)。你可以把它理解成「一个表格,每一行是一帧,每一列是一个字段」。
一帧大概长这样(概念示意):
| frame_index | observation.state (7个数) | action (7个数) | timestamp | task |
|---|---|---|---|---|
| 0 | 15.0, -40.0, -30.0, ... | 15.1, -39.8, -30.2, ... | 0.000 | place two test tubes... |
| 1 | 15.1, -39.8, -30.2, ... | 15.2, -39.6, -30.5, ... | 0.033 | place two test tubes... |
| 2 | ... | ... | 0.067 | ... |
meta/ ------ 存的是「关于数据的描述」。
info.json:关节名叫什么、fps 是多少、录了几条 episode、相机叫什么名;stats.json:归一化统计(最重要,下面单独讲)。
为什么是「chunk-000」这种分块命名
lerobot 会把数据分成若干块(chunk),每块里放若干 episode 的数据。这么做是为了:
- 读写效率:不用一次加载所有数据;
- 可扩展:数据量变大时不用重排整个目录。
对我们的项目来说,chunk-000 意味着「第一块」。数据量小的时候只有这一块。
数据集的「身份证」:info.json
info.json 是数据集的元信息。它回答这些问题:
- 这个数据集用了哪些相机?叫什么名字?
- 有几个关节?叫什么名字?
- 录了多少条 episode?每条多少帧?
- fps 是多少?
- 任务文字是什么?
为什么它重要? 因为训练脚本会读它来确认「数据集的格式对不对得上模型的要求」。
你可以随时手动查看:
bash
sed -n '1,260p' /root/pi05_b601/datasets/orange_rack_test_tubes_848/meta/info.json
检查重点:
observation.images.*的名字------决定你要不要做字段映射(见 4.11);robot_type------决定这份数据是不是给你这台臂用的;fps------必须和相机 fps 一致。
想直接看数据内容?用这个命令
less
python -c "import pyarrow.parquet as pq; p='/root/pi05_b601/datasets/orange_rack_test_tubes_848/meta/tasks.parquet'; print(pq.read_table(p).to_pylist())"
这条命令读的是 meta/tasks.parquet------它存的是任务语义。
为什么单独有这个文件? 因为任务文字在所有帧里都一样(比如都是 place two test tubes into the orange rack),如果每帧都存一遍就浪费了。所以单独存一份,每帧只存一个「任务 id」。
这条命令的输出会告诉你:这个数据集里的任务文字到底是什么。 这个信息非常关键,因为------
⚠️ 训练时的任务文字和推理时的
TASK必须一字不差。所以先用这条命令确认数据集里的文字,然后原样复制 到
predict_server.py的TASK变量里。不要自己重新打一遍(很容易打错)。
4.8 数据集从哪来:两种来源、两套逻辑
到这里你可能会问一个很实际的问题:数据从哪来?
答案是两种,而且它们对应着完全不同的阶段:
| 来源 | 具体是什么 | 用在哪个阶段 |
|---|---|---|
| ① 公开数据集 | nyancos/orange_rack_test_tubes_848(别人录的) |
第一阶段:验证管线 |
| ② 自己录的数据 | 你自己用从臂示教录的 my_b601/place_tubes |
第二阶段:真正能用 |
① 公开数据集:nyancos/orange_rack_test_tubes_848
它在哪? HuggingFace 上,地址是:
ruby
https://huggingface.co/datasets/nyancos/orange_rack_test_tubes_848
关于这份数据集,我知道的客观事实:
| 项 | 值 |
|---|---|
| 名称 | nyancos/orange_rack_test_tubes_848 |
| 作者/组织 | nyancos |
| 模态 | Video(视频) |
| 大小档位 | < 1K(小于 1000) |
| 行数(episode 数) | 16 |
| 总文件大小 | 约 2.51 GB |
| 下载量(上月) | 182 |
| 许可证 | 页面上没有标注(无 dataset card) |
| 是否有说明文档 | 没有(页面显示 "No dataset card yet") |
关于「16 行」这个数字 :HuggingFace 数据集页显示的 "Rows: 16" 通常对应 episode 数(即录了 16 遍),但页面未提供 dataset card,无法完全确认。若为 16 条,对「放试管」这类精细任务而言数量偏少------这是此前训练效果欠佳的可能原因之一。如需确认,下载后查看 meta/info.json 中的 episode 数。
我们本地怎么放它?
bash
conda activate lerobot
export HF_HOME=/root/pi05_b601/cache/huggingface
export HF_DATASETS_CACHE=/root/pi05_b601/cache/huggingface/datasets
export HUGGINGFACE_HUB_CACHE=/root/pi05_b601/cache/huggingface/hub
mkdir -p /root/pi05_b601/datasets/orange_rack_test_tubes_848
hf download \
nyancos/orange_rack_test_tubes_848 \
--repo-type dataset \
--local-dir /root/pi05_b601/datasets/orange_rack_test_tubes_848
逐行解释这三条环境变量(新手最容易照抄但不知道在干什么):
| 变量 | 作用 |
|---|---|
HF_HOME |
HuggingFace 的总缓存目录 |
HF_DATASETS_CACHE |
数据集专用的缓存目录 |
HUGGINGFACE_HUB_CACHE |
模型/文件下载的缓存目录 |
为什么要专门指定它们? 因为默认缓存会在 ~/.cache/huggingface,也就是系统盘 。数据集和模型动辄几十 GB,很容易把系统盘塞满。指定到 /root/pi05_b601/cache/ 是为了控制存放位置。
hf download 的参数:
| 参数 | 含义 |
|---|---|
nyancos/orange_rack_test_tubes_848 |
要下载的仓库名(用户名/仓库名) |
--repo-type dataset |
说明这是数据集(不是模型) |
--local-dir |
下载到本地哪个目录 |
如果下载慢或者连不上,可以用国内镜像:
iniexport HF_ENDPOINT=https://hf-mirror.com export HF_HUB_DISABLE_XET=1
② 自己录的数据:为什么它才是最终答案
现在讲一个至关重要的结论。它可能有点扫兴,但必须讲清楚:
公开数据集不能直接用来最终控制你的机械臂。
为什么? 看 info.json 里的 robot_type 字段就知道了:
| 数据集 | robot_type |
|---|---|
公开数据集 orange_rack_test_tubes_848 |
seeed_b601_rs_follower(RS 型号) |
| 我们的机械臂 | seeed_b601_dm_follower(DM 型号) |
RS 和 DM 是两种不同的机械臂型号。 还记得 2.1 节那句「B601 RS 和 B601 DM 看起来很像,一定要分清楚」吗?就是这个。
即使两者的 action 都是 7 维,也可能存在这些差异:
| 差异 | 后果 |
|---|---|
| 关节顺序不同 | 模型以为第 1 个数是肩,实际是肘 → 动作完全乱 |
| 正负方向不同 | 模型说「往左」,机械臂往右 |
| 零点不同 | 同一句「转到 30 度」,两只臂的物理位置不一样 |
| 夹爪范围不同 | 夹爪开合尺度对不上 |
| 绝对/相对动作定义不同 | 一个说「转到 30 度」,一个说「再转 30 度」 |
| 运动学响应不同 | 发同样的角度,两只臂到不了同一个位置 |
所以:公开数据只能用来「验证管线通不通」,不能用来「真的抓试管」。
正确的流程是「两阶段」:
yaml
第一阶段(公开数据,验证管线):
用 orange_rack_test_tubes_848 训练 3000 步
目的:确认数据格式、动作维度、模型管线都能跑通
不指望它真能抓试管
第二阶段(自己的数据,真正能用):
采 5 条 → 确认格式
采 20~50 条 → 第一轮微调
采 100 条以上 → 稳定训练
目的:让模型学会「你这台 DM 臂 + 你的桌面 + 你的试管架」
为什么第一阶段还值得做
你可能会想:既然公开数据没用,那为什么还要花时间训它?
因为它能帮你排除故障。 如果你跳过第一阶段直接用自己的数据训,而结果不好,你无法判断问题出在哪:
- 是数据格式不对?
- 是方向约定错了?
- 是模型管线有问题?
- 还是数据真的太少?
但如果你先用公开数据跑通了,你就知道:模型管线是好的、数据格式是对的、方向约定是能用的。那么后面自己数据不 work,问题就只可能在「数据质量和数量」上。
这是工程上非常重要的思路:一次只改一个变量。 用一个已知能跑通的基线,去对比新东西。
两个阶段用同一套「动作空间」才能真正复用
还有个更细的点:为什么第一阶段训出来的东西,第二阶段还能拿来当起点?
因为关节顺序和语义是同一套 (7 个关节一一对应)。RS 和 DM 的方向、零点、范围不同,但「7 个关节、顺序都是 shoulder_pan 到 gripper」这件事是一样的。
所以第一阶段的模型学到了「放试管这类任务大概怎么做」的通用能力,第二阶段用自己数据微调,把它「校准」到自己的机械臂上。
这就是为什么训练命令里,第二阶段的
--policy.pretrained_path可以指向第一阶段的 checkpoint------它是在前面基础上继续学,不是从零开始。
4.9 stats.json 是什么、为什么它是最关键的文件
这是我要重点讲的一节。因为新手最常在这里踩坑,而且报错信息完全不知所云。
先理解:为什么需要「归一化」
问题:不同关节的角度范围差别很大。
makefile
shoulder_pan: -145 ~ +145 (跨度 290 度)
elbow_flex: -200 ~ 0 (跨度 200 度)
wrist_yaw: -90 ~ +90 (跨度 180 度)
gripper: -270 ~ 0 (跨度 270 度)
如果把原始角度直接喂给模型,会发生什么?
模型看到的是一堆数字:
css
[15.0, -40.0, -30.0, 10.0, 0.0, 15.0, 20.0]
↑ ↑ ↑ ↑ ↑ ↑ ↑
pan lift elbow flex yaw roll grip
问题在于:模型无法知道「15 对 pan 来说是中间值,-40 对 lift 来说也很小」。
因为不同关节的量级和范围都不一样。模型会「偏向」数值大的关节。
这就像:
老师评分时,语文卷子是 100 分制,数学卷子是 150 分制。
如果直接比较分数,数学的分数总是看起来更高------不是因为数学学得好,而是因为卷子满分不同。
归一化就是「把所有卷子都折算成同一个满分」。
归一化后在做什么
ini
归一化前:
pan = 15 (它自己的范围 -145~145)
elbow = -30 (它自己的范围 -200~0)
归一化后:
pan = -0.79 (在它自己的范围里偏中间)
elbow = -0.70 (在它自己的范围里偏中间)
↑
现在它们「可比」了------都在 [-1,1] 里
归一化之后,每个关节的值都在同一个标准区间(-1,1),模型才能「公平地」看待每一个关节。
π0.5 用的是「分位数归一化」,不是 min/max
stats.json 的结构:
json
{
"observation.state": {"q01": [...7个...], "q99": [...7个...]},
"action": {"q01": [...7个...], "q99": [...7个...]},
"observation.images.front": {"mean": ..., "std": ...}
}
关键点:π0.5 读的是 q01(第 1% 分位数)和 q99(第 99% 分位数),不是 min/max/mean/std。
什么是「分位数」?
分位数 = 「排序后,排在某个百分比位置的那个值」。
举个例子。 假设某个关节在 1000 帧里的角度值,从小到大排序:
erlang
排序后的第 1 个值 → 最小值
排序后的第 10 个值 → q01(第 1% 位置)
排序后的第 500 个值 → q50(第 50% 位置,也就是中位数)
排序后的第 990 个值 → q99(第 99% 位置)
排序后的第 1000 个值 → 最大值
用图表示:
matlab
角度从小到大排序:
min q01 median(q50) q99 max
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
──┼─────┼───────────────────┼──────────────────┼─────┼──→
│←1%→│ │←1%→│
极端低值 极端高值
(可能是异常) (可能是异常)
为什么用 q01/q99 而不是 min/max
核心原因:分位数对「异常值」不敏感。
举个具体的例子。 假设你录数据的时候,有一次机械臂瞬间抖了一下,某个关节读到了 -350°(这是个异常值,正常情况下不该出现)。
如果用 min/max:
ini
min = -350 ← 这个异常值变成了「下界」
max = +100
结果:所有正常数据(-30 ~ +30)被压缩到了很小的范围里
-30 → 归一化后 ≈ 0.19
+30 → 归一化后 ≈ 0.67
↑ 正常数据只能占 0.19~0.67 这一小段
↑ 一半以上的范围被那个异常值「浪费」了
如果用 q01/q99:
ini
q01 = -30 ← 第 1% 位置的值(不受那个 -350 影响)
q99 = +100
结果:正常数据能充分展开
-30 → 归一化后 = -1.0
+30 → 归一化后 ≈ -0.14
↑ 正常数据能占满整个区间
用人话讲:
min/max 会被「一个坏点」毁掉整个范围; q01/q99 只看「1% 到 99% 这一段」,一个坏点影响不了它。
我们数据集的真实分位数
ini
action q01: [-26.745, -0.362, -104.246, -58.670, -31.971, -10.265, 0.412]
action q99: [ 37.953, 120.979, 0.141, 60.491, 7.598, 46.667, 38.006]
state q01: [-26.522, 0.042, 0.067, -61.172, -7.291, -9.955, 3.947]
state q99: [ 37.728, 124.516, 101.810, 56.063, 31.528, 46.463, 215.074]
对着看每一列(每个关节):
| 关节 | state q01 | state q99 | 跨度 |
|---|---|---|---|
| shoulder_pan | -26.5 | 37.7 | 64 度 |
| shoulder_lift | 0.04 | 124.5 | 124 度 |
| elbow_flex | 0.07 | 101.8 | 102 度 |
| wrist_flex | -61.2 | 56.1 | 117 度 |
| wrist_yaw | -7.3 | 31.5 | 39 度 |
| wrist_roll | -10.0 | 46.5 | 56 度 |
| gripper | 3.9 | 215.1 | 211 度 |
注意几件事:
- 每个关节的范围差别很大(从 39 度到 211 度)------这正是需要归一化的原因;
- 这些值和
joint_limits不一样 ------joint_limits是「物理能到哪」,q01/q99是「这批数据实际用到哪」; - gripper 的 q99 是 215.1,说明数据里夹爪开到过很大。
💡 第 2 点很关键 :
q01/q99反映的是**「这批数据里实际出现的范围」**,不是「机械臂物理上能到的范围」。所以如果你的演示只用了机械臂活动范围的一小块,那
q01/q99就只覆盖那一小块------模型也只会学那一小块范围内的动作。
为什么训练和推理必须用同一份 stats
这是整个部署最容易出隐蔽错误的地方。
推理时的数学(第 6 步)是这样的:
bash
模型输出的归一化值 → 用 q01/q99 反归一化 → 变回角度
具体公式:
ini
训练时(归一化):
s_norm = 2 * (s - q01) / (q99 - q01) - 1
推理时(反归一化):
s = (s_norm + 1) / 2 * (q99 - q01) + q01
这两个公式必须用同一套 q01/q99,才能互相抵消。
如果两边不一致会怎样?
css
训练时用 A 数据集的统计:
s_norm = 2 * (s - q01_A) / (q99_A - q01_A) - 1
推理时用 B 数据集的统计:
s = (s_norm + 1) / 2 * (q99_B - q01_B) + q01_B
↑ 用 B 去解 A 的方程 → 算出来的 s 是错的
结果:
模型输出「正确的归一化值」
↓
用错的统计反归一化
↓
得到「错误的度数」
↓
机械臂动作完全不对
而且------不会有任何报错。 因为从计算机的角度看,所有的除法、乘法都成功了,只是结果是错的。
⚠️ 这就是为什么推理脚本里这个变量如此关键:
iniSTATS_PATH = '/root/pi05_b601/datasets/orange_rack_test_tubes_848/meta/stats.json'这一行绝对不能随便改。 它必须指向「训练时用的那份数据集的 stats」。
stats.json 是怎么生成的
在你录制的时候,lerobot 会自动生成。
具体过程:
① 录制过程中,每个 episode 都记录它的分位数摘要
↓
② 录完后,聚合所有 episode 的摘要
↓
③ 写进 stats.json
⚠️ 一个风险点:录制生成的是「保守包络」。
什么意思?
arduino
q ≤ 50 的 → 取 min(最小值)
q > 50 的 → 取 max(最大值)
也就是说,它给的是一个「保守估计」------比真实分位数更宽。
数据量少的时候(比如只有 10 条),这个统计可能不够准。
为什么? 因为分位数需要足够多的样本才准确。
ini
1000 个数据点:q01 = 第 10 个值 ← 样本足够,q01 有代表性
10 个数据点: q01 = 第 0.1 个值 ← 样本太少,q01 只能是「最小值-ish」
如果怀疑统计不准,可以重新扫描:
css
python src/lerobot/scripts/augment_dataset_quantile_stats.py \
--repo-id=nyancos/orange_rack_test_tubes_848 \
--root=/root/pi05_b601/datasets/orange_rack_test_tubes_848 \
--overwrite \
--skip-images
| 参数 | 作用 |
|---|---|
--repo-id |
数据集标识 |
--root |
数据集路径 |
--overwrite |
覆盖旧的统计 |
--skip-images |
跳过图像的统计(只重算 state/action) |
这个步骤只用 CPU 和磁盘,可以和模型下载并行。
另一条路:如果你不想用分位数
如果你的数据集只有 min/max/mean/std,没有 q01/q99,那训练时会报错。
解决办法:显式告诉它用「均值方差归一化」。
css
--policy.normalization_mapping='{"ACTION":"MEAN_STD","STATE":"MEAN_STD","VISUAL":"IDENTITY"}'
这个参数的意思:
| 部分 | 含义 |
|---|---|
"ACTION":"MEAN_STD" |
动作用「均值-标准差」归一化 |
"STATE":"MEAN_STD" |
状态用「均值-标准差」归一化 |
"VISUAL":"IDENTITY" |
图像不做归一化(内部自己处理) |
MEAN_STD 和 QUANTILES 的区别:
| MEAN_STD | QUANTILES | |
|---|---|---|
| 用什么 | 均值、标准差 | q01、q99 |
| 抗异常值 | 差(均值会被异常值拉偏) | 好 |
| 需要什么 | mean/std | q01/q99 |
| π0.5 默认 | ------ | ✓ 这个 |
所以:
bash
数据集有 q01/q99 → 用默认(QUANTILES),什么都不用加
数据集没 q01/q99 → 加 --policy.normalization_mapping 切到 MEAN_STD
→ 或者用 augment_dataset_quantile_stats.py 补算 q01/q99
💡 对新手最省事的做法 :直接加
--policy.normalization_mapping切到MEAN_STD。 代价:对异常值不那么鲁棒。但比「训练直接报错」好。
4.10 录制特有的相机坑
录制走 lerobot-record,相机配置和推理脚本有一处关键不同:
| 项 | 录制命令 | 推理脚本 | 原因 |
|---|---|---|---|
| front 分辨率 | 640×480 | 848×480 | 录制有 _validate_fps 校验,848×480 只有 15fps 会报错;推理脚本直接 cv2.read() 不校验 |
这不是错误,是历史遗留。 但我第一次发现「两处分辨率不一样」的时候,以为是哪里写错了,查了很久。
为什么会这样? 因为两条路径的代码不一样:
- 录制路径 走
lerobot-record,它内部有_validate_fps检查------设了 30fps 就必须真能达到 30fps,达不到直接报错。848×480 只能跑 15fps,所以必须降到 640×480。 - 推理路径 是我自己写的脚本,直接
cap.read()读帧,没有帧率校验。848×480 也能读到帧(只是实际跑 15fps)。
为什么推理时不在乎帧率? 因为推理时相机的帧率不影响发送动作的节奏。我的脚本是「拍一张图 → 发给服务器 → 收到 50 帧动作 → 按 30fps 发出去」。相机是 15fps 还是 30fps,只影响「拍图快慢」,不影响「发动作的 30fps」。
但模型看到的分辨率是无所谓的------π0.5 内部会把图 resize 到 224×224。所以 640 还是 848,对模型没区别。
4.11 录制前 checklist
按依赖顺序排(顺序很重要,前面的不通后面必卡):
- 从臂 ping 通 :
python ping_leader.py7 个全 True; - 串口权限 :
usermod -aG dialout $USER; - 主臂夹爪校零 :
lerobot-calibrate; - 相机编号 :
v4l2-ctl --list-devices确认 front=video6、wrist=video4; - 相机参数:640×480 + MJPG。
4.12 字段映射:为什么有时候要改字段名
这是一个很具体、但新手一定会在某一步遇到的问题。
问题:字段名对不上
π0.5 期望的图像字段名是固定的:
arduino
observation.images.image
observation.images.image2
但你的数据集里,字段名可能是:
observation.images.front
observation.images.wrist
一个是 image/image2,一个是 front/wrist。名字不一样,模型就找不到图。
解决:用 --rename_map 做映射
css
--rename_map='{"observation.images.front":"observation.images.image","observation.images.wrist":"observation.images.image2"}'
这个参数的意思是:训练时,把 front 当成 image、把 wrist 当成 image2。
为什么要这么设计
因为 π0.5 是通用模型,它不认识「front」「wrist」这种具体的相机名。它只认识「第一视角」「腕部视角」这种抽象角色。
所以你需要告诉它:「我这个叫 front 的相机,就是你说的 image。」
💡 一个容易混乱的点 :
image/image2是软件里的逻辑名字,不要求你的物理相机也叫这个。就像你给同事起外号------「老王」和「王工程师」指的是同一个人,只要你说明白就行。
什么时候需要映射、什么时候不需要
| 情况 | 要不要 --rename_map |
|---|---|
数据集字段是 front/wrist,模型要 image/image2 |
要 |
数据集字段已经是 image/image2 |
不要 |
只有单个相机,字段正好叫 image |
不要 |
怎么判断? 打开数据集的 info.json 看字段名:
bash
sed -n '1,260p' /root/pi05_b601/datasets/orange_rack_test_tubes_848/meta/info.json
看 observation.images.* 里有几个、叫什么,再对照 π0.5 期望的名字。
第五部分 · 微调:让通才变成专才
数据录好了,现在开始训练。
5.1 训练之前:先把「基座模型」下载下来
在讲微调之前,有个前置动作------你得先有基座模型。
我们的基座是 lerobot/pi05_base。下载命令:
bash
conda activate lerobot
mkdir -p /root/pi05_b601/models/pi05_base
hf download \
lerobot/pi05_base \
--repo-type model \
--local-dir /root/pi05_b601/models/pi05_base
这个下载有个坑:π0.5 用了「受限(gated)」的 PaliGemma tokenizer。
「受限」是什么意思?意思是:这个组件不是随便就能下的 ------你必须在 HuggingFace 上先接受它的许可证,才能下载。
具体操作:
- 登录 HuggingFace 账号;
- 找到 PaliGemma 模型页面;
- 点击「接受许可 / Agree」;
- 然后才能正常下载。
如果不接受会怎样? 下载会失败,报一个和数据无关的错(比如 403、或者「You need to agree to share your contact information」)。这个错第一次看会很懵,因为它完全不提「许可」这两个字。
下载时用 token 的写法(推荐,避免每次输密码):
bash
conda activate lerobot
cd /root/pi05_b601/src/rebot_lerobot
read -rsp "请输入新的 Hugging Face Read Token: " HF_TOKEN
echo
export HF_TOKEN
export HF_ENDPOINT=https://hf-mirror.com
export HF_HUB_DISABLE_XET=1
export HF_HUB_DISABLE_UPDATE_CHECK=1
mkdir -p /root/pi05_b601/models/pi05_base
hf download \
lerobot/pi05_base \
--repo-type model \
--local-dir /root/pi05_b601/models/pi05_base
unset HF_TOKEN
逐句解释:
| 语句 | 作用 |
|---|---|
read -rsp "..." HF_TOKEN |
安静地(不回显)读一行输入存进 HF_TOKEN。-s 是不显示,-r 是不转义,-p 是提示文字 |
echo |
换行(因为 -s 不回显,按回车后屏幕上没反应,加个换行好看点) |
export HF_TOKEN |
把它变成环境变量,给后面的命令用 |
export HF_ENDPOINT=https://hf-mirror.com |
用国内镜像,速度快 |
export HF_HUB_DISABLE_XET=1 |
关掉 XET 传输协议(某些网络环境下反而更慢或失败) |
export HF_HUB_DISABLE_UPDATE_CHECK=1 |
关掉版本检查(减少无关的网络请求) |
unset HF_TOKEN |
用完清掉,安全 |
下载完检查一下模型完整性:
bash
ls -lh /root/pi05_b601/models/pi05_base
重点确认存在 model.safetensors,大小约 14G。
为什么是
.safetensors而不是.bin?.safetensors是 HuggingFace 推的一种更安全的权重格式------它只能存张量数据,不能执行代码,避免了.bin(pickle)反序列化可能带来的安全问题。
关于「基座模型」的一个直觉
我在这里想再说一次,因为它太重要了:
pi05_base 这个基座模型,是「通才」。它什么都懂一点,但什么都不精。
它已经会:看图、读话、知道机械臂大概怎么动。
它不会:你的桌面、你的机械臂、你的试管架。
所以微调要做的事,就是给它「补上你这个场景的课」。它不是从零学起,而是在已有的能力上做精细调整。
这个区别非常重要------因为它解释了为什么:
- 你不需要几百万条数据(基座已经会大部分了);
- 你不需要训练几天几夜(只是微调);
- 几十条数据 + 几千步就能看到效果。
5.2 先建立一个直觉
想象一个什么都会一点、但什么都不精 的通用人才------这就是 π0.5 的基座模型(base model) 。
它见过海量的互联网图片、文字和机器人数据,所以:
- 它看得懂图像(知道什么是试管、什么是架子);
- 它听得懂语言(知道「放进去」是什么意思);
- 它懂得机器人怎么动(知道手臂该怎么协调)。
但它不知道「你这台臂 + 你的桌面 + 你的试管架」这个具体场景该怎么操作。 你的机械臂型号、你的相机角度、你桌面上的橙色试管架,这些细节它从没见过。
微调(fine-tuning)就是:拿你自己的几十条示范数据,让这个通用模型「补课」,把它变成「专门在你的场景里放试管」的专家。
一句话:基座模型 = 通才;微调后的模型 = 专才。
5.3 为什么用 LoRA,而不是全量微调
先搞懂:什么叫「全量微调」
先说最朴素的做法:全量微调(full fine-tuning) 。
意思是:把模型的所有参数都拿去训练,每一个都要算梯度、被修改。
π0.5 的基座有几亿到几十亿参数。全量微调意味着:
- 每一个参数都要算梯度、更新;
- 显存里要同时放:模型权重 + 梯度 + 优化器状态;
- 每存一个 checkpoint 都是几个 GB。
为什么显存要 3~4 倍? 因为训练时你需要在内存里同时保存:
| 内容 | 大小 |
|---|---|
| 模型权重本身 | 1× |
| 每个参数的梯度(反向传播要用) | 1× |
| 优化器状态(比如 Adam 会存每个参数的一阶/二阶动量) | 2× |
| 合计 | 约 4× |
这就是为什么训练比推理耗显存得多。 推理只需要模型权重(1×),训练要 4×。
对你的单卡服务器(DGX Spark)来说,全量微调压力很大。而且没必要------你只是想让它学会「放试管」,不需要重写它已经会的一切。
LoRA 的核心洞察(用一个比喻讲清楚)
先说结论:LoRA 的洞察是「微调造成的权重变化,可以用一个『低秩矩阵』来近似」。
这句话很抽象。我用一个比喻讲:
想象一本 1000 页的百科全书。现在你想给某一章加一点补充说明。
全量重写的做法:把整本书 1000 页全部重印一遍,只在其中 2 页上加了点东西。
LoRA 的做法 :不动原书,只在书里夹一张小纸条,上面写着「关于第 500 页第 3 段,补充说明如下......」。
显然第二种更高效。 你只写了几个字,就达到了补充的目的。而且原书还在------你随时可以把纸条抽掉,回到原样。
LoRA 就是这个道理:
css
输出 = 原始权重 · 输入 + (B · A) · 输入
↑ 冻结不动 ↑ 只训练这个(很小的一张「纸条」)
- 原始权重 :那本 1000 页的书,完全冻结(不训练);
B · A:那张小纸条,只有它被训练。
为什么叫「低秩」
A 和 B 是两个小矩阵。假设原矩阵是 d × d(比如 4096×4096),那 LoRA 的 A 是 d × r、B 是 r × d,其中 r 很小(比如 32)。
css
原始权重矩阵:4096 × 4096 = 16,777,216 个参数
LoRA 的 A + B:(4096×32) + (32×4096) = 262,144 个参数
参数量差了 64 倍。 这就是「低秩」的意思------它用一个很「窄」的矩阵去近似一个大矩阵的变化。
数学上为什么有效? 因为研究发现:微调过程中,权重矩阵的变化实际上集中在少数几个方向上(也就是它的「秩」很低)。所以用一个窄矩阵就能近似得很好。
你不用深究这个数学。记住结论就行:LoRA 用极少的参数,近似了全量微调的效果。
LoRA 的三个好处
- 可训练参数极少:可能只占原模型的 0.1%~1%,训练快、显存省;
- 产物极小:一个 adapter 可能就几十 MB,而不是几个 GB;
- 基座不动:可以在同一个基座上训练多个任务的 adapter,随时切换。
第 3 点值得展开说:因为你冻结了基座,所以你可以:
- 在同一个
pi05_base上,训一个「放试管」的 adapter; - 再训一个「倒水」的 adapter;
- 再训一个「开抽屉」的 adapter。
推理时想用哪个,就把 ADAPTER_DIR 指向哪一个。基座一直在,只换「纸条」。
LoRA 的两个关键参数
| 参数 | 含义 | 对效果的影响 |
|---|---|---|
r(rank,秩) |
低秩矩阵的「宽度」 | r 越大越接近全量微调,参数也越多;太小可能学不动 |
lora_alpha |
缩放系数,实际缩放 = lora_alpha / r |
控制 adapter 对输出的影响强度 |
怎么理解 r?
r 就像那张「纸条」能写多少字:
r=8:纸条很小,只能写几句 → 表达能力弱,可能学不动;r=32:纸条适中 → 常用值;r=256:纸条很大 → 接近全量微调,但也没那么省了。
怎么理解 lora_alpha?
它是「纸条上的字,要用多重的语气说」。
实际生效的强度是 lora_alpha / r。所以:
- 如果
r=32、alpha=32,比值是 1.0 → 正常强度; - 如果
r=32、alpha=64,比值是 2.0 → 语气加倍。
为什么要有这个参数而不是直接用 r? 因为它让你可以独立调节「容量」和「强度」 。你可以用小的 r(省参数)+ 大的 alpha(强影响)。
leRobot 会自动选目标层
你不用手动指定「给哪些层加 LoRA」------leRobot 会自动选。
对 π0.5 这类模型,通常微调:
- 「动作专家」的
q_proj/v_proj层; - 状态投影矩阵;
- 动作投影矩阵。
而冻结视觉编码器和语言骨干的大部分。
为什么这么选? 因为「动作专家」和「状态/动作投影」最可能跟你的具体任务相关;而视觉和语言能力是通用的,不需要改。
这就是为什么服务器目录名里带
lora:pi05_tubes_lora_...= 「π0.5 + 放试管 + 用了 LoRA」。
LoRA 和全量微调的效果差多少
这是一个很实际的问题。
结论:在大多数「场景适配」任务上,LoRA 的效果接近全量微调。
为什么?因为这类任务的本质是「把通用能力适配到具体场景」,需要的改变量本来就不大。而全量微调那巨大的参数量,大部分是浪费的。
什么时候 LoRA 会明显不如全量? 当你要模型学一个全新的、和原来差别很大的能力时。但我们的任务(学会你桌面上放试管)不属于这一类------基座已经会「抓取放置」这类动作了,我们只是让它适配具体场景。
5.4 训练命令逐行解释
ini
conda activate lerobot
lerobot-train \
--dataset.repo_id=my_b601/place_tubes \
--policy.type=pi05 \
--policy.pretrained_path=/root/pi05_b601/models/pi05_base \
--peft.method_type=LORA \
--peft.r=32 \
--peft.lora_alpha=32 \
--policy.dtype=bfloat16 \
--policy.device=cuda \
--policy.gradient_checkpointing=true \
--batch_size=8 \
--steps=3000 \
--save_freq=500 \
--output_dir=outputs/pi05_tubes_lora \
--job_name=pi05_tubes_lora \
--seed=1000
| 行 | 在做什么 |
|---|---|
lerobot-train |
训练入口命令 |
--dataset.repo_id |
用什么数据:你刚录的示范数据 |
--policy.type=pi05 |
用哪个模型:π0.5 |
--policy.pretrained_path |
从哪起步:加载基座模型权重(不是从零开始,是「补课」) |
--peft.method_type=LORA |
怎么训:用 LoRA,只训适配器,冻结基座 |
--peft.r=32 |
LoRA 的秩 |
--peft.lora_alpha=32 |
LoRA 缩放系数 |
--policy.dtype=bfloat16 |
用 bf16 半精度,省显存提速 |
--policy.device=cuda |
在 GPU 上训练 |
--policy.gradient_checkpointing=true |
用显存换算力:不存中间激活,省显存(代价是稍慢) |
--batch_size=8 |
每次梯度更新看几条数据 |
--steps=3000 |
总共训练多少步 |
--save_freq=500 |
每 500 步存一个 checkpoint |
--output_dir / --job_name |
结果存哪、叫什么名字 |
--seed=1000 |
随机种子,保证结果可复现 |
5.5 三个新手最容易混淆的概念
① step vs epoch vs batch_size
batch_size:一次喂给模型几条数据(比如 8 帧);step:模型处理完一个 batch、更新一次权重 = 1 步;epoch:把整个数据集从头到尾过一遍。
你有 10 条 episode、假设共 3000 帧数据、batch_size=8,那么一个 epoch ≈ 375 步。--steps=3000 ≈ 8 个 epoch。
你直接关心的只有 --steps:步数越多,学得越「熟」,但也越可能「背答案」(过拟合)。
② 为什么数据少,步数不能太多
10 条数据非常少。如果步数太多,模型会把这几条数据背下来 ------换个位置就废。这叫过拟合(overfitting) 。
所以:少数据 → 少步数 + 早点停。
我之前的训练(从目录名反推):
| 目录名 | 解读 |
|---|---|
pi05_tubes_lora_first100_fast_1000 |
π0.5 + 放试管 + LoRA + 前 100 条数据 + 1000 步 |
pi05_tubes_lora_100_200_fast_4000 |
π0.5 + 放试管 + LoRA + 100~200 条数据 + 4000 步 |
为什么真机成功率低? 直接原因就是:数据少(100200 条)+ 步数少(10004000 步)+ 任务难(放试管是精细操作)。
③ 训练会自动挑「动作维度」
π0.5 内部把动作补到 32 维 ,但你的任务只有 7 个关节。训练时会只用前 7 维算损失,后 25 维忽略------这个你不用管,是自动的。
5.6 训练产物长什么样
每 save_freq 步会在 output_dir/job_name/ 下存一个 checkpoint:
bash
outputs/pi05_tubes_lora/
└── checkpoints/
├── 000500/
│ └── pretrained_model/ ← 这是一个可用的 LoRA adapter
├── 001000/
│ └── pretrained_model/
├── 001500/
│ └── pretrained_model/
└── ...
每个 checkpoints/NNNN/pretrained_model 都是一个完整可加载的 adapter,数字越大 = 训练越久。
什么是 checkpoint(检查点)
checkpoint 就是「训练过程中的一次存档」。
想象你在打一个很长的游戏关卡,打了 3 小时。如果这时候停电,你希望从头再打吗?当然不------你希望游戏能「存档」。
checkpoint 就是这个存档。它记录了:
- 训练到第几步;
- 当前模型的全部权重;
- 当前优化器的状态(这样能接着训)。
为什么每 500 步存一次,而不是最后只存一次?
因为你不知道哪一步的模型最好。
这听起来奇怪------训练越久不应该越好吗?不是的。 尤其是数据少的时候:
yaml
步数 500 → 模型刚开始学,动作还不太准
步数 1500 → 模型学得不错 ← 可能是最好的
步数 5000 → 模型开始「背答案」,换个位置就废(过拟合)
步数 10000 → 过拟合更严重,实际表现反而更差
这叫「过拟合(overfitting)」------模型把训练数据背下来了,但没有学到「通用的规律」。就像学生背了答案,题目变一下就懵。
所以存多个 checkpoint,让你可以逐个试:
bash
python eval_pi05_robot.py --episodes 10 --steps-per-episode 5
# 把 ADAPTER_DIR 指向 000500,测一遍
# 改成 001500,再测一遍
# 改成 005000,再测一遍
# 哪个成功率最高用哪个
这就是为什么 --save_freq 是个重要参数。 它决定了你有几个「候选答案」可以试。
checkpoint 里到底有什么
checkpoints/NNNN/pretrained_model/ 这个目录里,通常有:
| 文件 | 内容 |
|---|---|
adapter_model.safetensors |
就是 LoRA 的权重(那「纸条」上的字) |
adapter_config.json |
LoRA 的配置(r 是多少、alpha 是多少、加了哪些层) |
config.json |
模型的整体配置 |
tokenizer/ |
分词器文件(把文字拆成 token 的工具) |
注意:这里没有基座模型的一大堆权重。 因为基座是共享的------checkpoint 只存「增量」。
这就是为什么它小:基座约 14GB,而一个 adapter 可能只有几十 MB。
加载时发生了什么
推理时,服务器脚本里这两行对应起来:
ini
BASE_DIR = '/root/pi05_b601/models/pi05_base' # 基座(冻结)
ADAPTER_DIR = '.../checkpoints/005000/pretrained_model' # 你训练的 adapter
ini
policy = PeftModel.from_pretrained(_base, ADAPTER_DIR)
PeftModel.from_pretrained 做的事是:把 adapter 的增量,「并联」到冻结的基座上。
用人话讲------它把你那张「纸条」,夹进了那本 1000 页的书里。之后模型的行为就同时受基座(通用能力)和 adapter(你的场景)影响。
「换权重」= 把
ADAPTER_DIR指向不同数字的 checkpoint 目录。仅此而已。
last 和数字目录的区别
你有时会看到 checkpoints/last/pretrained_model,有时是 checkpoints/005000/pretrained_model。
| 路径 | 含义 |
|---|---|
checkpoints/last/ |
训练结束时最新的那个(软链接/副本) |
checkpoints/005000/ |
训练到第 5000 步时的存档 |
用哪个? 取决于你的目标:
- 训练完整跑完了,用
last最省事; - 想对比哪一步最好,逐个数字目录试。
⚠️ 但记住:「最新」不等于「最好」。 训练完整跑完的
last,可能已经过拟合了。
关于训练目录的命名:从名字反推你训了什么
我之前的训练目录名是这样的:
| 目录名 | 解读 |
|---|---|
pi05_tubes_lora_first100_fast_1000 |
π0.5 + 放试管 + LoRA + 用前 100 条数据 + 快训 + 1000 步 |
pi05_tubes_lora_100_200_fast_4000 |
π0.5 + 放试管 + LoRA + 100~200 条数据 + 快训 + 4000 步 |
养成好习惯:让目录名自己说明实验配置。 三个月后你会感谢自己------因为你绝对记不住每一组参数。
为什么之前的真机成功率低? 现在你应该能自己诊断了:
- 数据少(100~200 条,而且是 RS 臂的数据);
- 步数少(1000~4000 步);
- 任务难(放试管是精细操作)。
5.7 训练时的显存管理
如果你在共享服务器上训练(比如服务器上还有别人的 vLLM-Omni 进程在跑),显存是要抢的。
重要提醒:DGX Spark 的 CPU 内存和 GPU 内存是统一内存。 服务器显示「内存很大」不等于可以无条件再加载一个大模型。
训练启动前先检查:
bash
if pgrep -afi 'vllm|vllm-omni' >/tmp/pi05_gpu_blocked.txt; then
echo "检测到现有 vLLM 服务,为避免影响其他用户,本次不启动训练。"
cat /tmp/pi05_gpu_blocked.txt
exit 2
fi
显存不够的应对手段(按优先级):
- 调小
batch_size; - 开
gradient_checkpointing; - 用
bf16; - 冻结视觉编码器(
freeze_vision_encoder=true); - 只训动作专家(
train_expert_only=true)。
5.8 微调的常见坑
| # | 坑 | 说明 | 应对 |
|---|---|---|---|
| 1 | 数据太少 → 过拟合 | 10 条数据背死答案,换位就废 | 少步数、早停、多录几条 |
| 2 | stats.json 不一致 | 训练和推理必须用同一套 q01/q99 | 别改 STATS_PATH |
| 3 | 显存不足(OOM) | batch_size 太大 | 调小 batch、开 gradient_checkpointing |
| 4 | 语言指令不一致 | 训练时 single_task 和推理时 TASK 必须一字不差 |
固定死这句话,复制粘贴 |
| 5 | 步数太少学不动 / 太多过拟合 | 少数据尤其敏感 | 用 save_freq 存多个 checkpoint,逐个试 |
| 6 | 方向约定 | 自录数据的 joint_directions 若和之前数据集不同,STATE_FLIP 要重验 |
微调后真机干跑复验方向 |
坑 4 我特别想强调 :语言指令必须一字不差 。训练时写
place two test tubes into the orange rack,推理时就不能写成put two test tubes into the orange rack。差一个词,模型就「听着新指令、做着老动作」,输出会乱。
待续~