Microduck 是 Pollen Robotics(Hugging Face 旗下)今年 8 月发布的双足小鸭子,官网标价 $399。它的有意思之处在于开源得很彻底:机器人端的 Rust 代码、训练和仿真的 Python 代码、训练配置、模型文件都放在 GitHub 上,设计文档也写得非常扎实,很多”为什么这么做”都有记录。
最近我把两个仓库从头读了一遍,又在本地把仿真跑通了。这篇按硬件、软件、训练、部署四块记下我看到的东西,最后附上仿真里”鸭子不走路”的排查过程。

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

每条腿 5 个自由度(髋 3 个、膝、踝),脖子和头 4 个,再加一个能开合的嘴,一共 15 个 Dynamixel XL330 舵机。腿和头颈这 14 个由强化学习策略控制;嘴不进策略,由 robotd 用脚本单独驱动,捡东西的时候用嘴尖去够地面。
MJCF 里给的关节范围:髋偏航 −25°~+30°,髋横滚 ±22°,髋俯仰、膝、踝都是 ±90°,头偏航 ±170°。有个细节值得一提:运行时并没有按这些范围限位,安全层只把目标角度限制在 ±π,也就是舵机能转的整圈。代码注释里自己承认了这一点。
主要硬件

| 部分 | 配置 |
|---|---|
| 尺寸 / 重量 | 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 手册上写的输入电压是 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 ÷ 实测电压”缩放,补偿电压变化对舵机力矩的影响。
自己做硬件的话,这个设计不建议照搬。
软件
一组守护进程

机器人上跑的是一组 Rust 写的守护进程,没有用 ROS,也没有框架。每个进程一个 Unix socket,统一说 JSON-RPC 2.0。手机 App、网页控制台、游戏手柄、你自己写的脚本,发的都是同一套调用。
robotd 是核心:它独占舵机总线,跑 50 Hz 控制循环,加载 ONNX 策略,负责安全。策略推理在 CPU 上单线程跑,没有用 NPU。网络很小,单次推理不到 1 ms(推算)。
一个控制周期

每 20 ms 一个周期:
- 一次同步读,把 IMU 和 15 个舵机的状态一起读回来,约 4.2 ms。
- 拼 61 维观测,跑 ONNX,得到 14 个关节的目标偏移。
- 目标角度 = 站姿 + 缩放 × 动作,再做低通滤波(头 0.5,腿 0.7)。
- 安全检查:NaN 直接拒绝,其余限制在 ±π 以内。
- 一次同步写,0.94 ms。
总线利用率只有四分之一左右。文档里实测过 15022 个周期只丢了 3 个。有个小优化很能说明作者的细致:舵机出厂的回复延迟是每台 500 µs,16 个设备就是 8 ms,占掉 40% 的周期,所以启动时会强制把它改成 0。
从上电到站起来


上电后 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 个数

| 位置 | 内容 | 维数 | 为什么要它 |
|---|---|---|---|
| 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 加行为克隆


训练框架是 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 等 |
下面是这个模型的计算图。
发布和下发

导出后,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 却不带齿隙。
对照实验

| # | 运行方式 | 策略 | 执行器 | 场景 | 结果(指令 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.07~0.09 m/s)。我一开始以为是自己哪里还没配对,后来在官方基准里看到 0.4 m/s 也只能走到 0.17 m/s。看起来速度跟踪不足是策略本身的短板,不是配置问题。
上游已经有人报告了同样的问题(issue #498),修复的 PR #500 也做了这两处改动,写这篇的时候还没合并。
几点体会
强化学习策略对环境非常挑剔。观测维度、执行器模型、场景里的一个齿隙,任何一处跟训练时对不上,策略都可能站得住但不迈步,而且不报任何错。所以排查时,第一步应该是翻出策略的 manifest 和训练任务名对一遍,而不是先猜。我那个 BAM 的猜测是被对照实验推翻的;要是装上 BAM 碰巧走起来了,我大概就停在一个错误的结论上。
Microduck 的工程质量整体很高,最让我佩服的是”软件和策略分开发布”以及处处可回滚的部署设计,还有那些把”为什么”写清楚的设计文档。仿真里的问题出在一个写死的文件名上:策略集升级了,脚本没跟着改。只要有写死的值,迟早会和数据对不上。
如果你也想动手:一张支持 CUDA 的显卡就能训练,没有机器人也能在仿真里把整套守护进程跑起来。
请我喝杯咖啡
