7. 语言控制
这一章把前 6 章做的 PD / IK / trot / RL policy 都包装成工具,把一个 LLM 放在最上层当"大脑",让机器人能听懂 "往前走两米然后原地转身" 这样的语言指令。这是整门课程的"agent 骨架"——下一章再把视觉感知接进来,让 "追那个红球" 这种依赖外部信号的指令也能被执行。
本章目标
- 能设计一套机器人可用的"工具 API"(例如
walk(vx, vy, wz, t)、look_for(color)、stop()) - 能用 function calling 把 LLM 的自然语言输出转成工具调用
- 能把工具调用结果串成一个闭环,让机器人在仿真里完成多步任务
- 做出课程最终 demo:一段语音/文本指令 → Pupper 完成动作序列 的视频
前置阅读
- 第 5–6 章(步态需要做基础工具)
- VLM · LLM 基础与生成机制
- VLM · 从 VLM 到 VLA
7.1 语言到动作
到第 6 章为止,机器人能接受的"指令"还是 (vx, vy, ωz) 这种数值。普通用户不会这样说话,他们说的是 "往前走两米然后停下"、"绕桌子转一圈"。把这两端连起来有两条主流路线:
| 路线 | 怎么做 | 能听懂的指令 | 局限 |
|---|---|---|---|
| 关键词识别(KWS) | 一个 CNN/ResNet 在固定 vocab 上分类("forward" / "left" / "stop"…) | 固定词表,没参数 | 没法说"走 0.3 米"或"绕到那张桌子后面" |
| LLM + 工具调用 | LLM 把自然语言映射成参数化的 tool call | 任意自然语言、带数字、可组合 | 推理延迟 + 成本 |
CS123 Lab 6 slides 让学生两条都做一遍,目的是逼你亲眼看到 KWS 的天花板:一旦你想说 "前进 0.3 米"、"转 45 度",KWS 立刻退场。
本章主线是第二条路线,三层架构如下:
语音 / 文本 ──▶ ASR (Whisper, 7.7) ──▶ 自然语言
│
▼
LLM + tool schemas (7.3)
│
▼
walk / turn / stop / get_pose ... (7.2 / 7.6)
│
▼
第 6 章 RL policy 或第 5 章 trot
│
▼
MuJoCo 物理
设计上的关键判断:LLM 只负责"决定下一步调什么工具、传什么参数",它绝不直接下力矩——这条边界让整套系统在 LLM 出错时也能落到工具的安全检查里,不会一句"乱说"就把机器人弄坏。
7.2 工具 API
工具 API 是 LLM 和机器人之间的"接口契约"。三条原则按重要性排:
- 原子性:一个工具只做一件事。
walk(vx, wz, t)是好的;walk_to_object(name)把"找物体 + 走过去"绑死,LLM 没法在中间插判断,这种"复合工具"少用。 - 可组合:能用纯文本(JSON schema)描述输入/输出、所有工具都通过同一个
dispatch(tool_name, args)入口。这样 LLM 改提示词就能换组合,不需要改后端。 - 有副作用边界:明确把工具分成 read-only(
get_pose、get_battery)和 acting(walk、turn、stop)。read-only 失败可以静默重试,acting 失败必须让 LLM 知道。
下面是一份适配 Pupper 的最小工具集,足够跑完整章 demo:
TOOLS = [
{
"name": "walk",
"description": "Walk in the body frame for a fixed duration. "
"Stops automatically when the duration elapses.",
"input_schema": {
"type": "object",
"properties": {
"vx": {"type": "number", "description": "forward speed m/s, range [-0.5, 0.5]"},
"wz": {"type": "number", "description": "yaw rate rad/s, range [-1.0, 1.0]"},
"duration": {"type": "number", "description": "seconds, range (0, 10]"},
},
"required": ["vx", "wz", "duration"],
},
},
{"name": "stop", "description": "Immediately set commanded velocity to 0.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "get_pose", "description": "Return current (x, y, yaw) in world frame.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "wait", "description": "Hold position for N seconds.",
"input_schema": {"type": "object", "properties": {
"seconds": {"type": "number"}},
"required": ["seconds"]}},
]
关键的两个细节:
- 每个工具的
description里都把单位、量程、副作用写清楚,这是 LLM 能正确传参的唯一线索。vx写成"forward speed m/s, range [-0.5, 0.5]"比"forward speed"让 LLM 出格的概率低一个数量级。 walk带duration而不是终点位置,把"走多远 = 走多久"留给 LLM 自己算(vx * duration ≈ 距离)。这样工具实现不需要任何里程计闭环就能跑通——闭环交给后面 7.5 节的失败处理 + 重调用。
这套接口接近 CS123 slides 里的 KarelPupper API 思路:少数几个高层动词、参数化、一次只完成一段——和 Karel the Robot 的教学语言同源。
7.3 Function calling
直接上 Anthropic SDK 的最小可跑代码。系统提示告诉 Claude 它能调什么工具,工具列表用 7.2 节那份:
import anthropic, json
from robot_tools import TOOLS, dispatch # dispatch(name, args) → JSON-able 结果
client = anthropic.Anthropic()
SYSTEM = (
"You are a controller for a small quadruped robot in a MuJoCo simulation. "
"Translate the user's natural-language commands into one or more tool calls. "
"Use SI units. After each tool result, decide whether the goal is reached; "
"if not, call another tool. When done, reply in one short sentence."
)
def run_agent(user_msg, max_turns=8):
messages = [{"role": "user", "content": user_msg}]
for _ in range(max_turns):
resp = client.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
system=[
# system prompt + tool 列表都加上 cache_control,
# 跨多轮调用直接命中缓存,显著降本。
{"type": "text", "text": SYSTEM,
"cache_control": {"type": "ephemeral"}},
],
tools=TOOLS,
messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return resp.content[-1].text # LLM 给出了最终回复
# 把所有 tool_use 块各执行一次,组装 tool_result 喂回去
tool_results = []
for block in resp.content:
if block.type == "tool_use":
result = dispatch(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
messages.append({"role": "user", "content": tool_results})
return "max turns reached"
跑一次试试 "往前走两米然后停下",期望的多轮交互形如:
turn 1 Claude → tool_use(walk, vx=0.4, wz=0, duration=5.0)
turn 2 你 → tool_result {"ok": true, "final_pose": [2.05, 0.07, 0.01]}
turn 3 Claude → tool_use(stop, {})
turn 4 你 → tool_result {"ok": true}
turn 5 Claude → "Walked forward about 2 m and stopped."
prompt caching 的位置:上面
system里加了cache_control标记。系统提示 + 工具列表加起来很长(一份 schema 几百 token)但每次调用都不变,开缓存能把这段 token 价格打到 1/10。机器人 demo 一晚上要跑几十轮,省下来的成本立刻可见。
7.4 任务规划
按指令复杂度分三档:
- 单步(single-tool):"立刻停下" → 一次
stop()就完事。 - 多步顺序(fixed plan):"前进两米再原地转半圈" →
walk然后walk(vx=0, wz=...)。LLM 会在一次回复里给出多个tool_use块;7.3 的循环按顺序执行就够。 - ReAct(Thought-Action-Observation 循环):"先看看自己在哪,如果还没到 (1, 1) 就走过去" → LLM 需要先调
get_pose,根据返回值再决定walk多久。每一轮拿到 tool_result 再决定下一动。
好消息:Claude 的 tool_use 协议天然就是 ReAct——stop_reason="tool_use" 就是 Thought + Action,下一轮的 tool_result 就是 Observation。你不需要 LangChain 一类的框架,7.3 那个 50 行循环就够了。
多步指令最常见的失误:LLM 把所有步骤放在第一轮一次性吐出来(比如 "走 2 米 + 转身 + 走 1 米" 三个
tool_use块),但你后端串行执行,第二步真的发生时机器人已经偏离 LLM 假设的位姿。纠正办法在 system prompt 里写一句:"After each acting tool, re-evaluate usingget_posebefore the next step."——LLM 就会自动在两步之间插get_pose,恢复 ReAct 节奏。
7.5 失败处理
工具调用失败的两类场景:
- 预期失败:参数越界(
vx=2.0超出量程)、机器人摔倒、超时未到目标。 - 意外失败:MuJoCo 抛异常、tool 实现自身 bug。
两类都用同一种"软失败"方式回报——绝不 raise,只在 tool_result 里写错误:
def dispatch(name, args):
try:
if name == "walk":
if not (-0.5 <= args["vx"] <= 0.5):
return {"ok": False, "error": "vx out of range [-0.5, 0.5]"}
return _walk(args["vx"], args["wz"], args["duration"])
...
except Exception as e:
return {"ok": False, "error": str(e)}
LLM 拿到 {"ok": false, "error": "..."} 后会:
- 改参数重试:把
vx=2.0自动减到0.5、把duration=20自动减到10; - 换策略:如果
walk一直走不到目标,会改成turn修正朝向再walk; - 如实报告:连 3 次失败后 LLM 一般会给"尝试了什么、为什么不行"的总结。
注意一条工程上的硬底线:超时和"机器人已摔倒"必须由你的 dispatch 层强制中断、绝不依赖 LLM 自己判断。机器人摔倒时把后续所有 acting 工具一律返回 {"ok": False, "error": "robot has fallen, please call reset()"},LLM 自然就停手了。
7.6 接入策略
7.2 节的 walk(vx, wz, duration) 工具是空壳,真正干活的是 第 6 章训练出来的 PPO policy。把 policy 包成 walk 工具:
import mujoco, numpy as np
from stable_baselines3 import PPO
env = PupperEnv() # 第 6 章那份
policy = PPO.load("pupper_ppo.zip", env=env)
def _walk(vx, wz, duration):
obs, _ = env.reset()
env.cmd = np.array([vx, 0.0, wz], dtype=np.float32)
t_end = env.data.time + duration
while env.data.time < t_end:
action, _ = policy.predict(obs, deterministic=True)
obs, _, term, trunc, _ = env.step(action)
if term or trunc: # 摔了
return {"ok": False, "error": "robot fell mid-walk",
"final_pose": list(env.get_pose())}
return {"ok": True, "final_pose": list(env.get_pose())}
三条会救命的工程经验:
env.reset()不一定每次都做。如果 LLM 是连续多步控制(多次walk之间不停),reset()会把机器人瞬移回原点——你想要的是只更新cmd、保持物理状态。改成提供_walk_continuous与_reset_episode两个工具,把"重置"显式交给 LLM 决定。- policy 不是把 cmd 跟得很死。LLM 命令
vx=0.4期间机器人实际可能走 0.32 m/s。把这个速度跟踪误差作为final_pose一起返回,LLM 会自己学着补一段距离——这就是 7.5 节"软失败 + 自动重试"在数值层面的体现。 - 手写 trot(第 5 章)也能挂在这里,作为 RL 不收敛或断网时的 fallback 工具
walk_safe。把它作为 LLM 在"walk连失败 2 次时"的备选项,鲁棒性立刻上一个台阶。
7.7 Demo 录制
7.7.1 文本输入版
最朴素的 demo 是一个 REPL:你打字,agent 跑完一轮 tool_use 循环,回复一句话。
while True:
cmd = input("you> ")
if cmd in ("quit", "exit"):
break
print("bot>", run_agent(cmd))
至少录这三条命令的录屏作为动手任务交付物:
| 类型 | 例子 | 期望的 agent 行为 |
|---|---|---|
| 简单 | "原地停下" | 一次 stop() 就完 |
| 复合 | "前进 1 米后顺时针转 90 度" | walk → get_pose(自检) → walk(vx=0, wz>0) |
| 容错 | "以 2 m/s 的速度向前冲" | LLM 收到 vx 超界 error,自动降到 0.5 m/s重试 |
7.7.2 语音版
CS123 Lab 6 slides 的真正卖点是 Whisper → GPT 的语音控制。在仿真版课程里把 Whisper 接到 7.7.1 的 REPL 上即可:
import whisper, sounddevice as sd, numpy as np
whisper_model = whisper.load_model("base") # 国内可换 'small' + 中文
def record(seconds=4, sr=16000):
audio = sd.rec(int(seconds * sr), samplerate=sr, channels=1, dtype='float32')
sd.wait()
return audio.squeeze()
while True:
input("press Enter to talk (Ctrl-C to quit)...")
audio = record()
text = whisper_model.transcribe(audio, language='zh')['text']
print("you>", text)
print("bot>", run_agent(text))
把它和文本版的成功率做对比写到动手任务笔记里——Whisper 的中文识别率在嘈杂环境下大约 80–90%,这部分错误会一路传给 LLM,能否被 LLM 通过上下文纠正过来本身就是一个值得记录的实验。
7.7.3 安全注意
"The KarelPupper API moves Pupper very fast if you command it to move forward."
仿真里也要遵循同样的纪律:
vx量程从 0.2 m/s 起步,确认行为正常再放到 0.5。LLM 偶尔会把 "快一点" 解读成 1.0 以上,靠 7.5 节那道量程检查兜底。- demo 录屏前先空跑 30 秒,看 LLM 在你常用指令上有没有反复"超界 → 重试"的循环——有的话调 system prompt 把量程明确写进去。
- 设一个全局 kill switch:在 REPL 里把 Ctrl-C 绑到
dispatch("stop", {})上。LLM 任何时候出问题,手动 stop 是最后一道防线。
下一章我们会给 LLM 装一个 look_for(color) 工具,"追那个红球" 这类需要外部感知的指令才能真正闭环。
动手任务
§7.3 的 50 行 run_agent 循环已经能跑通单条指令,§7.7.1 的 REPL 也能交互式对话。但作品集里需要更有说服力的展示——本章动手任务把 agent 的能力具象化为 5 张难度递增的任务卡 GIF + 一份消息轨迹 markdown,从"向前走两步然后停下"到"走到坐标 (2,1)"再到"以 3 m/s 飞奔"(LLM 自动降速重试),完整展示理解、规划和容错三层能力。Ch6 用的上游 RTNeural policy(test_policy.json)在这里第一次被当作"工具"调用——LLM 说"往前走 1 米",底层就是 §5.6 那套 50 Hz policy / 500 Hz physics 循环在跑。

L3 多步顺序任务:用户说"前进 1 米,然后右转 90 度",LLM 自动拆成 walk(vx=0.5, wz=0, duration=2.0) → walk(vx=0, wz=-1.57, duration=1.0) 两次 function call,Pupper 先直走再原地转弯。字幕栏实时显示任务等级和仿真时间,底层物理和 Ch5 / Ch6 完全相同——区别只在于谁在发命令:§5.7 是你手写的 (vx, wz, duration),这里是 LLM 从自然语言里推理出来的。
要做的三件事:
- 写
dispatch():实现 walk / stop / get_pose / wait 四个工具的参数校验(vx ∈ [-1.5, 1.5]、wz ∈ [-2.0, 2.0]、duration ∈ (0, 10])和软失败返回(§7.5),绝不 raise——这是整个 Lab 的核心设计:LLM 收到{"ok": false, "error": "vx out of range"}才能自动重试 - 写
run_agent():用 OpenAI-compatible SDK 实现 tool_use 循环——发消息 → 检查finish_reason→ 遍历tool_calls→ 调用dispatch→ 把结果包成{"role": "tool", "tool_call_id": ..., "content": ...}喂回去(§7.3),支持 DeepSeek / OpenAI / Ollama 等任何兼容接口 - 写
render_task_gif():reset → 定义frame_callback(每个物理步后按 12 fps 采帧)→ 调用run_agent→ 加字幕 → 返回帧列表,跑完 L1–L5 五级任务各出一段 GIF
五级任务考察不同的 LLM 能力:L1 单工具调用、L2 参数转换("走 1 米"→ 算 duration)、L3 多步顺序、L4 ReAct 反馈(get_pose + 迭代逼近目标)、L5 容错降级(3 m/s 超界 → 自动 clamp 重试)。
完整 starter / 测试 / 交付清单见 exercises/lab_7_llm_control/。
进一步延伸
- 把 LLM 换成 VLM,让它看图说话、直接根据画面决策
- 把 function calling 换成 VLA(Vision-Language-Action)一体化模型
- 跑到真机:参考 SO-101 + LeRobot 真机教程
参考资料
- CS123 Lab 7: Do What I Say
- CS123 Lectures 8–9 · AI-enabled Quadrupeds (LLMs)
- OpenAI · Function calling 指南