一句话如何变成机器人技能:给启元搭一个本地技能调度器
“先说一句欢迎光临,然后挥手,最后跳一秒的舞。”
这句话人听起来很简单,交给机器人却有好几件事要处理:有哪些技能能用?欢迎词传给谁?挥手和跳舞能不能同时做?挥手失败了,还要继续跳吗?
上一篇给“拿衣服”技能接了本地视觉,这次沿着启元的技能开发思路,往上搭一层:用一台 RTX 5070 Ti,让本地模型把中文指令变成可执行的技能计划。
底座选用启元社区项目 PrimeBuddy。我复用了它的技能注册和执行引擎,另外写了本地规划器。模型真实跑在 GPU 上,技能函数进入子进程,再驱动 MuJoCo 中的启元 Q1。这次把“计划能执行”继续往下做到了“仿真里有对应动作”。

我把任务编排和仿真视窗放到了一起。上图停在仿真时间 1.64 秒:问候已完成,挥手正在执行,舞蹈还在等前置任务。拖动进度条,右侧机器人画面、左侧任务高亮和下方事件日志会一起变化。这是同一次真实运行的联动回放,方便逐帧检查调度结果。
先让三个小技能接得起来
这次只做问好、手势、跳舞三个技能。问好接收一句文本,手势支持挥手与比心,跳舞接收 1~3 秒的时长。仿真里问好只输出文字;挥手用左臂,舞蹈用腰部和双臂,比心简化为双臂内收,不涉及手指控制。

上图来自实际运行记录。模型把欢迎指令拆成三个任务,执行器依次启动,动作端逐个写下 start → success → released。三张画面取自同一次迎宾任务的物理仿真,动作之间沿用上一段的关节位置、速度和仿真时钟,没有重新摆回初始姿态。
一个技能的目录很小:
skills/gesture/
├── SKILL.md # 给模型读的说明
├── cfg/pkg.yaml # 动作、参数与资源声明
└── src/actions.py # 执行入口
以手势技能为例,pkg.yaml 中的关键部分是:
perform:
parameters:
name:
type: string
enum: [wave, heart]
resources: [robot.body]
这里的 robot.body 是我定义的资源名,用来表达“这几个动作都要占用身体”,不是启元 SDK 的函数名。挥手和跳舞都声明占用它;如果计划让两者同时执行,校验器会拒绝,要求补上先后关系。
技能文档负责讲清楚“我能做什么”,参数声明负责限制“你能怎么调用”。只给模型一段介绍,还不足以形成可执行接口。
最难的坑,竟然是“来一个吧”
最初我尝试直接接入通用工具调用循环,让小模型自己找技能、读说明、发起执行。实际跑下来,它会漏掉必需的读取步骤,或者交出不合要求的调用。对于只有三个技能的实验,这条路反而绕远了。
我改成由程序读取技能目录,让模型直接生成任务计划,并用 LM Format Enforcer 约束输出格式。技能名、动作名、参数枚举都从注册信息生成,不允许随口编一个接口。
然后遇到了更有意思的问题。
输入“来一个吧”,模型交出了一份格式合法的计划,还把三个技能都安排上了。JSON 没错,执行意图却错了:用户根本没说要来哪一个。
所以最终流程分成两次模型调用:
用户的话
↓
判断意图:执行 / 需要追问 / 当前不支持
↓ 只有“执行”继续
生成受格式约束的任务计划
↓
校验参数、依赖和资源 → 执行技能 → 根据实际状态返回结果
第一步让 Qwen3-4B 开启思考,判断这句话是否真的要求执行、信息是否够用。输出不是有效的决策 JSON,就停止,不启动动作。第二步关闭思考,只生成结构明确的计划。
这不是靠“挥手”两个字做关键词匹配。补测“给大家挥手致意,结束后再比心”,模型同样生成了两个有顺序的任务;而“我只是问你会不会跳舞,先别做动作”没有启动任何技能。
“然后”要变成一条真正的依赖
模型输出里,最值得盯的字段是 after。例如先挥手、再跳一秒舞,对应的任务结构是:
{
"tasks": [
{"id":"t1","skill":"gesture","action":"perform",
"args":{"name":"wave"},"after":[]},
{"id":"t2","skill":"dance","action":"perform",
"args":{"seconds":1},"after":["t1"]}
]
}
after: ["t1"] 不是备注。它决定第二个任务必须等待第一个完成,也决定前一步失败时后一步怎么办。
代码中先调用 PrimeBuddy 的 validate_plan,再调用 execute。校验不通过时,最多把错误反馈给模型修正一次;这发生在任何动作启动之前。执行失败则按实际失败处理,不让模型自动重试动作。
还有个容易忽略的细节:模型生成计划时的 reply,不能当作执行完成的回执。它可能写“已为您安排挥手”,但此时手势函数还没运行。因此有动作的请求,最终回复由程序根据执行状态生成。
从技能函数接到 MuJoCo,还差一个控制器
Q1 的官方 q1_v3_mc.xml 有 22 个力矩驱动器。技能函数不能把“挥手”直接塞给它,也不能把角度写进 ctrl,需要先生成关节目标,再计算力矩:
q_ref = target(action, t, duration, joint_names)
tau = 35 * (q_ref - q) - 1.2 * qvel + bias_force
data.ctrl[:] = np.clip(tau, ctrl_min, ctrl_max)
mujoco.mj_step(model, data)
挥手被写成 2.4 秒的平滑轨迹:抬起左臂、左右摆动、放回。舞蹈则让腰部与双臂按周期摆动。模型负责选择技能和参数,具体关节轨迹由技能实现负责,不让语言模型直接猜每个电机的力矩。

这里做了一个固定骨盆的仿真支架,保留重力,关闭机器人碰撞。目的是先把调度与关节动作接通;它不包含自由站立平衡或接触控制,所以图里的“舞蹈”是固定底座上的上半身动作。
我还保存了目标角度与实际关节角度。下面这张曲线比截图更能说明问题:虚线是控制目标,实线是 MuJoCo 计算出来的运动,抬臂过程存在可见的跟踪滞后。

单次挥手执行了 2,400 次物理步进,仿真步长为 1 毫秒。截图是从计算后的状态渲染的,没有把目标角度直接设置成截图姿势。
我故意让挥手失败了一次
为了看看调度器是否真的处理依赖,我在 MuJoCo 动作入口注入了 INJECTED_ACTUATOR_BUSY,模拟执行器拒绝启动。
请求仍然是“先挥手,再跳一秒舞”。结果中,挥手为 failed,跳舞为 skipped;后端日志只有挥手的启动、失败与释放,没有跳舞启动事件。
取消也单独测了一次:输入“先跳三秒舞,然后比心”,等舞蹈函数实际启动 0.3 秒后,由测试程序发出取消信号。仿真循环退出并保存最后状态,后面的比心没有启动。这里的取消是终止仿真任务,还不是实体机器人的制动控制器。

这里验证的是执行器的取消链路,没有接语音识别。要做“说一声停就停”,还需要把语音打断事件接到同一个取消入口。
换成 MuJoCo 执行端后,重新跑了一遍七个主案例:挥手、欢迎组合、比心后跳舞按计划完成;“来一个吧”得到追问;去厨房倒水得到能力范围说明;失败和取消两个案例没有继续执行后续动作。此前在 Mock 端补测的四种不同说法也保留在工程里。代码还单独检查了不存在的技能、资源冲突和循环依赖,三类非法计划都被拒绝。
在自己的机器上跑
完整工程和运行记录已经放到仓库。进入 local-scheduler 后执行:
bash code/setup.sh
bash code/setup_sim.sh
.venv/bin/python code/run.py --backend mujoco \
--text '先说一句欢迎光临,然后挥手,最后跳1秒的舞。'
安装脚本会固定 PrimeBuddy 与 Qwen3-4B 的版本。首次需要下载约 8 GB 权重;下载完成后,推理从本地加载,不调用云端模型。下载较慢时,可在安装命令前设置 HF_ENDPOINT=https://hf-mirror.com。
完整复跑和查看记录:
.venv/bin/python code/run.py --backend mujoco --suite
.venv/bin/python code/check_sim.py
.venv/bin/python code/make_sim_media.py
.venv/bin/python code/build_workbench.py
打开生成的 仿真工作台.html,可以切换迎宾、取消和失败案例,播放或逐帧查看。视频已内嵌,下载这个 HTML 就能离线打开。
results/mujoco/ 保存模型计划、动作事件、关节轨迹和逐帧截图;welcome_sequence.mp4 是连续迎宾动作。make_sim_media.py 从已记录的物理状态生成正面视角和文章配图,方便对照计划检查实际动作。
在这台 16 GB 的 5070 Ti 上,BF16 模型运行时 PyTorch 峰值已分配显存约 7.64 GiB。模型加载完成后,本轮单次挥手请求耗时 12.38 秒,欢迎组合耗时 19.00 秒,包含意图判断、计划生成、仿真和逐帧保存。这个速度适合开发验证,离流畅的现场交互还有距离。
往下接真机时,任务计划和依赖可以保留,替换的是技能函数里的 MuJoCo 实现。具体动作接口可以对照启元开发者社区的资料逐步接入,再把成功、失败和取消状态准确返回。我更想先把这件事做好:机器人说“完成了”,是因为执行器确实收到了完成结果。
更多推荐



所有评论(0)