具身智能系列之从零到「机械臂会放试管」:π0.5 VLA 真机部署完全指南【第一节】

本文面向具备计算机基础、但从未接触过机器人领域的读者。假定读者熟悉编程、数据结构、模型训练的基本概念,但不了解电机、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) :给定状态 ss s 与观测 oo o,输出动作 aa 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. 图像------相机输出
  2. 关节角------机械臂状态
  3. 动作------机械臂指令
  4. 语言指令------任务描述
  5. 成功率------评估结果

整个系统的全部行为,都是这五类信息的变换与传递。

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)就是:给电机定这个零位

具体操作:

  1. 手动 把机械臂摆到一个已知的姿势(通常是伸直零位);
  2. 告诉电机:「你现在就是 0 度」;
  3. 系统把这个参考写进电机的 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 下载到本地哪个目录

如果下载慢或者连不上,可以用国内镜像:

ini 复制代码
export 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 度

注意几件事:

  1. 每个关节的范围差别很大(从 39 度到 211 度)------这正是需要归一化的原因;
  2. 这些值和 joint_limits 不一样 ------joint_limits 是「物理能到哪」,q01/q99 是「这批数据实际用到哪」;
  3. 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 是错的

结果:

复制代码
模型输出「正确的归一化值」
    ↓
用错的统计反归一化
    ↓
得到「错误的度数」
    ↓
机械臂动作完全不对

而且------不会有任何报错。 因为从计算机的角度看,所有的除法、乘法都成功了,只是结果是错的。

⚠️ 这就是为什么推理脚本里这个变量如此关键:

ini 复制代码
STATS_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

按依赖顺序排(顺序很重要,前面的不通后面必卡):

  1. 从臂 ping 通 :python ping_leader.py 7 个全 True;
  2. 串口权限 :usermod -aG dialout $USER;
  3. 主臂夹爪校零 :lerobot-calibrate;
  4. 相机编号 :v4l2-ctl --list-devices 确认 front=video6、wrist=video4;
  5. 相机参数: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 上先接受它的许可证,才能下载。

具体操作:

  1. 登录 HuggingFace 账号;
  2. 找到 PaliGemma 模型页面;
  3. 点击「接受许可 / Agree」;
  4. 然后才能正常下载。

如果不接受会怎样? 下载会失败,报一个和数据无关的错(比如 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 的三个好处

  1. 可训练参数极少:可能只占原模型的 0.1%~1%,训练快、显存省;
  2. 产物极小:一个 adapter 可能就几十 MB,而不是几个 GB;
  3. 基座不动:可以在同一个基座上训练多个任务的 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

显存不够的应对手段(按优先级):

  1. 调小 batch_size;
  2. 开 gradient_checkpointing;
  3. 用 bf16;
  4. 冻结视觉编码器(freeze_vision_encoder=true);
  5. 只训动作专家(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。差一个词,模型就「听着新指令、做着老动作」,输出会乱。


待续~

相关推荐
小宋10211 小时前
一套服务托管多个 LoRA:适配器加载、租户隔离与热切换实战
大数据·人工智能·算法
Rosanci1 小时前
Codex 下载与本地部署实战:从安装到运行全流程指南
开发语言·前端·算法·chatgpt·codex
我爱工作&工作love我1 小时前
P14358 [CSP-J 2025] 座位
算法
旖旎夜光1 小时前
LeetCode 1576: 替换所有的问号(模拟) —— 题解
c++·学习·算法·leetcode·力控
朝朝辞暮i1 小时前
C++ 第 38 课:线程、SingleThreadedExecutor、MultiThreadedExecutor、Callback Group
开发语言·c++·算法·ros2
(Charon)2 小时前
【C++面试】互斥锁与读写锁:使用场景、实现原理与写饥饿问题
c++·算法
longlongzihan2 小时前
回溯法详解:LeetCode 46. 全排列
算法·leetcode·回溯
秋名RG2 小时前
时间复杂度、空间复杂度与算法稳定性详解
数据结构·算法·排序算法
白色的北极熊2 小时前
c语言 求余运算符
c语言·数据结构·算法