はじめに
スマートフォンの中だけで LLM を動かすと、どこまで実用的な機能を作れるのか?
この問いを、Flutter 製の検証アプリを iPhone と Android の実機で動かしながら確かめてみました。
結論を先に言えば、Function Calling と RAG はどちらも動きます。
ただし小型モデル特有の課題が出てきて、この研究はいま頓挫しています。
本記事では、実装の工夫と実機で得た発見、そして頓挫に至った理由を記録します。
スマートフォンの中だけで LLM を動かすということ
オンデバイス LLM とは、モデルファイルを端末に置き、推論も端末内で完結させる構成を指します。
クラウドの LLM API と違って通信が要らないため、電波のない場所でも動き、入力した内容が端末の外へ出ず、従量課金もありません。
通信の無い環境や、入力内容を外部へ送れない用途では、この構成が事実上唯一の選択肢になります。
一方で使えるモデルは端末に載るサイズが上限となり、クラウドのフロンティアモデル(GPT や Claude のような最大級のモデル)より数段階小さいものを使うことになります。
検証用の題材
検証用の題材に、美術作品のデータベースを用意しました。
作品名、作者、画像を持つ SQLite データベースを端末に同梱し、チャットで「北斎の作品はある?」と聞けば該当作品のカードが表示される検証アプリを Flutter で作ります。
確かめたいこと
確かめたいことは一つです。
20 億パラメータ級の小型モデルは、AI エージェントアプリの定番構成である Function Calling と RAG を、スマートフォンの上で実用レベルで動かせるのか?
検証アプリの全体構成
開発環境と主要な構成要素は次のとおりです。
| 項目 | 採用したもの |
|---|---|
| フレームワーク | Flutter 3.44.2 / Dart 3.12.2(iOS と Android を単一コードベース) |
| LLM 実行 | flutter_gemma 1.1.1 + flutter_gemma_litertlm 1.0.2 |
| LLM モデル | Gemma 4 E2B(.litertlm 形式・約 2.58GB) |
| 推論バックエンド | 端末 GPU(iPhone = Metal / Android = OpenCL) |
| 埋め込みモデル | EmbeddingGemma 300M(約 179MB・後述の RAG で使用) |
| データベース | SQLite(作品 DB・読み取り専用で同梱) |
| ベクトルストア | sqlite-vec(SQLite 拡張・後述の RAG で使用) |
| 検証端末 | iPhone 17 Pro / Galaxy S24(Snapdragon 8 Gen 3) |

モデルファイルはアプリに同梱せず、端末内のアプリ用データ領域に配置します。
アプリ本体(数十 MB)とモデル(2.58GB)を分離しておくと、アプリだけを差し替える反復開発ができるためです。
LLM エンジンは抽象インターフェースの上に乗せて、決まった応答を返すモック実装へ差し替えられるようにしました。
これで実際のモデル推論以外のロジックは実機なしのホストマシン上で 200 件以上の自動テストを回せます。
実測の速度感も書いておきます(いずれも検証時点の一例で、端末や設定によって変わる値です)。
Galaxy S24
- 文章生成は毎秒約 20 トークン、Function Calling を含む 1 往復は約 3.5 秒
- ただし上記は短い会話でのピーク値で、後述する 46 ターンの連続対話では発熱の影響もあり毎秒 8.8 トークン程度まで低下
iPhone 17 Pro
- 文章生成は概算毎秒 22 トークン、1 ターンの所要は中央値約 2.1 秒(後述する 46 ターンの連続対話での実測)
- 2.58GB のモデルが特別な設定なしでロードに成功
Note
メモリ上限の拡張(entitlements)なしでロードできたのは、フラッグシップ端末での話です。
メモリの少ない端末で動かす場合は、拡張の設定が必要になる可能性があります。
Function Calling で SQLite の実データを返す
Function Calling とは、LLM に「呼び出せる関数の仕様」を渡しておき、必要な場面で関数を呼ばせる仕組みです。
流れは次のとおりです。
- アプリがモデルへ、質問と一緒に「呼び出せる関数の仕様」を渡す
- モデルが必要と判断したときに、関数名と引数を構造化データで出力する
- アプリがそれを受けて実際の処理(ここでは DB 検索)を実行し、結果をモデルへ返す
- モデルが結果を踏まえて最終的な応答文を作る
Note
以後この記事では、手順 2 の「モデルがツール呼び出しを出力すること」を 発火 と呼びます。
呼ぶべき場面で発火し、呼ぶべきでない場面では発火しないことが、Function Calling の品質そのものになります。

作者検索ツールの定義はこう書きました。
// lib/ai/artwork_tools.dart
const searchArtworksByAuthorTool = Tool(
name: 'search_artworks_by_author',
description:
'この展示コレクションを「作者名」で検索し、該当作品の一覧(作品名・作者)を返す。'
'ユーザーの発話に特定の作者・作家・アーティスト名(例: 葛飾北斎、北斎、歌川広重)が含まれ、'
'その作者の作品について尋ねた・見たい・知りたいときに呼ぶ。'
'作者名を含まない問い(挨拶・雑談・開館/閉館時間・場所・チケット)では呼ばない。',
// 後述するテーマ検索ツールへの誘導文は省略
parameters: { /* author: string(省略) */ },
);description には「いつ呼ぶか」だけでなく「いつ呼ばないか」の否定例まで書いています。
小型モデルでは、この description が発火精度(呼ぶべきときに呼び、呼ぶべきでないときに呼ばない正確さ)を左右する主要な調整手段でした。
作品カードは LLM の文章から作らない
設計で最初に決めたのが、この不変条件です。
画面に出す作品カード(作品名、作者、画像)は、LLM が生成した文章からパースして作るのではなく、ツール実行結果の作品 ID で SQLite を引き直した実データだけから描画します。
// lib/ai/artwork_tools.dart(テーマ検索の抜粋)
for (final h in hits) {
final id = (h.artworkId ?? '').trim();
final art = await repo.getById(id); // ヒットの artwork_id で SQLite を引き直す
if (art == null) continue; // ストアと DB の不整合はスキップ
artworks.add(art);
}
// ...
onArtworks(artworks); // カードは実データ一本(生成文からパースしない)LLM は作品名をもっともらしく間違えます(ハルシネーション)。
生成文をパースする設計だと、ハルシネーションがそのまま「事実らしい画面表示」に化けます。
実データからだけ描画すれば、ナレーションの文章がどれだけ乱れても、カードに嘘は載りません。
Tip
- 正確に伝えるべき「事実」は、構造化された実データから出力
- その実データから「コミュニケーション用の発話」の文章を生成
この 2 段構えの設計は、同じ命令でも揺らぎが大きい小型モデルほど効果的です。
後述する出力の乱れをカードの信頼性から切り離せたことが、検証を最後まで支えました。
マルチターンで発火しなくなる罠
奇妙な現象
実機で会話を続けると、奇妙な劣化が起きました。
会話履歴が伸びるほど、モデルがツールの発火をためらうようになりました。
会話の序盤は確実に作品検索するのに、ターンが進むと、作者名を聞かれても検索せず手持ちの知識だけで答えようとします。
原因の推測
クラウド AI エージェントの API を利用する場合、会話セッションを維持してツール呼び出し結果も含んだ「会話履歴」を積み上げていきます。
文脈を理解するために必要なことです。
しかし、ローカル LLM エージェント(flutter_gemma と Gemma 4 E2B)においては、発火をためらう原因はこの積み上がった「履歴」にあると考えました。
履歴ガード
そこで 履歴ガード を実装しました。
毎ターン、次の手順で文脈を作り直します。
- モデルの会話文脈を空にして、セッションを作り直す
- 直近 N ターン分の「ユーザーとモデルの会話履歴」を入れ直す(ツール呼び出しの JSON や実行結果は取り除く)
- そのうえで今回の質問を渡す
モデルは毎ターン、少量の会話履歴を継承して始め直すことになります。
N=0 なら何も入れ直さず、モデルにとって毎ターンが会話の 1 ターン目になります。
定量評価で見えたプラットフォーム差
定量評価の構成
履歴ガードの効果を測るため、46 ターンの会話シナリオを流して発火の正誤を数える定量評価を作りました。
モデルの出力は、毎回もっとも確率の高いトークンだけを選ぶ設定(greedy decoding)にしているため決定的で、同じ入力なら同じ結果が再現します。
統計的なサンプリングを繰り返すのではなく、1 パスで測れる回帰テストとして設計できるのは、この構成の利点でした。
結果
結果は予想外のものでした。
| 端末(バックエンド) | N=2(直近 2 ターンを入れ直す) | N=0(何も入れ直さない) |
|---|---|---|
| iPhone 17 Pro(Metal) | 発火率 91.2%(ターンが深まっても劣化なし) | 計測せず(N=2 で問題がなく、0 を試す動機がないため) |
| Galaxy S24(OpenCL) | 発火率 58.8%(前ターンの作者名が引数に混入する誤抽出も発生) | 発火率 88.2%(各会話のターンが深まった終盤に限れば 90.9%、誤抽出なし) |
同一のモデル、同一のプロンプト、同一のシナリオで、違うのは推論バックエンド(Metal / OpenCL)だけです。
少量の会話履歴を継承させる行為が Android では発火を阻害する引き金になっていました。
なぜバックエンドで挙動が分かれるのか、その理屈までは特定できていません。
アプリ側から確かめられたのは次の事実だけです。
- 会話履歴の「継承の有無」が結果を分ける
- Android は会話履歴の継承をさせてはいけない
対応策
対応として、プラットフォーム別に、会話履歴の「継承の有無」を分けました。
Android は毎ターンが会話の 1 ターン目になります。
指示語の続き質問(「その作者の代表作は?」)に答えられなくなりますが、発火しないよりましだという判断です。
iOS は 2 ターン分の継承をしても発火が劣化しなかったため、ある程度文脈に沿った回答が実現できています。
// lib/config/app_config.dart(プラットフォーム既定)
// 0 ターンは直近の文脈を捨てる設定
maxHistoryTurns: isAndroid ? 0 : 2,Important
iOS / Android で共通の設定は、オンデバイス LLM では成立しづらいと分かりました。
今回の発火率のような品質指標は、プラットフォームごとに実機で測る必要があります。
RAG でテーマ検索を実現する
RAG の導入
テーマ検索が実現できていない
作者検索だけでは「富士山の絵はある?」のような「テーマ」に沿った質問へ答えられません。
作者名はデータベースの列にありますが、「富士山が描かれている」という情報はどの列にもないからです。
RAG とは
そこで RAG(Retrieval-Augmented Generation)を導入しました。
RAG は、あらかじめ文章を「埋め込み(embedding)」と呼ばれる数値ベクトルに変換して保存しておく仕組みです。
質問が来たら質問文も同じくベクトル化し、意味の近い文章を検索して、その結果をモデルに渡して回答させます。
キーワードの一致ではなく意味の近さで探すため、「富士山」という語が説明文にどう書かれていても拾えます。
構成要素
LLM 本体と合わせて、RAG に必要なものは次のようにすべて端末内で完結できました。
- flutter_gemma に内蔵された RAG API を使う
- 埋め込みモデルには EmbeddingGemma 300M(約 179MB)を使う
- ベクトルの保存には SQLite 拡張の sqlite-vec を使う
ハイブリッド検索
既存の作者検索ツールは温存し、テーマ検索を第二のツールとして追加する、 ハイブリッド検索 の構成にしました。
ユーザーの発話内容に応じて、モデルが検索方法を選択します。
- 作者を主軸にした質問を受けた → 作者検索
- モチーフや主題に関する質問を受けた → テーマ検索
テーマ検索がヒットを返したあとの流れは作者検索と同じで、ヒットに含まれる作品 ID で SQLite を引き直してカードを描画します。

説明文はチャンクに割って埋め込む
対象のコーパス
コーパスは 20 作者 40 作品で、各作品に題材や見どころを書いた説明文を持たせています。
丸ごと埋め込むと起きる問題
説明文を 1 本のベクトルへまとめて埋め込むと、長い文章ほど内容が平均化され、個々のモチーフのスコアがぼやけます。
断片への分割と検索時の集約
- 格納時:説明文を 320 字前後の断片(passage)に分割し、それぞれを別々に埋め込む
- 検索時:ヒットした断片を作品単位へ集約し、最も近い断片のスコアを作品の代表値とする
分割の上限 320 字は、埋め込みモデルの入力長(この構成では日本語で 300〜400 字程度)から決めています。
埋め込みをいつ計算するか
| 規模 | 埋め込みの計算方法 |
|---|---|
| 今回(40 作品) | 初回起動時に端末上で計算して保存(これで十分だった) |
| 作品数を 3 桁に増やす場合 | ビルド時の事前計算か、進捗表示付きの初回プロビジョニングが要る見込み |
しきい値は実測の谷で決める
ベクトル検索は作品ごとに類似度スコアを付けて返すため、どこから下を無関係とみなすかのしきい値が要ります。
これは理屈では決められませんでした。
実機でクエリ「富士山」を流したときの類似度スコアは、次の二群にはっきり分かれました。
| 作品 | 類似度スコア |
|---|---|
| 富士山を描いた作品 | 0.38 前後 |
| 無関係な作品 | 0.16〜0.20 |
両者のあいだを取って、しきい値を 0.3 に置きました。
スコアの絶対値は埋め込みモデルと量子化の具合で変わるため、どの環境でも通用する値はありません。
手元のモデルとコーパスで実測し、該当と非該当のあいだの谷を探すのが唯一の決め方だと考えています。
短いクエリはクエリ展開で補う
一語のクエリはスコアが伸びない
「波」のような一語のクエリはスコアが伸びず、該当作品を取り逃しました。
しきい値を下げると、今度は無関係な作品が混ざります。
採ったのは、しきい値ではなくクエリの側を直す方法です。
- 展開前:「波」単独ではスコア 0.25(しきい値 0.3 未満で取り逃し)
- 展開後:同義語を連ねた「波 大波 海 水しぶき 波頭 うねり」ではスコア 0.53(しきい値 0.3 のままで正しくヒット)
対策は手書きの同義語辞書
同義語は、アプリ内に手書きで持つ小さな辞書から索引するようにしました。
「動物」「花」のような抽象語は、下位の実在モチーフ(犬、鶏、ひまわり、睡蓮 など)へ展開します。
次の二つのどちらかの手段を取ろうとしましたが、諦めました。
- 外部のシソーラスを導入
- LLM にクエリを言い換えさせる
これらの手段を採用すると「語を足しすぎるとクエリ全体の埋め込みが薄まる」や「LLM にクエリを言い換えさせると応答が遅くなる」への調整に作業時間を取られてしまいます。
本件は研究であり、同義語辞書を効率よく運用することは調査対象ではありません。
Note
将来の埋め込みモデル自体の性能向上に期待します。
ツールを呼ばないときの安全網
不可解な現象
ローカル LLM エージェント(flutter_gemma と Gemma 4 E2B)は、呼ぶべき場面でツールを呼ばないことがありました。
これは、前述の 履歴ガード の件とは別要因で、後述する頓挫の最大の理由につながります。
安全網の実装
テーマ検索については、モデルが発火しなかったターンに限り、発話に既知のテーマ語が含まれていれば検索を強制実行するルーターを安全網として敷きました(前掲のフロー図の点線の経路です)。
発火しなかったときだけ動かすため、モデルの判断を上書きせず、雑談で検索が走るような過発火も招きません。
それでも、抽象的な単語は拾いきれませんでした。
「動物」で動物の作品を探す問いは、クエリ展開後もスコア 0.26 でしきい値に届かず、残課題になりました。
音声の入出力もオンデバイスで
音声インターフェースも検証しました。
OS 標準の機能だけで実装する
専用の音声認識モデルを同梱しなくても、OS の機能だけでオフラインの音声対話は実装できました。
| 機能 | Flutter 側 | iOS のネイティブ実体 | Android のネイティブ実体 |
|---|---|---|---|
| 音声認識(STT) | speech_to_text プラグイン(オンデバイス指定) | SFSpeechRecognizer | SpeechRecognizer |
| 読み上げ(TTS) | 自前の MethodChannel | AVSpeechSynthesizer | TextToSpeech(Google 製エンジンを指定) |
Caution
Android の TTS は、Google 製エンジン(com.google.android.tts)を明示指定しています。
検証端末(Galaxy S24)の標準エンジンでは、読み上げ(speak)実行時に黙ってハングする事象に当たったためです。
機内モードでの実機確認
機内モードのまま、次の一連の流れが通ることを両 OS の実機で確認できました。
- 音声で聞く
- 検索
- カード表示
- 読み上げ
Caution
Android のオンデバイス認識は、発話の終端で最終結果を返さないことがあります。
そのため、途中結果を終端で確定させる救済処理が必要でした。
固有名詞の誤字の問題
一般語彙で学習された認識器にとって、固有名詞の誤字は今も発生する問題です。
| 固有名詞 | 認識(STT) | 読み上げ(TTS) |
|---|---|---|
| 葛飾北斎 | 正確 | 正確 |
| 菱川師宣、歌川広重 | 誤字が出る | 読み間違いが出る |
- 認識の誤字への対策:データベースへ読み仮名を持たせ、編集距離で誤字を吸収する曖昧一致の検索に使う(実装済み)
- 読み間違いへの対策:固有名詞を仮名で読み上げる方式(計画したが未実装)
Flutter 固有のつまずきどころ
機能の実装そのものより、ビルドと配置で苦労した点をまとめます。
ビルドが無限ハングする罠
flutter_gemma のネイティブライブラリは、初回ビルド時に GitHub Release からダウンロードされます。
回線が遅いと、このダウンロードがリトライなしで止まり、flutter build が無限にハングします。
Tip
筆者の環境では、ライブラリを curl で完全取得してキャッシュディレクトリへ手動展開することで回避しました。
CocoaPods 不要
利用したプラグインのドキュメントは CocoaPods 前提で書かれていますが、iOS は実機の release ビルドまで Swift Package Manager だけで可能です。
2.58GB モデルの再転送の回避
iPhone のアプリデータ領域へ devicectl コマンドで無線コピーし(約 79 秒)、アプリの更新にはデータを保持するインストール方法を使います。flutter install はアンインストールを伴うため、使うとモデルの再転送からやり直しになってしまいます。
debug ビルドは単体起動不可
iOS の debug ビルドは JIT で動く制約があり、ケーブルを外してホーム画面から起動すると即終了します。
実機で常用するビルドは release が望ましいです。
研究が頓挫した理由
ここまで書いたとおり、やりたかったことは首の皮一枚で実現できた、というのが正直なところです。
「履歴ガード」、「手書きの同義語辞書」、「テーマ検索の安全網」は、いくらなんでも実用的な実装とは言えません。
これらの原因は、おおよそツール呼び出しの不安定さにあります。
そこで、この「ツール呼び出しの不安定さ」の真因を調べました。
原因は、「プラグインの実装」と「モデルそのもの」の二層に分かれていました。
後者は、アプリ側から根治できないものでした。
これが、研究を止めた理由です。
症状と原因の二層構造
プラグインとモデルの挙動をソースまで追い、観測した症状とその原因を層ごとに整理しました。
| 観点 | 第一層(プラグインの実装) | 第二層(モデルの傾向) |
|---|---|---|
| 症状 | ナレーションの文章に、ツール呼び出しの JSON がそのまま漏れて表示される | 会話の最初のターンでツールが発火せず、ツール呼び出しらしき JSON がただのテキストとして出力される |
| 何が起きているか | ツールの実行結果が「構造化されたメッセージ」ではなく JSON テキストとして会話文脈へ入る | JSON の手本が文脈にない最初のターンでも、ツール呼び出しを OpenAI API の形式(tool_calls スキーマ)のテキスト JSON として書き出すことがある |
| なぜ起きるか | 直前の文脈に JSON があると、モデルが「次も JSON を書く」ほうへ引っ張られる | ツール使用の学習例は OpenAI 形式が支配的で、モデルにとって「もっともありそうなツールの書き方」になっている |
| アプリ側からの根治 | 可能(修正できる実装上の課題) | 不可能(モデルに焼き付いた傾向) |
第一層について
第一層の症状は、漏れた JSON を表示から取り除く処理を実装して抑えました。
上流プロジェクトへの報告も考えましたが、その後公開された flutter_gemma 1.5.6(2026 年 8 月)には、テキストへ漏れた tool_call の JSON を抑止する修正が入っています(この検証は flutter_gemma 1.1.1 時点のものです)。
第二層について
第二層が致命的でした。
低ビット量子化(モデルを小さくするためにパラメータの精度を落とす圧縮)が、本来使うべき専用トークンの出やすさをさらに削っている可能性もあります。
greedy decoding の設定で決定的に再現するため、これは乱数の揺らぎではなく、モデルに焼き付いた系統的な傾向です。
プロンプトと description の調整という手は既に使い切っており、その外側で起きている故障でした。
Android の履歴再投入による発火阻害も同じく、プロンプトの外側(実行系)の問題です。

クラウドの LLM API に比べて欠けているもの
| 観点 | クラウドの LLM API | 今回のオンデバイス構成 |
|---|---|---|
| 関数呼び出しのパイプライン | 専用の構造化チャネルで返り、テキストと混ざる事故が仕組みの上で起きない | 専用パイプラインがなく、テキストへ漏れる事故が実際に起きる |
| モデルの規模 | フロンティアモデル | 20 億パラメータ級 |
| プロンプトと description の工夫による発火精度の担保 | 成立する | 成立しない(不具合がプロンプトの外側で起きる) |
テキストへ漏れた JSON を拾って正規のツール呼び出しとして扱う「救済パーサ」で、この構造化チャネルの不在を後付けで補う計画も作りました。
しかし対症療法を積むほど、ローカル LLM の効率的な運用からかけ離れていくことは目に見えていたため、計測へ進む前に計画を見送りました。
今後の課題と、期待する技術の進歩
残った三つの課題
- ツール呼び出しのパイプライン:モデルの出力形式が多少乱れても、ランタイムが構造として受け取れる仕組みがあること
- マルチターン耐性:履歴が伸びても発火率が落ちない、または劣化を検知して制御できること
- 量子化と特殊トークンの忠実度:小型化と引き換えに構造化出力の信頼性が下がらないこと
期待する技術の進歩
これらの課題は、世の中の技術の進歩で解ける見込みがあります。
もっとも期待しているのは、文法制約付きデコード(constrained decoding)のオンデバイス対応です。
これは、生成時に「この文法に合うトークンしか選ばせない」ようモデルの出力を制約する技術で、クラウド側の構造化出力では実用済みです。
オンデバイスの推論ランタイムに載れば、ツール呼び出しの形式が乱れる問題は仕組みごと消えます。
ツール使用に特化して学習された小型モデルも有望です。
今回の故障の多くは、小さいモデルにとってツール呼び出しが不慣れな分野である、という点に帰着します。
学習段階で身についていれば、プロンプトで説得する必要自体がなくなります(探せばあるのでしょうか)。
より地道な方向では、モバイル向け推論基盤の構造化チャネル整備、量子化を前提に学習する QAT(quantization-aware training)の普及、NPU の活用が進めば、今回当たった課題は一つずつ解消されていくはずです。
スマートフォンの中だけで動く LLM アプリが「なんとか」動くところまでは来ている、というのが今回の研究でわかったことです。
安心して任せられるようになるかどうかは、モデル単体の性能よりも、ツール呼び出しを運ぶ基盤の進歩にかかっていると考えています。
