概要
本記事は、Android 上でのローカルLLM推論の実装可能性を検証するレポートです。具体的には、Google Play On-Device AI (AI Pack) を使用して大容量の言語モデルをAndroidアプリに配信する際に直面する制限と、その回避策を実装パターンと共にまとめました。
検証対象は Gemma 4 E2B のような 2~3 GB のモデルで、推論機構(LiteRT-LM)、配信方法(AI Pack の分割配信)、実装上の制約などを含めた包括的な検証結果を報告します。
背景:ローカルLLMの戦略的な位置付け
なぜローカルLLMを検証するのか
Android アプリに自然言語処理機能を組み込む際、開発者は複数のアプローチから選択できます。以下の表に、各アプローチの長所・短所をまとめました:
| アプローチ | モデルベンダーのAPI | バックエンド推論(GPU) | ローカルLLM |
|---|---|---|---|
| 初期構築コスト | 低(API統合のみ) | 中(サーバー構築・運用) | 中(モデル最適化・テスト) |
| 運用コスト | 高(API呼び出しごとの課金) | 中~高(GPU インスタンス、スケーリング) | 低(配信後はほぼゼロ) |
| レイテンシ | 高(ネットワーク往復) | 中(サーバー応答時間) | 低(デバイス上処理) |
| 推論精度 | 高(最新モデルを常時利用) | 高(カスタマイズ可能) | 中~高(量子化による精度低下の可能性) |
| オフライン対応 | ✗ | ✗ | ✓ |
| プライバシー | ✗(データ送信) | △(自社サーバー) | ✓(デバイス内) |
| スケーリング | 自動(API側で対応) | 手動(サーバー増強必要) | ゼロ(クライアント側で完結) |
| デバイス互換性 | 全デバイス対応 | 全デバイス対応 | メモリ依存(6GB 推奨) |
| 適用場面 | 最新 AI 活用、エンタープライズ | 大規模ユーザー、カスタマイズ必須 | オフライン対応、プライバシー重視 |
本稿で検証対象とするローカルLLMは、上表のメリット(オフライン対応、プライバシー保護、スケーリングコストゼロ)が重要なユースケースでの採用が想定されます。ローカルLLMの検証が価値を持つシナリオは、特に以下の通りです:
- オフライン対応が必須:飛行機モード、圏外地域など、常時接続が期待できない環境
- プライバシー保護:ユーザーデータがデバイスの外に出ない設計
- コスト最適化:API呼び出しごとの課金が不要で、スケーリングが実質ゼロコスト
- レイテンシ削減:クラウド往復のネットワーク遅延がなく、ユーザー体験が向上
- 依存性の低減:特定のクラウドサービスへの依存を最小化し、独立したサービス提供が可能
推論機構の選択:LiteRT-LM が Gemma 4 に最適な理由
ローカルLLM実装を選定した場合、推論機構の選択が次の課題となります。Android AI Overview に示される主要な選択肢は以下の通りです:
- Gemini Nano:デバイス上で実行される Google の公式LLMだが、Pixel 8/9 などの限定的なデバイスのみで利用可能
- LiteRT-LM(TensorFlow Lite for Language Models):オープンソースの言語モデル(Gemma など)をデバイス上で実行。デバイス互換性が広く、幅広いモデル選択が可能
- ML Kit:汎用的な機械学習タスク向けだが、LLM向けには最適化されていない
本稿で Gemma 4 を検証対象とした理由は、Gemini Nano の限定的な端末対応を回避し、より広範なデバイスで実装可能性を検証するためです。Gemini Nano は Pixel 8 Pro、Pixel 9 Pro など限定的な端末でのみ利用可能であり、大多数のユーザーが使用する中価格帯の Android デバイスには対応していません。一方、Gemma 4 は google-ai-edge/gallery で公開されているリファレンス実装を参考にすることで、汎用的なデバイス対応が実現できます。
LiteRT-LM(Custom Models) は、言語モデル推論に特化した TensorFlow Lite の拡張です。通常の LiteRT は画像分類や物体検出などの静的モデルに最適化されていますが、言語モデルの推論には以下の課題がありました:
- シーケンス生成:テキスト生成では 1 トークンずつ逐次的に推論を繰り返す必要があり、効率的な KV キャッシュ管理が重要
- メモリ効率:大規模モデルの重みを効率よくメモリに配置し、推論中の中間テンソルを管理
- 量子化との互換性:推論精度を保ちながら、モデルサイズを数分の一に圧縮
LiteRT-LM はこれらの課題に特化した最適化を提供し、Gemma などのオープンな言語モデルを LiteRT-LM 形式で量子化することで、低スペックなデバイスでも実用的な推論速度を実現します。
Gemma は Google による軽量かつ効率的なオープンソース言語モデルのシリーズです。Gemma 4 は最新版で、複数の量子化バリアント(E2B、E4B など)が提供されており、異なるメモリ・計算能力を持つデバイスでの運用が可能です。本稿での検証対象は以下の理由から Gemma 4 を選定しました:
- オープンソース性:商用利用が可能で、デプロイメント上の制約がない
- 複数の量子化バリアント:同じモデルでも E2B(より軽量)と E4B(より高精度)の選択肢があり、デバイス特性に応じた最適化が可能
- 広い端末互換性:Gemini Nano と異なり、Pixel や Samsung など多くの Android デバイスで実行可能
- リファレンス実装:google-ai-edge/gallery でAndroid向けの詳細な実装例が公開されており、学習リソースが豊富
本記事で報告する検証は、これらの利点を活かしながら、実際のプロダクション環境での課題と解決策を明確にすることを目的としています。
問題:大容量モデルの配信制限
上記のメリットがある一方で、実装面での課題が存在します。本格的なLLMは容量が大きく、Google Play On-Device AI(AI Pack)で配信する際に制限に直面します。
The (base_llm_e2b) asset pack exceeds the maximum compressed download size (1,500 MB).
The (base_llm_e2b_e4b) asset pack exceeds the maximum compressed download size (1,500 MB).
Your app bundle's asset packs exceed the total maximum compressed download size (4,000 MB).
Google Play On-Device AI(AI Pack)を使用することで、モデルファイルを段階的にダウンロードでき、初回インストール時の APK サイズを抑えられます。しかし、AI Pack の仕様により、1パック上限 **1,500 MB(圧縮後)** という厳しい制限があり、大容量モデルでは物理的に収まりません。
- Gemma 4 E2B: 2.4 GB
- Gemma 4 E4B: 3.4 GB
LLMの量子化済み重みファイルは圧縮効率が低く、ZIP圧縮後もほぼ元のサイズのままになります。つまり、単一のAI Packパックでは物理的に配信不可能なのです。
検証の結論:Gemma 4 + LiteRT-LM + AI Pack 分割配信の実装可能性
本稿で報告する検証は、以下の3つの要素を組み合わせた実装パターンの実現可能性を評価することが目的です:
- 言語モデル:Gemma 4(オープンソース、広範なデバイス対応)
- 推論機構:LiteRT-LM(言語モデル特化の最適化)
- 配信方法:AI Pack 分割配信(大容量モデルの Google Play Store 経由での効率的な配信)
これら3つの要素は個別には既に確立した技術ですが、プロダクション環境での 統合実装 にはいくつもの課題が伴います。本検証では、実装過程で遭遇した制限、その原因、そして実装可能な回避策を詳細に報告することで、他の開発者が同様のアーキテクチャを採用する際の参考資料となることを目指しています。
本記事の検証は、単なる技術仕様の紹介ではなく、実装経験に基づく一次情報であり、特に以下の側面で有価な知見を提供できると考えています:
- 実装可能性の確認:実装過程での実測値とプロダクションでの課題
- 制約事項の言語化:API の非公開な制限(例:`fetch()` がフォアグラウンドのみなど)
- ツールの課題:bundletool のバグなど、公式ドキュメント未掲載の落とし穴
- 実装パターン:実際に動作する Kotlin コード例
大容量LLMをAI Packで配信するには、対象デバイスのメモリ環境を事前に確認する必要があります。Gemma 4 E2B は、Gemma 4 の仕様に記載されている環境で実行可能です。本稿の検証では、Pixel 9a において簡易的な推論タスクでの動作確認ができています。ただし、十分な期間にわたるメモリ動作検証は実施していないため、本番環境での導入前には対象デバイスでの十分な負荷テストを実施することが重要です。
AI Pack の配信先を制限し、メモリ不足のリスクを軽減することは実装上の重要な選択肢です。Google Play Console の AI Pack 設定では、デバイスの最小メモリ、API レベル、デバイス型などの要件を指定できます。対象デバイスのスペックに応じた配信制限を組み込むことで、メモリ不足が見込まれるデバイスへの配信を回避し、ユーザー体験の劣化を防ぐことができます。
解決策:モデルファイル分割と段階的インストール
1. モデルをバイナリレベルで複数パックに分割
1パックあたりの上限 1,500 MB に収まるよう、モデルファイルを分割します。LLMの重みファイルは連続したバイナリなため、単純な分割・結合で正常に動作します。以下は Gemma 4 E2B を2分割する例です:
split -b 1200m gemma4_e2b.litertlm gemma4_e2b_part
mv gemma4_e2b_partaa gemma4_e2b_part1.bin # base_llm_e2b_1/src/main/assets/
mv gemma4_e2b_partab gemma4_e2b_part2.bin # base_llm_e2b_2/src/main/assets/
各分割ファイルが 1,200 MB 程度に収まることで、圧縮後も 1,500 MB の制限以内に留まります。
2. 各AI Packモジュールの設定
分割されたモデルを配信するため、2つのAI Packモジュールを作成します。各モジュールの `build.gradle.kts` は以下のように設定します:
plugins { id("com.android.ai-pack") }
aiPack {
packName = "base_llm_e2b_1" // アンダースコアのみ使用可(ハイフン不可)
dynamicDelivery {
deliveryType = "on-demand" // fast-follow ではなく on-demand を使う
}
}
重要な注意点:AI Pack のパック名に使用できるのはアンダースコア(`_`)のみです。ハイフン(`-`)を含むパック名は Google Play Console でのアップロード時にエラーになります。
3. `on-demand` を採用する理由
AI Pack の配信方法には `fast-follow` と `on-demand` の2つのタイプがあります。本来であれば、`fast-follow` はアプリインストール直後に自動ダウンロードされるため高速です。しかし、ローカルLLMのように以下の条件を満たす場合は `on-demand` を選択すべきです:
- 必要に応じて動的にダウンロード・削除を繰り返したい
- ストレージ効率を最大化したい
- ユーザーのネットワーク環境に応じた配信制御が必要
`fast-follow` では、`removePack()` でAI Pack領域から削除しても、Google Play は自動的に再ダウンロードしようとします。一方、`on-demand` なら `fetch()` を明示的に呼び出した時だけダウンロードされるため、削除後の再ダウンロードは発生しません。
4. 結合後に `removePack()` でAI Pack領域を解放
両パックのダウンロードが完了したら、バイナリレベルで結合し、アプリの内部ストレージに移動します。結合完了後、AI Pack領域から削除することで、ストレージ効率を大幅に改善できます。
重要な実装パターンとして、アプリ起動時に既にアセンブル済みモデルが存在する場合は、ダウンロード処理をスキップする初期化ロジックが必須です。これにより、ユーザーがアプリを再起動した際の冗長なダウンロードを防げます:
init {
val assembled = assembledModelFile()
if (assembled.exists()) {
_state.value = AiPackState.Installed(assembled.absolutePath)
}
}
実際の結合処理の実装例は以下の通りです:
private suspend fun assembleAndInstall(assetsPath1: String, assetsPath2: String) {
_state.value = AiPackState.Assembling
val dest = File(context.filesDir, "models/gemma4_e2b.litertlm")
try {
dest.parentFile?.mkdirs()
if (dest.exists()) dest.delete()
FileOutputStream(dest).use { out ->
File("$assetsPath1/gemma4_e2b_part1.bin").inputStream().use { it.copyTo(out) }
File("$assetsPath2/gemma4_e2b_part2.bin").inputStream().use { it.copyTo(out) }
}
manager.removePack("base_llm_e2b_1")
manager.removePack("base_llm_e2b_2")
_state.value = AiPackState.Installed(dest.absolutePath)
} catch (e: Exception) {
dest.delete()
_state.value = AiPackState.Failed("Assembly failed: ${e.message}")
}
}
このアプローチにより、ストレージの使用推移は以下のようになります:
| フェーズ | AI Pack 領域 | filesDir | 合計 |
|---|---|---|---|
| ダウンロード開始 | ~2.4 GB | 0 MB | ~2.4 GB |
| 結合処理中 | ~2.4 GB | 増加中 | 最大 ~4.8 GB |
| 結合完了・削除後 | 0 MB | ~2.4 GB | ~2.4 GB |
実装時の重要な制約
`AiPackManager.fetch()` の呼び出し制限
AI Pack API の仕様により、`AiPackManager.fetch()` は フォアグラウンドActivity からのみ呼び出せます。公式ドキュメントに記載されている制約は以下の通りです:
`AiPackManager.fetch()` must be called from the main thread when your app is in the foreground.
具体的には、以下の呼び出し元からの実行は失敗します:
InputMethodService内からの呼び出し → error code -7 (`Asset Pack requested but in background`)- WorkManager の Worker 内からの呼び出し(`setForeground()` で foreground service に昇格させても不可)
正しい実装例として、設定Activityに紐づくViewModelの初期化時に呼び出すことが推奨されます:
@HiltViewModel
class LLMSettingsViewModel @Inject constructor(
private val aiPackRepository: AiPackRepository,
) : ViewModel() {
init {
viewModelScope.launch {
aiPackRepository.fetch() // Settings Activity がフォアグラウンドにある
}
}
}
初期化時のパック状態確認
アプリ起動時に既にパックがダウンロード済みである可能性があります。その場合は、キャッシュされたパック位置から直接アセンブルを開始できます。これにより、再度のダウンロードを避けられます:
val loc1 = manager.getPackLocation(PACK_NAME_1)
val loc2 = manager.getPackLocation(PACK_NAME_2)
if (loc1 != null && loc2 != null) {
val path1 = loc1.assetsPath() ?: run {
_state.value = AiPackState.Failed("assetsPath is null for $PACK_NAME_1")
return
}
val path2 = loc2.assetsPath() ?: run {
_state.value = AiPackState.Failed("assetsPath is null for $PACK_NAME_2")
return
}
scope.launch { assembleAndInstall(path1, path2) }
return
}
ダウンロード中の状態遷移と完了検知
AI Pack API には「登録されたパック名一覧を取得する」メソッドがないため、両パックのダウンロード完了判定は `getPackLocation()` の戻り値で行う必要があります。同時に、ユーザーの確認待機状態(WiFi接続待ち等)を適切に処理することも重要です。以下は、`AiPackStateUpdateListener` を使った完全な実装例です:
private fun handlePackStateUpdate(packState: PlayAiPackState) {
when (packState.status()) {
AiPackStatus.COMPLETED -> {
val loc1 = manager.getPackLocation(PACK_NAME_1)
val loc2 = manager.getPackLocation(PACK_NAME_2)
if (loc1 != null && loc2 != null) {
manager.unregisterListener(listener)
val path1 = loc1.assetsPath() ?: run {
_state.value = AiPackState.Failed("assetsPath is null for $PACK_NAME_1")
return
}
val path2 = loc2.assetsPath() ?: run {
_state.value = AiPackState.Failed("assetsPath is null for $PACK_NAME_2")
return
}
scope.launch { assembleAndInstall(path1, path2) }
}
}
AiPackStatus.FAILED -> {
manager.unregisterListener(listener)
_state.value = AiPackState.Failed("Download failed (${packState.errorCode()})")
}
AiPackStatus.CANCELED -> {
manager.unregisterListener(listener)
_state.value = AiPackState.Failed("Download canceled")
}
AiPackStatus.WAITING_FOR_WIFI, AiPackStatus.REQUIRES_USER_CONFIRMATION ->
_state.value = AiPackState.WaitingForConfirmation
else -> {
val total = packState.totalBytesToDownload()
val downloaded = packState.bytesDownloaded()
val progress = if (total > 0) downloaded.toFloat() / total else 0f
_state.value = AiPackState.Downloading(progress)
}
}
}
上記のコードから注目すべき点は以下の通りです:
- 状態遷移の完全性:WAITING_FOR_WIFI や REQUIRES_USER_CONFIRMATION 状態を適切に処理し、ユーザーに確認を促す
- エラー情報の保持:assetsPath() が null になるケースを事前にチェックし、具体的なエラーメッセージを保存
- リスナーの適切な登録解除:COMPLETED や終了状態に達したら必ずリスナーを登録解除し、メモリリークを防止
ローカルテスト時の落とし穴
bundletool を使ったローカルテスト環境でも注意が必要です。bundletool の `build-apks --local-testing` コマンドは、ZIP内部のオフセット計算に32bit整数を使用しており、2.1 GBを超えるファイルで以下のエラーが発生します:
ArithmeticException: integer overflow
このバグは bundletool 1.17.x・1.18.x ともに未修正です。回避策は以下の通りです:
- ローカルテスト環境ではダミーの空ファイルを使用してビルド・テスト
- 実際のモデルファイルでの検証は Google Play Internal App Sharing 経由で実機テストを行う
Gemma 4 モデルの実装上の注意
実装検証の過程で、Gemma 4 の異なる量子化バリアント(E2B、E4B)の動作に関する知見が得られました。これらのモデルを実装する際は、以下の点に留意してください:
- Gemma 4 E2B:Pixel 9a での検証では、簡易的な推論タスクでは メモリオーバーフロー等の問題は確認されませんでした。推論の応答性も許容可能な範囲内です。
- Gemma 4 E4B:より高精度ですが、メモリ使用量が増加するため、デバイスのメモリ帯域幅が限定された環境ではアウトオブメモリのリスクがあります。対象デバイスの仕様を慎重に検討してから採用してください。
推論の品質と安定性のバランスを取る必要があるため、プロトタイプ段階で実機テストを十分に行い、対象ユーザーのデバイス環境に応じた最適なモデル選択が重要です。
まとめ
Google Play AI Pack を使用して大容量LLMを配信する際の主要なポイントをまとめます:
- ファイル分割:1パック上限 1,500 MB に収まるよう、モデルを段階的に分割して複数パックで配信する
- `on-demand` 選択:ストレージ効率とユーザー体験を最適化するため、自動ダウンロードよりも明示的な制御を優先する
- API制約への対応:`fetch()` はフォアグラウンドActivityからのみ呼び出せるという制限を理解し、実装設計に組み込む
- ローカルテストの工夫:bundletool の制限を回避するため、Internal App Sharing による実機検証を重視する
- モデル選択:推論精度とデバイスのメモリ特性のバランスを慎重に検討する
本記事で紹介した実装パターンは、現状の AI Pack 制約(1パック 1,500 MB 上限)の中での回避策です。ファイル分割による複数パック配信、`on-demand` による段階的インストール、結合後の削除によるストレージ最適化といった手法により、大容量モデルの配信が理論上は実現できることが確認できました。
一方で、本検証の過程で以下の課題が明らかになりました:
- 1パック上限の拡大:将来的に 1,500 MB の上限が緩和されれば、分割配信の複雑性を大幅に削減できる
- 複数パックの統一管理:全パックの完了判定が非効率であり、API に統一的なクエリ手段があると便利
- ツール側の改善:bundletool の 2 GB 超ファイルへの対応
これらの制約の改善を期待しつつ、本稿で紹介した実装パターンは、現在入手可能な Android AI 関連の API と組み合わせた、実装可能な回避策として有効です。本記事で紹介した実装パターンは、他のAndroidアプリに大容量リソースを配信する際にも応用できます。AI Pack の仕様を理解し、制約の中で最適な配信戦略を設計することが、良好なユーザー体験の実現につながります。
0 件のコメント:
コメントを投稿