OnDevice Kit

Swift と llama.cpp で iPhone 上でローカル LLM を動かす

小さな言語モデルなら最近の iPhone に収まり、数秒で応答します。このガイドでは、空の Xcode プロジェクトから始めて、GGUF ファイルを読み込み llama.cpp と Metal でテキストを生成する Swift の actor を作るまでを順に解説します。

モデルをデバイス上で動かせば、サーバー費用はかからず、バイナリに API キーを埋め込む必要もなく、ユーザーのテキストが端末の外に出ることもありません。その代わりに必要になるのが、メモリ、大きなダウンロード、そして C API です。メモリとダウンロードについては以下とバックグラウンドダウンロードのガイドで扱います。C API は見た目ほど大きくありません。ディスク上のファイルからストリーミングされたテキストまで、必要な関数は十数個ほどです。

必要なもの

  • Xcode 26 と Swift 6。以下のコードは Swift 5 言語モードでも動作します。
  • 実機の iPhone または iPad、あるいは Mac。公式の llama.cpp xcframework には iOS デバイス用と macOS 用のスライスがありますが、iOS シミュレータ用のスライスはありません。シミュレータが必要な場合は、llama.cpp リポジトリの build-xcframework.sh でフレームワークを自分でビルドしてください。
  • GGUF モデル。スマートフォンで現実的なのは、4-bit 量子化した 2〜3B のモデルです。LFM2 2.6B の Q4_K_M は 1.6 GB です。
  • 十分な RAM。1.6 GB のモデルには 6 GB の RAM を搭載したデバイスが必要です。iPhone 13 Pro と Pro Max、iPhone 14 以降が該当します。iPhone 13 と 13 mini は 4 GB で、1B パラメータを超えるモデルには余裕がありません。

Swift Package Manager で llama.cpp を追加する

llama.cpp はリリースごとにビルド済みの xcframework を公開しています。ビルドの再現性を保つため、特定のリリースとそのチェックサムに固定したバイナリターゲットとして参照します。

Package.swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "LocalModel",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [.library(name: "LocalModel", targets: ["LocalModel"])],
    targets: [
        // llama.cpp b11887, official release build (MIT). iPhone (arm64) and Mac.
        .binaryTarget(
            name: "llama",
            url: "https://github.com/ggml-org/llama.cpp/releases/download/b11887/llama-b11887-xcframework.zip",
            checksum: "85aa7c9195918470c2de8dadf432bd8d01f5f7de2a4e65c1014491880ce673b6"
        ),
        .target(name: "LocalModel", dependencies: ["llama"]),
    ]
)

公式の xcframework には iOS デバイス用と macOS 用のスライスはありますが、iOS シミュレータ用はないため、これをリンクしたアプリはシミュレータ向けにビルドできません。シミュレータが必要な場合は、同じリリースを無変更でビルドし、シミュレータ用スライスを加えたものを使うか、llama.cpp リポジトリの build-xcframework.sh で自分でビルドしてください。シミュレータでは n_gpu_layers = 0 でモデルを読み込みます。シミュレータの Metal には llama.cpp のカーネルの大半がなく、CPU より遅くなるためです。

新しいリリースに移行するときは URL を更新し、swift package compute-checksum llama-bXXXX-xcframework.zip で新しいチェックサムを取得します。C API はリリース間でときどき変わるため、バージョンを固定し、更新は意図して行いましょう。

モデルを読み込む

読み込みは 2 段階です。モデル(重み)と、コンテキスト(1 つの会話のための作業メモリ)です。生成処理がメインスレッドで実行されないよう、両方を actor でラップし、生のポインタはちょうど 1 回だけ解放する小さなクラスに保持します。

LLM.swift
import Foundation
import llama

enum LLMError: Error {
    case loadFailed
    case promptTooLong(tokens: Int, limit: Int)
    case decodeFailed
}

/// Owns the llama.cpp pointers and frees them when the last reference goes away.
private final class Handle: @unchecked Sendable {
    let model: OpaquePointer
    let ctx: OpaquePointer
    let vocab: OpaquePointer
    init(model: OpaquePointer, ctx: OpaquePointer) {
        self.model = model
        self.ctx = ctx
        vocab = llama_model_get_vocab(model)
    }
    deinit {
        llama_free(ctx)
        llama_model_free(model)
    }
}

actor LLM {
    private let handle: Handle
    private let template: String?
    let contextLength: Int32 = 4096

    init(modelURL: URL) throws {
        llama_backend_init()

        var mp = llama_model_default_params()
        mp.n_gpu_layers = 99                       // all layers on the GPU (Metal)
        guard let model = llama_model_load_from_file(modelURL.path, mp) else { throw LLMError.loadFailed }

        var cp = llama_context_default_params()
        cp.n_ctx = UInt32(contextLength)           // prompt + answer
        cp.n_batch = cp.n_ctx                      // the whole prompt fits in one llama_decode call
        cp.n_ubatch = 512
        let threads = Int32(max(2, ProcessInfo.processInfo.activeProcessorCount - 2))
        cp.n_threads = threads
        cp.n_threads_batch = threads

        guard let ctx = llama_init_from_model(model, cp) else {
            llama_model_free(model)
            throw LLMError.loadFailed
        }
        handle = Handle(model: model, ctx: ctx)
        template = llama_model_chat_template(model, nil).map { String(cString: $0) }
    }
}

特に重要な設定が 2 つあります。n_gpu_layers = 99 はすべてのレイヤーを Metal に載せます。Apple silicon では GPU と CPU がメモリを共有しているため、コピーのコストはかかりません。n_batch = n_ctx は、初めて組み込むときにほとんどの人が遭遇する、長いプロンプトでのクラッシュを防ぎます。理由は n_batch のガイドで説明しています。

1.6 GB のファイルの読み込みは一瞬では終わりません。私たちの計測では、M シリーズの Mac で LFM2 2.6B の初回読み込みに約 20〜30 秒かかりました。2 回目以降はファイルがページキャッシュに載っているため速くなります。読み込みは 1 回だけにして actor を保持し続け、メモリ警告を受けたら解放してください。

モデルのチャットテンプレートでプロンプトを組み立てる

インストラクションチューニングされたモデルは、それぞれ固有のプロンプト形式を前提としています。ロール、ターンの終わり、回答の開始位置を示す特殊トークンです。GGUF ファイルにはそのテンプレートが含まれており、llama.cpp で適用できます。形式をハードコードせずにこれを使えば、同じコードで LFM2、Qwen、Gemma、Llama、Phi に対応できます。

Swift
extension LLM {
    func prompt(_ messages: [(role: String, content: String)]) -> String {
        let roles = messages.map { strdup($0.role)! }
        let contents = messages.map { strdup($0.content)! }
        defer { (roles + contents).forEach { free($0) } }
        var chat = zip(roles, contents).map { llama_chat_message(role: $0, content: $1) }

        var buf = [CChar](repeating: 0, count: 16_384)
        var n = llama_chat_apply_template(template, &chat, chat.count, true, &buf, Int32(buf.count))
        if n > buf.count {                          // too small: the return value is the size needed
            buf = [CChar](repeating: 0, count: Int(n) + 1)
            n = llama_chat_apply_template(template, &chat, chat.count, true, &buf, Int32(buf.count))
        }
        if n > 0 { return String(decoding: buf.prefix(Int(n)).map { UInt8(bitPattern: $0) }, as: UTF8.self) }

        // Unknown template: ChatML is the most common format.
        return messages.map { "<|im_start|>\($0.role)\n\($0.content)<|im_end|>\n" }.joined() + "<|im_start|>assistant\n"
    }
}

引数の true はアシスタントのプレフィックスを付加します。これにより、モデルはユーザーのメッセージの続きを書くのではなく、回答を書き始めます。

トークン化、デコード、サンプリング

生成はループです。プロンプトをトークンに変換してモデルに一度に渡し、その後は 1 トークンずつサンプリングしてモデルに戻す処理を、モデルが生成終了トークンを出力するまで繰り返します。

Swift
extension LLM {
    func generate(_ messages: [(role: String, content: String)], maxTokens: Int = 512) throws -> String {
        let ctx = handle.ctx, vocab = handle.vocab
        llama_memory_clear(llama_get_memory(ctx), true)     // start a fresh conversation

        var tokens = tokenize(prompt(messages))
        let limit = Int(contextLength) - maxTokens
        guard tokens.count <= limit else { throw LLMError.promptTooLong(tokens: tokens.count, limit: limit) }

        guard let sampler = llama_sampler_chain_init(llama_sampler_chain_default_params()) else { throw LLMError.decodeFailed }
        defer { llama_sampler_free(sampler) }
        llama_sampler_chain_add(sampler, llama_sampler_init_greedy())

        let ok = tokens.withUnsafeMutableBufferPointer {
            llama_decode(ctx, llama_batch_get_one($0.baseAddress, Int32($0.count))) == 0
        }
        guard ok else { throw LLMError.decodeFailed }

        var bytes: [UInt8] = []
        for _ in 0..<maxTokens {
            var token = llama_sampler_sample(sampler, ctx, -1)
            if llama_vocab_is_eog(vocab, token) { break }
            bytes += piece(token)
            let next = withUnsafeMutablePointer(to: &token) { llama_decode(ctx, llama_batch_get_one($0, 1)) }
            if next != 0 { break }
        }
        return String(decoding: bytes, as: UTF8.self)
    }

    private func tokenize(_ text: String) -> [llama_token] {
        let vocab = handle.vocab
        let len = Int32(text.utf8.count)
        let needed = -llama_tokenize(vocab, text, len, nil, 0, false, true)   // parse special tokens
        guard needed > 0 else { return [] }
        var tokens = [llama_token](repeating: 0, count: Int(needed))
        let n = llama_tokenize(vocab, text, len, &tokens, needed, false, true)
        return n > 0 ? Array(tokens.prefix(Int(n))) : []
    }

    private func piece(_ token: llama_token) -> [UInt8] {
        var buf = [CChar](repeating: 0, count: 64)
        var n = llama_token_to_piece(handle.vocab, token, &buf, Int32(buf.count), 0, false)
        if n < 0 {
            buf = [CChar](repeating: 0, count: Int(-n))
            n = llama_token_to_piece(handle.vocab, token, &buf, Int32(buf.count), 0, false)
        }
        return n > 0 ? buf.prefix(Int(n)).map { UInt8(bitPattern: $0) } : []
    }
}

間違えやすい細かい点がいくつかあります。

  • 特殊トークンを有効にしてトークン化する。チャットテンプレートには <|im_start|> のようなマーカーが含まれます。parse_special = false にすると、これらが通常のテキストとして扱われ、モデルが混乱します。
  • 文字列ではなくバイトを集める。トークンは「ä」や「漢」のようなマルチバイト文字の途中で終わることがあります。バイト列は最後にまとめて変換するか、ストリーミングする場合は String(bytes:encoding: .utf8) が成功するまでバイトを保留してください。
  • 先に長さをチェックする。llama.cpp が途中で失敗するのに任せるより、デコード前に promptTooLong を投げるほうがはるかに適切です。
  • 抽出タスクには greedy サンプリング。正解が 1 つのタスクでは、temperature 0(greedy)で安定した出力が得られます。チャットでは llama_sampler_init_top_p、llama_sampler_init_temp、llama_sampler_init_dist に置き換えてください。

SwiftUI から使う

Swift
let llm = try LLM(modelURL: modelFile)
let reply = try await llm.generate([
    (role: "system", content: "Answer in one sentence."),
    (role: "user", content: "Why is the sky blue?"),
])

LLM は actor なので呼び出しはサスペンドし、UI は応答性を保ちます。初回の読み込み中は進行状況を表示し、インスタンスは再生成されるビューではなく、長く生存するオブジェクトに保持してください。

メモリと App Store

モデルの重み、コンテキスト用の KV キャッシュ、Metal バッファは、すべてアプリのメモリ上限に計上されます。次の 3 点が役立ちます。

  • com.apple.developer.kernel.increased-memory-limit エンタイトルメントを追加します。対応するデバイスではメモリ上限が引き上げられます。
  • 読み込むモデルは 1 つにします。1.6 GB のモデルを 2 つ同時にスマートフォンに載せるのはまず無理なので、別のモデルを読み込む前に古いモデルを解放してください。
  • メモリ警告を監視し、モデルを解放します。再読み込みは生成より遅いものの、アプリが強制終了されるよりはるかにましです。

モデルをアプリにバンドルしないでください。ユーザーの操作に応じてダウンロードし、事前にサイズを表示し、ダウンロード後に検証します。ミラーと SHA-256 を使ったバックグラウンドダウンロードはこちらのガイドで解説しています。App Review のメモにはダウンロードについて記載し、設定画面にモデルのライセンスへのリンクを置いてください。詳しくはドキュメントの App Review に関するメモを参照してください。

次のステップ:構造化された回答

チャットなら自由形式のテキストで十分です。しかし多くのアプリでは、日付、金額、カテゴリといったフィールドが必要になります。小さなモデルに丁寧に JSON を頼んでも、ときどき波かっこを書き忘れます。文法(grammar)を使えば壊れた JSON は生成されなくなります。GBNF のガイドでは、Swift の記述から文法を組み立てます。Apple Intelligence が使えるデバイスではそちらも使いたい場合は、Foundation Models フォールバックのガイドで、両方を 1 つの呼び出しにまとめる方法を紹介しています。