把语音机器人拆成两台机器,我用了两天,踩了九个坑
一个 LLM Agent × ROS2 项目的 联调实录:
PC 端 2 个坑,Jetson 端 7 个坑,全部解决,附带一堆以后能救命的经验。
先交代背景
我最近在做一个小项目:语音控制机器人。你对麦克风说"左转九十度",机器人真的会转。
链路是这样的:
麦克风 → 语音识别(ASR) → 大模型理解意图 → 发指令 → 机器人执行
之前所有东西都跑在一台 PC 上,相安无事。Day11 的目标是把它拆成两台机器:
- Jetson Orin Nano:跑"耳朵"和"大脑"——语音识别 + 大模型推理(这才是机器人本体的样子)
- PC:跑"身体"和"环境"——Gazebo 仿真小车 + 传感器 + 扬声器
想法很美好:DDS 跨机自动发现,代码一行不改,插上网线就能通。
现实是:代码确实一行没改,但两天里我踩了九个坑。
下面按时间线讲,每个坑都是真实翻车现场。
第一天:PC 端,两个"环境变量"的坑
坑 1:配置文件里写了一个不存在的中间件
上手第一件事,ros2 node list,直接炸:
[ERROR] [rcl]: RMW implementation not installed
(expected identifier of 'rmw_cyclonedds_cpp'),
failed to load shared library 'librmw_cyclonedds_cpp.so':
No such file or directory
任何 ros2 命令都秒退,连节点列表都看不了。
排查用了一分钟:
dpkg -l | grep rmw-
# ros-humble-rmw-fastrtps-cpp ✅ 有
# ros-humble-rmw-cyclonedds-cpp ❌ 根本不存在
真相:我之前在配置脚本里写了 export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp,但系统里从来没装过 CycloneDDS。ROS2 启动时按这个变量去找对应的库文件,找不到,直接躺平。
这个坑的坑点在于:配置是我自己写的,写的时候想当然,写完从没验证过。
解法也简单——跟现实和解,系统里有什么用什么:
unset RMW_IMPLEMENTATION # 用 Humble 默认的 Fast DDS
顺便学到一个铁律:两台机器的 DDS 实现必须一致。Fast DDS 和 CycloneDDS 的节点互相发现不了对方,一个用 A 一个用 B = 互盲。最稳的策略就是都不设,都用发行版默认。
坑 2:最阴的坑——配置"时好时坏"
坑 1 解决后,开始跨机测试。Jetson 上的 llm_agent 明明在跑,PC 这边:
$ ros2 topic pub --once /voice_text std_msgs/msg/String "data: '测试指令'"
Waiting for at least 1 matching subscription...
Waiting for at least 1 matching subscription...
Waiting for at least 1 matching subscription...
永远在等。更诡异的是——换个终端窗口就好了,新开一个窗口又不行。
这种"时好时坏"最容易把人带进沟里,一度怀疑是网络不稳定、WiFi 抽风。
冷静下来做了个对比实验:
$ ROS_DOMAIN_ID=1 ros2 node list # → 空
$ ROS_DOMAIN_ID=0 ros2 node list # → /llm_agent ← 在这呢!
节点活在 Domain 0,我的终端活在 Domain 1。ROS2 的 DOMAIN_ID 就是隔离墙,不同域的节点完全互相看不见——跟"网络"半毛钱关系没有。
那为什么配置脚本明明设了 0,终端里却是 1?顺着查:
$ grep -n "ROS_DOMAIN" ~/.bashrc
186:export ROS_DOMAIN_ID=1 # ← 抓到了
我的 ~/.bashrc 里硬编码过一句 export ROS_DOMAIN_ID=1。于是整个故事是:
- 打开新终端 → bashrc 执行,DOMAIN_ID = 1
- source 配置脚本 → DOMAIN_ID = 0,这个窗口正常
- 新开窗口 → bashrc 又执行一遍,回到 1,挂
在哪个窗口操作决定了通不通,表现就是"时好时坏"。
教训一句话:全局 bashrc 里不要硬编码场景性变量。 配置脚本设置的值再正确,也架不住 shell 初始化文件在背后补刀。验证环境要 printenv 看当前真实值,不能信"我跑过配置脚本了"。
第一天结束,PC 端通了。当时觉得:网络层是最难的,过了。
天真了。
第二天:Jetson 端,七个坑连击
坑 3:连环问——你到底是哪个 Python?
Jetson 上跑 voice_input,报 No module named 'sounddevice'。装了。还报。再装。还报。
最后 which python3 一看:
/home/zy/miniconda3/envs/ros2_env/bin/python3
在 conda 环境里。我 pip 装的包全进了 conda 的 site-packages,而 ros2 run 的入口脚本用的是系统 Python。两个解释器各玩各的,装一百遍都没用。
而这只是 conda 深坑的入口……
坑 4:Python 3.13 vs ROS2 Humble
顺手查的时候发现更大的雷:miniconda 的 base 环境是 Python 3.13,而 ROS2 Humble 只支持 Python 3.10。
证据特别直观:
运行中的节点: /opt/ros/humble/.../python3.10/ ← ROS2 用 3.10
构建产物: install/.../lib/python3.13/ ← colcon 编到了 3.13 目录
构建和运行用了两个不同版本的 Python,编译产物 ROS2 根本加载不了。
解法:老老实实建一个 3.10 的专用环境,删掉全部旧构建产物重编:
conda create -n ros2_env python=3.10 -y
conda activate ros2_env
rm -rf build install log # 必须!增量构建会保留 3.13 的残留
colcon build --packages-select ...
记住这条排查技巧:对比"进程路径里的 Python 版本"和"install 目录里的 Python 版本",不一致就是环境混了。
坑 5:conda 里编 ROS2,依赖装不齐
新环境里 colcon build 又连环报缺:No module named 'em'、No module named 'catkin_pkg'、No module named 'lark'……
一个一个装,装完 empy 4.x 还报 module 'em' has no attribute 'BUFFERED_OPT'——empy 4.x 的 API 和 Humble 不兼容,必须锁死老版本:
pip install "empy==3.3.4" catkin_pkg lark-parser
就一个版本号的差别,不查文档根本猜不到。这套清单已经记下来了,下次建环境一次装齐。
坑 6:我调了半天 Function Calling,结果服务器根本不是我以为的那个
这是第二天最有戏剧性的坑。
Jetson 上没装 Ollama,正好项目里预留了 llama.cpp 后端的切换开关,就顺势迁过去。结果接上之后,Function Calling 全部失败,模型返回:
{"finish_reason": "error", "content": "我无法实际执行这个动作..."}
第一反应:模型不支持 FC?查证 Qwen2.5-3B 官方明明支持。查配置?模型名、端口全对。
折腾好一会儿,最后干了件本该第一件事就干的事——看看 8000 端口上到底是谁:
ps aux | grep 8000
# /usr/local/bin/mlc_llm serve /workspace/dist/Qwen2.5-3B-Instruct-q4f16_1-MLC ...
跑的根本不是 llama.cpp,是 MLC LLM! 8000 端口上一直驻留着之前启的 MLC 服务,我所有的"llama.cpp 行为异常"都是在跟 MLC 对话。而且 MLC 的 OpenAI 兼容层不完整,tools 参数传进去直接报 error。
事后调研清楚了三个后端的 FC 支持情况:
| 后端 | Function Calling | ARM64 | 结论 |
|---|---|---|---|
| MLC LLM | ⚠️ 挑模型 | ✅ | 当前不可用 |
| Ollama | ✅ | ❌ 不支持 ARM64 Linux | 排除 |
| llama.cpp | ✅ | ✅ | 真身,用它 |
换上真的 llama.cpp + GGUF 模型(q4_k_m 量化,约 2GB 内存,7.4GB 的 Orin 很从容),一切正常。
教训值千金:怀疑后端行为不对时,先 ps aux | grep 端口 确认真身,一分钟的事,别信配置文件,别急着查文档。
坑 7:模型"不按套路"输出 FC——其实是另一种正常
换完 llama.cpp 又出新状况:tool_calls 字段一直是 null,但模型的 content 里赫然是:
```json
{"name": "move_forward", "arguments": {"distance": 0.5}}
乍看是坏消息:FC 没触发。细看是好消息:**模型完全理解了意图,只是把工具调用以文本形式写在回答里**,而不是填进结构化的 `tool_calls` 字段。这不是故障,是 llama.cpp + 这套模型的实现方式。
解法很干脆:写一个双模式解析,四种正则兜底从文本里抠 JSON,解析优先级:
文本模式 FC → 标准 tool_calls FC → 纯文字回答
改完压测:"停止"指令 10/10 全过,浮点参数、中文数字全识别。
这个坑打破了我一个认知:**FC 的"正确格式"不止一种。** 判断 FC 是否工作,要看模型输出了什么,不能只盯 `tool_calls` 字段是否为空。
### 坑 8:单条全过,第二条必炸——隐藏最深的坑
单条指令全部正常,我开始测多轮对话。第二条指令一发:
500 Server Error: Internal Server Error
服务端: Failed to parse messages: Missing tool call type:
{“function”:{“name”:“turn_left”,“arguments”:{“angle”:90}}}
错误信息其实已经把答案贴脸上了——`Missing tool call type`。因为文本模式 FC 的分支里是我**自己手工构建** `tool_calls` 写进对话历史的,构建的时候漏了 `"type": "function"` 字段:
```python
# 我写的(错)
tool_calls = [{"function": {"name": ..., "arguments": ...}}]
# 正确的
tool_calls = [{"type": "function", "function": {"name": ..., "arguments": ...}}]
为什么第一条能过?因为本轮解析只读 function.name 和 arguments,不读 type。而下一轮请求要把历史 messages 原样发回服务器,服务器做完整校验,一查 type 缺失,500。
本轮能跑 ≠ 格式正确。跨请求的持久状态(对话历史)是"第二次才出错"类问题的第一嫌疑人。
补上字段,连续三条指令左转→前进→停止,一路绿灯。
坑 9:绕了一圈,又是 DOMAIN_ID
最后测 ROS2 跨机通信,好家伙,第一天的剧本又来了:节点日志显示"就绪",进程也活着,另一个终端 ros2 node list 就是看不见。
这次学乖了,直接读进程的真实环境(比 echo 当前终端靠谱多了,进程环境不会骗人):
cat /proc/<pid>/environ | tr '\0' '\n' | grep ROS
# 节点进程: ROS_DOMAIN_ID=0
echo $ROS_DOMAIN_ID
# 当前终端: ROS_DOMAIN_ID=1
又是域不一致。又是一句 export ROS_DOMAIN_ID=0 统一解决。
两天踩了两次同一个坑,这个教训是刻进肌肉记忆了:“节点在跑但看不见”,第一步永远是对比两端的 DOMAIN_ID。
九个坑踩完,系统长这样
PC (Fast DDS, DOMAIN_ID=0)
└─ ros2 topic pub /voice_text "向前走半米"
↓ DDS 跨机发现 + 通信
Jetson Orin (Fast DDS, DOMAIN_ID=0)
└─ llm_agent (llama.cpp 后端)
└─ llama-server (localhost:8000)
└─ qwen2.5-coder-3b-instruct-q4_k_m.gguf
实测数据:
| 指标 | 数值 |
|---|---|
| FC 延迟 | 1000–1650 ms |
| 指令发布延迟 | 0.2–0.6 ms |
| 指令识别 | 压测 10/10,多轮对话正常 |
| 模型内存 | ~2GB(Q4_K_M 量化 3B) |
"左转九十度"从 Jetson 的麦克风说出来,PC 上的小车真的转了 90 度。
复盘:两天踩坑换来的方法论
排查速度表(全部实战验证过)
| 症状 | 第一反应 |
|---|---|
| import 报错 | which python3,对比构建产物路径里的 Python 版本 |
| 后端行为诡异 | ps aux | grep 端口,确认真身 |
| “第二条才出错” | 想跨请求的持久状态:对话历史、缓存 |
| 节点互相看不见 | cat /proc/<pid>/environ 对比 DOMAIN_ID |
| 时好时坏 | 90% 是环境变量污染,查 bashrc/profile |
三条最大感悟
1. 环境变量的坑会重复出现,因为你只修了"这一次"。
DOMAIN_ID 踩了两次,RMW 写错一次,根因都是同一个习惯:凭记忆写配置,从不验证。修坑要修习惯——写环境变量前先 echo/printenv 确认,改完再看进程的真实环境。
2. "时好时坏"不是玄学,是信号。
换个终端结果就不同的现象,指向的永远是 shell 初始化文件或者后台 daemon 缓存,而不是网络。出现这种症状,先怀疑环境,再怀疑命运。
3. 兼容性问题的正解是抽象出降级链。
文本模式 FC 这个坑如果只写死一种解析,换个模型/换个后端又得返工。最后落地的方案是优先级链:文本模式 → 标准 tool_calls → 纯文字回答。不指望所有后端都标准,而是让系统能消化所有后端的不标准。 这个思路放到任何集成工作里都成立。
写在最后
两天,九个坑,代码核心逻辑零改动——消息契约和分层设计在这次联调里救了我无数次,每次排查都不用怀疑业务代码,问题全在环境和集成层。
分布式就是这样:单机跑通只完成了 30%,剩下 70% 是让两台机器像一台一样工作。
下一篇打算写跨机延迟对比数据——Jetson 端推理比 PC 慢了约 5 倍,但指令缓存命中依然 3ms,这个反差很有意思。
项目:LLM Agent × ROS2 语音机器人(Day11 分布式联调)
硬件:Jetson Orin Nano 8GB + Ubuntu 22.04 PC · ROS2 Humble · llama.cpp + Qwen2.5-3B
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)