AIエージェントでROS2を動作させてみた〜RAIを参考にマルチAIエージェントでロボットを動かす 〜
こんにちは、竹内 です。
前回は、ロボットを走らせるための土台として Gazebo・SLAM・Nav2・RViz の4つを整理しました。走る場所と、地図と、経路と、それを覗く窓です。
今回はその上に載る側、AIエージェントの話です。題材は RAI(Robotec.AI)という ROS 2 向けのフレームワークです。
最初は、LLM にツールを持たせれば動くものだと考えていました。実際に作ると、難しいのは道具の持たせ方ではなく、LLM に何を見せるかでした。
まずロボット×LLM の設計にどれだけ幅があるかを整理して、そのうえで RAI が何を選んだのかを見ていきます。
ロボットに LLM を載せる、やり方が多すぎる
ロボットに LLM を載せる構成には、決まった正解の形がありません。
入力側だけでも、カメラ画像をそのまま VLM(画像を読めるLLM)に渡す、先に文章へ落としてから渡す、地図を絵にして渡す、と選択肢が並びます。
出す側も同じです。速度を LLM に直接決めさせるのか、行き先の座標だけ決めさせて Nav2 に任せるのか、あるいは誰にやらせるかだけ決めさせるのか。
一から決める前に、既存の AI エージェントフレームワークがどう作られているかを調べることにしました。
それで読んだのが RAI です。この幅の中で何を選んだのかが、そのまま構造に出ていました。
RAI とは何か
RAI は Robotec.AI が公開している、ROS 2 向けのロボットAIエージェント・フレームワークです。ライセンスは Apache-2.0。
思考のループは LangGraph(LangChain の StateGraph)の上に組まれています。私が入れたのは rai-core 2.12.1 で、ROS 2 は Humble / Jazzy が対応でした。
作りで特徴的なのは、役割が4つのレイヤに切り分けてある点です。
比べるために、NASA JPL の ROSA も読みました。あちらは「LangChain のエージェント+ROS のコマンドを叩くツール」で、実質1層です。
どちらが良い悪いではありません。ただ、並べると RAI の性格がはっきりします。知覚を層として持っているかどうかです。
核心は「LLM に何を見せるか」だった
4層のうち、今回いちばん参考になったのが②の知覚層です。RAI ではここを Aggregator と呼びます。
コードは小さく、実質2つのメソッドしかありません。溜める __call__ と、畳む get() だけです。
class BaseAggregator(ABC, Generic[T]):
def __call__(self, msg: T) -> None:
"""Connector から呼ばれる。ただバッファに溜めるだけ"""
@abstractmethod
def get(self) -> BaseMessage | None:
"""LLM が読める1メッセージに畳んで返す"""
トピックが届くたびに __call__ が呼ばれて、バッファに積むだけ。溜まったぶんは get() で1メッセージに畳まれます。
エージェントを ReActAgent から StateBasedAgent に替えると、この get() が一定間隔(私は15秒)で呼ばれ、返ってきた文章がそのまま会話履歴に積まれます。
入る形は /rosout: ... の1行で、人が打ち込んだ文と同じ種類のメッセージとして並びます。しかもエージェント側のコードには、この受け渡しが一行も出てきません。
ここに何を積むかが、そのまま「LLM に何を見せるか」です。素材は標準で4種類ついてきます。
この4つから、役ごとに載せるものを変えました。3体とも StateBasedAgent ですが、積む素材は別々です。
- 移動担当:
/rosoutを機械的に要約。Nav2 の「goal rejected」「recovery 発動」が、自動的に届きます - 把持担当(アームを動かす役):何も載せません。作業台に何があるかは、掴む直前に自分のカメラ道具で引きます
- 管理者:何も載せません。ROS を直接触らせない役なので、知覚も空にしました
1体に全部やらせず、役割で分ける
先に、名前だけ整理します。
- エージェント=役。管理者・移動担当・把持担当の3体です
- 道具=LLM が呼んだときだけ動く関数です
- 知覚層=エージェント1体につき1つの入口。積んであれば呼ばれなくても会話へ入ります
エージェントは1体にせず、3体に分けました。分ける単位は道具と権限で、役ごとに使える道具と読み書きできるトピックを別々に決めています。
- RAI 標準:役の顔ぶれは決まっておらず、振り分けも道具です(
create_handoff_tool)。ただし振り先は同じプロセスの中で、外からは1個のノードにしか見えません - 私の構成:役ごとに1プロセス。委譲は
/motion/from_human→/motion/to_humanのトピック往復なので、ros2 topic echoで会話を覗けます
書き方はこれだけです。受け取るトピック・出すトピック・使える道具・書き込みを許すトピックを並べます。
MOTION = RoleSpec(
name='motion', label='移動担当',
source='/motion/from_human', # 指示を受け取るトピック
target='/motion/to_human', # 応答を出すトピック
readable=('/odom', '/scan', '/tf', '/robot_cam/image'),
writable=('/cmd_vel',), # Nav2 稼働時は空にする
drive_tools=True, nav_tools=True, map_tools=True,
)
MANIPULATION = RoleSpec(
name='manipulation', label='把持担当',
source='/manipulation/from_human',
target='/manipulation/to_human',
readable=('/arm/joint_states', '/robot_cam/image'),
writable=(), # 関節トピックは1つも渡さない
arm_tools=True, camera=True,
)
- 効いたのは賢さではなく権限。移動担当は
/cmd_velに書けますが、把持担当はwritableが空で publish が全部拒否されます - 知覚層も1体ずつ。移動担当の履歴には管理者の指示と自分の知覚層の要約が並び、どちらも
human:です
この履歴は、他の担当には見えません。移動担当が受け取った goal rejected は、管理者にも把持担当にも届きません。知覚層は1体の中の話、委譲は1体と1体の間の話です。
移動は、全部 Nav2 に寄せた
Nav2 が居るときの移動担当には、行き先を渡す道具だけを持たせます。速度を出す道具は取り上げました。
両方あると、LLM はときどき速度のほうを選びます。Nav2 が経路を追っている最中に割り込むので、走りも自己位置もおかしくなります。
プロンプトで「Nav2 を使って」と書いても、たまに直接動かします。選ばせたくないなら、持たせない。これが確実です。
道具を消すだけでは足りません。/cmd_vel への書き込み許可も一緒に外します。許可が残っていると、汎用の道具が抜け道になるので。
RAI 公式の Nav2 デモも同じで、ツール一覧に /cmd_vel を叩く道具はありません。
地図は「目盛り付きの絵」にして VLM に渡す
Nav2 に渡せるのは座標だけです。でも人の命令は「柱の向こう側へ」「開いているほうへ抜けて」と、空間の言葉でやって来ます。
この間を埋めるものが要ります。地図はあるので LLM に見せればよい、と考えて、まず数値のまま渡そうとしました。
数値のままでは渡せませんでした。前回出てきた占有格子は、私の環境で 222×222、約5万マスあります。プロンプトに入る量ではありません。
結局、地図は画像にして VLM に見せることにしました。RAI 純正の道具も同じ考え方で、2m 間隔のグリッド線と座標ラベルを焼き込んでから渡しています。
ここを担うのは知覚層ではなく、道具のほうです。地図を絵にする関数は移動担当だけが持っていて、LLM が呼んだときだけ動きます。
この座標ラベルが要ります。目盛りの無い絵だと、VLM は「なんとなく真ん中あたり」までしか読めません。
言葉だと伝わりにくいので、実際にエージェントへ渡している絵を置いておきます。
渡すのは画像だけではありません。現在位置は文章でも一緒に返しています。どこに居るかまで絵から読ませると、そこを外すと行き先も一緒にずれるためです。
整理すると、こうなります。座標しか受け取らない Nav2 と、空間の言葉で喋る人間。そのあいだを、地図の絵を読む VLM が繋いでいます。
なおこの絵は、Web UI で人が見ている図と同じ描画コードで作っています。人とエージェントで見え方がずれないので、動作確認もこの1枚で足ります。
読むだけでは出てこなかった話
最後に、動かして分かった話を1つ。汎用ツールだけで「0.5m 前進」をやると、行き過ぎます。
汎用の道具立てだと、LLM は3手を踏みます。/cmd_vel に速度を publish して、少し待って、0 を publish。手順としては何も間違っていません。
ところが、この手と手のあいだに毎回 LLM の往復が入ります。実測で3〜6秒。その間もロボットは、前の指令のまま走り続けています。
ログを見ると、意図した走行1秒に対して実走行11秒。0.5m の指示で 1.54m 進んでいました。3倍です。
シミュレータが速すぎるのかとも考えましたが、実時間との比は 0.984 でほぼ等速でした。思考待ちがそのまま走行時間になっていたのが原因です。
プロンプトで「短く刻め」と書いても直りません。刻むたびに同じ誤差が乗るだけです。
なので、発進から停止までを1回のツール呼び出しに閉じ込めました。/odom を見ながら、目標に達したら自分で止まる道具です。
LLM から見れば drive_distance(distance=0.5) の1手で "Moved forward 0.503 m" が返るだけ。RAI 本体には手を入れず、RAI の道具と並べて持たせています。
これは「LLM に任せない範囲」を決める話です。判断は任せて、時間の制約がある制御は任せない。そこが線引きでした。
ここまでを1枚にすると、こうなる
部品が増えてきたので、最後に全部つないだ図を置いておきます。前回の Gazebo・SLAM・Nav2・RViz も、今回の RAI も、同じ1枚に収まります。
見てほしいのは、エージェントが特別扱いされていないところです。3体とも「トピックを読んで、トピックに書く」だけのノードで、Nav2 や RViz と同じ立場で並んでいます。
だから実機に替えるときも、差し替えるのは一番下の段だけで済みます。上の2段はそのままです。
まとめ
- RAI=ROS 2 向けのエージェント基盤。通信・知覚・思考・手足の4層が独立しているので、1層だけ借りられる。
- 学びの本体は道具ではなく Aggregator(知覚層)。畳んだ状況が、聞かれる前に会話履歴へ差し込まれる。
- 積むのは道具の戻り値に出ない情報だけ。移動担当だけ
/rosoutを積み、あとの2体は空。 - 役割で分ける利点は賢さより権限。書き込み先を空にすれば、そもそも叩けない。Nav2 と速度ツールも共存させない。
- 地図は目盛り付きの絵で VLM へ。座標しか受け取らない Nav2 と、空間の言葉で喋る人間を繋ぐ。
- 時間制約のある制御は LLM に任せない。0.5m の指示で 1.54m 進んだのは、思考待ちが走行時間になっていたから。
読む前は「エージェントの設計=プロンプトの書き方」だと考えていました。実際に効いたのは、何を見せるかと、何を持たせないかです。
次回は、この構成を実際に動かします。環境構築から、地図を作って、自然言語の指示だけで自走するところまでを手順ごと。お楽しみに!
