拆一只开源鸭子:Microduck 的硬件、软件、训练和部署

Microduck 是 Pollen Robotics(Hugging Face 旗下)今年 8 月发布的双足小鸭子,官网标价 $399。它的有意思之处在于开源得很彻底:机器人端的 Rust 代码、训练和仿真的 Python 代码、训练配置、模型文件都放在 GitHub 上,设计文档也写得非常扎实,很多”为什么这么做”都有记录。

最近我把两个仓库从头读了一遍,又在本地把仿真跑通了。这篇按硬件、软件、训练、部署四块记下我看到的东西,最后附上仿真里”鸭子不走路”的排查过程。

Microduck 在 MuJoCo 仿真中
仿真画面。机器人模型来自 Pollen Robotics 的 microduck_rl(网格文件为 CC BY-NC-SA 4.0),下同。

两个仓库:

  • microduck:机器人端,Apache-2.0。
  • microduck_rl:训练和仿真。代码和 MJCF 是 Apache-2.0,STL 网格是 CC BY-NC-SA 4.0,不能商用。本文的渲染图都用的是它的模型。

硬件

15 个关节

Microduck 15 个关节的位置和舵机 ID

每条腿 5 个自由度(髋 3 个、膝、踝),脖子和头 4 个,再加一个能开合的嘴,一共 15 个 Dynamixel XL330 舵机。腿和头颈这 14 个由强化学习策略控制;嘴不进策略,由 robotd 用脚本单独驱动,捡东西的时候用嘴尖去够地面。

MJCF 里给的关节范围:髋偏航 −25°~+30°,髋横滚 ±22°,髋俯仰、膝、踝都是 ±90°,头偏航 ±170°。有个细节值得一提:运行时并没有按这些范围限位,安全层只把目标角度限制在 ±π,也就是舵机能转的整圈。代码注释里自己承认了这一点。

主要硬件

Microduck 硬件框图:供电和数据连接
部分 配置
尺寸 / 重量 25 cm 高,约 800 g(官网);MJCF 里各部件质量加起来 0.744 kg
舵机 15 个 Dynamixel XL330,Protocol 2.0,总线 1 Mbps
主控 RK3566(4 核 A55,0.8 TOPS NPU,1 GB 内存,32 GB eMMC)
摄像头 前置,IMX219 或 GC2093,取决于主板版本
深度 8×8 ToF(VL53L5CX / VL53L8CX)
IMU 两个:身体一个,挂在舵机总线上(ID 200);头部一个
音频 1 个麦克风、1 个小喇叭,编解码 TLV320AIC3104
无线 Wi-Fi、BLE、NFC(只用来配对手柄)
电池 NP-F550 相机电池,2S 锂电

主板有两个版本,代码里叫 zero3 和 beta:

zero3(Radxa Zero 3W) beta(Seeed 定制板)
摄像头 IMX219,转 90° 安装 GC2093
头部 IMU BMI088,默认关闭,CPU 做姿态融合 LSM6DSV16X,芯片内融合,默认开
ToF 接口 I²C SPI
NFC USB 读卡器(台架用) ST25R100,两个天线轮询
脸部 LED 无 12 颗:闪光灯、摄像头、状态、网络、三格电量

电池直接给舵机供电

这是我读硬件部分时最意外的一点。

XL330 手册电压范围和 Microduck 实际供电对比

XL330 手册上写的输入电压是 3.7~6.0 V,而 Microduck 把 2S 电池(满电 8.2 V)直接接到舵机总线上,中间没有稳压。训练配置里,舵机电压的随机化范围也是 6.5~8.2 V。

能这么用,靠的是一个寄存器设置。XL330 有个 Max Voltage Limit,默认 7.0 V,超过它会触发输入电压故障,舵机自锁、断力矩。Microduck 启动时把每个舵机的 shutdown 寄存器写成 52,故意清掉”输入电压”这一位,满电的电池才不会让 15 个舵机集体罢工(duck-control/src/model.rs)。

// duck-control/src/model.rs
/// `shutdown = 52` is `0b110100` — overload, electrical shock,
/// overheating — with the input-voltage bit **clear**,
/// where the factory's 53 sets it.
pub const EXPECTED_REGISTERS: &[(&str, u8)] = &[
    ("return_delay_time", 0),
    ("baud_rate", 3), // 3 = 1 Mbps, must agree with BAUD_RATE
    ("pwm_slope", 255),
    ("shutdown", 52),
];

电源相关的其他做法:

  • 没有电量计,也没有 ADC。电压取的是舵机自己报告的输入电压,每秒采一次,做时间常数约 10 秒的平滑。
  • 电量百分比按 6.6 V = 0%、8.2 V = 100% 线性换算。
  • 平滑后的电压掉到 6.6 V,机器人先坐下,再执行 systemctl poweroff。
  • 动作幅度按”7.4 V ÷ 实测电压”缩放,补偿电压变化对舵机力矩的影响。

自己做硬件的话,这个设计不建议照搬。

软件

一组守护进程

Microduck 软件架构图

机器人上跑的是一组 Rust 写的守护进程,没有用 ROS,也没有框架。每个进程一个 Unix socket,统一说 JSON-RPC 2.0。手机 App、网页控制台、游戏手柄、你自己写的脚本,发的都是同一套调用。

robotd 是核心:它独占舵机总线,跑 50 Hz 控制循环,加载 ONNX 策略,负责安全。策略推理在 CPU 上单线程跑,没有用 NPU。网络很小,单次推理不到 1 ms(推算)。

一个控制周期

一个 20 ms 控制周期的时间分配

每 20 ms 一个周期:

  1. 一次同步读,把 IMU 和 15 个舵机的状态一起读回来,约 4.2 ms。
  2. 拼 61 维观测,跑 ONNX,得到 14 个关节的目标偏移。
  3. 目标角度 = 站姿 + 缩放 × 动作,再做低通滤波(头 0.5,腿 0.7)。
  4. 安全检查:NaN 直接拒绝,其余限制在 ±π 以内。
  5. 一次同步写,0.94 ms。

总线利用率只有四分之一左右。文档里实测过 15022 个周期只丢了 3 个。有个小优化很能说明作者的细致:舵机出厂的回复延迟是每台 500 µs,16 个设备就是 8 ms,占掉 40% 的周期,所以启动时会强制把它改成 0。

从上电到站起来

robotd 从上电到站起来的流程
MJCF 的四个姿态关键帧

上电后 robotd 先保持舵机当前的姿态,不动力矩。收到 robot.enable 以后,再用 2 秒慢慢上力矩、回到起始姿态。如果检测到是坐着的,就交给 sitstand 策略站起来。

安全层:有什么,没什么

有 没有
NaN 目标直接拒绝 单关节限位(只限到 ±π)
指令 500 ms 没更新,速度清零、原地站住 软件层的过流、过热保护(只靠舵机自己的)
跌倒预测:降增益顺势倒下,再自己爬起来 硬件急停
抱起检测(一个小 CNN,默认关闭)

NaN 为什么是”拒绝”而不是”限幅”,代码注释说得很直白:把 NaN 限幅,会得到一个看起来合理的边界角度,机器人会猛地甩到极限位置,还不如原地不动。

// duck-control/src/safety.rs
// Non-finite is refused, not clamped. Clamping `NaN` silently
// produces a boundary value, which is a plausible-looking joint
// angle — far worse than declining to move, because the robot
// would lurch to a limit rather than hold still.
if targets.iter().any(|v| !v.is_finite()) {
    // ...
    self.io.write(&JointTargets::new(hold))?;
    return Ok(applied);
}

跌倒判断只上报,不打断控制。代码注释里的理由是:一歪就坐下的机器人,人拿在手里调的时候会一直坐下。

训练

先说结论:一个 20 万参数的小网络

走、站、摔倒后爬起来,Microduck 出厂时都靠同一个策略文件 velstand.onnx。打开它看,里面只有一个很小的多层感知机:先做一次归一化,再过三层全连接(512、256、128 个神经元,ELU 激活),最后输出 14 个数。一共约 19.8 万个参数,文件 775 KB。

难的不是网络,而是三件事:给它看什么(观测)、让它输出什么(动作)、用什么标准奖励它(奖励)。下面一项项说。

观测:每 20 ms 给网络看 61 个数

61 维观测的组成
位置 内容 维数 为什么要它
0~2 机身角速度(陀螺仪) 3 知道自己在往哪边歪、转得多快
3~5 重力方向在机身坐标系里的投影 3 知道自己的姿态。比直接给欧拉角稳定,没有角度跳变
6~19 14 个关节角度,减去站姿 14 以站姿为零点,数值集中在 0 附近,网络好学
20~33 14 个关节速度 14 判断腿在往哪边动
34~47 上一步自己输出的动作 14 舵机有延迟,网络要知道自己”刚才下了什么命令”,才能判断现在关节角度和目标的差距
48~50 速度指令:vx、vy、转向 3 要它往哪走
51~54 头部指令:4 个头颈关节 4 头很重,抬头低头会改变重心,所以头的目标交给策略,由它一边动头一边保持平衡
55~60 身体姿态指令:高度、横滚、俯仰等 6 只在站立时用,可以让鸭子蹲一点、歪一点

几个设计上的取舍值得一说:

  • 没有机身线速度。 真机上没有可靠的方法直接测出机身在地面上的速度,所以 Actor(部署的那个网络)看不到它。训练时用的 Critic(只用来打分、不部署)却能看到,这叫”非对称 Actor-Critic”。Critic 有了特权信息,打分更准,训练更稳;部署时又不依赖拿不到的东西。
  • 没有步态相位。 很多双足机器人会给网络一个”时钟”信号,告诉它现在该迈左脚还是右脚。Microduck 的行走策略没有,步态完全靠奖励里的”腾空时间”逼出来。
  • 嘴不在里面。 嘴不影响平衡,交给脚本控制,网络少学一件事。
  • 故意加噪声和延迟。 陀螺仪每个分量加 ±0.03 的噪声,关节速度加 ±0.25 的噪声并且固定滞后一步,IMU 有最多 6° 的安装误差,编码器有 ±0.015 rad 的偏置。真机传感器就是这么脏,训练时不脏,上机就会翻车。
  • 机器人端不做任何缩放。 robotd 直接把物理单位(rad、rad/s)塞进去,归一化在 ONNX 里面做,后面”导出”一节会说为什么。

机器人端拼观测的代码,开头的注释就是这张表,作者把它称为”这个 crate 里风险最高的代码”:错一个下标不会报错,只会得到一个”看起来正常但会摔倒”的机器人。

// duck-control/src/obs.rs
//! index   width  contents
//! 0..3        3  gyro, trunk frame, rad/s
//! 3..6        3  projected gravity, trunk frame, unit vector
//! 6..20      14  joint position minus home pose, mouth excluded
//! 20..34     14  joint velocity, mouth excluded
//! 34..48     14  previous action, mouth excluded
//! 48..61     13  command (below)
pub const OBS_LEN: usize = 61;
pub const ACTION_LEN: usize = 14;

动作:14 个关节的目标偏移

网络输出 14 个数,含义是”各关节相对站姿的目标偏移”。最后的目标角度 = 站姿 + 缩放系数 × 输出,再交给舵机自己的位置环(P 控制,增益 200)去追。

为什么不直接输出力矩?XL330 是位置舵机,固件里就有位置环,输出目标角度最直接。代价是策略学到的不是”用多大力”,而是”把目标放在哪里”,舵机怎么追这个目标,就得靠后面说的 BAM 舵机模型在仿真里模拟准。

// robotd/src/control.rs
// 14 个输出放回 15 个关节,跳过嘴
let offsets = Observation::scatter_action(&action);
let mut targets = [0.0; NUM_JOINTS];
for joint in 0..NUM_JOINTS {
    targets[joint] = DEFAULT_POSITION[joint] + scale * offsets[joint];
}

奖励:告诉它什么是”走得好”

奖励项 权重 作用
线速度跟踪 2.0 跟上 vx、vy 指令
角速度跟踪 2.0 跟上转向指令
精细偏航跟踪 2.0 v8 新加的,减少走直线时跑偏
身体直立 2.0 躯干保持水平
腾空时间 3.0 每步腾空 0.125~0.3 s 才给分,逼它真正迈步,而不是拖着脚蹭
头部姿态跟踪 2.0 头跟上指令
爬起进度(高度) 30.0 摔倒后每往上抬一点就给奖励
爬起成功 +10 一次性奖励
动作变化率 −1.0 动作平滑,不抖
自碰撞 −1.0 腿别打到身体
落地冲击 −0.01 ~ −0.02 / N 摔倒时别硬着陆
舵机堵转 −0.05 低速大力矩时惩罚,保护齿轮

正的是”做到了给分”,负的是”做了扣分”。爬起相关的权重特别大,因为”从地上爬起来”这件事本身很难碰巧做到,奖励给得少,网络根本学不会。

“腾空时间”是逼出步态的那一项。注释里还提到一个细节:零指令时原地站着不动,不是靠专门的”别迈步”惩罚教会的,而是让 25% 的环境练站立:

# microduck_rl/.../tasks/microduck_velocity_env_cfg.py
# air_time window [0.125, 0.300] s. NOTE: standing still at zero
# command is taught by the standing_envs curriculum (→25% standing
# envs by ~iter 2000), not by an explicit stillness/no-stepping term.
cfg.rewards["air_time"].weight = 3.0
cfg.rewards["air_time"].params["threshold_min"] = 0.125
cfg.rewards["air_time"].params["threshold_max"] = 0.300

指令范围也是调出来的。原来给的转向范围是 ±2.0 rad/s,超出了这只鸭子的能力,训练到后期反而越来越差,改成 ±1.0 才学会转向:

# lin ±0.4 / ang ±2.0 outpaced the robot's capability and tracked a
# post-iter-1000 reward/episode-length decline. ang ±1.0 is the big
# change — it makes turning learnable.
command.ranges.lin_vel_x = (-0.4, 0.4)
command.ranges.lin_vel_y = (-0.3, 0.3)
command.ranges.ang_vel_z = (-1.0, 1.0)

随机化:让仿真”不准”得刚刚好

仿真永远和真机不一样。与其把仿真调得完全准确,不如每个环境都随机一点,逼策略在一大片”差不多”的世界里都能走:

项目 范围
脚底摩擦 0.7~1.3
躯干质心偏移 最多 ±15 mm
躯干质量和惯量 ±5%
电池电压 6.5~8.2 V,带负载压降,最低 6.0 V
舵机指令延迟 3~6 个仿真步(推算 15~30 ms)
行走时推搡 ±0.3 m/s,每 3~6 秒一次
齿隙 ±1°
初始状态 最多 45% 的环境从趴着开始,20% 蹲着,5% 坐着

其中最关键的是舵机模型。训练用的是 BAM(Better Actuator Models),用真实 XL330 的台架数据拟合,分三段模拟:固件的 P 控制把位置误差变成电压,直流电机模型(带反电动势)把电压变成力矩,再加上一组摩擦。电池电压也进了这个模型,电压低了力矩就小。README 里有句话:执行器的保真度,就是 sim2real 差距的大头。

# microduck_rl/.../robot/microduck_constants.py
_BAM_ACTUATOR_KWARGS = dict(
    motor_name="xl330",
    model="m6",
    # 带 passive_ 前缀的齿隙关节不加电机
    target_names_expr=(r"^(?!passive_).*",),
    kp_fw=200.0,
    vin_range=(6.5, 8.2),          # 电池电压随机化
    vin_drop_gain_range=(0.0, 0.2),
    vin_min=6.0,
    delay_min_lag=3,               # 指令延迟 3~6 个仿真步
    delay_max_lag=6,
)

齿隙的模拟方法也很巧:每个舵机后面串一个 ±1° 的被动关节,编码器读的是间隙后面的角度,和真实的减速箱一样。

训练过程:PPO 加行为克隆

v8 行走策略的训练流程
PPO 一轮训练的步骤

训练框架是 mjlab(MuJoCo Warp,物理仿真跑在 GPU 上)加 rsl_rl 的 PPO。仿真步长 5 ms,每 4 步做一次决策,正好 50 Hz,和机器人的控制循环一致。

一轮训练分几步:4096 个环境各跑 24 步收集样本;用 GAE 算出每一步”比预期好多少”(优势);用 PPO 的裁剪目标更新 Actor,每批数据过 5 遍、分 4 个小批量,学习率按 KL 散度自动调整,防止一次改得太猛;最后再做一次行为克隆。

行为克隆是 velstand 能”一个网络干三件事”的关键。它有两个冻结的老师:一个只会走路,一个只会爬起来。每轮 PPO 之后,按机身倾斜角度挑一个老师来模仿:倾斜超过 35°(多半是摔了)学爬起来,低于 25° 学走路。这样网络不用从零探索怎么爬起来,强化学习只负责把两种能力缝在一起,再优化细节。

PPO 的配置和两个老师的阈值,都在训练仓库里:

# microduck_rl/.../tasks/microduck_velstand_env_cfg.py
MicroduckVelStandRlCfg = RslRlOnPolicyRunnerCfg(
    actor=RslRlModelCfg(
        hidden_dims=(512, 256, 128),
        activation="elu",
        obs_normalization=True,
        ...
    ),
    critic=RslRlModelCfg(
        hidden_dims=(512, 256, 128),
        activation="elu",
        obs_normalization=True,
    ),
    algorithm=PpoWithExpertBcCfg(
        clip_param=0.2, entropy_coef=0.01,
        num_learning_epochs=5, num_mini_batches=4,
        learning_rate=1.0e-3, schedule="adaptive", desired_kl=0.01,
        gamma=0.99, lam=0.95,
        bc_cfg={...},              # 行为克隆,见下
    ),
    num_steps_per_env=24,
    max_iterations=6_000,
)

# microduck_rl/.../tasks/distill.py
# stand expert teaches frames with tilt > this
"gate_tilt_deg": 35.0,
# the warm-start walk teaches frames with tilt < this
"anchor_tilt_deg": 25.0,
# dedicated Adam (PPO's adaptive-KL LR must not throttle the BC)
"learning_rate": 3e-4,

注意行为克隆用的是单独的优化器:PPO 的学习率会按 KL 自动调小,如果共用,模仿老师这一步也会被一起拖慢。

出厂策略是一代代练出来的

出厂行走策略的训练谱系

出厂的 v8 不是从零练的。两个老师先各自练好,velstand v1 在平地上靠行为克隆起步,之后每一代在上一代基础上加新本事:v5/v6 加粗糙地形和齿隙,v7 加站立时的身体姿态控制,v8 先改进起身,再修正走直线跑偏。用的存档要靠评估脚本扫一遍挑出来:训练曲线上看着差不多的两个存档,走直线时一个往左偏、一个往右偏。

官方自己记录的短板

docs/velstand_policy.md 里的基准测试很坦诚:指令 0.4 m/s 时实际只能走到 0.17 m/s;原地慢转(0.3 rad/s)不太跟得上;身体高度(蹲下)没学会。

部署:从训练存档到机器人

导出成 ONNX

训练存档是 PyTorch 格式,机器人上不装 PyTorch。所以要导出成 ONNX,一种跨框架的网络格式,机器人用 ONNX Runtime 加载就行。

导出时最容易踩的坑是归一化。训练时网络看到的不是原始观测,而是减去均值、除以标准差之后的值,均值和标准差是训练过程中统计出来的。Microduck 的导出脚本把这一步也写进了 ONNX 图里,所以导出的文件就是”归一化 + 网络”。仓库里专门警告:不要手工转换存档,仿真回放时会自动补上归一化,漏了也看不出来,一上机就错。

# microduck_rl/src/mjlab_microduck/export.py
# Observation normalization is baked into the exported graph
# automatically — EmpiricalNormalization is a submodule of the
# policy's MLPModel (obs_normalization=True), so
# export_policy_to_onnx emits actor(normalizer(obs)).
runner.export_policy_to_onnx(path, filename)

# Provenance travels WITH the file
metadata = get_base_metadata(runner.env.unwrapped, run_path=...)
metadata.update(_provenance_metadata(task_id, resume_path))
attach_metadata_to_onnx(onnx_path, metadata)

我打开了出厂的 velstand.onnx 看:

项目 内容
大小 775 KB,约 19.8 万参数
输入 / 输出 obs [1, 61] → actions [1, 14]
计算图 Sub、Div(归一化)→ 4 个 Gemm(全连接)、3 个 Elu
格式 opset 18,PyTorch 2.9.1 导出
自带元数据 关节名、站姿、观测名、action_scale = 1.0 等

下面是这个模型的计算图。

velstand.onnx(v8)

发布和下发

守护进程和策略的两条部署通道

导出后,uv run publish 做一次 50 步的冒烟测试,把 ONNX 和 manifest 传到 Hugging Face Hub 的 microduck-policies 仓库,再手动打 tag(比如 v8)。manifest 里记着训练任务名、代码提交、存档编号,出了问题能一路查回去是哪次训练产出的。

机器人上,seed-policies.sh 按 tag 下载,先放 staging 目录,再原子切换。守护进程(Rust 二进制)走另一条通道:签名 OTA,失败自动回滚。两条通道分开,换策略不用发守护进程的新版本。

守护进程的更新策略在 deploy/updater.toml 里,注释把取舍写得很清楚:机器人可能在主人不在家时自己更新,所以只有”必需”的版本才无人值守安装,而且正在走路、正在推流时会拒绝更新,等下一次检查:

# microduck/deploy/updater.toml
check_interval = "6h"
auto_apply = "mandatory"   # "off" | "mandatory" | "all"

[component.daemon.health]
probe   = "socket"
timeout = "30s"            # 30 秒内起不来就回滚

机器人上怎么跑

一次推理从观测到舵机目标的路径

robotd 用 Rust 的 ort 库动态加载 ONNX Runtime(要求 1.23 以上)。加载时先核对形状:输入必须是 [1, 61],输出必须是 [1, 14],对不上就拒绝,继续用旧的。新网络在后台线程里加载、预热;如果换的是正在驱动机器人的那个网络,先让机器人回到站姿、停稳,再切换。

每个 20 ms 周期里:拼 61 维观测,跑一次推理(单线程 CPU,这台 Intel Mac 上约 0.2 ms),得到 14 个偏移;目标角度 = 站姿 + 缩放 × 偏移;可选的低通滤波;安全检查;一次同步写给舵机。推理没有用 NPU,网络这么小,CPU 足够。

加载时的形状检查和每次推理的代码,都在 duck-control/src/policy.rs。它同时支持带 LSTM 状态的模型(多出 h_in、c_in 两个输入),目前出厂的策略都是不带状态的:

// duck-control/src/policy.rs — 加载时
check_matrix(path, outlet(path, inputs, "obs")?, OBS_LEN)?;
let recurrent = match (inputs.len(), outputs.len()) {
    (1, 1) => false,   // obs -> actions
    (3, 3) => true,    // obs/h_in/c_in -> actions/h_out/c_out
    counts => return Err(shape_error(/* ... */)),
};
check_matrix(path, action, ACTION_LEN)?;

// 每个周期
if !observation.as_slice().iter().all(|v| v.is_finite()) {
    return Err(fail("non-finite observation".into()));
}
let input = Value::from_array((
    [1usize, OBS_LEN],
    observation.as_slice().to_vec(),
))?;
let outputs = self.session.run(ort::inputs!["obs" => &input])?;
// ...
if actions.len() != ACTION_LEN
    || !actions.iter().all(|v| v.is_finite())
{
    return Err(fail("expected 14 finite actions".into()));
}

robotd 里其实装着好几个网络:行走、坐起、捡东西、踢球、翻滚。同一时刻只跑一个,按优先级选:翻滚 > 踢球 > 捡东西 > 坐起 > 站立 > 行走。

训练和部署对不上的两处

读代码时我注意到两个地方,训练端和机器人端的默认值不一致:

训练端(microduck_rl) 机器人端默认(robotd)
动作缩放 1.0(ONNX 元数据里也写着 1.0) 0.9,注释写着”原型时期 alpha 策略的默认值”
动作低通滤波 不滤波,AGENTS.md 明确写着”策略训练时不滤波,加滤波必须配套测试” 头 0.5、腿 0.7,注释写着”必须和训练一致”

看起来是原型时期的默认值没跟上新的训练流程。我在仿真里用 robotd 的默认值跑 velstand,照样能走(10 秒 0.763 m),所以不至于走不了路,但有没有影响走得好不好,我没有测。上游修仿真的 PR #500 也把仿真里的动作缩放改成了 1.0。

附:仿真里鸭子为什么不走路

仿真怎么搭的

robotd --sim 把舵机总线换成一个 TCP 连接,另一头是 MuJoCo 进程(body_server)。控制循环、策略、安全层、IPC 都是机器人上的同一份代码,它分不清对面是真身体还是仿真。

我的机器是 Intel Mac,踩了几个环境坑:

  • Rust 要 ≥ 1.89,用 rustup toolchain install 1.94 单独装一个,不动默认工具链。
  • microduck_rl 锁定的 torch 2.9.1 没有 Intel Mac 的包,uv sync 装不上。好在 body_server 只依赖 mujoco 和 numpy,单独建一个环境就行:mujoco==3.10.0(还有 Intel Mac 包的最新版)加 onnxruntime。
  • macOS 的 Unix socket 路径上限约 104 字符,状态目录别放太深,用默认的 ~/.cache/duck-sim。
  • 查看器要用 mjpython 启动;挪过虚拟环境的话,记得改它的 shebang。

现象和排查

仿真起来后一切正常:1.00 倍实时,控制循环 50 Hz,鸭子能从坐姿站起来,头会东张西望。但发 0.15 m/s 的前进指令,8 秒只挪了 1 cm 左右。robotctl monitor 显示指令已经生效,腿却几乎不动。

我先怀疑是执行器模型:训练用 BAM,而 body_server 只用 MJCF 里的简化位置执行器。装上 BAM(要用 uv.lock 锁定的 62bd8ce,分支最新的提交改了接口)、用官方参考脚本跑,鸭子照样不动,这个猜测就排除了。

真正的线索在策略集的 manifest.json 里:负责行走的是 velstand.onnx,而 duck-sim 脚本把行走槽写死成了旧的 alpha_walking.onnx。再加上 velstand 是在带齿隙的模型上训练的,body_server 默认的 scene.xml 却不带齿隙。

对照实验

6 组对照实验的实际前进速度
# 运行方式 策略 执行器 场景 结果(指令 0.15 m/s)
1 robotd + body_server alpha_walking 简化位置执行器 scene.xml 不走(8 s 约 1 cm)
2 官方 infer_policy.py alpha_walking BAM scene.xml 不走(0.00 m/s)
3 官方 infer_policy.py velstand BAM scene_backlash.xml 走,约 0.07 m/s
4 官方 infer_policy.py velstand 简化位置执行器 scene_backlash.xml 走,约 0.08–0.09 m/s
5 robotd + body_server velstand 简化位置执行器 scene.xml 不走(10 s 5 mm)
6 robotd + body_server velstand 简化位置执行器 scene_backlash.xml 走,10 s 0.763 m

实验 2 和 4 说明 BAM 不是关键,实验 5 和 6 说明 velstand 需要带齿隙的场景。改两处就好:

- walk = "$POLICIES/alpha_walking.onnx"
- stand = "$POLICIES/alpha_stand.onnx"
+ walk = "$POLICIES/velstand.onnx"
RL=<microduck_rl 的路径>
DUCK_SIM_SCENE=$RL/src/mjlab_microduck/robot/microduck/scene_backlash.xml \
  scripts/duck-sim
同样是 0.15 m/s 的前进指令。左:修复前(alpha_walking + scene.xml),基本原地不动;右:修复后(velstand + 齿隙场景)。左上角是实时移动距离。

走起来以后,实际速度只有指令的一半左右(0.07~0.09 m/s)。我一开始以为是自己哪里还没配对,后来在官方基准里看到 0.4 m/s 也只能走到 0.17 m/s。看起来速度跟踪不足是策略本身的短板,不是配置问题。

上游已经有人报告了同样的问题(issue #498),修复的 PR #500 也做了这两处改动,写这篇的时候还没合并。

几点体会

强化学习策略对环境非常挑剔。观测维度、执行器模型、场景里的一个齿隙,任何一处跟训练时对不上,策略都可能站得住但不迈步,而且不报任何错。所以排查时,第一步应该是翻出策略的 manifest 和训练任务名对一遍,而不是先猜。我那个 BAM 的猜测是被对照实验推翻的;要是装上 BAM 碰巧走起来了,我大概就停在一个错误的结论上。

Microduck 的工程质量整体很高,最让我佩服的是”软件和策略分开发布”以及处处可回滚的部署设计,还有那些把”为什么”写清楚的设计文档。仿真里的问题出在一个写死的文件名上:策略集升级了,脚本没跟着改。只要有写死的值,迟早会和数据对不上。

如果你也想动手:一张支持 CUDA 的显卡就能训练,没有机器人也能在仿真里把整套守护进程跑起来。

请我喝杯咖啡