本記事は temi × ローカル LLM シリーズの3本目です。GPU / MLX を検証するまでの過程は前回(part2)の記事に書いています。
前回は、GPU を使うことで生成速度が約1.3〜1.45倍になること、MLX と GGUF の速度差は当環境では数%だったことを確認しました。
しかし、実際の temi 運用では、質問を変えるたびに応答まで十数秒かかる状態が残っていました。そこで、モデルや GGUF・MLX といった実行経路に加え、GPU、Docker、埋め込み検索、DB など、応答時間に影響しそうな要素を順に調べました。しかし、今回の計測では、これらは主要な要因ではないことが分かりました。
1. 結論
生成速度を下げていた直接の原因は、GPU を使わず CPU で実行していたことでした。一方、質問を変えるたびに応答が遅くなっていた最大の原因は、プロンプトキャッシュが効かず、manual.txt の約2000トークンを毎回プレフィルしていたことでした。さらに、temi の喋り出しには、最初の文が完成するまで待つアプリ側の処理も影響していました。
| 原因 | 影響 |
|---|---|
| CPU 実行 | GPU 実行と比べて生成速度に約1.3〜1.45倍の差が出る |
| プロンプトの順序(プレフィックスキャッシュが効かない) | 別質問のたびに約2000トークンを再計算する |
| 文単位の TTS | temi の喋り出しが最初のトークンより遅れる |
直したのは _build_prompt() の数行です。プロンプトのブロックの並び順を変えただけで、別の質問をしたときの喋り出しが 約10.7秒 → 約1.7〜2.2秒 になりました。
2. 何を測ったか
システム構成
- temi の Android アプリ → FastAPI backend → Apple M3 / 16GB の Mac 上のローカル LLM
- backend は毎リクエストで、イベント案内マニュアル
manual.txtの全文をプロンプトに同梱する - エピソード記憶、つまり過去の良い応答を埋め込み検索して、これもプロンプトに差し込む
- 応答は NDJSON でストリーミングし、アプリが文単位で TTS 発話する
プロンプトの中身
_build_prompt() が組み立てる単一の user メッセージに、次のブロックが入ります。
役割宣言
JSON schema
固定ルール(25行ほどの箇条書き)
availableTemiLocations: ← temi の登録地点。リクエストごとに変わりうる
retrievedEpisodeMemories: ← 埋め込み検索の結果。質問ごとに毎回変わる
eventManual: ← manual.txt 全文。約2000トークン。常に同じ
locale:
robotState:
conversationHistory:
userText: ← 今回の質問
これが改善前の順序です。 この時点では何もおかしく見えませんでした。「関連情報を先に、重い資料を後に」という、むしろ自然な並びに見えます。
計測指標
-
TTFT:
/v1/assistant/respond/streamを叩いて最初のdeltaが返るまで -
total:
finalが返るまで - 条件は session / locations を固定し、
userTextだけ変える
以降の数値は、モデル・プロンプト・キャッシュ状態・測定回によって変動します。表ごとに測定条件が異なるため、単純な横比較ではなく、同一表内の比較として見てください。
3. warm 状態と実運用での応答時間の差
初期の計測ではこうでした。
| 状況 | 内訳 | 合計 |
|---|---|---|
| 1問目(コールド) | モデルロード 4.8s + プロンプト評価 10.4s(190 t/s) + 生成 5.0s(90tok) | 約20秒 |
| 2問目以降(warm) | プロンプト評価 0.1〜0.5s + 生成 5.0s | 約5秒 |
ストリーミング発話の喋り出しも測りました。
| 状態 | 喋り出しまで | 全体 |
|---|---|---|
| warm | 約0.95秒 | 約5秒 |
| cold(新セッション) | 約20.3秒 | 約24秒 |
この時点で「同一プロンプトを連投するとプロンプト評価が 10.4s → 0.1〜0.5s に落ちる」ことは分かっていました。プレフィックスキャッシュが働いているのです。
だから「2問目以降は速い」と思い込みました。
実際の temi 運用では、2問目以降も遅い状態が続きました。実運用では毎回異なる質問が入力されるため、同じ質問を連続して送る測定結果だけでは、実際の利用状況を再現できていませんでした。
4. A/B テストの条件を誤り、効果を確認できなかった
実は、プロンプト順序の変更は一度検討して見送っています。当時の検証メモにこう残っています。
| 案 | 期待効果 | 不採用の理由 |
|---|---|---|
| プロンプト順序の変更(manual を先頭へ) | プレフィックスキャッシュ命中率向上 | A/B 検証で連続呼び出しでは差が出ず、効果を確認できなかったため見送り |
A/B を「同じ質問の連続」で実施していました。
同じ質問を続けて投げれば、プロンプト全体が完全一致します。並び順が何であろうとキャッシュに全ヒットするので、当然どちらの順序でも差は出ません。
正しくは「質問を毎回変えて」測らなければいけませんでした。効果が出るのはまさにその状況だからです。今回のキャッシュ構成では、同じ質問を連投する A/B では順序最適化の効果を観測できません。
この検証条件の誤りにより、プロンプト順序の変更を一度見送っていました。後で戻ってこられたのは、原因の切り分けを真面目にやり直したからでした。
5. プレフィックスキャッシュの働き方
「LLM が遅いのか、連携部、つまり埋め込み・DB・通信が遅いのか」から始めました。
5-1. 計測方法
mlx_lm.server を --log-level DEBUG で起動すると、実際に prefill したトークン数がログに出ます。これと backend 側の TTFT / total を突き合わせました。
「何トークン再計算したか」を直接見られたことが、この切り分けで重要でした。 推測ではなく観測に基づいています。
5-2. 結果
| 実験 | 再 prefill 量 | TTFT | 総時間 |
|---|---|---|---|
| 新セッション・初回 | 2888 / 2888(全部) | 14.9 s | 17.9 s |
| 同セッション・同じ質問(全ヒット) | 41 | 1.2 s | 4.2 s |
| 同セッション・別の質問 | 2067 | 10.7 s | 14.2 s |
| 別セッション・同じ質問 | 1(ほぼ全再利用) | 0.8 s | 3.9 s |
埋め込み(Ollama nomic-embed-text)単体 |
— | 0.03〜0.04 s | — |
ここから分かったことを整理します。
(1) 連携部が主なボトルネックとは考えにくい
埋め込み単体は 0.03〜0.04 秒でした。また、キャッシュヒット時の TTFT も 0.8 秒程度です。今回の計測範囲では、埋め込み検索や通信よりも、LLM 側のプレフィルが主なボトルネックだと判断しました。
(2) 少なくとも今回の設定では、キャッシュはセッションをまたいで再利用される
「別セッション・同じ質問」で再 prefill が 1 トークンしかありません。今回使用した mlx_lm.server では、LRUPromptCache + fetch_nearest_cache によって、別セッションからのリクエストでも最長共通プレフィックスが再利用されました。少なくとも今回の設定では、セッション ID に依存せずキャッシュが共有されています。この挙動はサーバーの実装やバージョン、設定によって変わり得ます。
(3) 「同セッション・別の質問」だけが2000トークン超を再計算していた
再計算量は 2067 トークンで、manual.txt の約2000トークンとほぼ一致しました。このことから、manual を含む部分がキャッシュされず、ほぼ全体を再評価していると判断しました。
プレフィックスキャッシュは 最長共通プレフィックスにしか働きません。先頭から連続して一致している部分だけが再利用され、不一致となったトークンが出たら、そこから先は全部再計算です。
改善前の順序を思い出してください。
… 固定ルール
→ availableTemiLocations ← 変動しうる
→ retrievedEpisodeMemories ← 質問ごとに毎回変わる(埋め込み検索結果)
→ eventManual (約2000tok) ← 常に同じ、でも重い
→ locale / robotState / conversationHistory / userText
質問が変わると retrievedEpisodeMemories が変わります。するとその位置で共通プレフィックスが途切れ、それより後ろにある eventManual の約2000トークンが毎回再評価されるわけです。
図にするとこうです。
【改善前】質問が変わると…
[固定ルール][locations][ ★記憶(変動)★ ][ manual 約2000tok ][質問]
└──── ここまで再利用 ───┘└────────── ここから後ろを再計算 ──────────┘
↑ 重い固定部分が変動部分の後ろにある
【改善後】
[固定ルール][ manual 約2000tok ][locations][ ★記憶(変動)★ ][質問]
└────────── ここまで再利用 ──────────┘└──── ここだけ再計算 ────┘
↑ 再 prefill 約180〜240tok
大きな静的ブロックは、変動するブロックより前に配置する必要があります。
キャッシュの観点では、プロンプトは「変わらないものから順に並べる」のが正解です。これは言われてみれば当たり前なのですが、プロンプトを書いているときは「情報の読みやすさ」や「LLM にとっての文脈の自然さ」で並べてしまいがちです。
6. eventManual ブロックの配置を変更する
変更内容はこの配置変更だけです。
変更前:
… 固定ルール → availableTemiLocations → retrievedEpisodeMemories → eventManual → locale/robotState/conversationHistory/userText
変更後:
… 固定ルール → eventManual → availableTemiLocations → retrievedEpisodeMemories → locale/robotState/conversationHistory/userText
ai_backend/app/main.py の _build_prompt() で、eventManual ブロックを 2 つ上に移動しただけ。差分は数行です。
7. 改善結果
manual.txt 自体は、今回の修正後も毎回のリクエストにそのまま含めています。ネットワーク上・プロンプトの文字列としては変わらず全文を送っていますが、固定部分をプロンプトの前方に置いたことで、LLM 側ではプレフィックスキャッシュが再利用され、毎回の prefill 計算をほぼ省略できるようになりました。「毎回送っている」ことと「prefill コストが減少した」ことは矛盾しません。
| ケース(毎回違う質問) | 改善前 TTFT | 改善後 TTFT | 改善後の再 prefill 量 |
|---|---|---|---|
| 2問目以降(別の質問) | 約 10.7 s | 約 1.7〜2.2 s | 約 180〜240 トークン |
| 初回(priming, manual を1度 prefill) | — | 約 11.5 s | 約 2000 トークン(この1回だけ) |
別質問の TTFT が約11秒 → 約2秒。約5倍です。 総時間も、生成が主体の約5〜6秒に縮みました。
重要なのは、生成スループット自体は一切変わっていないことです。約18 tok/s のままです。改善はすべて prefill の削減、つまり TTFT の短縮です。
初回だけは manual を 1 回 prefill する必要があるので約11.5秒かかります。ただしこれは初回のみで、以降はキャッシュに乗り続けます。
Prompt Cache のメモリ使用量も確認しましたが、--prompt-cache-size 等で LRU trim され、際限なく増え続けることはありませんでした。16GB 機でも実用範囲でした。
8. 他の要因との比較
順序最適化後の状態で、実運用プロンプトを使って3種類のエンジンの backend 直 TTFT を測定しました。結果は次のとおりです(GGUF native GPU 1.96s、MLX 1.67s、Docker CPU はキャッシュ未ヒット時のみ約30.5s)。Docker CPU の突出はキャッシュ有無の差であり、順序最適化によって GGUF/MLX 側ではこの十数秒〜30秒のプレフィルがほぼ消えています。
応答時間への影響が大きい要因
すべて実測に基づく結論です。
① manual プレフィル(キャッシュ状態) … 十数〜30秒の差 ← 最大
② GPU か CPU か … 約1.4倍
③ モデルサイズ(4B/7B) … 約1.2倍
④ MLX か GGUF か … 数%(誤差) ← 最小
影響の大きさが大きく異なります。 ①だけが数十秒単位で、②③④はこれらを合わせても今回の条件では2倍未満です。
にもかかわらず、最初に手をつけたのは④のエンジン変更でした。「新しい技術で速くなるのでは」という発想は、切り分けの順序としては効率のよい進め方ではありませんでした。
②の「約1.4倍」は
19.3 vs 13.3の別セッション計測に基づく値です。短いプロンプトでの計測では13.6 → 18.4で約1.35倍でした。どちらも実測ですが、計測回と条件が異なります。
9. temi 経由で発生する追加時間を測定する
「ロボット経由だから遅いのでは」という疑いも検証しました。
| 区間 | 実測値 | 前段からの追加 |
|---|---|---|
| backend 直 TTFT(最初の token) | 1.96 s | — |
| temi 喋り出し(最初の 文) | 4.1〜4.7 s | +2.2〜2.7 s |
| backend 直 total(全文) | 6.51 s | — |
| temi final(全文受信) | 6.6〜7.3 s | +0.1〜0.8 s |
temi final ≒ backend total で、追加は +0.1〜0.8秒です。LAN + アプリ + TTS 起動の正味オーバーヘッドは 1秒未満です。
では喋り出しの +2.2〜2.7 秒は何かというと、アプリの設計でした。文末「。」まで貯めてから文単位で発話するため、最初の token ではなく最初の文を待っているのです。ネットワークやエンジンのせいではありません。
10. 得られた知見と検証上の限界
-
エンジンやモデルを変える前に、どこで時間を使っているか観測する。 今回は
--log-level DEBUGで「実際に何トークン再計算したか」を見られたのが決定打でした - プロンプトは「変わらないものから順に」並べる。 キャッシュは最長共通プレフィックスにしか働きません。大きな静的ブロックを変動部の後ろに置くと、変動のたびに全部作り直しになります
- A/B は「効果が出るはずの条件」で測る。 今回のキャッシュ構成では、同じ質問を連投する A/B では順序最適化の効果を観測できません。一度これで「効果なし」と誤結論しました
- 新しい技術への変更は、計測して原因を切り分けた後に検討する。 エンジン変更は数%、プロンプトを数行動かすだけで5倍でした
manual.txt を毎回プロンプトに入れる設計自体は変えていません。RAG で必要部分だけ抜くという選択肢もありましたが、順序を直すだけで実用速度になったので、そこまでの複雑さを持ち込まずに済みました。
未検証・限界は次のとおりです。
- 実測はすべて Apple M3 / 16GB の単一機での値です
-
manual.txtを短くした場合の効果は未実測です。プレフィルが支配的なので短縮できるはずですが、測っていません - 「最初の token で発話開始」に変えた場合の喋り出し短縮も未実測です。アプリ改修が必要です
- temi 実機の計測は、会話履歴が毎ターン蓄積することと、画面タップの取りこぼしでラン間変動があります。そのためレンジで記載しています
- GGUF GPU / MLX の cache-miss 単独 prefill 時間は、順序最適化でキャッシュされてしまい直接観測できていません
- temi 経由で MLX の final が GGUF より大きく見えますが、backend 直では MLX の方が速く 5.75s vs 6.51s なので、これはエンジン差ではなくラン間変動と解釈しています
次回予告
さて、次回の記事は?
「LLM に勝手をさせない」
「SQLite だけで RAG」
「temi、上の階に行けない」の3本です。
次回もまた、読んでくださいね。
じゃんけん…ポン!
✊
グーでした。