第1章:導入(自然言語でロボットを安全に動かしたい!)
はじめに:この記事を書いたきっかけ(開発の経緯)
この記事の執筆に至ったのは、趣味のラズパイ工作がきっかけです。
Raspberry Pi と PiCar-X のキットを使って自作ロボットを作り、しばらく動かしているうちに「せっかくならキーボード操作ではなく、自然言語で指示を出して自律的に動かせたら面白いのではないか?」と考えました。
いざ実装しようと調べ始めると、LLMの出力フォーマットをプログラムで正しく読み取り、安定してロボットを動かすことの難しさに突き当たりました。そこでさらに調査を進めたところ、Gemini APIの 「Structured Outputs(構造化出力)」 を使えば、意図通りのスキーマで出力を型保証し、スムーズにプログラムへ組み込めることがわかり、トライしてみました。
結果として非常に安定したロボット制御を実現できたため、その知見をまとめました。
ターゲット読者
- LLMの応答をプログラムで読み取る際のエラーに悩んでいる方
- 「Structured Outputs(構造化出力)」の具体的な活用例を知りたい方
- ラズパイやロボットキットをAIで動かすことに興味がある方
AIでロボットを動かすロマンと、立ちはだかる「パースエラー」の壁
「2秒前進して、障害物があったら止まって」
このような自然言語の指示から、ラズパイカー「PiCar-X」などのロボットを思い通りに制御できたら面白いですよね。
しかし、物理的なハードウェアをLLMで制御しようとするとき、避けて通れない大きなハードルがあります。それは 「LLMの出力の不確実性(パースエラー)」 です。
通常のLLMの用途(チャット、要約、メールの下書き作成など)であれば、出力されるテキストに多少の表記揺れや余計な挨拶、フォーマットの崩れがあっても、それを読む人間が柔軟に解釈できるため問題になりません。
しかし、今回の「ロボット制御」のように、LLMの出力をプログラムが直接読み込んで機械を動かす用途では話が変わります。1文字のパースエラーや、キー名のわずかな揺れ(例えば steer と steering の違いなど)によってエラーが発生し、ロボットが思うように動いてくれない問題が生じます。
不確実なLLMの出力と、思い通りに正しく制御したい物理ロボット。この2つをうまく繋ぐために、本プロジェクトでは Gemini APIの構造化出力(Structured Outputs) を活用しました。
本記事で解説すること
本記事では、この安全なロボット制御を実現するために工夫した以下のポイントについて順を追って解説します。
- 構造化出力がないと、LLMの応答がどのように破綻しロボットに危険を及ぼすのか
- Gemini APIの Structured Outputs を使い、APIレベルで出力スキーマを100%保証する方法
- 複雑な「自律停止条件(until条件)」をLLMに組み立てさせ、それをRust側で安全に実行するアーキテクチャ
第2章:構造化出力がないと何が起きるか?(制御破綻のリアル)
「JSONで返して」というプロンプトの限界
通常のLLM(JSONモードや特別なスキーマ制御を適用していない状態)に対して、「ロボットの制御指示をJSON形式だけで出力してください」とプロンプトで指示してみたとします。
その際、AIから返ってくるレスポンスには、以下のような「おなじみの問題」が多発します。
問題1. 余計な文字や装飾コードの混入
期待する出力(プログラムがそのまま読み取れる純粋なJSON):
{"commands": [{"action": "forward", "speed": 50, "duration_ms": 2000}]}
実際に返ってくる出力(LLMが気を利かせた結果):
了解しました!以下のコマンドを実行します:
```json
{"commands": [{"action": "forward", "speed": 50, "duration_ms": 2000}]}
上記を実行してください。何かご不明な点はありますか?
プログラムが受け取るのは文字列全体のため、JSONの前後にある「了解しました!」「上記を実行してください。」という自然言語の文章や、コードブロックを示す ` ```json ` という記号がそのまま混入してしまいます。これらを正規表現などで取り除こうとしても、AIの返し方は毎回微妙に変わるため、例外処理が際限なく増えてコードが汚れる原因になります。
---
## 複雑な条件分岐(ネスト)における「キー名」の揺らぎ
本プロジェクトの最大のこだわりは、単に「進め」「止まれ」だけでなく、「超音波センサーの距離が15cm未満になるまで進め」という **「センサー連動の自律停止(until条件)」** をLLMに考えてもらう点です。
これを実現するために、LLMには以下のようなネスト(入れ子)構造を持つ複雑なJSONを返すように求めます。
```json
{
"commands": [
{
"action": "forward",
"speed": 40,
"duration_ms": 15000,
"until": {
"sensor": "distance",
"op": "lt",
"value": 15
}
}
]
}
ポイント解説
-
untilフィールド: ロボットがアクションを中断する条件(条件式)を記述します。 -
sensor: 監視するセンサーの種類(distance=超音波距離センサー)。 -
op: 比較演算子(lt=「未満」)。 -
value: 判定の閾値(15cm)。
しかし、構造化出力がない状態でLLMにこれを生成させると、プロンプトでいくら厳密に指定しても、高確率で以下のような「揺らぎ」が発生します。
-
"sensor"というキーが"sensor_type"や"target_sensor"に変わってしまう。 -
"op"(演算子)の値を"lt"ではなく、"less_than"や"Under"と自由に変えて出力してしまう。 - ロボット側で定義していない無効な値(例: スピード制限が最大60なのに
"speed": 120など)を出力してしまう。
プログラム(Rustのデシリアライズ処理)は定義されたキー名やデータ型、列挙型(enum)と1文字でも違うと、エラーを吐いて読み取りを断念します。
物理ロボットにおける「パースエラー」による誤動作
Webブラウザ上で動作するアプリであれば、パースエラーが起きても「もう一度ボタンを押してください」と画面に表示してユーザーにやり直してもらえば済みます。
しかし、実世界で動くロボットの場合、パースエラーが起きると**「思い通りに動かない」「止まらない」といった誤動作**に繋がります。
例えば、「前方に障害物があるまで前進せよ」という指示をロボットが実行しているとします。
もし、LLMから返ってきたJSONの until 条件(停止条件)が1文字のタイポのせいで読み取れなかった場合、プログラムはどう動くべきでしょうか?
-
エラーとして無視して処理を中断する
- ロボットはその場で停止し、フリーズしたようになってしまいます。これでは指示通りに動いてくれません。
-
読み取れない部分(停止条件)を無視して、とりあえず前進コマンドだけを実行する
- 停止条件(障害物が近づいたら止まる)が読み取れないまま「前進」だけが走り出すため、ロボットは止まるタイミングを失って壁や障害物にぶつかってしまったり、タイヤが回りっぱなしになってしまったりします。
AIの「出力のあいまいさ」が、ロボットのちょっとした誤動作や、モーターなどへ余計な負荷をかける原因になってしまうのです。
第3章:GeminiのStructured Outputsがもたらす「型安全」な解決
救世主:Structured Outputs(構造化出力)とは?
前章で紹介した「JSONが崩れてロボットが誤動作する問題」を根本から解決するのが、Gemini APIの 「Structured Outputs(構造化出力)」 という機能です。
Rustの「型システム」との相乗効果
この構造化出力は、Rust言語と非常に強力なシナジー(相乗効果)を発揮します。
Rustには、JSONデータをRustの構造体や列挙型(enum)に一発で変換(デシリアライズ)できる非常に強力なライブラリ serde があります。
通常、LLMの出力が不安定だと、serde は「型やキーが合わない」としてすぐにパースエラー(エラー返却)を起こしてしまいます。しかし、Geminiの構造化出力によって「スキーマ通りであること」がAPIレベルで100%保証されると、以下のようなメリットが生まれます。
-
パースエラーによる強制終了の撲滅
- 届くデータが必ずスキーマに適合しているため、Rust側での
serde_json::from_strが失敗することがほぼゼロになります。
- 届くデータが必ずスキーマに適合しているため、Rust側での
-
無駄な「例外処理コード」の削減
- 挨拶のトリム処理、マークダウン記法の除去、キーの揺らぎに対する分岐処理など、これまでパースエラー対策のために書いていた泥臭いコードがすべて不要になり、ビジネスロジックに集中できます。
-
Rustの型安全性の恩恵をフルに受ける
- LLMから取得したコマンド情報を、直接Rustの型安全な
Commandenum にマッピングしてそのままロボットのモーター駆動関数などに流し込めるようになります。
- LLMから取得したコマンド情報を、直接Rustの型安全な
第4章:実装詳細:gemini_agent.rs の詳細解説
プロジェクトの全体像
本プロジェクトは大きく以下の3つの役割に分かれています。
ユーザーの自然言語入力
↓
┌──────────────────┐
│ GeminiAgent │ ← Gemini APIと通信。プロンプトを送り、構造化JSONを受け取る
└──────────────────┘
↓(Commandのリスト)
┌──────────────────┐
│ CommandQueue │ ← 受け取ったコマンドを順番にロボットへ実行させるキュー
└──────────────────┘
↓(モーター・サーボへの指示)
┌──────────────────┐
│ PiCar-X │ ← 実際のロボット本体(Raspberry Pi + PiCar-X)
└──────────────────┘
↑(センサーの値をフィードバック)
- GeminiAgent: ユーザーの自然言語入力をGemini APIへ送信し、構造化されたコマンドのリスト(JSON)を受け取ります。API通信は専用のバックグラウンドスレッドで行われるため、ロボットの制御ループをブロックしません。
-
CommandQueue: 受け取ったコマンドを1つずつ順番に実行するキューです。毎フレーム呼び出されるステートマシン方式で動作し、
until条件(センサー連動の停止条件)をリアルタイムに監視しながらコマンドを処理します。 - PiCar-X: Raspberry Pi上で動作する実際のロボット本体です。モーターやサーボの制御、超音波センサーやグレイスケールセンサーからのデータ取得を担います。
なお、本プロジェクトの制御部分はRustで実装しています。Rustを選んだ理由は、serde による堅牢な型マッピング、コンパイル時のバグ検出による安定性、そしてマルチスレッドを安全に扱える点が、今回のアーキテクチャに適していたためです。
構造化出力のためのリクエスト構造
本プロジェクトでGemini APIと通信する心臓部が gemini_agent.rs です。
Geminiに対して構造化出力を指示するには、リクエストの送信時に「JSONを要求すること」と「JSONの具体的なスキーマ」の2つを設定します。
具体的には、以下のようにリクエストボディを構築します。
let response_schema = serde_json::json!({"type": "OBJECT",
"properties": {
"commands": {
"type": "ARRAY",
"description": "List of robot commands to execute in sequence.",
"items": {
"type": "OBJECT",
"properties": {
"action": {
"type": "STRING",
"enum": [
"forward",
"backward",
"steer",
"stop",
"set_cam_pan",
"set_cam_tilt",
"get_distance",
"get_grayscale",
"get_battery_status"
]
},
"speed": {
"type": "INTEGER",
"minimum": 0,
"maximum": 60
},
"duration_ms": {
"type": "INTEGER",
"minimum": 100,
"maximum": 30000
},
"angle": {
"type": "INTEGER",
"description": "Streering angle in degrees. Can be used with forward/backward commands to turn while moving.",
"minimum": -90,
"maximum": 90
}
},
"required": ["action", "duration_ms"]
}
}
},
"required": ["commands"]
});
let request_body = serde_json::json!({
"contents": [{
"parts": [{
"text": prompt
}]
}],
"systemInstruction": {
"parts": [{
"text": SYSTEM_PROMPT
}]
},
"generationConfig": {
"temperature": 0,
"maxOutputTokens": 1024,
"responseMimeType": "application/json",
"responseSchema": response_schema
}
});
ポイント解説
-
JSON Schemaの定義:
response_schemaにて、最上位がcommandsという配列を持つオブジェクトであることを宣言しています。配列の各要素はaction(列挙型),speed,duration_ms,angleを持つオブジェクトです。 -
値の範囲指定:
minimum/maximumを用いて、ロボットのモーターに悪影響を与えないスピード範囲(0〜60)や、サーボモーターの回転角(-90〜90)をAPIレベルで制限しています。 -
generationConfigの指定:responseMimeTypeに"application/json"を指定し、responseSchemaに先ほど定義したresponse_schemaを渡すことで、Geminiにこのスキーマ通りのJSONを出力させます。
Rustの serde による型マッピング
Geminiから返ってきたJSONは、Rustの serde ライブラリによって Command という列挙型(enum)に自動変換されます。
#[serde(tag = "action")] というアノテーションを使うと、JSONの "action" フィールドの値("forward" や "stop" など)を見て、対応するRustの型に自動でマッピングしてくれます。構造化出力によってスキーマが保証されているため、このデシリアライズがほぼ確実に成功します。
メインループをブロックしない非同期アーキテクチャ
ラズパイカーは、走っている間も常にカメラの映像を描画したり、周囲のセンサー値をスキャンし続けたりしなければなりません。
もし、LLMのAPIリクエストをメインスレッドで実行してしまうと、AIが考えている数秒間、ロボットの画面描画やセンサー監視が完全に停止(フリーズ)してしまい、誤動作の原因になります。
そのため、gemini_agent.rs ではスレッドとチャネルを用いた非同期処理を採用しています。
pub struct GeminiAgent {
prompt_tx: Sender<String>,
response_rx: Receiver<LlmResponse>,
}
impl GeminiAgent {
pub fn new() -> Self {
let api_key = std::env::var("GEMINI_API_KEY").unwrap_or_default();
let (prompt_tx, prompt_rx) = mpsc::channel::<String>();
let (response_tx, response_rx) = mpsc::channel::<LlmResponse>();
// HTTP通信を行うバックグラウンド(専用)スレッドを起動
thread::spawn(move || {
for prompt in prompt_rx {
let result = Self::call_gemini(&api_key, &prompt);
if response_tx.send(result).is_err() {
break;
}
}
});
Self { prompt_tx, response_rx }
}
/// メインスレッドからプロンプトを送る(非ブロッキング)
pub fn submit_prompt(&self, prompt: String) {
let _ = self.prompt_tx.send(prompt);
}
/// メインスレッドから返事を取り出す(非ブロッキング)
pub fn try_recv_response(&self) -> Option<LlmResponse> {
self.response_rx.try_recv().ok()
}
}
-
thread::spawnでAPI通信を別スレッドに分離し、mpsc::channelでメインスレッドと安全にデータをやり取りします。 -
try_recv()は「返事が届いていればもらう、なければスルー」という非ブロッキング呼び出しで、ロボットの制御ループを止めません。
第5章:高度な工夫:until条件による安全な自律制御
時間制御だけでは、おもちゃのロボットでも不十分
通常、ロボットへの単純な命令は「スピード30で、2秒間前進する」といった時間ベースのものが基本です。
しかし、もし障害物との距離が近いときにこのコマンドが実行されてしまうと、ロボットは止まるタイミングを失って障害物にぶつかってしまいます。
これを回避するために、本プロジェクトでは 「until(〜まで)条件」 というセンサー連動の停止ルールをLLMに指示できるようにしました。
例えば、「障害物がない間は前進し、距離が15cm未満になったら停止する」という指示に対し、Geminiは以下のJSONを生成します。
{
"action": "forward",
"speed": 40,
"duration_ms": 15000,
"until": {
"sensor": "distance",
"op": "lt",
"value": 15
}
}
ポイント解説
-
duration_ms: 条件が満たされない場合に備え、15秒(15000ms)という長めの「安全用タイムアウト時間」を確保しています。 -
until: 超音波センサー(distance)の値が 15(cm)未満(lt)になった瞬間に、duration_msの経過を待たずに動作を終了します。
センサー条件とステートマシンによる制御
詳細コードは省きますがLLMが生成した until 条件は、AND / OR / NOT / XOR などの論理演算を組み合わせた再帰的な条件式として定義することで、どんなに複雑なネスト構造でも Rust 側でそのまま解釈できます。
コマンドの実行は thread::sleep によるブロッキング待機ではなく、メインループから毎フレーム呼び出されるステートマシン方式で処理されます。Instant::now() の差分で経過時間を測り、「指定時間が過ぎたか」「センサー条件が成立したか」の2条件で動作を切り替えます。これにより、スリープ中にセンサーが止まる問題を避けつつ、リアルタイムに until 条件を監視できます。
また、前進・後退コマンドが終了したタイミングでモーターを自動停止し、曲がったステアリング角度をまっすぐに戻す安全処理も組み込まれています。
第6章:まとめ(AIの「あいまいさ」を「確実な型」に閉じ込める)
ロボット制御における「型」の重要性
本プロジェクトでは、不確実なLLMの出力と、厳格な制御が求められる物理ロボット(PiCar-X)を繋ぐために、Gemini APIの Structured Outputs(構造化出力) を採用しました。
これにより、以下のような恩恵が得られました。
-
エラーのない読み取り
- API側が出力JSONのスキーマを100%保証してくれるため、Rustの
serde_jsonでのデシリアライズ失敗による動作エラーやフリーズをほぼゼロにできました。
- API側が出力JSONのスキーマを100%保証してくれるため、Rustの
-
複雑な自律ロジックの実現
- LLMに「障害物までの距離が15cm未満」のような動的なセンサー判定条件(
until)をJSON Schemaの制約下で正しく組み立てさせ、それをRust側の非ブロッキングなCommandQueue::tickでリアルタイムに監視・実行する高度な仕組みを安全に作ることができました。
- LLMに「障害物までの距離が15cm未満」のような動的なセンサー判定条件(
-
無駄な泥臭いコードの撲滅
- マークダウン記法の除去や、キー名のわずかな揺れ(表記ブレ)などを補正するための複雑な正規表現や文字列操作のコードが一切不要になりました。
物理ロボットだけでなく、スマートホームの制御や社内システムのAPI連携など、「AIの出力をもとにプログラムを動作させる」あらゆる用途において、構造化出力はもはや必須の技術であると言えます。
今後の展望
現在は、ロボットの「移動(前進、後退、旋回など)」に関する指示と制御が中心になっています。
今後は、ロボットができることをさらに広げるために、カメラの向きを変える指示(首振り)や、超音波・グレイスケールセンサーからの情報取得(距離や白線の検知)、バッテリー状況の確認など、移動以外の機能についても拡張性を持たせ、より複雑で自律的な対話制御ができるように機能を広げていきたいと考えています。
ラズパイやロボットキットに興味があれば、ぜひ購入して遊んでみてください!