一个 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。于是整个故事是:

  1. 打开新终端 → bashrc 执行,DOMAIN_ID = 1
  2. source 配置脚本 → DOMAIN_ID = 0,这个窗口正常
  3. 新开窗口 → 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 CallingARM64结论
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.namearguments,不读 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

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐