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 に任せるのか、あるいは誰にやらせるかだけ決めさせるのか。

LLM に見せるもの(入力) センサの生の数値 /scan の配列をそのまま カメラ画像を1枚 VLM に画像で渡す 画像を先に文章化 安いモデルで要約させる 地図を絵にして 俯瞰図を VLM に読ませる 組み合わせは掛け算。しかもどれも「それっぽく」動く LLM に決めさせること(出力) 速度を直接 「前へ 0.2 m/s」を自分で出す 行き先の座標 「x=2.0, y=-1.0」を Nav2 へ 誰に任せるか 「移動担当に頼む」を選ぶ
図1:何を見せて、何を決めさせるか。この掛け算ぶんだけ設計がある

一から決める前に、既存の 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つのレイヤに切り分けてある点です。

④ アプリ層 AgentRunner/frontend ── 複数エージェントの起動・停止・UI ③ エージェント層(LangGraph) ReAct/State-based/megamind ── 思考のループそのもの ② 知覚層 Aggregator ── センサを「LLM が読める1メッセージ」に畳む ① 通信層 ROS2Connector/HRIConnector ── topic・service・action・TF を均す ⓪ 手足 Tools/Toolkits ── LLM から呼べる道具(Nav2・トピック読み書き) ROS 2 のグラフ 思考ループはそのまま 借りて、自作の上に載せた RAI 独自の層(ROSA に無い)。 今回いちばん効いたのがここ 4層は互いを直接呼ばない。 だから1層だけ持ち出せる。
図2:RAI の構成。通信・知覚・思考・手足が別の層になっている

比べるために、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行で、人が打ち込んだ文と同じ種類のメッセージとして並びます。しかもエージェント側のコードには、この受け渡しが一行も出てきません

道具(Tool)── LLM が呼んで、はじめて動く LLM が「見たい」 トピックを読む 結果を返す 呼ばれなければ何も起きない 知覚層(Aggregator)── 聞かれる前に、勝手に積まれる トピックが届く ログ・カメラ 30Hz でも溜めるだけ 15秒ごとに畳む get() が1メッセージに 畳んだらバッファは空 会話履歴(LLM を呼ぶ直前) human: 棚の前まで行って human: /rosout: goal rejected … 下の1行が知覚層の差し込み。エージェントのコードには出てこない
図3:知覚層の差し込み。道具は呼ばれて動くが、こちらは呼ばれる前に会話へ入っている

ここに何を積むかが、そのまま「LLM に何を見せるか」です。素材は標準で4種類ついてきます。

ROS2Logs Aggregator 入力 ROS のログ 返すもの 重複を畳んだ 要約テキスト VLM は呼ばない ROS2GetLastImage Aggregator 入力 カメラ画像 返すもの 最新1枚を 画像のまま添付 VLM は呼ばない ROS2ImgVLM DescriptionAggregator 入力 カメラ画像 返すもの 最新1枚を VLM に 説明させたテキスト VLM を呼ぶ ROS2ImgVLMDiff Aggregator 入力 カメラ画像 返すもの 最初・中間・最後の 3枚を比べた差分 = スタック検知 VLM を呼ぶ
図4:同梱の4種類。右の2つは、要約を作る段階でもう LLM を呼んでいる

この4つから、役ごとに載せるものを変えました。3体とも StateBasedAgent ですが、積む素材は別々です。

  • 移動担当/rosout を機械的に要約。Nav2 の「goal rejected」「recovery 発動」が、自動的に届きます
  • 把持担当(アームを動かす役):何も載せません。作業台に何があるかは、掴む直前に自分のカメラ道具で引きます
  • 管理者:何も載せません。ROS を直接触らせない役なので、知覚も空にしました
🧠 管理者 道具が返すもの 部下からの報告そのもの 知覚層 なし ROS を直接読まない役。 現場は担当に聞く 🦿 移動担当 道具が返すもの 着いたか、着かなかったか = 経緯は落ちる 知覚層 /rosout の要約 goal rejected・recovery は ここにしか出てこない 🦾 把持担当(アーム) 道具が返すもの 実測しながら刻んで動き、 持ち上がったかまで返す 知覚層 なし 15秒ごとに畳んでも、 戻り値より古い値になる 積む価値があるのは「道具の戻り値に出ない情報」だけ。2体は空でよかった
図5:役ごとの知覚層。積んでいるのは3体のうち1体だけ

1体に全部やらせず、役割で分ける

先に、名前だけ整理します。

  • エージェント。管理者・移動担当・把持担当の3体です
  • 道具=LLM が呼んだときだけ動く関数です
  • 知覚層=エージェント1体につき1つの入口。積んであれば呼ばれなくても会話へ入ります

エージェントは1体にせず、3体に分けました。分ける単位は道具と権限で、役ごとに使える道具と読み書きできるトピックを別々に決めています。

  • RAI 標準:役の顔ぶれは決まっておらず、振り分けも道具です(create_handoff_tool)。ただし振り先は同じプロセスの中で、外からは1個のノードにしか見えません
  • 私の構成:役ごとに1プロセス。委譲は /motion/from_human/motion/to_human のトピック往復なので、ros2 topic echo で会話を覗けます
人の命令 /from_human /to_human 管理者 ROS は直接触らない。振り分けだけ /motion/from_human /motion/to_human /manipulation/from_human /manipulation/to_human 移動担当 道具 drive_distance / turn_angle navigate_to_pose(Nav2 稼働時) カメラ / check_progress 権限 読 /odom /scan /tf /camera / 書 /cmd_vel 知覚層 /rosout を要約(LLM 不使用) 🦾 把持担当(アーム) 道具 pick_object / place_object_at inspect_workspace / home_arm カメラ / check_progress 権限 読 /arm/joint_states /camera / 書 なし 知覚層 なし(道具が実測して返す) 会話は HRIMessage というトピック。プロセス内の関数呼び出しではなく、DDS を通って届く
図6:3体構成。委譲は ROS 2 のトピックを往復する

書き方はこれだけです。受け取るトピック・出すトピック・使える道具・書き込みを許すトピックを並べます。

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: です
移動担当(1プロセス)の中 入口は2つ 管理者からの依頼 /motion/from_human 届いたときだけ 自分の知覚層 /rosout を要約 15秒ごとに、自動で 会話履歴は1本 human: 棚の前まで行って (この間に、考えた結果や道具の返事) human: /rosout: goal rejected … 読むのは1つの LLM LLM が読む 2つの区別は付かない 道具を呼ぶ/返事 /motion/to_human この履歴は移動担当だけのもの。管理者にも把持担当にも流れない
図7:入口は2つでも、履歴は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 が呼んだときだけ動きます。

占有格子(/map) 222 × 222 マス = 約5万個の数値 画像に焼く 白・黒・灰の階調にして 2m のグリッド線と 座標ラベルを書き込む VLM 絵を読んで 行き先を決める 座標1つ x=2.0 y=-1.0 目盛りが無い絵だと「なんとなく真ん中あたり」までしか読めず、渡す数値が作れない
図8:数値のままは渡せない。絵にして VLM に読ませ、座標を1つ作らせる

この座標ラベルが要ります。目盛りの無い絵だと、VLM は「なんとなく真ん中あたり」までしか読めません。

言葉だと伝わりにくいので、実際にエージェントへ渡している絵を置いておきます。

地図の画像(実際に渡している1枚) 2 0 -2 -4 -2 0 2 4 map 222x222 @ 0.05m/px = 11.1 x 11.1 m grid 2 m, map frame (+x right, +y up) pose: x=-1.02 y=-1.62 yaw=52 deg VLM への入力 地図の画像 左の1枚を、そのまま添付する 2m の目盛りと座標ラベル入り 壁・柱・経路・自機も同じ絵に載る 現在位置の数値(文章) The robot is at x=-1.02, y=-1.62 m, heading 52 deg (0 deg = +x) 自分の居場所は絵から読ませない この2つを見て、行き先の座標を1つ返す navigate_to_pose に、そのまま渡せる形
図9:これが実際に渡っている1枚。目盛りと座標ラベルが入っているので、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枚に収まります。

ブラウザの Web UI に日本語で入力 LLM / VLM Anthropic・OpenAI・Ollama を設定で選ぶ 3体それぞれが呼ぶ AIエージェント層 ── RAI 準拠。1役 = 1プロセスの ROS 2 ノード 管理者 思考ループ(LangGraph) 道具= 誰に振るか(handoff)だけ 知覚= なし(空のまま) ROS は直接触らない 移動担当 道具= navigate_to_pose 地図を絵で見る get_map_image 知覚= /rosout の要約 /cmd_vel は持たない 把持担当(アーム) 道具= pick_object / place_object_at 知覚= なし(道具が実測して返す) アームのある世界でだけ立つ 書き込み権限ゼロ(道具越しだけ) /agent/instruction /motion/from_human /motion/to_human navigate_to_pose (action) /odom /scan /tf /map 速度指令は出さない /arm/joint_states /manipulation/from_human /manipulation/to_human ROS 2 のグラフ(DDS)── 会話も、センサも、指令も、全部ここに乗る navigate_to_pose /map 購読して描くだけ センサと速度指令 /clock /scan /odom /tf /robot_cam/image /cmd_vel Nav2 planner / controller amcl / behaviors 行き先から経路を作る 速度を出すのはここだけ slam_toolbox /scan と TF から /map を作り続ける 地図が無いと Nav2 も絵も成立しない RViz / Web UI 同じトピックを購読して描く エージェントとは無関係に動く Web UI は思考トレースも出す /agent/trace シミュレータ(Gazebo Harmonic)── ros_gz_bridge で ROS 2 に繋ぐ ワールドとロボットを物理で回し、センサの値を作る エージェントから見ると、ここが実機かどうかは分からない 今回の話は上の1段だけ。下の2段は前回までに出てきたもので、そのまま使っている
図10:全体像。エージェントも Nav2 も RViz も、同じ ROS 2 のグラフに並んで乗っているだけ

見てほしいのは、エージェントが特別扱いされていないところです。3体とも「トピックを読んで、トピックに書く」だけのノードで、Nav2 や RViz と同じ立場で並んでいます。

だから実機に替えるときも、差し替えるのは一番下の段だけで済みます。上の2段はそのままです。

まとめ

  • RAI=ROS 2 向けのエージェント基盤。通信・知覚・思考・手足の4層が独立しているので、1層だけ借りられる。
  • 学びの本体は道具ではなく Aggregator(知覚層)。畳んだ状況が、聞かれる前に会話履歴へ差し込まれる
  • 積むのは道具の戻り値に出ない情報だけ。移動担当だけ /rosout を積み、あとの2体は空
  • 役割で分ける利点は賢さより権限。書き込み先を空にすれば、そもそも叩けない。Nav2 と速度ツールも共存させない
  • 地図は目盛り付きの絵で VLM へ。座標しか受け取らない Nav2 と、空間の言葉で喋る人間を繋ぐ。
  • 時間制約のある制御は LLM に任せない。0.5m の指示で 1.54m 進んだのは、思考待ちが走行時間になっていたから。

読む前は「エージェントの設計=プロンプトの書き方」だと考えていました。実際に効いたのは、何を見せるかと、何を持たせないかです。

次回は、この構成を実際に動かします。環境構築から、地図を作って、自然言語の指示だけで自走するところまでを手順ごと。お楽しみに!

AI駆動開発
新規CTA