Gemma 4 E2BをiPhoneに載せる:CoreMLでオンデバイスLLMを動かす実装ガイド
この記事は実装の技術的な話です。「なぜこれを作ったのか」というお気持ちの話はZennに書きました。
👉 AIでアプリが作れる時代に、我々は何を作るのか — そして私は避難所管理アプリを作ってみた
はじめに
Gemma 4 E2B(2Bパラメータ)をiPhoneに載せて、完全オフラインで動くiOSアプリを作りました。この記事ではGemma 4をCoreML経由でiOSに組み込む手順と実装のポイントをまとめます。
「ローカルLLMをiOSアプリに入れてみたいけど、何から始めればいいかわからない」という方の参考になれば。
環境
| 項目 | 詳細 |
|---|---|
| モデル | Gemma 4 E2B(CoreML Int4, 約2.7GB) |
| ランタイム |
1. モデルの準備
CoreML変換済みモデルを取得する
Gemma 4 E2BのCoreML変換済みモデルはHugging Faceで公開されています。Int4量子化済みで約2.7GB。iPhone(6GB RAM)に収まるサイズです。
pip install huggingface_hub
python -c "
from huggingface_hub import snapshot_download
snapshot_download(
repo_id='mlboydaisuke/gemma-4-E2B-coreml',
local_dir='Models/gemma-4-E2B-coreml'
)
"
Hugging Faceで
Gemma 4ライセンスへの同意が必要です。
モデルのディレクトリ構成
ダウンロードすると以下のような構成になります。
Models/gemma-4-E2B-coreml/ ├── hf_model/ │ ├── tokenizer.json ← 必須 │ ├── tokenizer_config.json │ └── ... ├── StatefulGemma4E2BChunk1.mlpackage ├── StatefulGemma4E2BChunk2.mlpackage ├── ...(チャンク分割されたCoreMLモデル) └── config.json
重要:
hf_model/tokenizer.jsonがないとCoreML-LLMがクラッシュします。ダウンロード後に確認してください。
2. Xcodeプロジェクトへの組み込み
SPMでCoreML-LLMを追加
Package.swiftにCoreML-LLMを追加します。
dependencies: [
.package(url: "https://github.com/nicklama/CoreML-LLM.git", from: "0.1.0")
]
モデルの配置
モデルファイルは約2.7GBあるため、Gitリポジトリには含めません。アプリ内で複数のパスを探索するフォールバック設計にしました。
private func resolveModelDirectoryURL() throws -> URL {
let fm = FileManager.default
// 1. Bundle内蔵(ビルド時にコピー)
let bundlePath = Bundle.main.bundlePath
.appending("/Models/gemma-4-E2B-coreml")
if fm.fileExists(atPath: bundlePath) {
return URL(fileURLWithPath: bundlePath)
}
// 2. リソースバンドル経由
if let resourceURL = Bundle.main.resourceURL?
.appendingPathComponent("Models/gemma-4-E2B-coreml"),
fm.fileExists(atPath: resourceURL.path) {
return resourceURL
}
// 3. Documents(手動配置/ダウンロード)
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("gemma4")
if fm.fileExists(atPath: docs.path) {
return docs
}
throw NSError(domain: "GemmaService", code: -1,
userInfo: [NSLocalizedDescriptionKey:
"Gemma 4 model directory not found"])
}
なぜ3段階? 開発時はXcodeのビルドフェーズでコピー、App Store配布ならBundle内蔵、ユーザーが後からDLする場合はDocuments。どの方法でもアプリが動くようにしています。
ロード前のバリデーション
CoreML-LLMはtokenizerが見つからないとクラッシュするので、ロード前にチェックを入れます。
private func validateModelDirectory(_ modelURL: URL) throws {
let hfDir = modelURL.appendingPathComponent("hf_model")
guard FileManager.default.fileExists(atPath: hfDir.path) else {
throw NSError(domain: "GemmaService", code: -1,
userInfo: [NSLocalizedDescriptionKey:
"hf_model directory missing at: \(hfDir.path)"])
}
let tokenizerJSON = hfDir.appendingPathComponent("tokenizer.json")
guard FileManager.default.fileExists(atPath: tokenizerJSON.path) else {
throw NSError(domain: "GemmaService", code: -2,
userInfo: [NSLocalizedDescriptionKey:
"tokenizer.json missing"])
}
}
3. モデルの読み込みと推論
基本的な読み込み
import CoreMLLLM
@MainActor
final class GemmaService: ObservableObject {
private var llm: CoreMLLLM?
func loadModel() async throws {
let modelURL = try resolveModelDirectoryURL()
try validateModelDirectory(modelURL)
print("[GemmaService] Loading CoreMLLLM from: \(modelURL.path)")
llm = try await CoreMLLLM.load(from: modelURL)
}
}
初回ロードには10〜30秒かかります。アプリ起動時にローディング画面を出すのが実用的です。
テキスト生成(チャット)
let response = try await llm.generate(
[CoreMLLLM.Message(role: .user, content: "避難所で水はどのくらい必要?")],
maxTokens: 512
)
print(response)
マルチモーダル推論(画像+テキスト)
Gemma 4 E2Bはマルチモーダルに対応しています。画像とテキストを同時に渡せます。
guard let cgImage = uiImage.cgImage else { return }
let response = try await llm.generate(
[CoreMLLLM.Message(role: .user, content: "この画像に書かれている情報をJSONで抽出してください。")],
image: cgImage,
maxTokens: 256
)
これだけでiPhone上でマルチモーダルAIが動きます。サーバー不要、通信不要。
ストリーミング生成
長文の応答はストリーミングで返すとUXが良くなります。
for try await token in llm.stream(
[CoreMLLLM.Message(role: .user, content: prompt)],
maxTokens: 512
) {
// tokenが1つずつ届く → UIに逐次表示
self.responseText += token
}
4. プロンプト設計のポイント
2Bパラメータのモデルには、プロンプトの書き方で出力品質が大きく変わります。
短く、明確に
// ❌ 長すぎるプロンプト — 2Bモデルは途中で迷う
"""
あなたは日本の身分証明書を読み取る専門家です。
以下の画像には日本の身分証明書が写っています。
画像から名前、住所、生年月日、カードの種類を読み取り、
以下のJSON形式で出力してください。
出力はJSONのみとし、それ以外の文章は含めないでください。
...(続く)
"""
// ✅ 短いプロンプト — 必要最小限
"""
画像は日本の身分証明書です。次のJSONで抽出してください。
{"card_type":"","name":"","address":"","date_of_birth":""}
card_typeは: マイナンバーカード, 運転免許証, パスポート, 在留カード,
健康保険証, その他 のいずれか。
date_of_birthはYYYY年MM月DD日形式。読み取れない項目は空文字。JSONのみ出力。
"""
maxTokensは短めに
// JSON出力に必要なのは100トークン程度 // 長くすると不要な説明文を生成し始める maxTokens: 256 // ← 必要十分な長さに絞る
「しないこと」を明示する
2Bモデルはハルシネーションしやすいです。「読み取れない項目は空文字」と明示しないと、存在しない名前を生成することがあります。
5. 実際のアプリに組み込む際のTips
推論中のUI更新
CoreMLの推論はメインスレッドをブロックしないよう
async/awaitで書きますが、プログレス表示にはひと工夫要ります。
// Task.yield()だけだとSwiftUIが中間値を飛ばすことがある // 短いsleepでrender機会を確保 await Task.yield() try? await Task.sleep(nanoseconds: 50_000_000) // 50ms
タイムアウト
推論が稀にハングすることがあります。タイムアウト付きで実行するのが安全です。
private func withTimeout<T: Sendable>(
seconds: Double,
operation: @escaping @Sendable () async -> T
) async -> T? {
await withTaskGroup(of: T?.self) { group in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(
nanoseconds: UInt64(seconds * 1_000_000_000))
return nil
}
let result = await group.next() ?? nil
group.cancelAll()
return result
}
}
LLMの出力を構造化データに変換する
LLMの出力はテキストなので、JSONとしてパースする必要があります。2Bモデルは不完全なJSONを返すことがあるので、修復処理を入れておくと安心です。
private func repairJSON(_ raw: String) -> String {
var s = raw
// シングルクオート → ダブルクオート
if !s.contains("\"") && s.contains("'") {
s = s.replacingOccurrences(of: "'", with: "\"")
}
// 末尾カンマ除去
s = s.replacingOccurrences(
of: ",\\s*}", with: "}", options: .regularExpression)
// 閉じ括弧の補完
let open = s.filter { $0 == "{" }.count
let close = s.filter { $0 == "}" }.count
if open > close {
s += String(repeating: "}", count: open - close)
}
return s
}
画像解析の精度を補う
オンデバイスの画像解析はクラウドモデルと比べると精度にブレがあります。ShelterAIではAppleのVision OCRを並走させて、信頼度に応じて結果を切り替えるフォールバック設計にしました。画像解析を実装する場合は、LLM単体に頼らず別系統の検証手段を組み合わせることを推奨します。
まとめ
Gemma 4 E2BをiPhoneに載せるまでの流れ:
- CoreML変換済みモデルをHugging Faceから取得(約2.7GB)
- CoreML-LLMをSPMで追加
- モデルを配置して
CoreMLLLM.load(from:)
で読み込み llm.generate()
でテキスト/マルチモーダル推論- プロンプトは短く、maxTokensは絞る
- タイムアウトとJSON修復で安定性を確保
やってみると意外とシンプルです。CoreML-LLMのおかげでSwiftから数行で推論が呼べます。
2.7GBのモデルがiPhoneに収まり、Neural Engineで2〜8秒で推論が走る。通信不要、サーバー不要、完全にデバイスの中で完結する。 この手軽さがオンデバイスLLMの魅力です。ぜひ試してみてください。
ソースコード: https://github.com/Resolver-TNG/ShelterAI
デモ動画: https://youtu.be/s2XrPMB0DGc