语音+大模型指挥机器人:LLM Agent × ROS2 端到端实战
你对着麦克风说"左转九十度",Gazebo 里的小车真的转了 90°;
你问"前面有障碍物吗",机器人用激光雷达的真实数据开口回答你。
全程离线,没有一个请求发到云端。这篇文章记录这个系统从零到闭环的完整实战。
一、这个系统长什么样
先看最终形态的数据流:
麦克风 ──▶ voice_input ──/voice_text──▶ llm_agent ──/robot_command──▶ robot_controller ──▶ Gazebo 差速车
silero VAD 切段 │ │
SenseVoice 离线识别 │ Qwen2.5-3B │ 20Hz 心跳发布
(sherpa-onnx int8) │ Function Calling │ stop 最高优先级
│ │
└─ 查询类走 /query_sensor 服务 ──▶ state_query(读雷达/里程计)
说一句话,走完这条链:VAD 断句 0.5s + ASR 语音识别 122ms + LLM 理解 457ms + 控制下发 <50ms ≈ 1.16 秒后车开始动(热态实测,7 条动作指令的标准差只有 ±0.05s)。
整个系统是这样一天天长出来的
实验
主题
干了什么
关键产出
1-3
ROS2 基础
环境搭建、话题/服务编程、Action + launch
my_package、action_demo
4
传感器 + TF
激光雷达/里程计数据流接入,TF 坐标变换
/scan /odom 数据通路
5
造车
自写 URDF 差速小车 + Gazebo 一键启动
2 轮差速车 + 激光雷达(不依赖 turtlebot3)
6
大脑原型
LLM Agent 设计:Function Calling + 拒绝规则 + 对话记忆
llm_core.py(纯 Python,未接 ROS)
7
★1 文字闭环
建包 robot_agent,LLM 输出转 RobotCommand.msg,controller 20Hz 心跳控制
终端输入"向前走半米" → 车前进 0.5m
8
★2 语音闭环
silero VAD 自动断句 + SenseVoice 离线识别,接入 /voice_text
说"左转九十度" → 车左转 90°,端到端 1.16s
9
★3 感知问答
state_query 节点把 /scan /odom 汇总成服务,llm_agent 双推理回答
“前方有障碍物吗” → 基于真实雷达数据回答
10
开口说话
matcha TTS 离线合成 + 回声门控(防自问自答死循环)
问答闭环 ≈2.2s 说完→出声
11
分布式多机
Jetson 跑"耳+脑",PC 跑"身体+环境",DDS 跨机发现
推理在端侧、仿真在主机;llama.cpp 后端迁移
12-14
打磨交付(计划)
一键 launch 收编全部节点、性能压榨、录屏
从"开五个终端"到"一条命令"
三个 ★ 是项目的三个验收里程碑:文字闭环 → 语音闭环 → 感知问答,每一步的下游都零改动——这就是下一节要说的两层契约在还债。
技术选型一句话概括:全离线、全端侧、小模型。
组件
选型
为什么是它
ASR (语音识别)
SenseVoice int8(~230MB)
中文友好,CPU 实时,数字识别准
VAD (自动断句)
silero VAD(2MB)
说完停顿 0.5s 自动断句,免按键识别
LLM
Qwen2.5-3B(Q4_K_M 量化)
原生 Function Calling,3B 够用
LLM 运行时
Ollama / llama.cpp
一条命令起服务,OpenAI 兼容 API
仿真
Gazebo Fortress + 自写 URDF
不依赖 turtlebot3,车是自己的
通信
ROS2 Humble(默认 Fast-DDS)
话题/服务/消息契约,工业标准
为什么不联网用 GPT?三个理由:机器人要求低延迟(云端往返至少加 500ms)、离线可用(真机在没网的厂房里也得干活)、以及最重要的——这是"端侧大模型部署"这个岗位的完整预演。
二、核心设计:两层契约(本文最重要的一个决定)
动手写第一行代码前,先定了一个架构规矩:
LLM 层输出灵活的 JSON,ROS 层只认强类型消息,两层之间只有 llm_agent 一个转换点。
LLM 世界(灵活) ROS 世界(严格)
───────────────── ─────────────────
“action”: “move_forward” ─┐
“distance”: 0.5 ├─▶ RobotCommand.msg
(JSON,模型想怎么吐就怎么吐) │ (action/distance/angle/sensor_type)
┘ (编译期类型检查,ros2 interface show 自文档)
这个决定在后续每个阶段都兑付了红利:
- 实验8 接语音时:ASR 只是把"谁往 /voice_text 发消息"换了人,下游零改动;
- 实验9 加感知问答时:只是给 llm_agent 多挂了一个服务调用,controller 零改动;
- 实验10 加 TTS 时:只是订阅了已有的 /agent_answer 话题,全链路零改动;
- 实验11 换 LLM 后端时(Ollama → llama.cpp):只改了 llm_core 一层,下游还是零改动。
每次"零改动"都是当初这一条架构规矩在还债。分布式/机器人系统里,消息契约的设计价值 = 后面所有阶段的返工成本。
三、三大功能模块的实现要点
3.1 耳朵:VAD + 离线 ASR(实验8)
语音输入节点就干一件事:** continuous 听音,说完自动断句,识别成文字发到 /voice_text**。
每 32ms 读一块音频喂给 VAD,VAD 说"说完了"就切出一段送 ASR
data, _ = stream.read(512) # 512样本 @16kHz = 32ms
self.vad.accept_waveform(data[:, 0])
if not self.vad.empty():
segment = self.vad.front.samples # 一句完整的话
self.recognize_and_publish(segment) # SenseVoice → /voice_text
三个实战细节值得说:
- VAD 参数就是体验:静默 0.5s 判定"说完"(太短会把停顿切成两句话,太长响应变慢);短于 0.25s 的声音当噪声丢掉(键盘声、咳嗽);单段最长 8s 兜底。
- SenseVoice 会吐控制标签:偶尔输出 <|zh|><|NEUTRAL|> 这类东西,一行正则清洗掉。
- 发布后 1s 冷却:同句话的尾音常被 VAD 切成第二段,冷却期直接丢弃。
3.2 大脑:Function Calling + 一堆工程兜底(实验7-9)
LLM 层不是简单的"调个 API",5 个工具(前进/左转/右转/停止/查传感器)背后是一整套工程化处理链:
用户输入
│
├─ ① ASR 同音纠错 ──── “左传"→"左转”(确定性替换,零token零延迟)
├─ ② 危险指令拦截 ──── “飞到月球” → 直接拒绝,不进LLM(零token)
├─ ③ 指令缓存查询 ──── 重复指令直接命中LRU缓存(FC≈1ms vs 457ms)
│
├─ ④ LLM Function Calling(温度0.1保证稳定)
│ ├─ 输出参数再校验 ── distance 必须在0.05~10米(模型输出不可全信)
│ ├─ 查询类:FC1 → 调/query_sensor真实服务 → FC2 生成自然语言回答
│ └─ 没触发工具但像查询?── 追加提醒强推一次工具调用(nudge重试)
│
└─ ⑤ 全程写对话历史(20轮上限)── 支撑"再走远一点"这种指代
为什么兜底这么多?因为 3B 小模型的输出就是会漂。实测踩过的坑:Qwen2.5-3B 偶尔丢 required 的 type 参数(兜底成 “all”)、偶尔吐出带冒号的 key “type:”(归一化一下)、感知问答后下一轮会模仿"纯文字回答"跳过工具(nudge 重试解决)。
小模型不是不行,是要用工程手段把它的不稳定磨平。 这套"规则前置 + 输出校验 + 失败重试"的结构,比换 7B 大模型便宜得多,也快得多。
3.3 嘴巴:TTS + 回声门控(实验10)
问答闭环的最后一块:机器人开口回答。选型走了一圈弯路——melo TTS 实测"不像普通话+语速2倍+音量1/10",配置全对仍异常,果断换 matcha(RTF 0.087,4.5s 音频 0.4s 生成)。
最有意思的设计是回声门控:机器人有嘴有耳,扬声器的声音会被麦克风重新听到 → ASR 把自己的回答识别成新指令 → 自问自答死循环(演示现场翻车名场面)。
全双工需要 AEC 回声消除,工程量大。我们的解法是时间片半双工:
tts_node 播报前: 发 /tts_state=true → voice_input 丢弃一切音频段
tts_node 播完後: 再静音 0.3s(房间混响尾巴)→ 发 false,恢复收音
代价是播报期间用户不能打断,收益是零依赖、零误触发。消费级硬件上,先保证演示可靠,再谈体验升级。
四、延迟:数字会说话
用自研的延迟采集器(订阅 /latency_diag + /cmd_vel,零侵入)实测:
动作类指令(说完 → 车动):
指令
ASR
FC
端到端
首次指令
~122ms
~158-457ms
1.16s ± 0.05
缓存命中
~122ms
1ms
0.64s
两个数字值得品:
- 缓存命中把 FC 从 457ms 干到 1ms——重复指令("左转90度"说第二遍)完全绕过 LLM,端到端直降 0.5s。但感知查询永远不缓存,因为"现在的情况"必须每次真实查询;
- 1.16s 里最大头是 VAD 的 0.5s 断句等待——这是"听懂你说完了"的必要成本,砍不得。
问答类指令(说完 → 开口回答):
VAD 0.5s + ASR 0.12s + FC1 0.45s + 服务 0.02s + FC2 0.7s + TTS生成 0.4s ≈ 2.2s
问答要两次 LLM 推理(先生成工具调用,再基于工具结果组织语言),所以比动作类慢约 1 秒。优化空间明明白白:把 FC2 换成模板化播报能省 0.7s,牺牲一点自然度——工程取舍。
拒绝类指令(“飞到月球”):FC ≈ 1ms,零 token。 危险指令在进 LLM 之前就被关键词规则拦下,这是整条链路里最快的"智能"。
五、实验11:拆成两台机器(分布式实战)
单机闭环只是开始。真正的目标形态:推理在端侧,仿真在主机——Jetson 跑"耳朵+大脑"(这是真机本体的样子),PC 跑"身体+环境"(Gazebo 仿真+扬声器)。
═════ Jetson Orin Nano(端侧智能)═════
麦克风 → voice_input → llm_agent ⇄ llama.cpp(Qwen2.5-3B GGUF)
▲ │
│ /tts_state ├─/robot_command──┐
═════ WiFi · DDS 跨机发现 ═══════════════════════╪═══
│ ▼
扬声器 ← tts_node ←──/agent_answer robot_controller → Gazebo
划分逻辑不是按算力切的,是按"信息在源头压缩": - /scan 雷达数据一帧 115KB/s,如果直接跨机传是灾难。state_query 放在 PC 本地把大流量消化掉,跨机的只有文本消息,总流量 <1KB/s,降两个数量级;
- 音频同理:麦克风在 Jetson,就地 VAD+识别,跨机只传 20 字节的文本。
跨机联调踩坑实录(九连坑,这里挑最有价值的五个)
坑 1:配置写了不存在的中间件。 脚本里 export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp,系统里根本没装 CycloneDDS,ros2 命令全部秒退。教训:配置声明的环境变量,先 ls /opt/ros/humble/lib/ 验证库存在再写。最终跨机直通用 Humble 默认的 Fast-DDS——CycloneDDS 既然没装,就别在配置里声明它。
坑 2:最阴的坑——配置"时好时坏"。 Jetson 节点明明在跑,PC 发消息永远 Waiting,且换窗口时好时坏。真凶是 ~/.bashrc 第 186 行硬编码 ROS_DOMAIN_ID=1:新开终端就被覆盖回域 1,而节点活在域 0。DOMAIN_ID 是隔离墙,不同域的节点互相完全不可见。“时好时坏”= 强烈怀疑环境变量污染,不是玄学网络。
坑 3:conda 陷阱。 Jetson 上 pip 装包死活不生效——which python3 发现在 miniconda 环境里,包全进了 conda 的 site-packages,而 ros2 run 用系统 Python。更深的雷:miniconda base 是 Python 3.13,ROS2 Humble 只支持 3.10,colcon 产物编到了 3.13 目录里 ROS2 根本加载不了。修法:专用 3.10 环境 + rm -rf build install log 全量重建 + empy 锁死 3.3.4 版本。
坑 4:调了半天 FC,服务器根本不是我以为的那个。 llama.cpp 后端 FC 全部失败,折腾半天模型配置,最后 ps aux | grep 8000 一看——8000 端口驻留着之前的 MLC LLM 服务。怀疑后端行为不对时,先确认端口上的真身,一分钟的事。
坑 5:文本模式 Function Calling。 换真 llama.cpp 后,tool_calls 字段一直 null,但模型 content 里赫然是完整的工具调用 JSON——模型完全理解意图,只是用文本形式输出。解法是双模式解析(文本抠 JSON → 标准 tool_calls → 纯文字回答,三级降级)。FC 的"正确格式"不止一种,判断 FC 是否工作要看模型输出了什么,不能只盯 tool_calls 字段。
连带的深坑:手工构建的 tool_calls 写进对话历史时漏了 “type”: “function” 字段——本轮解析不读 type 所以第一条能过,下一轮把历史发回服务器被完整校验,500。"第二条才出错"类问题,第一嫌疑人永远是跨请求的持久状态。
换后端后的实测
指标
数值
FC 延迟(Jetson llama.cpp)
1000–1650 ms(PC Ollama 是 ~457ms,慢约 2–3.5 倍)
重复指令缓存
依然 ≈1ms,跨后端零损耗
指令识别
压测 10/10,多轮对话正常
模型内存
~2GB(Q4_K_M 量化),Orin 8GB 很从容
六、复盘:这套系统教会我的事
- 契约先行,是分布式系统的"通货膨胀对冲"。 每个阶段的"下游零改动"都在偿还实验7 定下两层契约时的设计红利。反过来,凡是当时没定契约的地方(比如 TTS 门控信号),后面都补了一轮协调成本。
- 小模型的可控性 > 大模型的能力上限(对指令场景而言)。 3B 模型 + 厚实的工程兜底(规则拦截/参数校验/缓存/nudge 重试),比 7B 裸跑更快、更稳、更省内存。端侧部署的哲学是:模型负责智能,工程负责可靠。
- 延迟优化先测量再动手。 1.16s 的构成里,最大头是 VAD 的 0.5s(必要成本),其次是 LLM 的 457ms(缓存可消)。没有延迟打点和自动采集器,这些数字全靠猜。
- 环境问题是分布式联调的主战场。 两天九坑,业务代码零改动——每个坑都是 Python 版本、环境变量、端口真身这类"基础设施层"的问题。排查速度表值得贴墙上:
症状
第一反应
import 报错
which python3,对比构建产物路径的 Python 版本
后端行为诡异
ps aux | grep 端口,确认真身
“第二条才出错”
跨请求持久状态:历史/缓存
节点互相看不见
cat /proc//environ 对比 DOMAIN_ID
时好时坏
环境变量污染,查 bashrc