OnDevice Kit

OnDevice Kit ドキュメント

パッケージの全機能を、使い方のコードとともに説明します。ここの記述とソースコードが食い違う場合はソースコードが正しいので、ぜひお知らせください。

動作要件

  • Xcode 26 と Swift 6。アプリのターゲットは iOS 17 以降または macOS 14 以降。
  • ローカルモデルは iPhone と iPad、iOS シミュレータ(CPU 上)、Mac で動作します。シミュレータを参照してください。
  • ローカルモデルにはメモリが必要です。4 ビットの 2〜3B モデル(約 1.6 GB)は RAM 6 GB 以上の iPhone で動作します。iPhone 13 Pro と Pro Max、iPhone 14 以降が該当します。
  • iOS 26 と macOS 26 では、端末が対応していてユーザーがオンにしている場合、Apple Intelligence が自動的に使われます。

インストール

パッケージをプロジェクトの隣に解凍します。Xcode で File › Add Package Dependencies › Add Local… を選び、OnDeviceKit フォルダを選択します。または、自分の Package.swift に追加します。

Package.swift
dependencies: [
    .package(path: "../OnDeviceKit")
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "OnDeviceKit", package: "OnDeviceKit")
    ])
]

プロダクト:OnDeviceKit は import OnDeviceKit 1 行ですべてを取り込みます。OnDeviceLLM、OnDeviceModels、OnDeviceStore は個別にも使えます。llama.cpp(b11887、MIT)は Swift Package Manager によって取り込まれます。公式リリースに iOS シミュレータ対応を加えただけの無改変ビルドで、URL とチェックサムで固定しています。

動作を最短で確認するには、Example/OnDeviceDemo のサンプルアプリを使います。iPhone で実行して Download をタップし、任意のテキストを貼り付けて Make a note をタップしてください。どの層が答えたか、どれだけ時間がかかったかが表示されます。

Schema

Schema は、欲しい JSON オブジェクトをキーごとに、モデルが書き出す順序で記述します。1 つのスキーマですべての層を制御します。Kit はそれを llama.cpp 用の GBNF 文法と、Apple の Foundation Models 用の DynamicGenerationSchema にコンパイルします。どちらの場合も、結果は自分の Codable 型にデコードされます。

Letter.swift
struct Letter: Codable, Sendable {
    var sender: String
    var action: String
    var urgency: Int
    var deadline: String
    var steps: [String]

    static let schema = Schema("Letter") {
        Property("sender", .string, "Who sent the letter")
        Property("action", .choice(["none", "pay", "reply"]))
        Property("urgency", .integer(0...3), "0 only information, 3 deadline soon")
        Property("deadline", .date, "Only if the letter names one")
        Property("steps", .list(.string, max: 4))
    }
}

Property の第 3 引数は説明文です。プロンプトに含まれ(文法が形を強制し、説明文が意味を伝えます)、Apple 用のスキーマにも反映されます。

フィールド型

フィールドJSON備考
.stringstring1 行の任意のテキスト。
.choice([...])string指定した値のうち、ちょうど 1 つ。
.integer(0...3)number整数。範囲で制限することもできます。0〜9 に収まる範囲は、文法上 1 つの文字クラスになります。
.numbernumber小数。
.booleantrue / false
.datestring"YYYY-MM-DD"、該当なしの場合は ""。
.list(_, max:)array任意で最大 max 個まで。
.object(Schema)objectネストしたオブジェクト。

コンパイル結果

schema.gbnf は、まさにこの形の JSON だけを許可する llama.cpp の文法です。サンプリングでは文法が許可するトークンしか選べないため、回答は必ずパースできます。リトライも「JSON で答えてください」も不要です。schema.promptDescription は、システムプロンプト用にキーを並べた短いプレーンテキストです。iOS 26 と macOS 26 では、同じスキーマが DynamicGenerationSchema になります。選択肢は anyOf、整数の範囲はガイド、日付はパターン、リストは maximumElements 付きになります。

schema.gbnf (excerpt)
root ::= "{" ws "\"sender\":" ws str "," ws "\"action\":" ws root-action-choice "," ws "\"urgency\":" ws root-urgency-int "," ws "\"deadline\":" ws date "," ws "\"steps\":" ws root-steps-list ws "}"
root-action-choice ::= "\"none\"" | "\"pay\"" | "\"reply\""
root-urgency-int ::= [0-3]
root-steps-list ::= "[" ws "]" | "[" ws str ( ws "," ws str ){0,3} ws "]"

仕組みの詳細と単体で使えるコードは、GBNF 文法でローカル LLM に有効な JSON を出力させるを参照してください。

LocalLLM

LocalLLM は、GGUF ファイルを一度だけ読み込んでメモリに保持する actor です。生成はメインスレッド外で実行されます。

LocalLLM
let llm = try LocalLLM(model: url)                                   // loads the GGUF once
let text = try await llm.generate([.system("Be brief."), .user("Hi!")])
for try await piece in llm.stream([.user("Tell me a story")]) { print(piece, terminator: "") }
let letter = try await llm.generate(Letter.self, schema: schema, instructions: "...", input: text)
  • チャットテンプレート。メッセージはモデルファイルに格納されたチャットテンプレートで整形されるため、LFM2、Qwen、Gemma、Llama、Phi のいずれもプロンプトを調整せずに動きます。フォールバックは ChatML です。
  • n_batch クラッシュなし。コンテキストは n_batch = n_ctx(n_ubatch は最大 512)で作成されるため、長いプロンプトで llama.cpp の GGML_ASSERT(n_tokens_all <= cparams.n_batch) による強制終了が起きません。これが起きる理由。
  • promptTooLong。プロンプトと maxTokens の合計がコンテキストに収まらない場合、何かを実行する前に LocalLLMError.promptTooLong(tokens:limit:) が投げられます。tokenCount(_:) で事前に確認できます。
  • きれいなストリーミング。stream は文字単位でのみ値を返します。「ä」や「漢」の途中で終わるトークンは、文字が完成するまで保留されます。途中で止めるにはタスクをキャンセルします。
  • サンプリング。generate はデフォルトで greedy(temperature: 0)で、抽出にはこれが適しています。stream のデフォルトは 0.7、top-p 0.95 です。

オプション

LocalLLM.Options
var options = LocalLLM.Options()
options.contextLength = 4096   // prompt + answer; 4096 fits a page of text comfortably
options.gpuLayers = 99         // layers on the GPU (Metal), 99 = all
options.threads = 4            // default: active cores minus two, at least two
let llm = try LocalLLM(model: url, options: options)

エラー

LocalLLMError:modelNotFound、loadFailed(メモリ不足、または GGUF ファイルではない)、promptTooLong、decodeFailed、invalidOutput(回答を指定の型にデコードできなかった。生のテキストを保持)。いずれも読みやすい errorDescription を持ちます。

ModelCache

1.6 GB のファイルの読み込みには数秒かかり、生成はそれより短時間で終わります。ModelCache.shared.llm(for: url) は呼び出しをまたいで 1 つのモデルを読み込んだまま保持し、別のモデルを読み込む前に古いモデルを解放します。iPhone のメモリに 2 つ収まることはまずないためです。メモリ警告時やファイルを削除する前には await ModelCache.shared.unload() を呼んでください。

Assistant

Assistant は層を順に試し、最初にデコードできた回答を、それを返した層と一緒に返します。

Assistant
let assistant = Assistant<Letter>(
    schema: Letter.schema,
    instructions: "Read the letter. Use only facts from it.",
    examples: [Example(input: sampleLetter, output: .sample)],
    order: [.apple, .local, .rules],      // the default
    maxTokens: 1024,
    model: store.readyFile,               // nil until the model is downloaded
    rules: { text in Letter.guess(from: text) }
)

let (letter, tier) = try await assistant.run(scannedText)
  • .apple は AppleModel.isAvailable のときに使われます。AppleModel.unavailableReason は、「Apple Intelligence が設定でオフになっています。」のような、そのまま表示できる文を返します。
  • .local は model クロージャがファイル URL を返すときに使われます。store.readyFile は、ダウンロードと検証が完了して初めてファイルを返します。
  • .rules は自分で書くクロージャです。キーワードと正規表現を使います。諦める場合は nil を返します。
  • ある層がエラーを投げた場合(たとえば出力をデコードできなかった場合)、次の層が試されます。どの層も答えなければ、最後のエラー、または AssistantError.noTierAvailable が投げられます。
  • await assistant.currentTier() は、今この時点でどの層が答えるかを返します。ダウンロードを促す場合などに使えます。

Apple 層を単体で使う方法と利用可否の判定については、フォールバック付きの Apple Foundation Models を参照してください。

ModelStore

ModelStore はモデルをバックグラウンドで一度だけダウンロードし、保持します。@Observable でメインアクター上にあるため、SwiftUI のビューからその状態で分岐できます。

ModelCard.swift
@State private var store = ModelStore(.lfm2_2_6B.mirrored(first: URL(string: "https://your.cdn/lfm2.gguf")!))

switch store.state {
case .notDownloaded:        Button("Download (1.6 GB)") { store.download() }
case .downloading(let p):   ProgressView(value: p)
case .verifying:            Text("Checking …")
case .ready:                Text("Ready")
case .failed(let reason):   Text(reason)
}
  • バックグラウンドの URLSession を使用します。アプリがバックグラウンドにあっても閉じていてもダウンロードは続き、次回起動時に再接続します。
  • URL を順番に試します。まず自前のミラー、次に Hugging Face。HTTP エラー(404 でもダウンロードは「完了」扱いになります)やチェックサムが一致しないファイルの場合は、次の URL に進みます。
  • SHA-256 をメインスレッド外で 8 MB ずつ検証し、ファイルを iCloud バックアップの対象から外します。
  • cancel()、await delete()、onDelete。onDelete には { await ModelCache.shared.unload() } を設定できます。

アプリが動いていない間も iOS がダウンロードを完了できるよう、App Delegate からバックグラウンドイベントを転送します。

AppDelegate.swift
final class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     handleEventsForBackgroundURLSession identifier: String,
                     completionHandler: @escaping () -> Void) {
        if !ModelStore.handleBackgroundEvents(identifier: identifier, completion: completionHandler) {
            completionHandler()
        }
    }
}

独自のモデル

ModelSpec
// This is how the built-in .lfm2_2_6B spec is defined, with your server added first
let lfm2 = ModelSpec(
    id: "lfm2-2.6b-q4_k_m",
    urls: [
        URL(string: "https://your.cdn/models/lfm2-2.6b-q4_k_m.gguf")!,
        URL(string: "https://huggingface.co/LiquidAI/LFM2-2.6B-GGUF/resolve/main/LFM2-2.6B-Q4_K_M.gguf")!,
    ],
    fileName: "lfm2-2.6b-q4_k_m.gguf",
    bytes: 1_563_668_704,     // ls -l
    sha256: "384bc877b6c37064982f96885bef69e4475919f5969218ed4e3b9399ae0340df",   // shasum -a 256
    license: URL(string: "https://huggingface.co/LiquidAI/LFM2-2.6B/blob/main/LICENSE")
)

正確なサイズは ls -l、チェックサムは shasum -a 256 で取得します。サーバーがサイズを返さない場合、bytes がプログレスバーに使われます。ガイド2 GB のモデルをバックグラウンドでダウンロードするで、各部分を順を追って解説しています。

Store

オンデバイス AI アプリのビジネス面を支える 3 つの小さな型です。

Paywall
let quota = FreeQuota("letters", free: 2)            // Keychain: survives reinstalling
let daily = DailyPass("forest")                      // once a day for free
@State var unlock = Unlock("com.you.app.unlimited")  // StoreKit 2 non-consumable

if quota.isAvailable || unlock.isUnlocked { analyze(); quota.use() } else { showPaywall = true }
Button("Unlock for \(unlock.price ?? "…")") { Task { try await unlock.buy() } }
Button("Restore purchase") { Task { try? await unlock.restore() } }
  • FreeQuota は使用回数をキーチェーンに記録します。UserDefaults と違い、キーチェーンの項目はアプリを削除して再インストールしても残るため、その方法で無料枠をリセットすることはできません。remaining、isAvailable、use(_:)、テスト用の reset()。
  • DailyPass は最後に使った日を記録します:isAvailableToday、use()。
  • Unlock は非消耗型アイテム 1 つをラップします:price、buy()(承認と購入のリクエストやキャンセルの場合は false を返す)、restore()、isPurchasing。Transaction.updates を監視し、currentEntitlements を再読み込み(返金やファミリー共有の変更を含む)して最後の状態をキャッシュするので、起動時にペイウォールが一瞬表示されることはありません。
  • StoreError:productNotFound、unverified、nothingToRestore。いずれも読みやすいメッセージ付きです。

背景と落とし穴:再インストールしても消えない無料トライアル。

スクリーンショット

Tools/screenshots は、シミュレータから全言語の App Store 用スクリーンショットを生成します。

Tools/screenshots
PROJECT=MyApp.xcodeproj SCHEME=MyApp BUNDLE=com.you.myapp ./capture.sh en de ja ar
python3 compose.py            # all locales in captions.json
python3 compose.py de ja ar   # or some of them
  • shots.txt:スクリーンショット 1 枚につき 1 行で、その画面を開く起動引数を書きます。例:-demoData -open 0。アプリは DEBUG ビルドでこれを読み取ります。
  • capture.sh はアプリをビルドし、シミュレータ(SIM、デフォルトは iPhone 17 Pro Max)を起動して、ステータスバーを 9:41・バッテリー満タンに設定します。言語ごとに再インストールし、-AppleLanguages と -AppleLocale を付けて起動します。未加工のスクリーンショットは raw/<locale>/ に保存されます。
  • captions.json:言語ごとのタイトルとサブタイトル、背景色と文字色。
  • compose.py(Pillow が必要)は、キャプションを柔らかいグラデーションの上に、画面をデバイスフレームの中に配置し、6.9 インチ 1320×2868 と 6.3 インチ 1206×2622 で書き出します。
  • キャプションは小さな Swift ツールを介して macOS 自身のテキストエンジンで描画するため、アラビア語、ヘブライ語、日本語、韓国語、中国語も正しく表示されます。長い行は収まるまで縮小されます。
captions.json
{
  "style": { "background": ["#F4F6F5", "#E2EDEE"], "title": "#346270", "subtitle": "#687074" },
  "en": [
    {"title": "Your first line,\nshort and clear", "sub": "One benefit per screenshot"},
    {"title": "All on your iPhone", "sub": "No account, no cloud"}
  ]
}

モデルの選び方

モデルサイズ(Q4_K_M)得意なことライセンス
LFM2 2.6B(Liquid AI)1.6 GB抽出、8 言語、高速LFM Open License(売上 $10M 未満は無料)
Qwen3 4B Instruct2.5 GB推論、多言語Apache 2.0
Gemma 3 1B0.8 GB古い端末での短いタスクGemma Terms
Llama 3.2 3B2.0 GB英語のテキストLlama 3.2 Community License

M シリーズ Mac での計測では、LFM2 2.6B が 6 フィールドの郵便物スキーマを約 6.5 秒で埋めます。初回の読み込みには 20〜30 秒かかり、その後はモデルがキャッシュされます。ModelSpec.lfm2_2_6B はサイズ、SHA-256、ライセンスへのリンク付きですぐに使えます。リリース前に各ライセンスを自分で確認してください。モデルを配布するのはあなたです。

シミュレータ

llama.cpp の公式 xcframework には iOS(実機)と macOS しか含まれていません。OnDevice Kit は、同じリリース(b11887)に iOS シミュレータ対応を加えた無改変ビルドをリンクしています。このビルドは llama.cpp 自身の build-xcframework.sh で作成し、theyplay-games/llama-xcframework で公開しています。

シミュレータの Metal には llama.cpp のカーネルの大半がないため、シミュレータでは LocalLLM がデフォルトで CPU 上で動作します(gpuLayers = 0)。2〜3B のモデルでは、回答 1 回に数秒ではなく 1〜2 分かかります。開発中にダウンロードを省くには、GGUF をアプリのコンテナにコピーします。

Terminal
C=$(xcrun simctl get_app_container booted com.you.app data)
mkdir -p "$C/Library/Application Support/Models"
cp lfm2-2.6b-q4_k_m.gguf "$C/Library/Application Support/Models/"

シミュレータで Apple Intelligence を使うには、Mac 側で Apple Intelligence をオンにする必要があります。

統合テストは Mac 上で実際のモデルを使って実行します。

Terminal
ONDEVICE_MODEL=~/Models/lfm2-2.6b-q4_k_m.gguf swift test

App Review

  • 審査メモに、アプリがリクエストに応じて言語モデルをダウンロードし、端末内で実行することを記載します。
  • ダウンロードを任意にするか、オンボーディングで説明し、ダウンロード前にサイズを表示します。
  • 設定画面からモデルのライセンスにリンクします(store.spec.license)。
  • 非消耗型アイテムの購入には、目に見える「購入を復元」ボタンが必要です。Unlock.restore() はそのためのメソッドです。

ライセンス

Kit のコードは、パッケージに同梱の OnDevice Kit License に従います。クライアントワークを含め、自分のアプリでいくつでも使用できます。ソースコードの再販売や公開はできません。llama.cpp は MIT ライセンスです(THIRD_PARTY.md)。各モデルにはそれぞれ独自のライセンスがあります。ご質問は info@theyplay.games まで。