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 を公開しています。ビルドの再現性を保つため、特定のリリースとそのチェックサムに固定したバイナリターゲットとして参照します。
// 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 回だけ解放する小さなクラスに保持します。
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 に対応できます。
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 トークンずつサンプリングしてモデルに戻す処理を、モデルが生成終了トークンを出力するまで繰り返します。
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 から使う
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 つの呼び出しにまとめる方法を紹介しています。