第 10 章 从训练到上机:ONNX 导出、manifest 与 Hub 分发

本章代码走读:export.py 头注释、publish/manifest.py(形状门禁与来源记录)、Call 枚举 policy.* 组、Cargo.toml 的 [workspace.metadata.policies]。

10.1 代码走读:export.py——唯一的合法路径

导出模块的开篇注释把"为什么只能走这条路"讲透了:

"""Export a trained checkpoint to ONNX, with the observation normalizer baked in.

This is the ONE path from a checkpoint to a deployable `.onnx`: `runner.export_policy_to_onnx`
emits `actor(normalizer(obs))`, so what the robot runs is what training saw. In-sim `play`
applies the normalizer itself and hides a hand-converted checkpoint that forgot it — never
convert by hand.

`scripts/export.py` is the command-line wrapper; `mjlab_microduck.publish` calls
:func:`run_export` directly so a published policy cannot skip this step.
"""

三个设计互锁:

  1. 归一化器烘焙进图:导出产物是 actor(normalizer(obs)) 的复合图——机器人端只需要标准 ONNX 运行时,不存在"图对了、归一化没带上"的事故面;
  2. 禁手转的理由是具体的:不是"怕你转错",而是 play 工具会自动补归一化、把忘带归一化器的手转模型藏得好好的——上机才暴露。工具自动修 bug 的地方,就是 bug 能溜过测试的地方;
  3. 发布器直接调用导出函数:publish 不接受现成 ONNX 绕过导出——"发布的策略不可能跳过这一步"。路径唯一性靠调用图保证,不靠文档劝告。

10.2 代码走读:manifest.py——上传之前先替 daemon 拒一遍

发布侧的形状门禁,常量与校验逻辑:

#  ... 61 = 48 proprioception + 13 command; 14 = the servos.
OBS_LEN = 61
ACTION_LEN = 14

def check_onnx(path: Path) -> OnnxShape:
    """Refuse a file the daemon would refuse at load: wrong widths, or one that is not 61 -> 14."""
    ...
    if shape.obs_len != OBS_LEN:
        raise ManifestError(
            f"{path.name}: observation width is {shape.obs_len}, the robot builds {OBS_LEN} "
        )
    if shape.action_len != ACTION_LEN:
        raise ManifestError(f"{path.name}: {shape.action_len} actions, the robot has {ACTION_LEN}")
    return shape

注释一语道破架构:"拒绝一份 daemon 也会拒绝的文件"——上传侧预先执行设备侧的加载门禁。这样坏策略根本到不了 Hub,而不是装到一半在鸭子身上报错。文档注释还注明契约文档的位置:"Contract = docs/policy-manifest.md in the microduck repo"——两侧共享一份规范文档。

manifest(schema-2)还带 Provenance 区块——git_provenance 记录导出时的仓库状态、checkpoint 来源、wandb run 路径(ExportResult 的字段),让每个发布物可追溯到训练现场。发布命令同时限定可发布的策略类别:

uv run publish --task <TASK_ID> --wandb-run-path <...> --checkpoint N \
  --repo <user>/microduck-<name> --kind episodic --duration-s 4.0

AGENTS.md 的限定:"only constant-command episodic/perpetual policies are publishable (phase/posture-flag are the set's)"——社区可发布的是"常量命令"的策略(限时型 episodic 或持续型 perpetual),而相位编码、姿态标志这些命令槽盗用技巧属于官方策略集专用。这是把第 4/8 章那些"高级编码"与社区生态隔开的护栏:公共分发通道只走最不容易被用错的形态。

10.3 设备侧:槽位、安装与热切换

机器人侧的消费接口全在第 3 章读过的 Call 枚举里:

    // ── policy.* ─────────────────────────────────────────────────────────────
    /// What is installed and what the Hub offers; see [`method::POLICY_CHECK`].
    PolicyCheck,
    /// Install a set and make it live; see [`method::POLICY_INSTALL`].
    PolicyInstall(PolicyInstallParams),
    /// Fetch one policy into the library; see [`method::POLICY_FETCH`].
    PolicyFetch(PolicyFetchParams),
    /// Search the Hub; see [`method::POLICY_SEARCH`].
    PolicySearch(PolicySearchParams),

以及 robot.* 组的槽位管理:RobotPolicies(各槽位跑什么,返回 PoliciesResult,含每个槽的路径与来源)、RobotLoadPolicy(加载或复位某槽)、RobotReloadPolicies(全部重读)。PolicySlot 结构描述单个槽位——这就是第 4 章"热切换"的协议层入口。注意 PolicyInstall 的注释"Install a set and make it live"——安装即生效,没有"装完还要记得重启"的暗坑。

出厂策略集的版本政治在第 1 章的 [workspace.metadata.policies] 里:官方集在 pollen-robotics/microduck-policies,version = "v5" 是最低版本——新刷机的板子装到它,低于它的板子被安装后钩子拉上来,高于它的不动。步态升级走 robotctl policy update,不需要 daemon 发版。

10.4 端到端复现清单(v0.2 修订)

零硬件完整闭环,每一步都是真实命令:

# 0. 冒烟(纪律,不是可选)
uv run train <TASK_ID> --env.scene.num-envs 64 --agent.max_iterations 5

# 1. 正式训练(无本地 GPU 加 --hf-jobs)
uv run train Mjlab-Velocity-Flat-MicroDuck --env.scene.num-envs 4096

# 2. 回放复查(看 air_time / 熵 / 追踪得分,记下 run id)
uv run play <TASK_ID> --wandb-run-path <entity/project/run_id>

# 3. 导出(唯一合法路径,归一化器进图)
uv run scripts/export.py <TASK_ID> --wandb-run-path <...>

# 4. CPU 部署彩排(BAM M6 执行器与训练一致)
uv run scripts/infer_policy.py --walking output.onnx

# 5. 软件在环(真守护进程 + MuJoCo 身体)
cd ../microduck && scripts/duck-sim ctl "policy fetch <user>/microduck-walk"

# 6. 发布(上传侧先跑形状门禁与来源记录)
uv run publish --task <TASK_ID> --wandb-run-path <...> \
  --repo <user>/microduck-<name> --kind episodic --duration-s 4.0

# 7. 真机(有鸭子的话)
robotctl policy add <user>/microduck-<name>

10.5 给自研项目的分发模板

把这条链抽象成五条可移植的规则:导出自包含(归一化器/预处理进图,部署端零隐式状态);路径唯一(发布器直接调导出器,绕过在调用图上不可能);上传即门禁(设备侧会拒绝的,上传侧先拒绝);manifest 带来源(git/checkpoint/run 可追溯);生态分级(社区走安全形态,高级技巧留给官方集)。

10.6 本章小结

导出-发布-安装链用约三百行 Python 加几个枚举变体,实现了消费级 OTA 的全部关键性质:唯一、自包含、预校验、可追溯、可回滚(配合第 5 章的更新引擎)。全书两个仓库的故事至此闭环:Rust 世界负责"让策略安全地跑起来",Python 世界负责"让跑起来的策略值得安全地跑"。