本記事は temi × ローカル LLM シリーズの4本目です。
ロボット temi 向けに実装したローカル LLM の backend 実装と、マニュアル設計の記録です。
LLM の出力によるチャットボットの誤応答と、LLM の出力によって物理的に動くロボットが誤動作するのとでは、失敗した場合の影響が異なります。この記事では、LLM の出力をそのまま物理動作に使わず、アクションだけを backend とアプリで検証してから実行する仕組みと、ロボットに物理的にできないことをどう教えるかを扱います。返答文自体は活用しますが、移動や撮影などの動作には複数のガードを設けています。
1. 安全に動かす backend
LLM はローカルの Ollama qwen2.5:7b。backend は FastAPI で、応答は NDJSON でストリーミングします。LLM には system role を使わず、単一の user メッセージにすべてを詰めて渡し、JSON オブジェクトで返させて backend が正規化してからアプリに渡します。
{
"replyText": "受付までご案内しますね。",
"action": { "type": "goTo", "destinationId": "受付" },
"confidence": 0.98,
"reasonCode": "located"
}
1-1. action.type は allowlist で制限する
LLM が創作した action 名でロボットが動くと困ります。そこで複数段階で検証しています。プロンプトは LLM への指示であり強制力を持たないため、backend の入力検証(Pydantic の型・正規化)、DB の保存制約、実行直前のガードを組み合わせて安全性を確認しています。
class AssistantAction(BaseModel):
type: Literal["none", "speak", "goTo", "takePhoto"] = "none"
destinationId: str | None = None
型・正規化(未知の値は none に落とす)・DB の CHECK (action_type IN (...)) 制約で、LLM が指示を無視しても機械的に落とします。
1-2. goTo の2段ガード ― backend は地点マスタを持たない
移動指示は特に危険なので、2段でガードしています。
backend は固定の地点マスタを持ちません。 候補は毎リクエストで temi から渡される robotState.availableLocations だけです。backend が地点を知っていても、temi の地図に登録がなければロボットは行けないため、temi 側の一覧を唯一の正としています。降格理由は reasonCode(blocked_unknown_destination 等)として残し、「なぜ動かなかったのか」をログから追えるようにしています。
1-3. 返答文は制限せず、行動だけを検証する
action を降格させるとき、replyText は上書きしません。固定文に差し替えると、マニュアル由来の案内まで消えてしまうためです。固定的に「行けません」と返すよりも、「別のフロアにあるため、エレベーター前まで案内する」といった代替案を提示できる方が、案内システムとして適切です。
今回の実装では、特に誤操作の影響が大きい物理動作を厳しく制限し、返答文はマニュアルに基づく案内として活用する方針にしました。ただし、返答文も常に正しいとは限らないため、重要な情報についてはマニュアルや運用側での確認が必要です。
1-4. LLM が返す JSON をどのように検証するか
format:"json" で文法を制約していても、コードフェンスや前後の説明文が混ざることがあります。素の JSON として無条件には信用せず、3段階で解析しています。
ストリーミング発話には、JSON が完成する前に replyText の中身を取り出す必要があります。そこで「JSON のフィールドは必ず replyText を最初に出力する」というルールをプロンプトに入れ、backend 側で先頭からの増分デコードを実装しています。
この設計には弱点もあります。後続の JSON の解析に失敗しても replyText の該当部分はすでに発話済みで、最終的な正規化で差し替わっても発話済みの内容は戻せません。final が届かない場合に実行しないのは物理アクションだけで、発話はストリーム途中で進んでしまうため、通信断のタイミングによっては文の途中まで話すことがあります。物理動作の安全性と発話開始の速さを分けて扱っている、ということです。
1-5. NDJSON ストリーミング
delta / final / error の3イベントだけのシンプルな形式です。SSE ではなく NDJSON にしたのは、1行=1 JSON という単純さと、Android 側を HttpURLConnection + forEachLine だけで書けるからです。アプリ側は final が来ないまま接続が終わったら例外にします。中途半端な応答で物理アクションを実行しないためで、これは action の実行を止めるものであり、すでに発話済みの replyText を取り消すものではありません。
2. エピソード記憶と PII マスク
2-1. PII マスク ― 生テキストのカラムを作らない
会話ログを SQLite に永続化しますが、メール・電話番号・郵便番号・URL の4種類は保存前に正規表現でマスクします。ハッシュ化も可逆暗号もせず、復元できない形式に置き換えます。生テキストを保存するカラムは作っておらず、リクエスト/レスポンス JSON も再帰的にマスクし、エピソード記憶の埋め込みもマスク後のテキストから生成します。
ただし、これで個人情報を完全に除去できるわけではありません。氏名・会社名は対象外ですし、日本語の電話番号表記をすべて拾えるとは限らず、URL のクエリ文字列に個人情報が含まれる場合もあります。アプリケーションログや例外ログなど、DB 保存とは別経路で残る可能性も対象外です。今回のマスクは、正規表現で検出できる範囲を対象にした簡易的な対策です。氏名・会社名をパターンに入れていないのは、日本語の氏名を正規表現で拾うと誤検知が多すぎるためで、入力時に不要な情報を尋ねないという運用方針で補っています。
2-2. エピソード記憶 ― SQLite だけで作る RAG
「マニュアルに無い質問への対応を、再学習せずに蓄積したい」というのが出発点でした。会話ログを SQLite に保存しているため、埋め込みベクトルも同じデータベースに保存すれば、追加のデータベース依存を増やさずに済みます。件数が増えたら sqlite-vec 等への移行を想定した段階設計です。
コサイン類似度は純 Python で実装し、ライブラリは足していません。埋め込みモデル名と次元をベクトルと一緒に保存し、一致しないものはスキップします。次元が同じだと計算自体は通ってしまうため、これを入れ忘れると「埋め込みモデルを変えたら記憶がおかしくなった」という事故につながります。
検索件数(episode_memory_top_k=2)と会話履歴件数(session_history_limit=4)は、プロンプト評価時間を抑えるために品質を保てる範囲で絞った値です。プレフィルが最大のボトルネックだったため、RAG の件数を増やすことは評価時間の増加に直結します。類似度しきい値 0.55 は、少数の会話例で検索結果を確認しながら決めた初期値で、大規模な評価データに基づく値ではありません。
2-3. 自動学習はしない ― 人手レビューを挟む
会話中に自動で記憶へ保存することはありません。 保存経路は管理 API の4本(手動作成・会話ターンからの昇格・候補の一括昇格・誤答の修正登録)だけで、いずれも管理者が管理画面や管理 API から明示的に実行したときだけ動きます。auto-promote という名前ですが、これも会話中に自動発火するものではなく、管理者が実行する一括処理です。
理由は、誤った応答を記憶すると、以後の検索結果にその応答が含まれ、同じ誤りを繰り返す可能性があるためです。ロボットで「保健室は3階です」と誤答したものが記憶され、以降ずっと3階へ連れて行くようになったら困ります。/admin の管理画面は学習ではなく「人手レビュー → 良事例をエピソード記憶へ昇格」という運用ループの操作盤です。
誤答を correction として登録すると、元ターン由来の記憶を無効化し、プロンプト側にも「retrievedEpisodeMemories の内容を会話履歴より優先する」というルールを入れています。会話履歴には誤答がそのまま残りますが(監査のため消しません)、「記憶 > 履歴」という優先順位をプロンプトで明示することで、直近の誤答に引きずられないようにしています。
3. 物理制約をマニュアルに書く
ここからは、backend の実装だけでなく、ロボットの物理的な制約をマニュアルに反映する方法について説明します。ロボットには物理的にできないことがあります。フロアをまたげない、登録されていない場所には行けない。それを LLM にどう理解させるか、という話です。
3-1. 前提:temi には最初からノーコードで対話と動作を組む機能がある
temi Center という temi の公式管理画面には、FAQ 応答と動作シーケンスをノーコードで組める標準機能が最初から入っています(今回確認した環境でのものです。バージョンや契約プランによって変わる可能性があります)。ユーザーの発話とロボットの返答をペアで登録するだけで FAQ が完成し、地点への移動まで紐付けられます。表記揺れも temi 側である程度吸収してくれます。
つまり「表記揺れの吸収」のような軽い用途は標準機能で足ります。自前で LLM を載せる理由は、「マニュアルに載っているが FAQ には登録していない質問」に答えられるかどうかです。イベント案内では想定外の質問が必ず来るので、そこが自前構成の存在理由でした。
| temi Center | 自前(ローカル LLM) | |
|---|---|---|
| 想定していない質問への対応 | できない(登録したペアのみ) | できる(マニュアル全体から生成) |
| 会話データの所在 | temi Center の設定・会話機能に関連するデータは公式クラウド | 今回の自前構成で保存する会話ログは Mac 内で完結 |
| 応答の自由度 | 登録した固定文 | 文脈に応じて生成 |
| 会話履歴を踏まえた応答 | — | できる |
登録した FAQ に該当しない質問には、今回の構成では期待した案内を返せません(無言になるかどうかは temi Center 側の設定に依存します)。
3-2. 地点名は1文字でも違うと動かない
robot.goTo(locationName) は、temi の地図に登録した地点名と文字列が1文字でも違うと失敗します。LLM は「会議室A(展示会場)」のような説明的で親切な文字列を返そうとしますが、ロボットには通じません。
対策は3つです。地点名は 受付、トイレ のように、短く表記揺れの少ない名前で登録すること、backend とアプリの双方で完全一致でしか採用しないこと(一覧にない destinationId は none に降格)、そして候補リストをプロンプトに渡して「そのまま使え、作り話で補わない」と指示することです。backend 側が固定の地点マスタを持たないのも同じ理由です。
3-3. temi はフロアをまたげない ― 「中継地点」という発想
ここから先の会場設定・地点名は、検証用に AI と一緒に組み立てた架空のイベントのものです。実在の施設ではありません。
想定した会場は2階のワンフロアですが、フロアをまたぐケースを検証するため、案内対象の施設の一部を別フロアの1階に置きました。今回使用した temi の構成では、エレベーターのボタンを操作して自律的に別フロアへ移動する機能は実装していません。 仮に技術的に可能だとしても、人の付き添いなしで一人で乗り込ませるのは安全上好ましくないと判断しています。
そこで「エレベーター前」という中継地点を1つ登録し、「◯◯は1階にございます。2階のエレベーター前までご案内しますね」と、移動可能な中継地点まで案内する設計にしました。物理的な制約を、実行可能な範囲の案内に置き換える設計です。
3-4. 物理制約を「プロンプトのルール」として書く
「別フロアなら中継地点へ」というロジックはコードにも書けますが、そうすると対象が増えるたびにコードを直すことになります。そこでマニュアル(プロンプト)の「振る舞いルール」として書きました。これで LLM が自分で「別フロアの施設だからエレベーター前に連れて行く」と判断し、対象が増えてもマニュアルに1行足すだけで対応できます。
物理制約の説明はプロンプトで行いますが、それだけでは安全対策になりません。実際の移動可否はプロンプトに任せず、候補地点の allowlist とアプリ側の完全一致チェックで制限しています。物理制約の説明はプロンプトに任せ、実行可否はコード側のガードで制限する、というのが LLM を挟む構成ならではのやり方です。マニュアル本文の各施設の説明にも同じ振る舞いルールを埋め込み、施設の説明とそれに対する振る舞いを同じ場所に書くようにしています。
3-5. ファインチューニングではなく RAG を選んだ理由
マニュアルが育ってくると「AI に学習させたい」と思いますが、今回のデータ量と M3・16GB という環境では、ファインチューニングよりベクトル RAG のほうが扱いやすいと判断しました。 質問と回答のペアをまとまった件数用意する必要があり、今回試した範囲ではメモリ不足で処理が進まず数日間進展しなかったためです。追加学習は「新しい事実を覚えさせる」より「話し方や出力形式を調整する」ための技術という理解に落ち着いています。今回試した範囲では、マニュアルを学習させると数字を誤って生成することがあり、案内用途では避けたい挙動でした。
マニュアル全文を貼る方式のまま、検証の最後まで方式変更は不要でした。マニュアルが約2000トークンに収まっており文脈制限に届かなかったこと、プロンプトの順序を直すことでプレフィルのコストがほぼゼロになったこと(別記事で詳述)が理由です。RAG は、マニュアルの検索ではなく、過去の良い応答を再利用する「エピソード記憶」の方に使っています。
3-6. マニュアル=プロンプトの設計の工夫
見出しで区切った Markdown 形式にしたところ、今回使用した qwen2.5:7b では構造化していないテキストよりも期待した回答を返しやすくなりました。振る舞いルールはシステムプロンプト側だけでなくマニュアルの末尾にも重複して書いています(これはマニュアル本文内部の構造の話で、プレフィックスキャッシュのために固定部分を前方に置く eventManual ブロックの位置とは別の話です)。今回の手動確認では、この重複によってハルシネーションが起きにくくなりました。発話は「最大3文程度、約100文字以内」と文字数まで明示しています。
マニュアルは「案内したいこと」ではなく「聞かれること」で書くのが有効で、初版になかった「お昼どこで食べる?」のような質問を後から追加する形で3回改訂しました。マニュアル自体も、開発中に AI(Claude)と対話しながら生成しています。
マニュアルは全文が毎回プロンプトに乗るため、書いた情報はすべて LLM に渡ります。何を書くかは「その情報が LLM に渡ってよいか」を基準に判断する必要があります。PII マスクは会話ログの保存時にだけ働き、マニュアル本文には一切関与しません。 マスクがあるから安心なのではなく、そもそもマニュアルに書いてよい情報かどうかを先に判断する必要があります。
未検証事項
-
temi 実機で確認できたのは、チャット応答と
goToによる移動までです。 管理画面での会話ログのレビューとエピソード記憶への昇格は動作を確認済みですが、人検知あいさつ(proactive greeting)とtakePhotoは未検証です -
fine_tuning_examplesテーブルは将来構想としてドキュメントにあるだけで、未実装です - ファインチューニング比較にある所要時間の見積もりは、検討時の見積もりで実測ではありません
おわりに
安全に動かす backend の話と、ロボットができないことを前提に組む話でした。いずれも、物理的に動くロボットを LLM と連携させるうえで、特に重要な設計要素でした。
次回予告
さて、次回の記事は?
「speak、話の腰を折る」
「add と remove、名前が違う」の2本です。
次回はシリーズ最終回として、Android アプリへの実装と temi SDK の扱いについて説明します。
じゃんけん…ポン!
✊
グーでした。