跳到主要内容

7. 语言控制

这一章把前 6 章做的 PD / IK / trot / RL policy 都包装成工具,把一个 LLM 放在最上层当"大脑",让机器人能听懂 "往前走两米然后原地转身" 这样的语言指令。这是整门课程的"agent 骨架"——下一章再把视觉感知接进来,让 "追那个红球" 这种依赖外部信号的指令也能被执行。

本章目标

  • 能设计一套机器人可用的"工具 API"(例如 walk(vx, vy, wz, t)look_for(color)stop()
  • 能用 function calling 把 LLM 的自然语言输出转成工具调用
  • 能把工具调用结果串成一个闭环,让机器人在仿真里完成多步任务
  • 做出课程最终 demo:一段语音/文本指令 → Pupper 完成动作序列 的视频

前置阅读

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 和机器人之间的"接口契约"。三条原则按重要性排:

  1. 原子性:一个工具只做一件事。walk(vx, wz, t) 是好的;walk_to_object(name) 把"找物体 + 走过去"绑死,LLM 没法在中间插判断,这种"复合工具"少用。
  2. 可组合:能用纯文本(JSON schema)描述输入/输出、所有工具都通过同一个 dispatch(tool_name, args) 入口。这样 LLM 改提示词就能换组合,不需要改后端。
  3. 有副作用边界:明确把工具分成 read-onlyget_poseget_battery)和 actingwalkturnstop)。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 出格的概率低一个数量级。
  • walkduration 而不是终点位置,把"走多远 = 走多久"留给 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 using get_pose before the next step."——LLM 就会自动在两步之间插 get_pose,恢复 ReAct 节奏。

7.5 失败处理

工具调用失败的两类场景:

  1. 预期失败:参数越界(vx=2.0 超出量程)、机器人摔倒、超时未到目标。
  2. 意外失败: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 度"walkget_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 循环在跑。

Ch7 成果预览 把&quot;前进 1 米,然后右转 90 度&quot;拆成两次 tool call

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 真机教程

参考资料