本記事は temi × ローカル LLM シリーズの1本目です。全体構成と設計方針、そして検討の末に判断を変えた2つの転換点を扱います。
自律走行ロボット temi に、利用者の発話に応答し、登録済みの地点まで移動できる案内システムを実装しました。応答生成の推論には、Apple Silicon の Mac 1 台上で動くローカル LLM だけを使っています。ロボットの移動そのものは temi 側の Temi SDK が実行しており、LLM が直接ロボットを操作しているわけではありません。
1. この記事で作ったもの
本プロジェクトの目的は次のとおりです。
ロボットによる案内は定型文や固定ルートが中心になりがちだが、そこに生成 AI を組み合わせると、その場のやりとりでどこまで柔軟に動けるのかを確かめたい
temi は Android OS をベースにした独自 OS で動く自律走行ロボットで、Temi SDK を import した Android アプリを載せると、発話・移動・センサーといったロボット側の機能を呼び出せます。今回使ったのは temi V2(OS は Android 6.0.1)です。
今回は音声対話と登録地点への移動を使い、イベント会場の案内というシナリオで検証しました(以降「案内」と書いているのはこの検証シナリオを指します)。具体的には、曖昧な質問(「〜のセッションはどこ?」「お手洗いは?」)に対話形式で応答し、音声による指示だけで目的地まで案内することを目指しました。
| 項目 | 内容 |
|---|---|
| やったこと | 話しかけられた内容にローカル LLM が応答し、必要なら登録地点へ移動する |
| 入力 | temi マイク / 端末マイク / 画面テキスト(debug) |
| 出力・動作 | 音声発話 / 画面表示 / 移動(goTo) |
| 推論 | ローカル(Ollama GGUF/Metal または MLX) |
| 構成 | Android アプリ(temi) + Python backend(FastAPI/Docker) + ローカル LLM |
「ローカル」は制約ではなく要件です。この前提が、後述する PII マスクや manual.txt の扱いなど設計全体に関わってきます。理由は後述します。
2. 最終的な構成
| 層 | 内容 |
|---|---|
| Android | Kotlin, compileSdk 34 / minSdk 23 / targetSdk 30, Java 11, viewBinding, バージョンカタログ管理 |
| Temi SDK |
com.robotemi:sdk:1.137.1(mavenCentral) |
| backend | Python (FastAPI + uvicorn), コンテナ base python:3.11-slim, pydantic-settings |
| LLM | Ollama 0.30.11(Homebrew ネイティブ, Metal)/ mlx-lm 0.31.3(mlx_lm.server, OpenAI 互換) |
| モデル | チャット: qwen2.5:7b(GGUF Q4_K_M)。埋め込み: nomic-embed-text
|
| 実行機 | 開発機 Apple M3 / 16GB。temi は V2(rk3288 / Android 6.0.1) |
minSdk 23 / targetSdk 30 は、temi V2 の OS が Android 6.0.1(API 23)、V3 が Android 11(API 30)であることを意識して選んだ値です。ただし targetSdk は「アプリがどの API レベルの動作仕様に合わせて作られているか」の宣言であり、実機の OS バージョンそのものを指定するものではありません。今回の実機は V2 なので、実際に動作を左右するのは minSdk 23 の方です。
チャット生成は LLM_PROVIDER で Ollama / OpenAI 互換サーバを切替できますが、この構成では埋め込みは常に Ollama の nomic-embed-text を使っています。MLX 側の実行エンジンには埋め込み API を実装しておらず、埋め込みだけは切替の対象外にしています。
現在の運用構成は「Ollama は macOS 本体ネイティブで常駐(全インターフェースで待受)、Docker は backend のみ」です。全インターフェースで待ち受ける設定のため、同一 LAN 内の他端末からも到達できる点には注意が必要です。ここに至った経緯は別記事で扱います。
3. 1リクエストの流れ
ポイントは 全文を待たずに文単位で喋り始めることです。LLM が生成中の文字を NDJSON の delta として流し、アプリ側が文末記号で区切って完成した文から順に TTS に流します。warm 状態なら backend が最初のトークンを返すまで約 1 秒です。
ただしこの設計には副作用があり、「喋り出し」は最初のトークンではなく最初の文を待つことになります。実測で backend の TTFT 1.96 秒に対して temi の喋り出しが 4.1〜4.7 秒だったのは、ネットワークではなくこの設計が主因でした(別記事で扱います)。
このシリーズには「速さ」に関する数値が何種類か出てきますが、それぞれ測っている場所が違います。混同しないよう、ここで整理しておきます。
| 指標 | 内容 | 主に扱う記事 |
|---|---|---|
| LLM の生成速度(tok/s) | GPU/CPU・MLX/GGUF によるトークン生成の速さ | 別記事(GPU・MLX の検証編) |
| backend の TTFT | リクエストからプロンプト評価を経て最初の応答が返るまで | 別記事(プロンプト順序編) |
| temi の喋り出し・移動開始 | アプリ側の文単位発話や状態管理を経て実際に動くまで | 別記事(Android 実装編) |
4. 実装した機能と検証状況
| 機能 | 内容 |
|---|---|
| 音声対話 | temi マイク(Temi SDK)/端末マイクで聞き取り、AIの応答を音声で読み上げる |
| テキスト入力(debug) | 画面の入力欄から送信して backend 応答を確認(音声を使わない検証用) |
| ストリーミング逐次発話 | 生成途中の文を文末ごとに区切り、完成した文から順に発話 |
移動 (goTo) |
「受付はどこ」等の問いかけで temi 登録地点へ移動 |
| 人検知あいさつ | 人を検知した瞬間に短い挨拶を生成(proactive greeting) |
| エピソード記憶 | 会話を SQLite に永続化、埋め込み検索で過去の有用な会話例を参照 |
管理画面 (/admin) |
会話セッション一覧、有用度スコア、レビュー、エピソード記憶の確認・昇格 |
| 安全策 | action は allowlist のみ。低確信度の goTo は none に降格。対応している種類の PII は保存前にマスク |
このうち、temi 実機で確認できているのはチャット応答・goTo による移動・会話ログとエピソード記憶のレビュー/昇格までです。人検知あいさつと takePhoto はコードとしては実装済みですが、実機での検証はまだ行っていません。 詳細は別記事で扱います。
backend の API 一覧やレスポンス契約、NDJSON ストリームの詳細は別記事で扱います。
5. 設計上の重要な判断
このプロジェクトで明文化しているルールを挙げます。どれも実装の都合ではなく、ロボットが物理的に動くことから来ています。
5-1. action.type は allowlist のみ
型・プロンプト・正規化・DB の CHECK 制約の4層で縛っています。LLM が新しい action 名を創作しても、正規化の時点で none に落ちます。
5-2. backend は固定の地点マスタを持たない
goTo の候補は、毎リクエストに temi から渡される robotState.availableLocations だけです。backend 側に地点表を持ちません。地点マスタを二重管理することになるためです。
一覧外の destinationId、および低確信度の goTo は none に降格します。
5-3. manual.txt は学習データではない
イベント案内の内容は manual.txt に書き、backend が prompt に同梱します。なおこのマニュアル自体、開発中に AI(Claude)と対話しながら生成したものです。地点名も同様です。
ファインチューニングもしていませんし、長期記憶として保持されるわけでもありません。単にプロンプトに毎回入れているだけです。
そして、この「毎回入れている」ことが、後にシステム全体で最大のボトルネックだと判明します。
なお manual.txt はモジュール読み込み時(backend 起動時に一度だけ)に読んでキャッシュするため、内容を変えたら backend の再起動が必要です。SHA-256 の先頭 12 桁を MANUAL_VERSION として DB に記録し、どのバージョンの manual で生成された応答かを追えるようにしています。
5-4. 対応している種類の PII は保存前にマスクする
会話ログは SQLite に永続化しますが、メール/電話番号/郵便番号/URL は DB に保存する前に正規表現で置換します。生テキストを保存するカラムは存在しません。ただし、これですべての個人情報が除去されるわけではありません。氏名や会社名はパターンの対象外で、標準出力やアクセスログといった保存データ以外の経路もマスクの対象にしていません。詳細は別記事で扱います。
6. なぜローカル LLM にしたのか
ここからは、この構成に落ち着くまでの記録です。
最終構成は「Ollama(macOS ネイティブ)+ FastAPI(Docker)+ Kotlin アプリ」ですが、そこに至るまでに 2 回、判断そのものを大きく変更しています。検討を重ねた結果の方針転換です。
- クラウド LLM 前提 → ローカル
- 全部 Docker → Ollama だけネイティブ
「なぜその判断をしたか」「なぜ覆したか」を記録として書きます。この検討は大部分を AI(Claude)との対話で進めており、比較検討表や構成案を生成させ、自分の環境における条件や制約を追加で伝えながら内容を修正していく、という進め方です。これが役に立った場面と、逆に判断がぶれる原因になった場面の両方があったので、そこも書きます。
開発は本来「バックエンド → temi アプリ → 合体」の順で考えていましたが、実際にはバックエンドの設計が固まる前に、音声認識と発話ができる temi アプリの方が先にできあがりました。
最初の構成案は OpenAI / Azure OpenAI でした。想定コストは月数千円程度です。
ローカルに切り替えた理由は、速度ではなくデータが外に出ない構成にしたかったことでした。整理するとこうなります。
| 観点 | クラウド LLM | ローカル LLM |
|---|---|---|
| 話しかけられた内容 | 外部サービスへ送信される | Mac の中で処理され、外部のクラウドには送信されない |
| マニュアルの内容 | プロンプトとして外部へ送る | 外部に送信しない |
| ネットワーク依存 | 必須 | 同一 LAN 内で完結(インターネット接続は不要) |
不特定の人が話しかける機器なので、「誰が何を話したか」が Mac の中で完結する形にしたい。そういう構成にしたかった、というのが出発点です。
LLM推論とbackendの会話ログ保存はMac上で行っています。temiの音声認識・ウェイクワード等を含む音声データの通信経路は本検証では確認していないため、『音声データ全体が端末内で完結する』ことを示すものではありません。
境界線をこう整理しました。
- 第1の境界: temi ⇔ Mac … 同一 Wi-Fi 内のみ。インターネットには出ない
- 第2の境界: Docker … マニュアルと会話ログ(SQLite)はコンテナ内に隔離
- 第3の境界: Ollama … 外部 API を一切呼ばない
結果的にローカルで速度も足りたのですが、それは後の速度検証でチューニングした後の話です。最初から速いと分かっていたわけではありません。
7. なぜ Ollama だけネイティブにしたのか
検証後に環境を容易に削除できる構成にしたいと考え、「Ollama も Docker に入れたい」という発想に至りました。
実際に Ollama も backend も全部 Docker に閉じ込める構成を組んで動かしています。Mac 本体に直接インストールするものは避けられますが、Docker 自体が CPU・メモリ・ディスクを使うため、Mac への影響がまったくないわけではありません。加えて Docker 内部からは M3 の Metal(GPU)が使えません。計算が CPU になるため「イベントで使うには厳しいのでは」と当時は考えていました。
この見立ては後の実測で覆ります。遅さの原因は Docker ではなく CPU 実行になっていたことで、速度を分ける軸は Docker の有無ではなく GPU か CPU かでした。実測値は別記事で扱います。「応答まで 15〜30秒」も事前見積もりで実測値ではありません。
最終的には brew install ollama + brew services start ollama という、一般的な方法に落ち着きました。常駐と自動再起動を任せられるので、片付け手順だけドキュメントに残す形にしています。
brew services stop ollama
ollama rm qwen2.5:7b
ollama rm nomic-embed-text
brew uninstall ollama
rm -rf ~/.ollama # モデルや設定など、Ollama のローカルデータ一式。brew uninstall では消えない
8. AI を使って技術選定して分かったこと
この検討は大部分を AI(Claude)との対話で進めました。比較検討表や構成案を生成させ、自分の環境における条件や制約を追加で伝えながら内容を修正していく、という進め方です。比較検討表を素早く出せる点や、ルールを一貫して反映させられる点は助かりましたが、前提を伝えるまでは一般論で答えてくる、情報がバージョンに追いついていないことがある、といった場面で判断がぶれました。
結局のところ、AI は与えられた前提をもとに回答を生成するので、前提を出し切るのは人間の仕事でした。「制約と要件を混ぜない」という学びは、そのまま「AI にどう前提を渡すか」の話でもあります。
ここまでの検討経緯は開発中の記録に基づいています。実際に手を動かしたのは「全 Docker 構成」と「Ollama の直接インストール」です。時間の見積もり(「15〜30秒」等)も検討時の値で、実測ではありません。
9. 参考資料
- Ollama で Llama-3 を動かしてみる ― Mac ネイティブに Ollama を入れる手順の参考にしました
- schroneko さんの記事 ― 日本語モデルの選び方、Modelfile のカスタマイズ
- Ollama 公式の MLX 発表: https://ollama.com/blog/mlx
- Temi SDK: https://github.com/robotemi/sdk
- SDK Wiki: https://github.com/robotemi/sdk/wiki
手順書を読むときは、まず「この人の前提環境は何か」を確認する。 当たり前ですが、動かないときに役に立つチェックでした。
10. まとめ
構成そのものはありふれた形ですが、重要だったのは 動かしてから遅さの原因を切り分けていく過程でした。
「ローカル LLM だから遅い」と思っていたものが、実は GPU が使われていなかっただけだったり、エンジンの問題ではなくプロンプトの並べ方の問題だったり。
さて、次回の記事は?
「Ollama、Docker を出る」
「GPU、働いていませんでした」
「MLX は速いのか」の3本です。
次回もまた、読んでくださいね。
じゃんけん…ポン!
✌️
チョキでした。