🤖

toio x ATOM S3R-CAM x Unity x Rust x ChatGPT でロボットが歩く街を作ってみた #OMMF2024

に公開

2024 年 11 月 23 日(土)、24 日(日)に岐阜県大垣市で開催された Ogaki Mini Maker Faire 2024(OMMF 2024)にて、Ambient Works という 3 人のチームで出展してきました。

チームメンバーがそれぞれ自分の作品を出展しましたが、本記事ではその中で筆者(@nukopy)が企画、制作を行った「電脳ノマチ / Dennou no Machi」という作品のコンセプトや実際の展示の様子、システムの概要を紹介します。

※「2024 年」のイベントの振り返りです。ちょうと一年前のイベントの振り返りを今書いています。

Ogaki Mini Maker Faire 2024 とは

Ogaki Mini Maker Faire は Maker Faire 系列のローカルイベントで、岐阜県大垣市で隔年で開催されるイベントです。

https://www.iamas.ac.jp/ommf2024/about/

展示の様子は Twitter 上でハッシュタグ #OMMF2024 で検索すると見ることができます。

サマリー

まずは作品の全体像を紹介します。

作品概要

本作品のコンセプトは「身体を持った AI が街を歩き回るようになった近未来」です。

身体を持った AI、つまりロボットが街を歩き回り、人間や別のロボットと出会ったら会話をし、会話が終わったらまた街に出ていく、という近未来を想像しながら制作しました。

端的に言えば、「ロボットが街を歩き回る → 他のロボットと出会う → 会話する → 会話が終わると再び街に出ていく」という一連の流れをひたすら眺めて楽しむという作品です。

実物は以下のようになっています。

フィールド全景

ロボットを拡大するとこんな感じです↓

ロボット

フィールドを拡大するとこんな感じです↓

フィールド

ロボットがフィールド内を歩き回る様子は以下の動画をご覧ください。

https://www.youtube.com/watch?v=UkoQ069k38Q

技術スタック

使用した技術は以下の通りです。詳細はシステム概要で紹介します。

コンセプト

本作品のコンセプトは以下の通りです。こちらは展示ブースの配布資料に掲載したものです。

身体を持った AI が私たちの世界を自律的に歩き回るようになった少し先の未来を描きます。
この「マチ」では、AI が自由に歩き回り、偶然出会った人間や他の AI と対話を行います。AI は、過去の対話内容はもちろん、自身に搭載されたセンサーやインターネットから得た情報を蓄積し、それらを元にまた新たな対話を紡いでいきます。
電脳ノマチは、物理的な身体を持った AI が対話を積み重ねコミュニティを形成していく過程を観察するためのプロトタイピングです。

※ 実際には、人間は介入できない、かつ AI によるインターネットからの情報収集はなし、という構成となっています。コンセプトなのでお許しを。

作品詳細

ここからは作品の中身について紹介していきます。

展示の様子

先述の通り、本作品は「ロボットが街を歩き回る → 他のロボットと出会う → 会話する → 会話が終わると再び街に出ていく」という流れを眺めて楽しむ作品です。

まず、「」と「ロボット」は以下のようになっています。建物や自然に囲まれた街(フィールド)があり、その中をロボットが歩き回ります。

フィールド全景

実際の展示では、「街」と「ロボット」に加えて、「ロボット同士が出会ったときの会話内容」と「ロボットが見ている景色」(ロボットに搭載されたカメラの映像)をリアルタイムで確認できる UI を用意しました。

UI を含めた展示の様子は以下の通りです。

見づらくて恐縮ですが、下記写真内の縦長モニターに UI を表示しています。モニター下部に「会話内容」、上部に「ロボットが見ている景色」を表示しています。これにより、ロボットの動きとそれに連動したリアルタイムのデータを楽しむことができる展示となっています。

展示の様子

会話中のロボットの振る舞い

ロボットの動きは以下の動画のようになっています。

ロボット同士が出会うと、まずピコンと音が鳴り、会話が始まります(00:10)。会話中はロボットの身体を左右に震わせ、ボディに搭載されている LED ライトを点滅させています。ロボット同士が会話を終えると解散して再び街に出ていきます(00:15)。

https://www.youtube.com/watch?v=UkoQ069k38Q

実際の映像素材が少なすぎたのでシミュレータの録画も置いておきます。フィールドを上から俯瞰している構図です。

https://www.youtube.com/watch?v=Oxf44EwxD0U

「街中に歩いているロボットが無言で通信しているのも不気味だよな」と考えた上で、このような会話中の振る舞いを実装してみました。人間で言う「会話中の頷き」のような振る舞いをロボットにさせてみたいなと。結果的に、展示を見ている方への分かりやすさとしても良かったのではないかと思います。

個人的にはかわいくてお気に入りの動きです。

フィールドの囲い

コンセプトにある「ロボットが街を歩いている」ことを表現するために、フィールドの囲いを学校、オフィスビル、山などの形状にしています。これにより、ロボットが見ている景色(UI で確認できるカメラの映像)がロボットの位置や向きによって変化し、「今はオフィス街にいるな」とか「山の近くにいるな」みたいな「街を歩いている感」を演出しています。

フィールド

こちらはチームメンバーの @topper さん(今回の展示の「Arr01: Morpho Tile」の作者)にアイディアをいただき、レーザーカッターによるフィールドの囲いの制作にもご協力いただきました。

制作当初は、フリー素材の背景画像を印刷して囲いを作ることを考えていたのですが、それよりも断然美しい仕上がりになったと思います。木のアナログ感がとても好きです。

(影がいい感じ)

影の様子

ちなみに、囲いの建物や木のシルエットは Etsy で販売されている画像を購入し、これをレーザーカッターで出力しました。

見本画像

クリックすると Etsy のページへ飛べます。こういうのって売ってるもんなんですねー

囲いのシルエット画像 1

囲いのシルエット画像 2

システム概要

ここからは作品のシステムがどのような要素で構成されているかを紹介していきます。

「システム構成」の節でシステムの全体像を概観し、「ハードウェア」と「ソフトウェア」の節でその構成要素を深堀ります。

システム構成

システム構成は以下の通りです。

システム構成図

以下、ロボット同士の出会いから会話の生成、会話の終了までの流れを簡単に説明します。

まず、中心となるのは dnm-toio-controller という Unity 製のサーバです[1]。このサーバがフィールド内でのロボットの動作(移動、停止、回転)、ロボット同士の出会い判定(=衝突判定)、状態遷移(「移動中 → 会話中」など)を管理しています(以下、「ロボット制御サーバ」と呼びます)。

ロボット同士が出会う = ロボット同士が閾値以下の距離に近づくと、ロボット制御サーバにより衝突判定が行われ、ロボットの状態は「移動中」→「会話中」へと遷移します。このとき、dnm-llm-controller という Rust 製の会話生成のライフサイクル管理を担うサーバへ会話開始リクエストが送信されます(以下、「会話管理サーバ」と呼びます)。

次いで、会話管理サーバから OpenAI API へリクエストが送信され、LLM によりロボット同士の会話が生成されます。生成された会話は「会話内容表示 UI」に表示されるようになっています。

規定回数会話を繰り返すと会話の生成は終了し、会話管理サーバからロボット制御サーバへ会話終了の通知が非同期に送信されます。その通知を受けてロボットの状態は「会話中」→「移動中」へと遷移し、ロボットは再び街に出ていきます。

以上のような流れで「ロボットが街を歩き回る → 他のロボットと出会う → 会話する → 会話が終わると再び街に出ていく」という一連の流れが実現されています。

ハードウェア

システムの全体像を概観したところで、ここからはその構成要素を説明していきます。まずはハードウェアについてです。

本作品で制作したハードウェアには「ロボット」と「フィールド」があります。

ロボット

フィールドを歩き回るロボットは以下の 4 つのパーツで構成されています。実際の展示ではこれを 3 セット用意してフィールド内を歩き回らせています。

パーツの選定には以下のツイートを大いに参考にさせていただきました(toio の開発者の方のツイートでした)。

https://x.com/akichika/status/1713940863003554161

フィールド

ロボットが歩き回るフィールドは、フィールド本体とフィールドマットで構成されています。

  • フィールド本体
    • 寸法
      • 幅 430 mm × 奥行 430 mm(+ 囲い立て 20 mm 程度)
      • 高さ: 囲いの種類により異なる。50 mm ~ 100 mm 程度。
    • 材料
      • MDF ボード(レーザーカッターによる加工に適している)。
    • 実物画像
      フィールド本体
  • フィールドマット

「toio™ 開発用プレイマット」は、特殊印刷されている toio 専用の A3 サイズの紙材です。この上で toio を走らせると Bluetooth(BLE)経由で toio の絶対座標を取得・制御できる仕組みになっています。このマットには通し番号が振られており、適切に連結するとフィールドの広さを拡張することができます(仕様リンク)。

本作品のフィールドマットでは、この紙材を 2 枚連結しています。フィールドマットの設計、座標計算(原点を (0, 0) にするためのオフセット値の計算など)は以下のような設計図を作りながら行いました。

フィールドマットの設計

ソフトウェア

続いてソフトウェアについて説明します。

本システムでは、以下の 4 つのソフトウェアの開発を行いました。ここでは、バックエンド、ファームウェア、フロントエンドのカテゴリに分けて整理しています。

  • バックエンド
    • ロボット制御サーバ dnm-toio-controller
    • 会話管理サーバ dnm-llm-controller
  • ファームウェア
    • カメラモジュール ATOM S3R-CAM 用のファームウェア dnm-camera-firmware
  • フロントエンド
    • 展示用 Web UI dnm-web

バックエンド

バックエンドは主に以下の 2 つのサービスで構成されています。

  • ロボット制御サーバ:dnm-toio-controller
    ロボットのフィールド内での移動の制御、衝突判定、状態の管理を行うサーバ
  • 会話管理サーバ:dnm-llm-controller
    ロボット同士の会話の開始・終了といった会話のライフサイクルを管理するサーバ

システム構成図では、以下の赤枠で囲んだ部分がバックエンドに相当します(各種ミドルウェアと外部サービスも含まれます)。

バックエンドのシステム構成図

上図にある通り、これらの 2 つのサービスは HTTP、またはメッセージブローカーを介して通信を行います。このバックエンドのサービス間の通信が本システムの肝となっていますが、詳細はロボットの状態遷移と会話のライフサイクル管理の節にて説明します。

  • ロボット制御サーバ dnm-toio-controller
    • 機能
      • ロボットのフィールド内での移動の制御
      • ロボットの位置情報に基づく衝突判定
      • ロボットの状態の管理(位置情報や「移動中 → 会話中」といった状態遷移の管理)
      • イベントハンドリング
    • 技術スタック
  • 会話管理サーバ dnm-llm-controller
    • 機能
      • ロボットのシステムプロンプトの読み込み(設定ファイルに外出し)
      • 会話生成の開始・終了の制御
      • 会話生成、会話内容の取得用の HTTP API の提供
      • イベントハンドリング
      • 会話内容、状態遷移イベントの DB への保存
      • 会話内容の標準出力
    • 技術スタック
      • Rust
      • Axum
      • sqlx
      • DB: PostgreSQL
      • メッセージブローカー: NATS
      • OpenAI API

ファームウェア

カメラモジュール ATOM S3R-CAM 用のファームウェア dnm-camera-firmware は Arduino で開発しました[2][3]。このファームウェアは各ロボットのカメラモジュールに搭載されています。

システム構成図では、以下の赤枠で囲んだ部分がファームウェアに該当します。

ファームウェアのシステム構成図

  • dnm-camera-firmware
    • 機能
      • 無線ネットワークへの接続管理
      • mDNS クライアント(DNS サーバが無いローカルネットワーク内でホスト名と IP アドレスを解決する仕組み)
      • カメラ映像をストリーミングするための HTTP サーバ
    • 技術スタック
      • Arduino(C 言語 like な言語で書かれたマイコン向けのプログラミング言語)
補足:Motion JPEG over HTTP によるカメラ映像のストリーミング

カメラ映像のストリーミングは Motion JPEG over HTTP で実装しています。これはデフォルトのファームウェアに実装されているものをそのまま流用しました。ネットワークカメラのストリーミングでよく使われている方式のようです。

Motion JPEG over HTTP は、multipart/x-mixed-replace というサーバプッシュ用の MIME タイプを用いて JPEG を連続してクライアントへ送信する方式です。この MIME タイプは HTTP/1.1 で定義されています。

この方式で画像を配信する HTTP サーバは、初回のリクエスト受信時にこの MIME タイプを含んだ「MIME ヘッダ」と、boundary と呼ばれる「配信データの区切りを表す文字列」をヘッダに含めてレスポンスを返します。このレスポンス以後、同一のコネクション上でクライアントへ JPEG を連続して送信(プッシュ)することができます。

今回のケースでは、カメラ映像をフレーム単位で画像データとして送信することになるので、フレームごとに boundary を HTTP メッセージの境界に挟んで JPEG を送信することになります。

Motion JPEG over HTTP の通信の流れをシーケンス図で表すと以下のようになります。

MIME タイプ multipart/x-mixed-replacemultipart/mixed)の理解には以下のサーバープッシュの解説が分かりやすいです。

https://docstore.mik.ua/orelly/web2/xhtml/ch13_03.htm

boundary の扱いは、実際のコードを見てみると理解が捗ると思います。以下に、今回のシステムで使用した Arduino 製のファームウェアのコードを抜粋しコメントを付けてみたので、興味がある方はぜひご覧ください。

Motion JPEG 配信サーバのコード抜粋

以下、Arduino で実装したファームウェアのコードの Motion JPEG 配信サーバの部分を抜粋、コメントしました。boundary の使い方の雰囲気は伝わるかなと思います。

なお、以下のコードは必要なヘッダファイルのインクルードが省略されているなど不十分なので動かないです。実際に動かしてみたい方は、ATOM S3R-CAM の商品ページの「ソフトウェア」の節から Arduino のコードをダウンロードしてみてください。

// HTTP サーバの設定
WiFiServer server(80);

// カメラ映像用のバッファ(使いまわし)
camera_fb_t *fb = NULL;
uint8_t *out_jpg = NULL;
size_t out_jpg_len = 0;

// JPEG のストリーミングを行う関数の宣言
static void jpegStream(WiFiClient *client);

// boundary 文字列の定義
#define PART_BOUNDARY "dnmboundary"

// MIME タイプ `multipart/x-mixed-replace` の定義
static const char *_STREAM_CONTENT_TYPE =
    "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;

// boundary: 各フレームの区切りを示す文字列
static const char *_STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";

// サーバプッシュ用(JPEG の push 用)の MIME ヘッダ
static const char *_STREAM_PART =
    "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

// JPEG のストリーミングを行う HTTP サーバのエントリーポイント
static void jpegStream(WiFiClient *client) {
  // 初回のリクエスト受信時、`multipart/x-mixed-replace` MIME タイプを含んだレスポンスを返す
  client->println("HTTP/1.1 200 OK");
  client->printf("Content-Type: %s\r\n", _STREAM_CONTENT_TYPE);
  client->println("Content-Disposition: inline; filename=capture.jpg");
  client->println("Access-Control-Allow-Origin: *");
  client->println();
  /* HTTP レスポンスヘッダ
  HTTP/1.1 200 OK
  Content-Type: multipart/x-mixed-replace;boundary=dnmboundary
  Content-Disposition: inline; filename=capture.jpg
  Access-Control-Allow-Origin: *
  */

  // 以後、同一のコネクション上で JPEG を連続して送信する
  for (;;) {
    // カメラ映像のバッファを取得
    fb = esp_camera_fb_get();
    if (!fb) {
      Serial.println("Camera capture failed");
      continue;
    }

    // JPEG に変換
    frame2jpg(fb, 255, &out_jpg, &out_jpg_len);
    Serial.printf("pic size: %d\n", out_jpg_len);

    // フレームの開始を boundary を使って通知
    // boundary の文字列: `--dnmboundary`
    client->print(_STREAM_BOUNDARY);

    // JPEG サイズを添えて MIME ヘッダを送信
    client->printf(_STREAM_PART, out_jpg_len);
    /* HTTP ヘッダ
    Content-Type: image/jpeg
    Content-Length: 1234
    */

    // JPEG を連続して送信する
    int32_t to_sends = out_jpg_len;
    int32_t now_sends = 0;
    uint8_t *out_buf = out_jpg;
    uint32_t packet_len = 8 * 1024;
    while (to_sends > 0) {
      now_sends = to_sends > packet_len ? packet_len : to_sends;
      if (client->write(out_buf, now_sends) == 0) {
        // クライアントのコネクションが切れた場合、ループを抜ける
        goto client_exit;
      }
      out_buf += now_sends;
      to_sends -= packet_len;
    }

    // おまけ:フレームレートを計算
    int64_t fr_end = esp_timer_get_time();
    int64_t frame_time = fr_end - last_frame;
    last_frame = fr_end;
    frame_time /= 1000;
    Serial.printf("MJPG: %luKB %lums (%.1ffps)\r\n",
                  (long unsigned int)(out_jpg_len / 1024),
                  (long unsigned int)frame_time,
                  1000.0 / (long unsigned int)frame_time);

    if (fb) {
      // カメラ映像のバッファを解放
      esp_camera_fb_return(fb);
      fb = NULL;
    }
    if (out_jpg) {
      // JPEG のバッファを解放
      free(out_jpg);
      out_jpg = NULL;
      out_jpg_len = 0;
    }
  }

client_exit:
  // クライアントのコネクションが切れた場合、無限ループを抜けてここに飛ぶ
  if (fb) {
    // カメラ映像のバッファを解放
    esp_camera_fb_return(fb);
    fb = NULL;
  }
  // クライアントのコネクションを切断
  client->stop();
}

全然関係ないですが、HTTP のレスポンスメッセージをローレベルでクライアントに書き込んでいるコードが新鮮です。telnet で 1 行ずつ HTTP メッセージを送信したときを思い出します。

フロントエンド

展示用の Web UI は TypeScript / React で開発しました。ものすごくシンプルな SPA です。

システム構成図では、以下の赤枠で囲んだ部分がフロントエンドに該当します。

フロントエンドのシステム構成図

  • dnm-web
    • 機能
      • 3 体のロボットに搭載されたカメラの映像をストリーミングで受信・描画するための Web UI
    • 技術スタック
      • TypeScript
      • Vite
      • React

主要な機能の仕組み

ここまで説明してきた通り、本システムの主要な機能は「ロボットが街を歩き回る → 他のロボットと出会う → 会話する → 解散して再び歩き回り始める」という一連の流れをひたすら繰り返すことです。

ここからはこの一連の流れがどのように実現されているか、その仕組みを概説します。具体的には 3 つの機能に分けて解説していきます。

動きのおさらい用に実際の録画とシミュレータの録画を置いておきます。

https://www.youtube.com/watch?v=UkoQ069k38Q

https://www.youtube.com/watch?v=Oxf44EwxD0U

仕組み 1:ロボットの状態遷移と会話のライフサイクル管理

ロボットの出会いから始まり、会話の開始 → 会話の生成 → 会話の終了、そして、再び街に出ていくまで、ロボットはいくつかの状態を行き来します。つまり、ロボットの状態遷移に伴って会話のライフサイクルが回ります。

ここでは、まず「状態遷移」の節でロボットが取りうる状態について説明し、その後「処理の流れ」の節にて会話のライフサイクルを含む一連の処理の流れを「ロボットの状態遷移」を追いながらシーケンス図で説明することを試みます。

状態遷移

本システムで一番重要なのはロボットの状態の管理です。この状態の管理は先述した Unity 製の dnm-toio-controller が担います。

ロボットの状態遷移図は以下の通りです。開始状態を除き、ロボットは RUNNINGWAITINGTALKING の 3 つの状態を持ちます。

実装上は C# の enum を使って状態を定義しています(ソースコード上は CamelCase で定義)。上図の ● で表されている開始状態は INITIALIZING として定義しています。

public enum RobotStatus
{
    Initializing,
    Running,
    Waiting,
    Talking,
}

処理の流れ with シーケンス図

一連の処理の流れを状態遷移とともにシーケンス図で整理します。

全 6 ステップに分けて説明していきます。

  • Step 1: ロボット移動中
  • Step 2: ロボット同士が衝突 💥
  • Step 3: 会話開始リクエスト
  • Step 4: 会話開始
  • Step 5: 会話中
  • Step 6: 会話終了

登場人物

各ステップの解説に入る前に、登場人物だけ先に列挙しておきます。シーケンス図を見ている最中に分からなくなったらここに戻ってきてください。

  • ロボット 1、2
    • ロボット本体。ロボットの出会い、会話には最低 2 体のロボットが必要なので 1、2 と番号付けをしている。
  • ロボット制御サーバ dnm-toio-controller
    • ロボットの移動、状態遷移を管理する
  • 会話管理サーバ dnm-llm-controller
    • ロボット制御サーバからの会話開始リクエストを受けて会話を生成する
  • 非同期タスク in 会話管理サーバ
    • 会話管理サーバが会話開始リクエストを受けたとき、裏側では非同期タスクを立ち上げる。この非同期タスクが実際に LLM(OpenAI API)と通信をして会話を生成する。
    • 出会い ~ 会話終了までの一連の流れを「セッション」と呼ぶ。1 会話セッションにつき 1 つの非同期タスクが立ち上がり、セッションの開始から終了までの一連の処理を担う。
  • OpenAI API
    • 外部サービスの API。会話を生成する実体。

システム構成図も再掲しておきます。以下の赤枠で囲んだ箇所が本節で説明する部分に該当します。

システム構成図

なお、シーケンス図中では、ロボットの状態遷移の流れを分かりやすく表現するため、重要ではありますがメッセージブローカー、DB への書き込みは省略し、サービス間の通信のみを記述しています。図中のイベントの "publish" と書かれた部分は全てメッセージブローカー経由であり、また、全てのイベントは会話管理サーバにて DB へ書き込みを行っています。

Step 1: ロボット移動中

ロボットは常時 RUNNING 状態でフィールド上を巡回しています。各ロボットは自分の位置情報をロボット制御サーバに Bluetooth 経由で常時送信し[4]、サーバ側では受け取った座標を用いて毎フレーム衝突判定を行います。

Step 2: ロボット同士が衝突 💥

移動を続ける中で 2 体のロボット間の距離が閾値以下になると、ロボット制御サーバが衝突を検知します。このタイミングで 2 体のロボットの状態は RUNNING から WAITING へ遷移し、会話開始に向けて準備を行います。

この準備の中で、ロボット制御サーバは衝突から会話終了までの一連の処理を一意に識別するためのセッション ID を発行し、会話管理サーバへセッション開始イベントを publish します。

Step 3: 会話開始リクエスト

衝突が検知され、各ロボットの状態が WAITING になると、ロボット制御サーバは会話管理サーバへ会話生成の開始をリクエストします。

会話管理サーバはリクエスト内容を検証し、問題がなければ非同期タスク[5]を立ち上げて即座に 202 Accepted を返します。レスポンス受信後、ロボット制御サーバは続く会話開始イベントの受信を待機します。このステップが成功しても、まだ各ロボットの状態は WAITING のままです。

もし、リクエストが不正であったり、非同期タスクの生成に失敗した場合、エラーレスポンスを返し、ロボットは WAITINGRUNNING へ遷移します。そして、この RUNNING への遷移が起きたとき、セッションに紐づく非同期タスクなどのリソースを解放するためにロボット制御サーバから「セッション終了イベント」を publish し、それを会話管理サーバで処理するようにしています。

「セッション終了イベント」は、セッション中の異常系、または会話終了時に発行されるようになっています。Step 2、Step 3 で説明した「セッション開始イベント」、「セッション終了イベント」は状態遷移自体には影響を与えません。

Step 4: 会話開始

会話開始リクエストの成功後、生成された非同期タスクは会話の開始が成功したことを表す「会話開始イベント」をロボット制御サーバに publish します。これにより 2 体のロボットは WAITING から TALKING に遷移します。

非同期タスク内でエラーが発生した場合、非同期タスクからエラーイベントが送信され、ロボット制御サーバはこれを受けてロボットを WAITINGRUNNING へ遷移させます。また、ネットワーク接続など何らかの要因で会話開始イベント、エラーイベントがいつまで経っても publish されない場合、ロボット制御サーバ側でタイムアウトを検知してロボットを WAITINGRUNNING へ遷移させます。

WAITING から TALKING に遷移するまで Step 2 ~ 4 が必要なため、この遷移にかかる時間が長いと感じるかもしれませんが、実際には Step 2 ~ 4 の衝突検知から会話開始イベントの受信までの一連の処理の時間は非常に短いので、ロボットの状態が WAITING である時間は "正常系においては" 非常に短いです(100 ms は下ったはず)。

Step 5: 会話中

会話中は、会話内容が生成されるたびに非同期タスクから順次「会話生成イベント」が publish されます。このイベントには会話テキスト、タイムスタンプなどが含まれています。

ロボット制御サーバはこのイベントを受け取る度に最新のタイムスタンプを記録し、一定時間メッセージが届かない場合のタイムアウト処理に備えます。この「会話生成イベント」自体はなんらかの状態遷移は引き起こしません。

非同期タスクによる OpenAI API への会話生成リクエスト失敗時には、規定回数リトライを行い、成功するまでリクエストを行います。規定回数リトライに失敗した場合、非同期タスクからエラーイベントを送信し、ロボット制御サーバはこれを受けてロボットを TALKINGRUNNING へ遷移させます。

また、非同期タスク側で会話生成リクエストのリトライが行われている最中に、ロボット制御サーバ側でタイムアウトが起きてセッションが終了してしまうことがあります。このとき、コンピュータリソースと API 使用量のお金が無駄になるので、終了したセッションに対する会話生成リクエストが行われ続けることがないようにしたいです。

これは、タイムアウト時に会話制御サーバから publish される「セッション終了イベント」を非同期タスクで受信し、リクエストの中止、タスク自体のキャンセルを行うことで防いでいます。

Step 6: 会話終了

会話回数が、会話生成リクエスト時に定められた規定会話ターン数[6]に到達すると、非同期タスクは「会話終了イベント」を publish します。

ロボット制御サーバはこれを受けて 2 体のロボットを TALKING から RUNNING へ遷移させます。これにより会話が終了したロボットは再び移動を開始します。

状態遷移における異常系の設計思想

異常系の設計では、「何かエラーや不具合があったときにロボットを WAITINGTALKING の状態に留まらせずに RUNNING に戻す」ことを最優先しました。

会話管理サーバや外部ネットワークに起因する遅延やエラーに引きずられ、ロボットが WAITINGTALKING の状態に張り付いてしまうと、フィールド上のロボットが停止したままになります。こうなると「ロボットが街を歩き回る」という作品の主題が崩れ、展示としても動きのないつまらないものになってしまいます。

そこで、特にエラーの原因になりやすい「会話生成」の成功を優先するよりも、「サーバ側がどんな状態であっても最終的にロボットの状態を RUNNING に戻し、ロボットにはフィールドを動き続けさせる」ことを優先する設計に振り切りました。

ここまでシーケンス図とともに見てきたように、会話開始・生成・終了の各タイミングでタイムアウトやエラーを検知したら、ロボット制御サーバが即座にロボットの状態を RUNNING へ戻し、再び移動経路へ戻す設計となっています。

もちろん、会話生成側にも基本的なエラーハンドリングやリトライの仕組みは実装されていますが、リトライが規定回数失敗したらセッションを終了する、つまり会話の生成を諦めるという実装になっています。この場合、コンセプトの「身体を持った AI が街を歩き回る」の「AI」の部分は崩れてしまいますが、それでも展示でロボットの動きを見て楽しんでもらうことの方が重要なのでこのような着地点になりました。

状態遷移の実装上の工夫

ロボットの状態遷移を実装する上でいくつか工夫が必要な箇所があったのでそれについて説明します。

クールタイム機能

状態遷移における実装上の工夫の 1 つとしてクールタイム機能があります。これは、2 つのロボットの状態が TALKINGRUNNING に遷移した直後に、即座に会話が始まるのを防ぐための機能です。

勘の良い方は気づかれたかもしれませんが、TALKINGRUNNING という遷移が起きた直後は 2 つのロボットの距離が当然閾値以下になっているので、素直な実装だと会話終了後に即座に衝突が検知され、再び会話を開始してしまうという無限ループに入ります。

そのため、これを防ぐために TALKING を含め、RUNNING 以外の状態から RUNNING に遷移した直後は一定時間(本番では 5 秒)WAITING に遷移しないようにするためのクールタイムを設けています。クールタイム中は「衝突判定イベント」は発生し続けていますが無視されるので会話が開始されません。

同時に会話を行うロボットを 2 体のみにする

本システムでは、同時に 2 体のロボットしか会話できないようになっています。この実装はとてもシンプルで、2 体のロボットが衝突判定されたとき、どちらかのロボットが RUNNING 以外の状態であれば会話を開始できないようにする(WAITING への遷移と会話開始リクエストをスキップする)ことで実現しています。

これにより、2 体のロボットが会話中のところに別のロボットが近づいてきても会話中のロボットの状態に影響を与えないようになっています[7]

仕組み 2:フィールド上での各ロボットの移動

次に、フィールド上での各ロボットの移動を実現するための仕組みについて説明します。

toio SDK for Unity の機能によるロボットの移動

フィールド上での各ロボットの移動は、Unity 製のロボット制御サーバ dnm-toio-controller 内に実装されています。

ロボット制御サーバでは toio SDK for Unity を使用してロボットの移動を制御しています。この SDK は toio の動きを制御を行うための Unity 製の SDK で、Bluetooth 経由での toio の制御はもちろんのこと、Unity Editor での toio 用のシミュレータ環境も提供しています。詳細はドキュメントの「機能一覧」の節を参照してください。

https://morikatron.github.io/toio-sdk-for-unity/

ロボット制御サーバによるロボットの移動は、具体的には、toio SDK for Unity の Navi2Target という関数を使用して実装されています。

この関数は toio の移動を制御する 3 つの主要な機能を提供しています。

  • toio 座標系[8]にて、目標座標、スピード(≒ 毎フレームの移動量)を指定して toio を移動する
  • 目標座標への到達判定を行う
  • 目標座標へ移動中、toio 同士の衝突を回避する

ロボットにルートを巡回させ続ける処理の流れ

この SDK の機能により、各ロボットに一定のルート(=目標座標のリスト)を巡回させ続ける動きを実装することができます。巡回の流れとその実装のイメージ(C# のコード)は以下の通りです。

  1. ルート(=目標座標のリスト)の定義
  2. 目標座標の初期化
  3. 目標座標の設定
  4. 目標座標への移動
  5. 目標座標への到達判定。到達判定が true の場合 3 へ戻る。
コードの解説
  • RobotNavigator クラスの route フィールドには toio が辿るルート、つまり目標座標 MoveTarget のリストが格納されており、currentPhase フィールドには現在の目標座標を示すインデックスが格納されている。
  • 毎フレーム呼ばれる Move メソッド内で現在の目標座標を取り出し(route[currentPhase].target)、それを引数として Navi2Target 関数を呼び出して toio を移動させる。
  • 現在の目標座標に到達したとき、currentPhase フィールドをインクリメントして次の目標座標に移動する。ルートの最後の目標座標に到達したとき、currentPhase フィールドを 0 にリセットしてルートの最初の目標座標に移動する。これによりルートを巡回させ続ける動きが実現できる。
using System.Collections.Generic;
using UnityEngine;
using toio;

namespace Dnm
{
    public struct MoveTarget
    {
        public Vector2Int target;
    }

    public class RobotNavigator
    {
        // 1. ルート(=目標座標のリスト)の定義
        // 実際には各ロボットごとのルートが JSON 形式の設定ファイルに定義されている
        private readonly List<MoveTarget> route = new()
        {
            new MoveTarget { target = new Vector2Int(60, 60) },
            new MoveTarget { target = new Vector2Int(146, 55) },
            new MoveTarget { target = new Vector2Int(246, 60) },
            new MoveTarget { target = new Vector2Int(60, 225) },
            new MoveTarget { target = new Vector2Int(146, 250) },
            new MoveTarget { target = new Vector2Int(250, 225) }
        };

        // 2. 目標座標の初期化
        private int currentPhase = 0;

        // パラメータの設定
        private const float maxSpeed = 20.0f;
        private const float tolerance = 80.0f;

        // 3. 目標座標への移動(毎フレーム実行される)
        public void Move(toio.Navigation.CubeNavigator nav)
        {
            if (nav == null || route.Count == 0)
            {
                return;
            }

            Vector2Int target = route[currentPhase].target;

            // 目標座標への移動
            Movement result = nav.Navi2Target(
                target.x,
                target.y,
                maxSpd: maxSpeed,
                tolerance: tolerance
            ).Exec();

            // 4. 目標座標への到達判定
            if (!result.reached) return;

            if (currentPhase == route.Count - 1)
            {
                // ルートの最後のフェーズへ到達したらルートの最初のフェーズに戻る
                currentPhase = 0;
            }
            else
            {
                // ルートの次のフェーズに進む
                currentPhase++;
            }
        }
    }
}

移動ルートを設定ファイルに定義する

各ロボットごとのルートの定義(上記コードの RobotNavigator クラスの route フィールド)は、実際には JSON 形式の設定ファイルに定義し、それをサーバ起動時に読み込むようにしていました。以下、設定ファイルのサンプルです。

フィールドの設計を元に、フィールドの対角線に沿ったルートやひし形のルートなど、各ロボットのルートは自由に定義できるようになっています。本システムでは、各ロボットがあたかもランダムに移動しているように見せるために、それぞれのロボットには異なるルートを設計・設定しています。

設定ファイルのサンプル(ロボット 2 体分の設定)
config.json
{
    "robots": [
        {
            "cubeId": "ADA954C6-454B-4904-F91C-EFC76B4CCDED",
            "note": "対角線移動",
            "route": [
                {
                    "target": {
                        "x": 60,
                        "y": 60
                    }
                },
                {
                    "target": {
                        "x": 146,
                        "y": 55
                    }
                },
                {
                    "target": {
                        "x": 246,
                        "y": 60
                    }
                },
                {
                    "target": {
                        "x": 60,
                        "y": 225
                    }
                },
                {
                    "target": {
                        "x": 146,
                        "y": 250
                    }
                },
                {
                    "target": {
                        "x": 250,
                        "y": 225
                    }
                }
            ]
        },
        {
            "cubeId": "472145E1-7030-43B6-4A90-5D96ED220963",
            "note": "ひし形",
            "route": [
                {
                    "target": {
                        "x": 150,
                        "y": 55
                    }
                },
                {
                    "target": {
                        "x": 250,
                        "y": 150
                    }
                },
                {
                    "target": {
                        "x": 150,
                        "y": 250
                    }
                },
                {
                    "target": {
                        "x": 55,
                        "y": 150
                    }
                }
            ]
        }
    ]
}

toio SDK for Unity によるロボット同士の衝突回避アルゴリズムと「衝突判定」

先述の通り、toio SDK for Unity の Navi2Target 関数の機能の 1 つとして、「目標座標へ移動中、toio 同士の衝突を回避する」という機能があります。移動の基本的な仕組みはこれまで説明してきた通りですが、この衝突回避機能による移動の補助がなければ本システムは成り立ちません。

一言で言えば、「"いい感じに" 他の toio との衝突を回避しながら目標座標へ移動する」という機能です。

toio SDK for Unity の衝突回避のイメージ

引用元:toio SDK for Unity のドキュメント

これは toio SDK for Unity の Navigator というアルゴリズムで実現されています。ロボット制御サーバでは、この Navigator を構成する 2 つのアルゴリズムの内、「ヒューマンライク衝突回避」(HLAvoid)というアルゴリズムを SDK 経由で使用しています。

Navigator とは、複数のロボット(toio™コア キューブ)が存在する時、お互いのロボットの動きを考慮しながら上手く移動するために作られたアルゴリズムです。

このアルゴリズムは主に「ヒューマンライク衝突回避」(HLAvoid)と「ボイド」(Boids)二つのアルゴリズムに基づいています。

  • HLAvoid は自然に回避する手法
  • Boids は群れとして、同調した動作をする手法

引用元:toio SDK for Unity のドキュメント

衝突回避の計算におけるポイントは、「ある toio が次のフレームでどこに移動するかは、自分以外の全ての toio の位置情報がないと計算できない」です。

Navigator アルゴリズムでは、CubeManager という全 toio を管理するクラスが全ての toio の位置情報を集約し、各 toio が他の toio の位置情報を参照できるようにしています。そして、毎フレームごとに自身の位置情報と他の全ての toio の位置情報を使用して各 toio で独立して衝突回避の計算を実行し、次のフレームでどこに移動すればよいかを決定しています。

つまり、位置情報は中央集権的に管理し、衝突回避の計算は各 toio ごとに分散して行うことで、全体としての衝突回避を実現しています。

例えば、3 つの toio がフィールドに存在する場合、以下のようなイメージで位置情報がやりとりされています。

toio の衝突回避のイメージ

※ あくまで理解促進のためのイメージなので、実体を正しく表しているわけではありません。詳細はドキュメントを参照してください

また、説明を簡潔にするため、ここまで単に「位置情報」と表現しましたが、実際には位置情報以外にも速度情報や向き情報なども含まれています。以下のコードのように、toio の実体を表す CubeEntity というクラスの Update メソッドで毎フレーム各 toio の情報が更新されています。

https://github.com/morikatron/toio-sdk-for-unity/blob/0819ef16/toio-sdk-unity/Assets/toio-sdk/Scripts/Navigation/CubeInterface.cs#L9-L35

HLAvoid アルゴリズムの詳細については、ドキュメント、または、開発者による解説ブログがあるので、興味がある方は以下のリンクを参照してみてください。参考までにアルゴリズムの要約を引用しておきます。

ヒューマンライク衝突回避は、要約すると

  • 各方向に向かって最大速度で前進して、
  • 他のロボットが方向と速度を維持すると どの程度の距離で衝突するかを計算し、
  • 衝突する前の領域において目標と一番近いポイントを目指す という流れでロボットが他のロボットと衝突をしないように行動するアルゴリズムです。

各方向の距離を計算することを「スキャン」と呼びます。
HLAvoid の「スキャン」のイメージ
※ 図は 元論文から引用

引用元: ドキュメント - ヒューマンライク衝突回避 HLAvoid

https://tech.morikatron.ai/entry/2020/03/04/100000

前置きが長くなってしまいましたが、本システムにおけるロボット(≒ toio)同士の会話を発生させるための「衝突判定」は、上記の衝突回避機能の "上に" 実装されています。

各ロボットの移動中の基本動作は「衝突を回避しながらの移動」です。衝突回避中に一定距離以内にロボット同士が近づいときは、衝突回避機能による衝突回避に関係なく「衝突判定」が起き、次いで「会話開始」... という流れが起きるようになっています。つまり、SDK による衝突回避とは独立して「衝突判定」が起きます

これにより、 「ロボットが他のロボットとの衝突を回避しながら目標座標へ移動しつつ(= SDK による衝突回避)、その流れの中で会話を発生させる(=本システムにおける「衝突判定」)」 という仕組みを実現しています。

分かりづらいですが、以下の動画でも衝突回避機能がオンになっています(目標座標に向かって直線のルートではなく、若干膨らんだルートを進んでいます)。

https://www.youtube.com/watch?v=UkoQ069k38Q

仕組み 3:会話内容の生成

「ロボット同士の衝突判定が起きると会話が開始される」というのはこれまで説明してきた通りです。ここでは、その会話開始後の会話内容の生成の仕組みについて簡単に説明します。

概要

ロボット同士の会話内容は、会話管理サーバ dnm-llm-controllerOpenAI Assistants API を叩くことで生成します。LLM のモデルは gpt-3.5-turbo-1106 を使用しました[9]

この API の説明は他所で散々擦られていますし、既に deprecated になってしまったので今更必要ないかもしれませんが、本システムの説明に必要な概念だけ以下に整理しておきます。

概念 説明
アシスタント LLM の設定(モデル、ツール、プロンプト)を保持する役割。
スレッド 会話履歴。一連の会話の集合。コンテキストを保持する。

本システムでは、サーバ起動時に各ロボットごとにアシスタントを初期化します。1 ロボットにつき 1 アシスタントです。各アシスタントには各ロボットごとの人格をプロンプトとして設定しているので、この設定を元にそれぞれの人格に沿った会話が生成されるようになっています。

ロボット同士の衝突判定が起きると、まずスレッドが作成されます。そして、そのスレッド内で「ロボット 1 のアシスタントでテキスト生成(HTTPS リクエスト)→ ロボット 2 のアシスタントでテキスト生成 → ロボット 1 のアシスタントでテキスト生成 → ...」のようにループを回して会話が生成されます。会話が終了したらそのスレッドを破棄します。

つまり、スレッド内で 2 つのアシスタントが交互に LLM でテキストを生成することで、本システムの「会話内容の生成」を実現しています。

会話内容の引き継ぎ、いわゆる「記憶レイヤー」の実装は行っていません[10]

おまけ:会話内容の DB への保存とメッセージブローカーへの書き込みの整合性の担保

今振り返ると設計的に良くなかった部分があったのでその問題点と改善策を残しておきます。おまけなのでお時間ある方は読んでみてください。

おまけ:会話内容の DB への保存とメッセージブローカーへの書き込みの整合性の担保

問題点

展示期間中は問題が表面化しませんでしたが、実装を振り返ると「DB には保存されたのにメッセージブローカーには書き込まれない」という不整合が起き得る構成になっていました。

問題のある処理の流れは以下の通りです。

  1. LLM が会話メッセージを生成する
  2. メッセージを DB に保存する
  3. 保存が成功したらメッセージブローカーへ publish を試みる。失敗したら一定回数リトライ。
  4. リトライが尽きるとエラーを返して失敗する
    • → 「DB には履歴が残るのにメッセージブローカーには通知されない」という不整合が起きる
    • (DB への保存が失敗した場合はそもそもメッセージブローカーへ publish していないので、「メッセージブローカーには通知したが DB へ保存されていない」という不整合は起きないようになっていた)

この構成では、publish が失敗したときに、DB には履歴が残るのに メッセージブローカー側には通知されないイベントが生まれることになります。この現象は展示中は起こることはありませんでしたが、今考えると十分起こり得る不整合です。

せっかく振り返りの記事を書いているので、どういう設計にすれば良かったのかの一設計例を考えてみます。

改善策

改善策の 1 つとしては、典型的な「トランザクションアウトボックス」パターンを検討しました。ざっくり以下のような構成です。

この構成における処理の流れは以下のようになります。

  1. 会話メッセージ用のテーブル messages とは別に、イベントとそのイベントのメタデータ(送信ステータスなど)を管理するテーブル message_outbox を用意しておく。
  2. messages テーブルに保存するのと同じトランザクションの中で、message_outbox にも status = pending のようなイベントのメタデータを保持するレコードを書き込む。
  3. コミット後、メッセージリレー(バックグラウンドワーカー)が message_outbox をポーリングし[11]pending のレコードだけをメッセージブローカーへ publish する。成功したら status = sent と送信時刻を更新する。失敗したらリトライカウントを進め、閾値を超えたらデッドレター扱いにする(今回はリアルタイムのイベントしか扱わないので再送の必要はなく、破棄でも良い)。
  4. 送信が完了したレコードは一定期間後に掃除(ログは残しておく)。

この構成であれば、メッセージブローカーへの書き込みだけが置いてけぼりになる状況を避けられます。また、リトライの履歴や送信状態がテーブルで可視化できるので、運用時のトラブルシュートもしやすくなります。

ただし、アウトボックス用のテーブルやメッセージリレーといった要素を追加することになるので、システムの複雑性になるデメリットがあります。また、今回のような小規模なシステムでは起こりにくいですが、大量の書き込みがある場合は追加したメッセージリレーのスケーラビリティも考えなければなりません。

さらに、ポーリング間隔とリアルタイム性のトレードオフもあります。ポーリング間隔を短くしすぎるとリアルタイム性は上がりますが DB への負荷が高くなり、逆にポーリング間隔を長くしすぎると DB への負荷は軽減されますがリアルタイム性が低下します。

本システムでは、展示の体験上ある程度のリアルタイム性が必要で、会話が生成されたら体感として 100ms 以内には会話イベントが publish されてほしいので、そのポーリング間隔による DB 負荷で問題ないかは検討が必要です。今回のケースはそもそも小規模システムで、100ms ごとにポーリングするプロセスは 1 つだけなので全く問題ないと思われます。

まとめ

とまあここまで設計の改善策の例を提示してみましたが、正直今回のような展示用のワンショットのシステムではここまでやる必要はないと思います。展示のクオリティを上げるなど他にやるべきことがあります。

また、「DB の書き込みとメッセージブローカーへの書き込みの整合性を担保するなら?」というお題で正解ありきで設計を試してみましたが、業務での設計では「そもそもメッセージブローカー使う必要ある?」とか「システムを複雑にしてまで整合性を担保する必要があるんだっけ?」という要件をベースにした議論が必要になると思います。

最近、システム設計の勉強をしていてトランザクションアウトボックスパターンを勉強していたときに「OMMF 2024 で作ったやつこれ適用できそう」と思い出したので練習がてら設計パターンを適用してみました。トランザクションアウトボックスについては以下のブログがとても参考になりました(理解できたとは言っていない)。

https://miraitranslate-tech.hatenablog.jp/entry/2022/09/20/120000

https://engineering.mercari.com/blog/entry/20211221-transactional-outbox/

https://techblog.zozo.com/entry/implementation-of-cqrs-using-outbox-and-cdc-with-dynamodb

技術選定

技術に関する話の最後に、本システムの技術選定、設計の意図についてまとめます。

技術選定の軸は主に以下の 3 つです。

  • (大前提)機能要件を満たせること
  • キャッチアップを含めて実装期間 2 ~ 3 週間で作り切れること
  • 機能拡張しやすいこと

toio の制御に C# / Unity を選んだ理由

  • 「toio の移動」の機能要件を満たせること
    • toio SDK for Unity の衝突回避機能がロボットの移動制御の要件にドンピシャだった
  • ドキュメントの充実度
    • toio SDK for Unity の公式ドキュメントが充実しているのでキャッチアップが容易
  • 開発環境の充実度
    • toio SDK for Unity にシミュレータ環境があるので開発サイクルを回しやすい

今回 C# / Unity を真面目に触ったのは初めてでしたが、上記の理由により Unity 以外の選択肢はあまり考えられなかったです。結果的にこの選択は良い選択だったと思います。

まず、衝突回避については仕組み 2:フィールド上での各ロボットの移動の節で書いた通りです。

ドキュメントの充実とシミュレータ環境に関しては、正直これらなしには実装期間内に作り切ることは難しかったと思います。

toio SDK for Unity のドキュメントでは、アニメーション付きのライブラリの使い方 + 最小のサンプルコードが用意されています。さらに、それに加えて内部構造を図解とともに説明した設計ドキュメント(例えば、本記事でも紹介した Navigator クラスのドキュメント)も用意されています。これらの使い方と仕組み、両方の丁寧なドキュメントがあったので、C# / Unity の基本を学んだあと必要な機能を実装するときに迷うことが少なかったです。

シミュレータ環境は、他の言語にはない Unity だからこそ実現しやすい機能だと思います。これは説明よりも以下の動画を見た方が早いかもしれません。このシミュレータ環境おかげで、実装した動きをすぐに Unity Editor 上で確認できるので、例えば、「衝突判定が起きるか」や「衝突判定が起きたときにイベントが正しく publish されるか」など機能ごとの動作確認をビジュアルで確認しながら素早く行うことができました。

https://www.youtube.com/watch?v=Oxf44EwxD0U

もちろん、実機を動かして初めて遭遇するバグもあるので実機でのテストは必須ですが、それでも実機の充電残量を気にしながら開発したり、Bluetooth 接続をリセットするために toio の電源オンオフしたり、みたいなハードを絡めた実装でよくある面倒事なしに開発を進めることができたのは開発体験としてとても良かったです。

バックエンドを 2 つのサービスに分けた理由

  • ライブラリの充実度と開発速度の担保
    • OpenAI API を利用した開発を行うとき、Unity より他の言語の方がクライアントライブラリ、インターネット上の事例が充実していた。かといって toio SDK for Unity ほどリッチな機能を提供している toio 用の SDK もなかった[12]
    • 機能要件を満たすために C# / Unity + toio SDK for Unity を使うことは確定していたため、あとは「C# / Unity で全てを頑張る」か、「別の言語で toio の制御以外を実装する」かの判断になる。筆者は C# / Unity に関してほぼ初心者だったため、ある程度開発時間が読める Rust に担当させることで開発速度が担保できるようにしたかった。
    • 結果的に、Unity には toio の制御とそれに伴う状態遷移のみを担当させ、データの永続化を含めそれ以外の全てを Rust に担当させることにした。
    • あと単純に Rust を書きたかった。

サービス間の通信をメッセージブローカーを介した非同期メッセージングにした理由

  • 拡張性を重視
    • ロボット制御サーバとその他のサービス間を疎結合に保つことで、他のサービスの追加や変更が容易になる。今回はそこまで実装できなかったが、「セッションや会話のライフサイクルの中で生じるイベントに応じて〇〇をする」のようなイベント駆動の機能が実装しやすい。
  • 非同期メッセージングを活用したシステムの練習
    • 非同期メッセージングを活用したシステムの設計、実装の練習したかった。正常系を作るのは簡単だったが、異常系をどう扱うかの設計に力を入れた。
  • コンセプトと構想(妄想)
    • 将来的に、「ロボットが街中を歩くとき、物理世界はセンサーや LLM などを搭載したロボットのハードウェアが動作し、そのバックエンドには多数の自律的に動くエージェントが存在する」と構想(妄想)していた。
    • そのとき、ロボットは物理世界の中で自律的に動き、エージェントは仮想世界(インターネット)の中で自律的に動くので、この二者間の通信は非同期になるのが当然だと思った。そういった構想を設計に取り入れてみたかった。
    • エージェントが勝手に動き回って情報を収集してバックエンドからロボットにデータを送信し、ロボットもエージェントに対してセンサーで収集した物理世界の情報を送信する、という双方が情報を垂れ流して必要ならそれを使う、みたいなイメージ。

ミドルウェアの選定理由

メッセージブローカーに NATS を選んだ理由

  • 環境構築、設定が楽
    • シングルバイナリ、ゼロコンフィグ[13]で環境構築が楽
    • モニタリングのセットアップが楽
  • 公式クライアントが豊富
  • シンプルなトピック管理、柔軟なトピック購読
    • 文字列によるシンプルなトピック(subject)管理、ワイルドカード(*>)による柔軟なトピック購読。特にセッションに関するイベントだけ購読したい(e.g. dnm.session.>)とかロボットのセンサーデータだけ購読したい(e.g. dnm.robot.raw.>)とかの設定が簡単。
    • (今回は使わなかったが request-reply で同期通信もできる。)
  • 低レイテンシ
    • センサーデータを流す用途に適している
  • 開発速度の担保
    • 利用経験があったのでキャッチアップコストが低い

今回は NATS の Core NATS という機能を使用しました。Core NATS の QoS は at-most-once です。メモリ内バッファに頼る TCP/IP と同等の信頼性を持つベストエフォート配信で、購読者が不在ならそのメッセージは諦める設計です。

永続化や再送は必要なかったので、at-least-once / at-least-exactly-once といった QoS は必要はありませんでした(NATS JetStream の機能)。

以下、Core NATS の QoS について公式ドキュメントを引用しておきます。

  • 英語

At most once QoS: Core NATS offers an at most once quality of service. If a subscriber is not listening on the subject (no subject match), or is not active when the message is sent, the message is not received. This is the same level of guarantee that TCP/IP provides. Core NATS is a fire-and-forget messaging system. It will only hold messages in memory and will never write messages directly to disk.

  • 日本語訳 by PLaMo 翻訳

少なくとも一度保証(At-most-once QoS): Core NATS は少なくとも一度のサービス品質を提供します。サブスクライバーがトピックを監視していない場合(トピックが一致しない場合)、またはメッセージ送信時にアクティブでない場合、メッセージは受信されません。これはTCP/IPが提供するのと同等の信頼性レベルです。Core NATS は送信後の処理を行わないメッセージングシステムです。メッセージはメモリ内に保持されるのみで、ディスクに直接書き込まれることはありません。

今回はシンプルな用途だったので、他のメッセージブローカーでも代替できるかなと思います。NATS の選定は必然性が低かったです。ただし、構築の楽さや機能のシンプルさが開発速度の担保のためには大きかったと思います。

DB に PostgreSQL を選んだ理由

  • データの整合性の担保(RDBMS を選んだ理由)
    • スキーマを厳密に管理したい
    • ロボットの状態遷移とそれを引き起こしたイベントのログを同一トランザクションで扱いたい
  • 拡張機能の豊富さ
    • センサーデータの集計には TimescaleDB 拡張が使える、かつ(拡張関係ないけど)JSONB を活用した集計が有用。
    • LLM を扱う上で pgvector ですぐベクトル DB として使える。今回のシステムと相性が良い。
    • MySQL も選択肢としてあったが、今回は拡張機能が決め手だった(今回の開発では使わなかったけど今後の発展として有用)。
  • 開発速度の担保
    • 利用経験があったのでキャッチアップコストが低い

運用で苦労したこと

当日運用する上で苦労したことの 1 つはロボットの電源管理でした。今回の展示スケジュールは 2 日間で、各日 6 時間に及びます。

  • 1 日目(2024/11/23):6 時間(本展示 5 時間 + 展示者のみの交流時間 1 時間)
  • 2 日目(2024/11/24):6 時間(本展示 6 時間)

ハードウェアの内、電源用のバッテリーを持っているのは toio とカメラモジュール用のバッテリー ATOM Mate for toio の 2 つです。これらの稼働時間、充電時間は以下のようになっています。これを見て分かる通り、ロボット 3 体を 1 セット用意しただけでは展示スケジュールには到底耐えられません。

ハードウェア 稼働時間 充電時間
toio 2.0 h 1.5 h
ATOM Mate for toio 0.5 h 2.0 h

そのため、「事前にそれぞれ何個ずつ用意する必要があるか」、「どのタイミングでバッテリー交換をすれば良いか」といったタイムテーブルを作成し、当日の運用に備えることにしました。

2 日分のタイムテーブルは以下のようになっています。上から下に向かって時間が経過していきます。「展示(25 分) + バッテリー交換(5 分)」を 1 展示セクションとしており、1 展示セクションは 30 分です。

充電残量の時間管理表

結局、toio を 6 台、ATOM Mate for toio を 12 台用意しました 💸

タイムテーブルを見ると、ATOM Mate for toio は 1 展示セクションごとにバッテリー交換が必要なことがわかります。ATOM Mate for toio は「30 分稼働した後 2 時間充電が必要」と燃費が良くないので、例えば、上図右側の b1 というバッテリーに着目すると、1 日目の 12:00 - 12:30 のセクションが終わったら、次に稼働できるのは 14:30 - 15:00 のセクションになります。

当日のオペレーションでは、(もちろん)このスケジュールの通りにはいきませんでした...お客さんと楽しく会話していたら展示セクションを跨いでしまい、いつの間にか toio やカメラ映像が止まっている、ということが何度かありました。これは展示を楽しむ方が大事なので良しとします。

改善するとしたら、(今の自分にはできないですが)バッテリモジュールを電子工作で自作してロボットの稼働時間を伸ばすとかでしょうか。toio は既製品なのでどうしようもないですが、カメラモジュールのバッテリーは自作のものにする余地があると思われます。

振り返り

最後にイベントの振り返りをします。

開発関連

最近だとコーディングエージェントを使った仕様駆動開発がホットですが、今回の開発でもまさに要件の整理、設計の重要さを感じました。

開発期間中、展示まで時間が迫っている中、焦りから「すぐに動くものを書きたい」欲求に駆られていました。しかし、それをぐっと我慢し、必要な機能と設計を事前にドキュメントに書き起こしたり、使用するライブラリ、SDK の仕様をすぐ検索できるようにリンクをまとめたりしてから開発を進めました。

今回開発したシステム自体が複雑という訳ではないですが、それでも事前設計なしにコーディングしながら設計を並行していたら、時間制限がある中での開発は難航していたと思います。結果的には事前設計のおかげで実装の段階での迷いや手戻りを軽減できました(これに関してはシステム開発の文脈では何回も擦られている話ではありますが...)。

ハードウェアが絡んだシステムだったので、実機での動作確認を行うことで始めて対処すべき問題が見つかることも多々ありましたが、それは設計に比べれば枝葉のようなものだったと思います。

展示関連

今回初めて「自分で作ったものを展示する」という体験をしました。目の前で老若男女が楽しそうに作品を体験してくださるのはとても嬉しい経験でした。

「AI 同士が会話する」というアイディア自体はソフトウェア界隈では既視感がありますが、普段技術に触れていない方にとっては新鮮に映っていたようでした。純粋に楽しんでくださったのはもちろん良かったのですが、「非技術系の方にはこう見えているんだ」みたいな認識のズレをチューニングできたのはとても貴重な経験だったと思います。

一方で、体験の作り込みには課題がたくさんありました。

まず、AI 同士に会話させるのはやっぱり面白くないなというのを感じました[15]。今回のコンセプトでは、「AI 同士の会話で云々」よりは「身体を持った AI」というテーマの方がより重要で、「物理空間の出来事をトリガーに AI 同士がやりとりをする」ということを実装するのに面白みを感じていました。そのため、「AI 同士の会話をどう見せるか」への工夫が不足していました。単純に LLM をシステムに組み込むことに関する理解が浅かったのも原因だと思います。

例えば、

  • プロンプトを工夫する
  • 記憶レイヤーを加える
  • 外部サービスやエージェントを呼び出す(tool calling / MCP)
  • (そもそも)UI をもっとリッチにする

などなど今なら工夫できることがもう少しあるかなと思います。ただし、工夫したとしても「AI 同士の会話を眺めるだけで面白いか」という根本的な問題が解決するかは疑問です。

また、今回は「見るだけ」の鑑賞型の体験だったので、少し退屈な体験になってしまった感があります。次回はユーザ参加型の体験を作ることににチャレンジしてみたいです。人間側からロボットに働きかけられる仕組みなどを作り込んでみたいなと考えています。そのほかにも、ロボットと UI のどっちを見てよいか迷うといった課題もありました。

時間切れで色々妥協してしまった部分があるので、そこは将来的にリベンジできればと思います。

終わりに

長い記事になりましたが、これにて OMMF 2024 の振り返りを終わりとさせていただきます。とにかく楽しさを追い求めた開発だったので満足です。

今後もこのような展示をする機会があれば、今回の作品を発展させてみたり、別の作品に挑戦したりしてみたいです。ぼんやりと群ロボットの制御とか家族っちのような箱庭?生態系?のようなシステムを作ってみたいという思いがあります。

チームメンバーには振り返り記事を書く書く詐欺をしていたので書き終わって一安心です。遅くなってごめんなさい。

本記事を最後まで読んでくださりありがとうございました。

宣伝

2025 年 5 月半ばに OMMF 2024 当時(2024 年 11 月)に在籍していた会社を辞め、現在個人事業主として生成 AI を活用した音声対話サービスの開発に携わっています。

その傍らバックエンドエンジニアとして転職活動も行っている最中なので、もし本記事を読んで面白いと思ってくださった方がいらっしゃったら Twitter でお気軽にご連絡いただければ幸いです。

あと単純に感想をもらえるとめっちゃ嬉しいです。

おまけ:SNS でいただいた反応

SNS 上で反応をいただいたのでここに引用させていただきます。遊びに来てくださりありがとうございました!

https://x.com/kenichih/status/1860452004411437522

実は↑のツイートをしてくださった方(@kenichih)にAtom Mate for toio 用の自作レゴパーツをいただきました。「toio で作ってみた!友の会 #toiotomo」という toio の開発コミュニティの方でした。ありがとうございました!

パーツはこんな感じ↓

Atom Mate for toio 用の自作レゴパーツ

https://x.com/nod_Y/status/1862154294776176900

脚注
  1. dnmDennou no Machi の頭文字を取った文字列です。 ↩︎

  2. 開発当初「ファームウェアを Rust で書くぞ!」と意気込んでいました。環境構築を行い、公式から提供されている C++、Arduino でそれぞれ書かれたファームウェア(商品ページの「ソフトウェア」の節を参照)を参考にしながら Rust で実装していました。今回のファームウェアに必要な機能である Wi-Fi 接続、mDNS クライアントの起動、HTTP サーバまではスムーズに実装できたのですが、カメラのデバイスドライバの移植が中々思うようにいかず、時間が来て断念しました...結局、先述の Arduino 製のファームウェアをドキュメント通りに動かしながら修正を少し入れたらすんなり実装が終わってしまい悲しい気持ちになりました。それと同時に Arduino(というか M5Stack 周辺?)のエコシステムへのありがたみも感じました。 ↩︎

  3. カメラモジュール ATOM S3R-CAM に載っているボードは ESP32-S3-PICO-1-N8R8 というボードでした。Rust の ESP32 環境の開発には esp-idf-svc というクレートを使っていました。 ↩︎

  4. toio の Bluetooth 仕様についてはドキュメントを参照してください。 ↩︎

  5. Tokio の非同期タスクを生成しています。 ↩︎

  6. 会話開始リクエストに会話のターン数を指定し、その回数に達すると会話終了イベントを publish するようにしています。回数はランダムで 5 ~ 10 回です。この設定は設定ファイルから設定できるようにしています。 ↩︎

  7. 制作中は実装をシンプルにするために 2 体のみの会話としましたが、3 体以上の会話の実装を考えると色々と複雑になりそうです。他人の会話への途中参加(割り込み)が必要になったり、会話の内容が 1 対 1 からフルメッシュになる(誰かが会話内容を生成したら他の 2 人のコンテキストにその内容を載せる必要がある)、会話のターンは誰が握るか(ネットワークのトークンパッシング方式がアナロジーとして良さそう?)など、「LLM による 3 人以上の会話」は色々実装しがいがありそうなテーマだなと思います。 ↩︎

  8. フィールドの節の設計図を参照 ↩︎

  9. もはや懐かしいです。記憶が薄れていますが、当時は早くて安くてそこそこの性能と言われていたと思います。OpenAI のダッシュボードを見てみると、2 日間 x 8 時間での API 使用量は約 $10(約 1,500 円)、7k リクエスト、8.7M トークンでした。 ↩︎

  10. 当初は簡易的な記憶レイヤーの実装として「スレッド作成時に前回のスレッドの会話内容を DB から読み込んでスレッドのコンテキストに載せる」みたいな実装を考えていたのですが、時間の都合上実装できませんでした。これがなかったので毎回「初対面の会話」になってしまい、出力が面白いものになりませんでした。改善の余地ありです。 ↩︎

  11. トランザクションアウトボックスパターンのメッセージリレーによる変更の検知は CDC(Change Data Capture)と呼ばれますが、ここでの DB クエリによる変更検知のパターンは Query-based CDC と呼ばれます。 ↩︎

  12. 公式から提供されている toio.jstoio.py などがある。 ↩︎

  13. 実際はペイロードサイズモニタリングWebSocket の設定だけやっていた。 ↩︎

  14. Unity で nats.net を使う例を Gist に置いておきます。 ↩︎

  15. 全然関係ないですが、ここまで作ってきたところでロボット同士が人間の自然言語を喋る必然性はないよなと思いました。人間とやりとりしたり、ロボット同士の通信を人間が理解するために自然言語を生成しているという感じ。 ↩︎

GitHubで編集を提案

Discussion