做过机器人控制的人都知道,单个策略网络解决不了所有问题。走路是一个动力学,站起来是完全另一个,翻滚又是第三个。Microduck 的方案不是训一个巨大的多任务网络,而是训了六个独立策略,部署时根据机器人状态自动切换。今天拆开看这个状态机怎么工作。
六个策略,一个观察契约
Microduck 的 RL 仓库训练了至少六种策略:Velocity(走路)、VelStand(走路+摔倒恢复)、StandUp(从地面站起来)、SitStand(坐下↔站立)、GroundPick(蹲下嘴触地)、Roulade(前滚翻)、BallKick(踢球)。每个策略独立训练,独立导出 ONNX,独立验证。
关键设计是 61 维观察契约。所有策略共享同一个输入格式:48 维本体感知加 13 维命令块。48 维本体感知包括关节角度、关节速度、IMU 姿态和上一步动作。13 维命令块是 [twist(3), head_pose(4), body_pose(6)]——线速度和角速度命令、头部姿态命令、身体姿态命令。
不使用某个命令槽的策略不能删掉它,必须零填充。CLAUDE.md 里把这条列为铁律:”An env that doesn’t use a command slot ZERO-PADS it (keep the obs term, sample tiny ranges) — never delete a slot.” 为什么?因为运行时切换策略时输入维度必须一致。如果走路策略有 61 维输入、站立策略只有 55 维,切换时就要重新构建 observation,增加延迟和 bug 概率。统一 61 维意味着切换只是换一个 ONNX 文件, observation 构建代码一行都不用改。
这个设计还有一个好处:命令槽里不用的维度虽然零填充,但训练时采样范围不是严格的零,而是”tiny ranges”。CLAUDE.md 解释了原因——”A command input that is never non-zero has dead weights forever.” 神经网络的输入神经元如果从来没有非零输入,梯度永远不会流过这些连接,权重就死了。将来如果你想给一个原本不需要头部命令的策略加上头部控制,这些死权重意味着你要从头训。保持微小范围的采样让神经元保持活跃,未来扩展时不用重训整个网络。
robotd 的 decide 阶段:优先级链
robotd 的 50 Hz 控制循环每个 tick 执行 read-decide-write 三步。decide 阶段是策略状态机的核心。从 robotd-design.md 的数据流图看,优先级链是这样的:
roulade > kick > ground pick > sit/rise > stand (by |twist|, or forced) > walk
这个优先级链的含义是:每个 tick 里 robotd 从上到下检查条件,第一个满足的策略就接管控制。不是轮询,不是投票,是硬优先级。
最顶层是 roulade(前滚翻)。一旦触发,它独占机器人直到动作完成。为什么优先级最高?因为前滚翻是一个不可中断的动态动作——半途停下来鸭子会脸朝地摔。kick 和 ground pick 也是类似的逻辑:动作一旦开始就必须完成,中间打断会摔。
中间层是 sit/rise(坐下和站起)。这是 commanded transition——用户通过 app 或 gamepad 发了坐下指令,策略执行过渡动作。优先级低于特技动作但高于走路和站立。
底层是 stand 和 walk。这是默认状态。当 |twist|(速度命令的模)大于某个阈值时走 walk 策略,否则走 stand 策略。stand 不是”什么都不做”——站立策略仍然在主动控制平衡,只是不产生位移。CLAUDE.md 里特别提到,零命令行为必须显式训练:”uniform sampling essentially never produces the all-zero command, which is exactly the deployment idle state.” 训练时如果命令是从均匀分布采样的,全零命令几乎不会被采到,策略在部署时遇到空闲状态(用户什么都不按)就会表现异常。
策略切换的物理问题:reset 不是可选项
策略切换不是一个 tick 里瞬间完成的。切换时新策略的第一步如果用了旧策略最后一步的状态,会产生一个”幽灵动作”——策略 A 在走路时腿部有前向动量,切到策略 B 站立时 B 的内部状态(比如 RNN 的隐状态或者 MLP 的输入历史)假设自己是静止起步的,两者不匹配就是一跤。
robotd-design.md 对此有明确处理。从启动和边沿逻辑看:
edges ─┬─ started driving ──► controller.reset()
└─ stopped driving ──► hold = current pose, captured once
controller.reset() 在策略开始驱动时调用。对于 MLP 策略来说 reset 主要是清空内部状态——如果策略没有隐状态这步很快,但如果有(比如用了历史窗口做 observation),reset 就是必须的。不清空就是让新策略继承了旧策略的运动历史,行为不可预测。
停止驱动时的 hold = current pose 也很关键。当策略停止时 robotd 不是把关节目标设为零或某个默认姿态,而是冻结在当前姿态。这个姿态是停止那一刻读取的真实关节位置,只捕获一次。为什么不每个 tick 重新读?因为关节在重力下会下沉——你松开舵机它不会停在原位,会慢慢往下掉。如果每 tick 重读,hold 就跟着往下掉,鸭子会逐渐塌下去。捕获一次然后保持,舵机的位置控制环会抵抗重力维持这个姿态。
limp-fall:策略之上的安全层
有一个特殊状态不在优先级链里但优先级比所有策略都高:limp-fall。
从 robotd 的 tick 逻辑看:
driving ─┬─ limp-fall ─► the sequence's own targets and gain
├─ yes ──────► step() → targets, gain, the active net's name
└─ no ──────► targets = hold, default gain, "held"
limp-fall 是 robotd 检测到机器人正在摔倒时接管的一个安全序列。它不是策略网络——它是一段确定性的运动序列,目标是让鸭子以一种不损坏舵机的方式落地。摔倒时如果继续让策略网络控制,网络可能输出各种疯狂的目标角度,舵机大力瞬间移动到极端位置,齿轮承受冲击载荷。limp-fall 序列把目标设为顺倒——让关节松到某个安全姿态,吸收冲击。
这个设计的关键在于 limp-fall 不是策略优先级链的一部分,而是一个独立的安全机制。它绕过了整个策略选择逻辑,直接从 safety 层接管。robotd-design.md 的不变量列表里第一条就是”Nothing above safety can write to a motor”——safety 拥有唯一的 RobotIo,策略代码无法绕过它直接写舵机。limp-fall 是 safety 层的一部分,不是策略层的一部分。
infer_policy.py:状态机的 sim2real 验证
scripts/infer_policy.py 是部署前的状态机验证工具。它加载多个 ONNX 策略到 CPU MuJoCo 里,用键盘模拟用户输入,让你在仿真里测试切换行为。
从 README 的命令看:
uv run scripts/infer_policy.py --walking walk.onnx \
--standing stand.onnx --sitstand sitstand.onnx \
--roulade roulade.onnx --new-cmd-obs
键盘映射是 G 触发 GroundPick,Y 触发 SitStand,R 触发 Roulade,K/L 触发踢球。切换的依据不只是按键——infer_policy 还检查机器人的物理状态。比如 Roulade 不是按了 R 就立刻执行,它会检查机器人是否在站立状态、速度是否在合理范围内。按了 R 但机器人正趴在地上,不会触发前滚翻,因为前滚翻需要先站起来。
这个验证步骤在 sim2real 流程里的位置很重要。你在仿真里可以验证单个策略的行为(用 play 命令),但策略切换的行为只能在 infer_policy 里验证。一个常见的 sim2real bug 是:单个策略在仿真里完美,切换时炸了——因为切换那一刻的 observation 可能是两个策略训练分布的盲区。走路策略训练时机器人不会突然从前滚翻状态切入,前滚翻策略训练时机器人不会从走路状态切入。infer_policy 让你在仿真里测这些跨策略的边界情况。
命令路由:intent 怎么到达策略
robotd 的客户端(padd gamepad、mediad WebRTC、btd BLE)发送的是 intent,不是直接的策略选择。intent 包括 robot.move(速度命令)、robot.head(头部姿态)、robot.stop、robot.enable 等。
策略选择不是客户端决定的。客户端说”往前走 0.2 m/s”,robotd 内部根据当前状态决定用 walk 策略还是 stand 策略。客户端不能说”切换到 roulade 策略”——它发的是 robot.trigger_roulade 这种 intent,robotd 根据当前状态判断是否安全执行。
这个设计意味着策略状态机是 robotd 的内部实现,对客户端不可见。app 和 gamepad 不需要知道机器人有几个策略、当前在跑哪个。它们只发 high-level 命令,robotd 翻译成策略选择。好处是你可以增加或减少策略而不改客户端代码——加一个新的踢球策略不需要更新 app。
热切换的延迟
策略切换的实际延迟是多少?ONNX 文件加载是磁盘 IO 加模型解析。一个 61→14 的 MLP 网络,ONNX 文件大概几十 KB。在 RK3566 的 eMMC 上读取几十 KB 是亚毫秒级。ONNX Runtime 创建 inference session 需要初始化计算图,大概几毫秒。
但实际切换延迟主要不是加载,而是 reset。controller.reset() 清空策略内部状态,然后第一个 tick 用新策略推理。从 robotd 的 tick 结构看,切换发生在两个 tick 之间——一个 tick 还在跑旧策略,下一个 tick 就跑新策略。这意味着切换延迟最多 20 毫秒(一个 tick 周期)。加上 ONNX session 创建的几毫秒,总共在 25 毫秒以内。
对于 50 Hz 的控制循环来说这个延迟可接受。但对于高速动态动作(比如前滚翻的触发),25 毫秒意味着鸭子多走了半步。如果触发条件是”即将摔倒”,25 毫秒的延迟可能让鸭子多偏了 0.5 度。这就是为什么 limp-fall 不走策略切换——它是一个不需要加载 ONNX 的确定性序列,延迟只取决于检测算法本身。
对复刻者的实践建议
统一 observation 维度。 如果你要训多个策略,从第一个开始就固定 observation 维度和顺序。后来加策略时零填充不用的命令槽,不要改维度。这个约束从训练第一天就要遵守,事后改维度意味着所有策略重训。
优先级链要有硬中断和软过渡。 不可中断的动作(特技、恢复)放高层,可中断的(走路、站立)放底层。高层策略执行完自动降级回底层,不要让客户端手动切回来——人忘了切,机器人就卡在某个状态。
切换时 reset 内部状态。 不管你的策略是 MLP 还是 RNN,切换时都要清空历史状态。不清空的代价是”幽灵动作”——新策略继承了旧策略的运动上下文,行为不可预测。
安全层要在策略之上。 摔倒检测和 limp-fall 序列不属于任何策略,它属于 safety 层。safety 可以否决策略的输出、接管控制、执行安全序列。策略代码不能绕过 safety 直接写舵机——这不是 convention,是 borrow checker 在编译时保证的。
在仿真里测切换行为。 单策略验证用 play,多策略切换验证用 infer_policy。跨策略的边界情况是 sim2real 的常见 bug 来源——两个策略各自的训练分布覆盖不到切换那一刻的状态。
数据来源
- GitHub: pollen-robotics/microduck — docs/design/robotd-design.md(50Hz 控制循环、策略优先级链、controller.reset()、hold 姿态捕获、limp-fall 序列、driving 条件、MissedTickBehavior::Skip)
- GitHub: pollen-robotics/microduck — docs/design/architecture.md(七守护进程架构、robotd 唯一触碰总线、intent 系统、JSON-RPC API)
- GitHub: pollen-robotics/microduck_rl — CLAUDE.md(61 维观察契约、零填充规则、命令槽活跃性、零命令训练、策略热切换不变量)
- GitHub: pollen-robotics/microduck_rl — README.md(任务注册表、六种策略族、infer_policy.py 多策略加载、ONNX 导出流程)
