Android端末でLLMを動かす完全ガイド：MediaPipeによるオンデバイス推論

# Android端末でLLMを動かす完全ガイド：MediaPipeによるオンデバイス推論

Google Pixel8a（iPhone SE的な廉価版端末）でWebサービスに引けをとらないスピードで動く

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235259/fa56215d-cd35-4bad-a7ee-cea708c866ee.gif width=400>

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235259/cb0eadf5-5300-423e-a25b-7f9176ed64a5.gif width=400>

ちなみに日本語でも動きます。

↓手元で論文を要約したときの速度
|Processor|Prefill Speed|Decode Speed|
|---|---|---|
|CPU|30.67token/s|5.94token/s|
|GPU|114.77token/s|7.73token/s|

サンプルアプリは以下

https://github.com/google-ai-edge/gallery

## はじめに

**AndroidデバイスでLLM（大規模言語モデル）を直接実行**できることをご存知ですか？

従来のクラウドAPIとは異なり、**デバイス上で直接推論**を行うオンデバイスLLMは、以下のメリットを提供します：

- 🔒 **完全なプライバシー保護**：データが端末から出ない
- ⚡ **低レイテンシ**：ネットワーク通信なしで即座にレスポンス
- 💰 **コスト削減**：API料金不要
- 🌐 **オフライン動作**：インターネット接続不要

本ガイドでは、GoogleのMediaPipeフレームワークを使用して、Android端末でLLMを実装する方法を、基本的な実装から高度な機能まで段階的に解説します。

**実用例：Google AI Edge Gallery**
本ガイドでは、Googleが公開する「AI Edge Gallery」アプリを実例として参照しながら、実際のプロダクションレベルの実装方法を学びます。

---

## 📖 目次

1. [なぜオンデバイスLLMなのか](#なぜオンデバイスLLMなのか)
2. [MediaPipeによるLLM実装の基礎](#MediaPipeによるLLM実装の基礎)
3. [最初の一歩：シンプルなLLMアプリ](#最初の一歩シンプルなLLMアプリ)
4. [画像入力対応：マルチモーダルLLM](#画像入力対応マルチモーダルLLM)
5. [チャットアプリケーションの構築](#チャットアプリケーションの構築)
6. [モデルの準備と変換](#モデルの準備と変換)
7. [開発環境の構築とデプロイ](#開発環境の構築とデプロイ)
8. [プロダクション実装例：AI Edge Gallery](#プロダクション実装例AI-Edge-Gallery)

---

## なぜオンデバイスLLMなのか

### クラウドAPIとの比較

| 項目 | クラウドAPI | オンデバイスLLM |
|------|-------------|-----------------|
| **プライバシー** | データを送信 | 完全にローカル |
| **レスポンス時間** | ネットワーク依存 | 即座（50-200ms） |
| **コスト** | 従量課金 | 初期開発のみ |
| **オフライン対応** | ❌ | ✅ |
| **スケーラビリティ** | 無制限 | デバイス性能に依存 |
| **モデルサイズ** | 制限なし | 1-5GB程度 |

### 技術的な実現可能性

**2025年現在の状況：**
- **モデル軽量化技術**：量子化により1-5GBでGPT-3.5相当の性能
- **モバイル端末の性能向上**：最新端末は8-16GBメモリ搭載
- **専用フレームワーク**：MediaPipe、TensorFlow Lite等の最適化

### 適用シーンの例

- **個人情報を扱うアプリ**：医療、金融、法務
- **リアルタイム処理**：ライブ翻訳、音声認識
- **エンタープライズ**：機密データを外部送信できない環境
- **教育・学習**：オフライン学習アプリ

---

## MediaPipeによるLLM実装の基礎

### MediaPipeとは

https://github.com/google-ai-edge/mediapipe

GoogleのMediaPipeは、**マルチモーダルAIタスク**に最適化されたフレームワークです：

- **統一API**：テキスト、画像、音声を同じインターフェースで処理
- **ハードウェア最適化**：GPU、NPU、CPUを自動選択
- **ストリーミング対応**：リアルタイムでの逐次出力
- **プロダクション実績**：YouTube、Google Assistant等で使用

### アーキテクチャ概要

```
あなたのAndroidアプリ
    ↓
MediaPipe LLM API
    ↓
.taskファイル（専用モデル形式）
    ↓
ハードウェアアクセラレーション（GPU/CPU/NPU）
    ↓
ストリーミング出力
```

---

## 最初の一歩シンプルなLLMアプリ

### 環境準備

**必要な依存関係：**
```kotlin
dependencies {
    implementation("com.google.mediapipe:tasks-genai:0.10.24")
    implementation("androidx.compose.ui:ui:$compose_version")
}
```

### 最小限の実装

まずは、1つの関数でLLMを動かしてみましょう：

```kotlin
// シンプルな関数ベースの実装
import android.content.Context
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession

fun simpleLlmExample(context: Context) {
    // 1. 単一のメソッド内で完結する実装
    val modelPath = "models/gemma-2b.task"
    
    // 2. LLMエンジンの作成
    val llmInference = LlmInference.createFromOptions(
        context,
        LlmInference.LlmInferenceOptions.builder()
            .setModelPath(modelPath)
            .setMaxTokens(256)
            .build()
    )
    
    // 3. セッションの作成
    val session = LlmInferenceSession.createFromOptions(
        llmInference,
        LlmInferenceSession.LlmInferenceSessionOptions.builder()
            .setTopK(40)
            .setTemperature(0.8f)
            .build()
    )
    
    // 4. プロンプトを送信して結果を取得
    session.addQueryChunk("Explain AI in one sentence.")
    
    // 5. レスポンスを処理
    session.generateResponseAsync { partialResult, done ->
        print(partialResult)  // コンソールに出力
        if (done) {
            println("\n--- Generation completed ---")
            // 6. クリーンアップ
            llmInference.close()
        }
    }
}
```

### UI付きアプリケーション

実際のアプリで使えるComposeベースの実装：

```kotlin
// Composeでの使用例
@Composable
fun SimpleLlmChat() {
    val context = LocalContext.current
    var response by remember { mutableStateOf("") }
    var isGenerating by remember { mutableStateOf(false) }
    
    Column(modifier = Modifier.padding(16.dp)) {
        Button(
            onClick = {
                if (!isGenerating) {
                    isGenerating = true
                    response = ""
                    
                    // インラインでLLMを使用
                    val llm = LlmInference.createFromOptions(
                        context,
                        LlmInference.LlmInferenceOptions.builder()
                            .setModelPath("models/gemma-2b.task")
                            .setMaxTokens(128)
                            .build()
                    )
                    
                    val session = LlmInferenceSession.createFromOptions(
                        llm,
                        LlmInferenceSession.LlmInferenceSessionOptions.builder()
                            .setTemperature(0.7f)
                            .build()
                    )
                    
                    session.addQueryChunk("Tell me a joke")
                    session.generateResponseAsync { partial, done ->
                        response += partial
                        if (done) {
                            isGenerating = false
                            llm.close()
                        }
                    }
                }
            },
            enabled = !isGenerating
        ) {
            Text(if (isGenerating) "Generating..." else "Generate Joke")
        }
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Text(
            text = response,
            modifier = Modifier.fillMaxWidth()
        )
    }
}
```

### 再利用可能なクラス実装

プロダクション向けの構造化された実装：

```kotlin
// LlmMinimalExample.kt
import android.content.Context
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession

class LlmMinimalExample(private val context: Context) {
    private var llmInference: LlmInference? = null
    private var session: LlmInferenceSession? = null
    
    fun initialize(modelPath: String) {
        // 1. LLM推論オプションの設定
        val options = LlmInference.LlmInferenceOptions.builder()
            .setModelPath(modelPath)                    // モデルファイルのパス
            .setMaxTokens(512)                          // 最大トークン数
            .setPreferredBackend(LlmInference.Backend.GPU)  // GPU使用
            .build()
        
        // 2. LLM推論エンジンの作成
        llmInference = LlmInference.createFromOptions(context, options)
        
        // 3. セッションの作成
        session = LlmInferenceSession.createFromOptions(
            llmInference!!,
            LlmInferenceSession.LlmInferenceSessionOptions.builder()
                .setTopK(40)
                .setTopP(0.95f)
                .setTemperature(0.8f)
                .build()
        )
    }
    
    fun generateResponse(prompt: String, onResult: (String, Boolean) -> Unit) {
        // 4. プロンプトを追加
        session?.addQueryChunk(prompt)
        
        // 5. 非同期でレスポンスを生成（ストリーミング）
        session?.generateResponseAsync { partialResult, done ->
            onResult(partialResult, done)
        }
    }
    
    fun cleanup() {
        // 6. リソースのクリーンアップ
        llmInference?.close()
    }
}
```

### 🔑 基本実装のポイント

1. **`.task`ファイル**：MediaPipe専用のモデル形式が必要
2. **ストリーミング出力**：`generateResponseAsync`でリアルタイム表示
3. **メモリ管理**：必ず`close()`でリソース解放
4. **非同期処理**：UIをブロックしないよう必須

---

## 画像入力対応マルチモーダルLLM

### Vision Language Modelとは

**マルチモーダルLLM**は、テキストと画像の両方を理解できるモデルです：

- **画像理解**：写真の内容を詳細に説明
- **視覚的質問応答**：画像について自然言語で質問可能
- **OCR機能**：画像内のテキストを読み取り
- **シーン解析**：複雑な場面の理解と説明

### 基本的な画像分析実装

```kotlin
// ImageLlmExample.kt
import android.content.Context
import android.graphics.Bitmap
import com.google.mediapipe.framework.image.BitmapImageBuilder
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession
import com.google.mediapipe.tasks.genai.llminference.GraphOptions

class ImageLlmExample(private val context: Context) {
    private var llmInference: LlmInference? = null
    private var session: LlmInferenceSession? = null
    
    fun initialize(modelPath: String) {
        // 1. 画像対応モデル用のオプション設定
        val options = LlmInference.LlmInferenceOptions.builder()
            .setModelPath(modelPath)
            .setMaxTokens(512)
            .setMaxNumImages(1)  // 画像入力を1枚まで許可
            .setPreferredBackend(LlmInference.Backend.GPU)
            .build()
        
        llmInference = LlmInference.createFromOptions(context, options)
        
        // 2. ビジョンモダリティを有効化したセッション
        session = LlmInferenceSession.createFromOptions(
            llmInference!!,
            LlmInferenceSession.LlmInferenceSessionOptions.builder()
                .setTopK(40)
                .setTemperature(0.8f)
                .setGraphOptions(
                    GraphOptions.builder()
                        .setEnableVisionModality(true)  // ビジョン機能を有効化
                        .build()
                )
                .build()
        )
    }
    
    fun analyzeImage(
        image: Bitmap, 
        prompt: String, 
        onResult: (String, Boolean) -> Unit
    ) {
        // 3. テキストプロンプトを追加（画像より先に追加する必要がある）
        session?.addQueryChunk(prompt)
        
        // 4. 画像を追加
        val mpImage = BitmapImageBuilder(image).build()
        session?.addImage(mpImage)
        
        // 5. レスポンス生成
        session?.generateResponseAsync { partialResult, done ->
            onResult(partialResult, done)
        }
    }
    
    fun cleanup() {
        llmInference?.close()
    }
}
```

### シンプルな画像分析関数

```kotlin
// 画像を分析する単純な関数
fun analyzeImageSimple(
    context: Context,
    modelPath: String,
    image: Bitmap,
    question: String = "What do you see in this image?"
) {
    // LLMエンジンの作成（画像対応）
    val llm = LlmInference.createFromOptions(
        context,
        LlmInference.LlmInferenceOptions.builder()
            .setModelPath(modelPath)
            .setMaxTokens(256)
            .setMaxNumImages(1)
            .build()
    )
    
    // セッションの作成（ビジョンモダリティ有効）
    val session = LlmInferenceSession.createFromOptions(
        llm,
        LlmInferenceSession.LlmInferenceSessionOptions.builder()
            .setTemperature(0.7f)
            .setGraphOptions(
                GraphOptions.builder()
                    .setEnableVisionModality(true)
                    .build()
            )
            .build()
    )
    
    // プロンプトと画像を追加
    session.addQueryChunk(question)
    session.addImage(BitmapImageBuilder(image).build())
    
    // 結果を出力
    session.generateResponseAsync { partial, done ->
        print(partial)
        if (done) {
            println("\n--- Analysis completed ---")
            llm.close()
        }
    }
}
```

### 画像分析UIの完全実装

```kotlin
@Composable
fun ImageAnalysisScreen() {
    val context = LocalContext.current
    var selectedImage by remember { mutableStateOf<Bitmap?>(null) }
    var analysisResult by remember { mutableStateOf("") }
    var isAnalyzing by remember { mutableStateOf(false) }
    
    // 画像選択ランチャー
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        uri?.let {
            val bitmap = MediaStore.Images.Media.getBitmap(
                context.contentResolver, it
            )
            selectedImage = bitmap
        }
    }
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 画像選択ボタン
        Button(
            onClick = { launcher.launch("image/*") },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Select Image")
        }
        
        // 選択した画像の表示
        selectedImage?.let { bitmap ->
            Image(
                bitmap = bitmap.asImageBitmap(),
                contentDescription = "Selected image",
                modifier = Modifier
                    .fillMaxWidth()
                    .height(200.dp)
                    .padding(vertical = 8.dp)
            )
            
            // 分析ボタン
            Button(
                onClick = {
                    if (!isAnalyzing) {
                        isAnalyzing = true
                        analysisResult = ""
                        
                        // インラインでLLM分析を実行
                        val llm = LlmInference.createFromOptions(
                            context,
                            LlmInference.LlmInferenceOptions.builder()
                                .setModelPath("models/gemma-3n-E2B-it-int4.task")
                                .setMaxTokens(256)
                                .setMaxNumImages(1)
                                .build()
                        )
                        
                        val session = LlmInferenceSession.createFromOptions(
                            llm,
                            LlmInferenceSession.LlmInferenceSessionOptions.builder()
                                .setGraphOptions(
                                    GraphOptions.builder()
                                        .setEnableVisionModality(true)
                                        .build()
                                )
                                .build()
                        )
                        
                        session.addQueryChunk("Describe this image in detail.")
                        session.addImage(BitmapImageBuilder(bitmap).build())
                        
                        session.generateResponseAsync { partial, done ->
                            analysisResult += partial
                            if (done) {
                                isAnalyzing = false
                                llm.close()
                            }
                        }
                    }
                },
                enabled = !isAnalyzing,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(if (isAnalyzing) "Analyzing..." else "Analyze Image")
            }
        }
        
        // 分析結果の表示
        if (analysisResult.isNotEmpty()) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 16.dp)
            ) {
                Text(
                    text = analysisResult,
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}
```

### 🔑 マルチモーダル実装のポイント

1. **対応モデルの確認**：画像入力対応の`.task`ファイルが必要
2. **順序が重要**：`addQueryChunk()`→`addImage()`の順番を守る
3. **ビジョンモダリティの有効化**：`setEnableVisionModality(true)`必須
4. **画像数制限**：`setMaxNumImages()`で同時処理可能な画像数を指定

---

## チャットアプリケーションの構築

### 会話履歴管理の仕組み

AndroidでのLLMチャットアプリは、**セッションベースの文脈管理**が鍵となります：

- **MediaPipeの自動管理**：`LlmInferenceSession`が会話履歴を自動保持
- **累積的なコンテキスト**：新しい入力が既存の文脈に追加
- **メモリ効率**：不要になった古い履歴の自動削除
- **リセット機能**：セッション再作成で履歴クリア

### 基本的なチャット実装

AI Edge Galleryのアーキテクチャを参考にした実装：

```kotlin
// LlmChatModelHelper.kt より抜粋

data class LlmModelInstance(val engine: LlmInference, var session: LlmInferenceSession)

object LlmChatModelHelper {
  fun initialize(
    context: Context, model: Model, onDone: (String) -> Unit
  ) {
    // モデル設定の取得
    val maxTokens = model.getIntConfigValue(key = ConfigKey.MAX_TOKENS, defaultValue = DEFAULT_MAX_TOKEN)
    val topK = model.getIntConfigValue(key = ConfigKey.TOPK, defaultValue = DEFAULT_TOPK)
    val topP = model.getFloatConfigValue(key = ConfigKey.TOPP, defaultValue = DEFAULT_TOPP)
    val temperature = model.getFloatConfigValue(key = ConfigKey.TEMPERATURE, defaultValue = DEFAULT_TEMPERATURE)
    val accelerator = model.getStringConfigValue(key = ConfigKey.ACCELERATOR, defaultValue = Accelerator.GPU.label)
    
    // バックエンドの選択（GPU/CPU）
    val preferredBackend = when (accelerator) {
      Accelerator.CPU.label -> LlmInference.Backend.CPU
      Accelerator.GPU.label -> LlmInference.Backend.GPU
      else -> LlmInference.Backend.GPU
    }
    
    // LlmInferenceのオプション設定
    val options = LlmInference.LlmInferenceOptions.builder()
      .setModelPath(model.getPath(context = context))
      .setMaxTokens(maxTokens)
      .setPreferredBackend(preferredBackend)
      .setMaxNumImages(if (model.llmSupportImage) 1 else 0)  // マルチモーダル対応
      .build()
    
    // インスタンスの作成
    val llmInference = LlmInference.createFromOptions(context, options)
    
    // セッションの作成
    val session = LlmInferenceSession.createFromOptions(
      llmInference,
      LlmInferenceSession.LlmInferenceSessionOptions.builder()
        .setTopK(topK)
        .setTopP(topP)
        .setTemperature(temperature)
        .setGraphOptions(
          GraphOptions.builder()
            .setEnableVisionModality(model.llmSupportImage)
            .build()
        )
        .build()
    )
    
    model.instance = LlmModelInstance(engine = llmInference, session = session)
  }
```

### 推論実行とストリーミング処理

```kotlin
fun runInference(
  model: Model,
  input: String,
  resultListener: ResultListener,
  cleanUpListener: CleanUpListener,
  image: Bitmap? = null,
) {
  val instance = model.instance as LlmModelInstance
  val session = instance.session
  
  // テキストクエリの追加
  session.addQueryChunk(input)
  
  // 画像対応モデルの場合、画像を追加
  if (image != null) {
    session.addImage(BitmapImageBuilder(image).build())
  }
  
  // 非同期でレスポンスを生成（ストリーミング対応）
  session.generateResponseAsync(resultListener)
}
```

### セッション管理の詳細

```kotlin
// セッションのライフサイクル
object LlmChatModelHelper {
    // 1. セッション作成（モデル初期化時）
    fun initialize(context: Context, model: Model) {
        val llmInference = LlmInference.createFromOptions(...)
        val session = LlmInferenceSession.createFromOptions(...)
        model.instance = LlmModelInstance(engine = llmInference, session = session)
    }
    
    // 2. メッセージ追加と推論
    fun runInference(model: Model, input: String, image: Bitmap?) {
        val session = (model.instance as LlmModelInstance).session
        session.addQueryChunk(input)  // 会話履歴に追加
        if (image != null) {
            session.addImage(BitmapImageBuilder(image).build())
        }
        session.generateResponseAsync(resultListener)  // 全文脈を考慮して応答
    }
    
    // 3. セッションリセット
    fun resetSession(model: Model) {
        // 古いセッションを閉じて新規作成
        session.close()
        val newSession = LlmInferenceSession.createFromOptions(...)
        instance.session = newSession
    }
}
```

### 🔑 チャット機能開発のポイント

1. **文脈の自動管理**：MediaPipeが会話履歴を透過的に管理
2. **ストリーミング表示**：ユーザー体験向上のため逐次出力必須
3. **メモリ制限の考慮**：長い会話での自動的な履歴削除
4. **エラー処理**：セッション障害時の自動復旧機能

---

## モデルの準備と変換

### 対応モデル形式の理解

AndroidでのLLM実行には、**MediaPipe専用の`.task`ファイル**が必要です：

| 元形式 | 変換後 | 用途 |
|--------|--------|------|
| PyTorch (.pt) | .task | LLM推論 |
| ONNX (.onnx) | .task | LLM推論 |
| TensorFlow (.pb) | .tflite | 分類タスク |
| Hugging Face | .task | 事前変換済み |

### 事前変換済みモデルの活用

**推奨入手先：**
- **Hugging Face**: `litert-community`タグ付きモデル
- **Kaggle Models**: Gemmaシリーズ等のMediaPipe対応版
- **Google AI Edge**: 公式サンプルモデル

**人気の画像対応モデル（2024年12月）：**
- `gemma-3n-E2B-it-int4.task`（3.1GB）- Vision対応
- `gemma-3n-E4B-it-int4.task`（4.4GB）- Vision対応、高性能

### 自前モデルの変換方法

MediaPipeでは、独自モデルを`.task`形式に変換するツールを提供：

**1. 環境準備**
```bash
pip install mediapipe
```

**2. 基本的な変換スクリプト**
```python
from mediapipe.tasks.python.genai import bundler

# バンドル設定
config = bundler.BundleConfig(
    tflite_model="model.tflite",           # TFLiteモデルのパス
    tokenizer_model="tokenizer.model",     # SentencePieceトークナイザー
    start_token="
",                     # 開始トークン
    stop_tokens=["
"],                  # 終了トークンのリスト
    output_filename="my_model.task",       # 出力ファイル名
    enable_bytes_to_unicode_mapping=True,  # GPT-2スタイルのマッピング
    
    # オプション設定
    system_prompt="You are a helpful assistant.",  # システムプロンプト
    prompt_prefix="User: ",                        # プロンプトの接頭辞
    prompt_suffix="\nAssistant: ",                # プロンプトの接尾辞
)

# .taskファイルを作成
bundler.create_bundle(config)
```

**3. 画像対応モデルの変換**
```python
config = bundler.BundleConfig(
    # 基本設定は同じ
    tflite_model="model.tflite",
    tokenizer_model="tokenizer.model",
    start_token="
",
    stop_tokens=["
"],
    output_filename="vision_model.task",
    
    # ビジョン関連のモデルを追加
    tflite_vision_encoder="vision_encoder.tflite",  # ビジョンエンコーダー
    tflite_vision_adapter="vision_adapter.tflite",  # ビジョンアダプター
)
```

### モデルサイズとパフォーマンスの最適化

**量子化レベルの選択：**
- **int4**: 最小サイズ（1-2GB）、やや精度低下
- **int8**: バランス型（2-4GB）、実用的な精度
- **fp16**: 高精度（4-8GB）、最新端末推奨

**実機での性能目安：**
| 端末クラス | 推奨モデルサイズ | 期待パフォーマンス |
|------------|------------------|-------------------|
| エントリー（4GB RAM） | 1-2GB | 5-10 tokens/sec |
| ミドル（8GB RAM） | 2-4GB | 10-20 tokens/sec |
| ハイエンド（12GB+ RAM） | 4-8GB | 20-50 tokens/sec |

---

## 開発環境の構築とデプロイ

### 開発環境のセットアップ

**必要なツールとバージョン：**
- **Android Studio**: Hedgehog以降
- **JDK**: 11以上（17推奨）
- **Android SDK**: API 35
- **NDK**: 25.1.8937393（C++使用時）

### プロジェクトの初期設定

**build.gradle.kts（アプリレベル）：**
```kotlin
dependencies {
    // MediaPipe LLM
    implementation("com.google.mediapipe:tasks-genai:0.10.24")
    
    // UI (Compose)
    implementation("androidx.compose.ui:ui:$compose_version")
    implementation("androidx.activity:activity-compose:1.8.2")
    
    // Image handling
    implementation("androidx.compose.ui:ui-graphics:$compose_version")
}
```

### AI Edge Galleryのビルド例

実際のプロダクションアプリを参考にしたい場合：

```bash
# 1. AI Edge Galleryをクローン
git clone https://github.com/google-ai-edge/gallery.git
cd gallery/Android/src

# 2. 依存関係のダウンロード
./gradlew build --refresh-dependencies

# 3. デバッグ版のビルド
./gradlew assembleDebug

# 4. 実機にインストール
./gradlew installDebug
```

### パフォーマンス測定とプロファイリング

**重要な指標：**
- **Time to First Token (TTFT)**: 最初のトークンまでの時間
- **Tokens per Second**: 生成速度
- **Memory Usage**: ピーク時メモリ使用量
- **Battery Impact**: バッテリー消費量

**測定コード例：**
```kotlin
// パフォーマンス測定
val startTime = System.currentTimeMillis()
var firstTokenTime = 0L
var tokenCount = 0

session.generateResponseAsync { partial, done ->
    if (firstTokenTime == 0L) {
        firstTokenTime = System.currentTimeMillis() - startTime
        Log.d("Performance", "TTFT: ${firstTokenTime}ms")
    }
    
    tokenCount++
    if (done) {
        val totalTime = System.currentTimeMillis() - startTime
        val tokensPerSecond = tokenCount * 1000.0 / totalTime
        Log.d("Performance", "Speed: $tokensPerSecond tokens/sec")
    }
}
```

---

## プロダクション実装例AI Edge Gallery


### プロダクションレベルの機能

**1. モデル管理システム**
```
{External Storage}/models/
├── {model_name}/
│   ├── model.task
│   ├── config.json
│   └── metadata.json
```

**2. パフォーマンス最適化**
- GPU/CPU/NPUの自動選択
- メモリ使用量の動的監視
- バックグラウンド処理の制御

**3. エラー処理とフォールバック**
- モデル読み込み失敗時の自動復旧
- メモリ不足時の軽量モデルへの切り替え
- ネットワークエラー時のローカルモデル使用

### UI/UXのベストプラクティス

**リアルタイム応答表示：**
- ストリーミング出力でタイピング効果
- プログレスインジケーターでの処理状況表示
- レスポンス時間の可視化

**ユーザビリティ配慮：**
- モデル切り替えの簡便性
- 設定パラメータの直感的な調整
- エラー状況の分かりやすい説明

### C++レベルでの最適化実装

AI Edge Galleryでは、Kotlin/Javaに加えてC++レベルでの最適化も活用：

```cpp
// llm_minimal_example.cc
#include <iostream>
#include <string>
#include "mediapipe/tasks/cc/genai/inference/c/llm_inference_engine.h"

// コールバック関数：レスポンスをストリーミング出力
void async_callback(void* context, LlmResponseContext* response_context) {
    std::cout << response_context->response_array[0] << std::flush;
    LlmInferenceEngine_CloseResponseContext(response_context);
}

int main() {
    // 1. モデル設定
    const LlmModelSettings model_settings = {
        .model_path = "path/to/gemma-2b.task",  // モデルファイルのパス
        .cache_dir = "./cache",                  // キャッシュディレクトリ
        .max_num_tokens = 512,                   // 最大トークン数
        .preferred_backend = kLlmPreferredBackendGpu  // GPU使用
    };
    
    // 2. エンジン作成
    void* engine = nullptr;
    char* error_msg = nullptr;
    if (LlmInferenceEngine_CreateEngine(&model_settings, &engine, &error_msg)) {
        std::cerr << "Engine creation failed: " << error_msg << std::endl;
        free(error_msg);
        return 1;
    }
    
    // 3. セッション設定と作成
    const LlmSessionConfig session_config = {
        .topk = 40,
        .topp = 0.95f,
        .temperature = 0.8f,
        .random_seed = 42
    };
    
    void* session = nullptr;
    if (LlmInferenceEngine_CreateSession(engine, &session_config, &session, &error_msg)) {
        std::cerr << "Session creation failed: " << error_msg << std::endl;
        free(error_msg);
        return 1;
    }
    
    // 4. プロンプト追加
    char prompt[] = "What is machine learning?";
    if (LlmInferenceEngine_Session_AddQueryChunk(session, prompt, &error_msg)) {
        std::cerr << "Failed to add prompt: " << error_msg << std::endl;
        free(error_msg);
        return 1;
    }
    
    // 5. 非同期推論実行
    std::cout << "Response: ";
    if (LlmInferenceEngine_Session_PredictAsync(session, nullptr, &error_msg, async_callback)) {
        std::cerr << "Prediction failed: " << error_msg << std::endl;
        free(error_msg);
        return 1;
    }
    
    // 6. クリーンアップ
    LlmInferenceEngine_Session_Delete(session);
    LlmInferenceEngine_Engine_Delete(engine);
    
    return 0;
}
```

---

## まとめ：AndroidでのLLM開発の未来

### 技術的達成点

AndroidでのオンデバイスLLMは、2024年時点で以下を実現しています：

- 🚀 **実用的な性能**: 最新端末で20-50 tokens/sec
- 📱 **モバイル最適化**: 1-5GBのコンパクトモデル
- 🔒 **完全なプライバシー**: データの外部送信不要
- 🌐 **オフライン動作**: ネットワーク接続不要

### 開発エコシステム

- **MediaPipe**: 統一APIでの簡単な実装
- **豊富なモデル**: Hugging Face、Kaggleでの事前変換済みモデル
- **プロダクション実例**: AI Edge Galleryによるベストプラクティス
- **クロスプラットフォーム**: Android、iOS、デスクトップ対応

### 今後の発展方向

1. **性能向上**: NPU活用による更なる高速化
2. **モデル軽量化**: より小さなモデルでの高性能実現
3. **マルチモーダル拡張**: 音声、動画への対応拡大
4. **開発者体験**: より簡単な実装とデバッグツール

AndroidでのLLM開発は、プライバシーを重視する現代のアプリケーション開発において、重要な選択肢となっています。本ガイドを参考に、ぜひあなた自身のAIアプリケーションを構築してみてください！

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235259/1e4f8fa4-da63-4770-8cdb-a28f17adf3b1.jpeg width=200>

🐣
****
フリーランスエンジニアです。
お仕事のご相談こちらまで
rockyshikoku@gmail.com

Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

[Twitter](https://twitter.com/JackdeS11)
[Medium](https://medium.com/@rockyshikoku)
[GitHub](https://github.com/john-rocky)