OnDevice Kit

Apple Intelligence 非対応の iPhone にもフォールバックする Foundation Models の使い方

Apple のオンデバイスモデルは無料で高速、しかも最初からインストールされています。ただし使えるのは一部の iPhone だけです。使える端末ではこれを活用しつつ、それ以外のすべての端末でも構造化された回答を返す方法を解説します。

iOS 26 の Foundation Models フレームワークを使うと、どのアプリからでも Apple のオンデバイス言語モデルにアクセスできます。ダウンロードも API キーもサーバーも不要です。問題は対応端末です。Apple Intelligence が動作するのは iPhone 15 Pro、iPhone 15 Pro Max 以降の端末で、しかも iOS 26 であること、ユーザーが機能をオンにしていること、システムがモデルのダウンロードを完了していることが条件です。現在あなたのアプリが動いている iPhone のかなりの割合で、モデルは「利用不可」と報告されるでしょう。

開発中の機能がアプリの中核であれば、「お使いの端末では使えません」という回答は許されません。このガイドでは 3 段構成のチェーンを組み立てます。まず Apple のモデル、次にローカルの GGUF モデル、最後の砦として独自のルールです。3 つとも同じ Codable 型を返します。

利用可否を確認し、その理由を伝える

SystemLanguageModel.default.availability は、モデルが今すぐ使えるかどうか、使えない場合はその理由を返します。この理由は UI にとって重要です。非対応の端末では今後も使えるようになりませんが、モデルのダウンロード中であれば数分後には使えるようになります。

AppleModel.swift
import FoundationModels

enum AppleModel {
    static var isAvailable: Bool {
        if #available(iOS 26.0, macOS 26.0, *),
           case .available = SystemLanguageModel.default.availability {
            return true
        }
        return false
    }

    /// Something you can show in Settings, or nil when the model is ready.
    static var unavailableReason: String? {
        if #available(iOS 26.0, macOS 26.0, *) {
            switch SystemLanguageModel.default.availability {
            case .available:
                return nil
            case .unavailable(.deviceNotEligible):
                return "This device doesn't support Apple Intelligence."
            case .unavailable(.appleIntelligenceNotEnabled):
                return "Apple Intelligence is turned off in Settings."
            case .unavailable(.modelNotReady):
                return "Apple Intelligence is still downloading its model."
            case .unavailable:
                return "Apple Intelligence isn't available right now."
            }
        }
        return "Apple Intelligence needs iOS 26."
    }
}

末尾の case .unavailable: は残しておいてください。理由を表す enum は将来の iOS で増える可能性があり、包括的な case があれば switch 文がコンパイルエラーになりません。デプロイメントターゲットが iOS 26 未満の場合は、#available のチェックによって同じバイナリを iOS 17 でも動かせます。

@Generable か DynamicGenerationSchema か

Foundation Models で構造化出力を得る方法は 2 つあります。よく知られているのは、Swift の型にマクロを付ける方法です。

Swift
import FoundationModels

@Generable
struct Letter {
    @Guide(description: "Who sent it, short")
    var sender: String
    @Guide(.anyOf(["none", "pay", "reply", "sign"]))
    var action: String
    @Guide(description: "Tiny steps, each starting with a verb", .maximumCount(4))
    var steps: [String]
}

let session = LanguageModelSession(instructions: "Read the letter. Use only facts from it.")
let letter = try await session.respond(to: scannedText, generating: Letter.self).content

Apple のモデルだけを使うのであれば、これが適切な選択です。しかし 2 段目のモデルが加わると、そうとは言えなくなります。@Generable 型は Apple のフレームワーク専用で、FoundationModels が利用できる環境でしかコンパイルできず、llama.cpp のローカルモデルはその存在を知りません。結果として同じ構造を 2 回記述することになります。一度はマクロとして、もう一度は別モデル用の文法やプロンプトとしてです。

DynamicGenerationSchema は、同じ制約を実行時にデータから構築します。出力をすでに値として記述しているなら(型と説明を持つフィールドのリスト)、その 1 つの記述を Foundation Models のスキーマにも、llama.cpp 用の GBNF 文法にも変換できます。記述は 1 つ、エンジンは 2 つです。

回答の形を一度だけ記述する

最小限のフィールド記述は次のとおりです。キットの Schema 型と同じ考え方です。

Fields.swift
struct Property {
    let name: String
    let field: Field
    let description: String?
}

indirect enum Field {
    case string
    case choice([String])
    case integer(ClosedRange<Int>?)
    case boolean
    case date                      // "YYYY-MM-DD" or ""
    case list(Field, max: Int?)
}

let letterFields: [Property] = [
    .init(name: "sender", field: .string, description: "Who sent it, short"),
    .init(name: "action", field: .choice(["none", "pay", "reply", "sign"]), description: nil),
    .init(name: "urgency", field: .integer(0...3), description: "0 information only, 3 deadline soon"),
    .init(name: "deadline", field: .date, description: "Only if the letter names one"),
    .init(name: "steps", field: .list(.string, max: 4), description: "Tiny steps, each starting with a verb"),
]

DynamicGenerationSchema に変換する

フィールドの型はそれぞれ 1 つのイニシャライザに対応します。選択肢は anyOf に、範囲は .range ガイドに、日付は正規表現ガイド付きの文字列になり、リストは最大要素数を保持します。

AppleSchema.swift
import FoundationModels

@available(iOS 26.0, macOS 26.0, *)
extension Field {
    func dynamic(name: String) -> DynamicGenerationSchema {
        switch self {
        case .string:
            DynamicGenerationSchema(type: String.self)
        case .choice(let values):
            DynamicGenerationSchema(name: name, anyOf: values)
        case .integer(let range):
            DynamicGenerationSchema(type: Int.self, guides: range.map { [.range($0)] } ?? [])
        case .boolean:
            DynamicGenerationSchema(type: Bool.self)
        case .date:
            DynamicGenerationSchema(type: String.self, guides: [.pattern(/^$|^\d{4}-\d{2}-\d{2}$/)])
        case .list(let item, let max):
            DynamicGenerationSchema(arrayOf: item.dynamic(name: name + "_item"), maximumElements: max)
        }
    }
}

@available(iOS 26.0, macOS 26.0, *)
func appleJSON(schemaName: String, fields: [Property], instructions: String, input: String) async throws -> String {
    let root = DynamicGenerationSchema(
        name: schemaName,
        description: nil,
        properties: fields.map { p in
            DynamicGenerationSchema.Property(
                name: p.name,
                description: p.description,
                schema: p.field.dynamic(name: "\(schemaName)_\(p.name)")
            )
        }
    )
    let schema = try GenerationSchema(root: root, dependencies: [])
    let session = LanguageModelSession(instructions: instructions)
    let response = try await session.respond(to: input, schema: schema)
    return response.content.jsonString
}

注意点が 2 つあります。anyOf のスキーマにはそれぞれ一意の名前が必要なため、プロパティ名を名前に組み込んでいます。また response.content は GeneratedContent で、その jsonString からプレーンな JSON が得られます。これを JSONDecoder で、他のすべての段が返すのと同じ Codable 構造体にデコードします。

Swift
struct LetterAnswer: Codable, Sendable {
    var sender: String
    var action: String
    var urgency: Int
    var deadline: String
    var steps: [String]
}

let json = try await appleJSON(schemaName: "Letter", fields: letterFields,
                               instructions: "Read the letter. Use only facts from it.",
                               input: scannedText)
let letter = try JSONDecoder().decode(LetterAnswer.self, from: Data(json.utf8))

JSONDecoder にとって構造体のプロパティ順は関係ありませんが、モデルにとって properties の順序は重要です。モデルはその順にキーを書き出します。推論の手がかりになるフィールド(sender、action)を、それに依存するフィールド(steps)より前に置いてください。

フォールバックチェーン

Apple の段が他と同じ JSON を返すようになれば、チェーンは各段をループで試し、最初にデコードに成功した回答を返すだけです。

Assistant.swift
enum Tier: String { case apple, local, rules }

struct LetterAssistant {
    var localModel: () async -> URL?                  // nil until the GGUF is downloaded
    var local: (URL, String) async throws -> LetterAnswer
    var rules: (String) -> LetterAnswer?

    func run(_ input: String) async throws -> (LetterAnswer, Tier) {
        var lastError: Error = CancellationError()
        for tier in [Tier.apple, .local, .rules] {
            do {
                switch tier {
                case .apple:
                    guard AppleModel.isAvailable, #available(iOS 26.0, macOS 26.0, *) else { continue }
                    let json = try await appleJSON(schemaName: "Letter", fields: letterFields,
                                                   instructions: "Read the letter. Use only facts from it.",
                                                   input: input)
                    return (try JSONDecoder().decode(LetterAnswer.self, from: Data(json.utf8)), .apple)
                case .local:
                    guard let url = await localModel() else { continue }
                    return (try await local(url, input), .local)
                case .rules:
                    guard let answer = rules(input) else { continue }
                    return (answer, .rules)
                }
            } catch {
                lastError = error   // try the next tier
            }
        }
        throw lastError
    }
}

重要なのは段ごとにエラーを catch することです。Apple のモデルは、こちらでは制御できない理由で例外を投げることがあります。ガードレールが入力を拒否した、コンテキストウィンドウを超えた、システムがメモリ不足に陥っている、などです。その場合は単に次の段に順番が回ります。

local クロージャには llama.cpp のモデルを入れます。読み込みと生成についてはiPhone で GGUF モデルを動かすで解説しています。同じ letterFields から生成した文法を使えば、出力は必ず LetterAnswer にデコードできます。rules クロージャは素の Swift です。キーワード、正規表現、日付や金額には NSDataDetector を使います。良い手順を書くことはできませんが、action と deadline は十分に埋められるので、iPhone 11 でもオフラインで機能が動作します。

どの端末がどの段になるか

端末想定される段
iPhone 15 Pro 以降、iOS 26、Apple Intelligence オンApple
上記と同じ端末で Apple Intelligence がオフ、またはモデルをダウンロード中ダウンロード済みならローカル、そうでなければルール
iPhone 13 Pro / Pro Max、iPhone 14、iPhone 15 / 15 Plus(RAM 6 GB)ローカルの 2〜3B モデル(4-bit)
iPhone 13 / 13 mini、iPhone 12 以前(RAM 4 GB 以下)ルール、または短いタスク向けの 1B モデル

4-bit の 2〜3B モデルには、重みだけで約 1.6〜2 GB の RAM が必要で、さらにコンテキスト分が加わります。6 GB の端末なら余裕がありますが、4 GB の端末では危険です。llama.cpp がエラーを報告する前に、システムがアプリを強制終了してしまいます。ローカルの段を有効にするかどうかは、モデル名ではなく ProcessInfo.processInfo.physicalMemory で判定してください。

2 つの異なるモデルへのプロンプト

Apple のモデルと 2B のオープンモデルは、同じ指示に対しても異なる反応をします。いくつかの習慣で、両者の回答を近づけることができます。

  • 指示は短く、文字どおりに書きます。「手紙に書かれた事実だけを使う」はどちらにも通じますが、長いルールの羅列は小さいモデルを混乱させます。
  • 具体例を 1 つ示します。Apple のモデルではプロンプト本文に含めます(「入力例 … 回答例 …」)。llama.cpp ではチャットに user/assistant のペアとして追加します。良い例が 1 つあれば、1 ページ分のルールより効果があります。
  • 各フィールドの意味は description に書きます。どちらのエンジンもそれを参照します。Apple のモデルはスキーマ経由で、ローカルモデルはシステムプロンプト内の短いキー一覧経由で受け取ります。
  • デバッグビルドではどの段が回答したかを表示します。回答がおかしいとき、どのモデルが書いたのかを知りたくなるはずです。