WWDC Quick Look 💓 By SwiftGGTeam
Code-along: Bring on-device AI to your app using the Foundation Models framework

Code-along: Bring on-device AI to your app using the Foundation Models framework

元の動画を見る

Highlight

Foundation Models フレームワークによって、Swift 開発者は @Generable アノテーションだけでカスタム構造体を直接生成でき、JSON パースを手書きする必要がなくなります。


主要内容

旅行プランニングで一番つらいのは、白紙の状態から始めることです。どこに行くか、何日滞在するか、どこに泊まるか、何を食べるか。本 code-along で Naomy がやろうとしているのは、こうした作業をオンデバイスの大規模言語モデルに丸投げすることです。観光地を 1 つ選び(例えば Joshua Tree)、Generate ボタンを押すだけで、モデルが日ごとのアクティビティ込みの完全な旅程をそのまま吐き出してくれます。一連の処理はネットワーク接続も不要、データのアップロードもありません。モデルが macOS、iPadOS、iOS、visionOS のシステムに組み込まれているからです。

ただし、このモデルを SwiftUI アプリに組み込もうとすると、開発者はいくつかの壁にぶつかります。prompt をどう書けば効率的に動くのか、モデルの出力は文字列だが Swift の構造体にどう変換するのか、MapKit のような外部データソースをモデルから呼び出すにはどうすればよいのか、出力の遅延をどう抑えるのか、デバイスで Apple Intelligence が無効な場合どうするのか——本セッションは 30 分でこの 5 つを一通りデモします。Xcode Playground で prompt をリアルタイムにイテレーションする方法(02:54)、@Generable@Guide で構造化データを生成させる方法(04:47)、Tool プロトコルを実装して MapKit と連携する方法(11:43)、ストリーミングレスポンスと PartiallyGenerated の組み合わせ(20:15)、そして新しい Foundation Models Instrument でボトルネックを特定し、prewarm と includeSchemaInPrompt: false で初トークンまでの遅延を体感できないレベルまで圧縮する手法(24:24)です。


詳細

Xcode Playground で prompt をチューニングする(02:54。アプリを何度も走らせて prompt を直していると、それだけで 1 日が終わってしまいます。アップデート後の Xcode の Playground ではより速いパスが用意されました。任意の Swift ファイルに #Playground と書くだけで、canvas が SwiftUI Preview のようにリアルタイムで結果を返してくれます。1 文字直せば、モデルがすぐに再実行されます。

Guided Generation(04:47。カスタム構造体を @Generable でマークすれば、モデルがその型のインスタンスを直接生成してくれます。@Guide マクロでは説明文の付与、取り得る値の制限、配列の長さの制限などが可能で、複数の guide を重ねて適用できます。

import FoundationModels

@Generable
struct Itinerary {
    @Guide(description: "A catchy title for the trip")
    var title: String

    var description: String

    var rationale: String

    @Guide(.count(3))
    var days: [DayPlan]
}

@Generable
struct DayPlan {
    var title: String
    var activities: [String]
}

ポイント:

  • @Generable アノテーションは構造体に付与するもので、その全プロパティも Generable である必要があります(StringInt などのプリミティブ型は標準でサポート済み)。
  • @Guide(description:) は説明文を schema に埋め込むもので、そのフィールドに対して追加で prompt を書くのと同等の効果があります。
  • @Guide(.count(3))days 配列を 3 要素ちょうどに強制します。この種の制約は生成段階で直接効くため、結果を受け取った後に検証する必要はありません。

builder API で Instructions を書く(06:50Instructions クロージャには文字列だけでなく、Generable インスタンスを few-shot 例として渡すこともできます。Foundation Models が Generable インスタンスをモデルが読めるテキストへ自動変換します。

@Observable
final class ItineraryPlanner {
    let landmark: Landmark
    var itinerary: Itinerary?

    private let session: LanguageModelSession

    init(landmark: Landmark) {
        self.landmark = landmark
        self.session = LanguageModelSession {
            "You are a travel planner. Build a 3-day itinerary."
            "Landmark: \(landmark.name)"
            "Here is an example itinerary:"
            Itinerary.exampleJapanTrip
        }
    }

    func generate() async throws {
        let response = try await session.respond(
            to: "Generate an itinerary for \(landmark.name).",
            generating: Itinerary.self
        )
        itinerary = response.content
    }
}

ポイント:

  • LanguageModelSession は trailing closure で instructions を受け取ります。内部は result builder になっていて、文字列と Generable インスタンスを混在させて渡せます。
  • respond(to:generating:) で出力型を Itinerary.self と明示すると、フレームワークが schema を自動的に prompt に注入します。
  • planner を @Observable にしておかないと、itinerary の変化で UI が再描画されません。
  • Naomy が強調していたのは(09:22)、planner は .task の中で生成すべきで、@State の初期値として直接渡してはいけないという点です。後者だと SwiftUI が繰り返し planner を構築してしまい、無駄なオーバーヘッドが発生します。

Tool プロトコルで外部データを取得する(11:12。いつ tool を呼ぶか、何回呼ぶかはモデルが自分で判断します。1 つの Tool には次が必要です。一意な namedescription(モデルにいつ使うべきかを伝える)、Arguments 型(Generable であること必須)、そして call メソッドです。

import FoundationModels
import MapKit

struct FindPointsOfInterestTool: Tool {
    let name = "findPointsOfInterest"
    let description = "Search for points of interest near a landmark, given a category."

    let landmark: Landmark

    @Generable
    enum Category: String {
        case restaurant
        case marina
        case desertAttraction
        case hikingTrail
    }

    @Generable
    struct Arguments {
        @Guide(description: "Natural language search query, e.g. 'sunset viewpoint'")
        var query: String
        var category: Category
    }

    func call(arguments: Arguments) async throws -> ToolOutput {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = arguments.query
        request.region = MKCoordinateRegion(
            center: landmark.coordinate,
            latitudinalMeters: 20_000,
            longitudinalMeters: 20_000
        )
        let response = try await MKLocalSearch(request: request).start()
        let names = response.mapItems.prefix(5).map(\.name).compactMap { $0 }
        return ToolOutput(names.joined(separator: ", "))
    }
}

ポイント:

  • namedescription はフレームワークが自動的に instructions に書き込み、モデルはそれを根拠に呼び出すかどうかを判断します。
  • Arguments は Generable でなければなりません。モデルは schema に従って query と category を生成します。Great Barrier Reef には marina、Joshua Tree には desertAttraction を割り当てるあたりは、モデル自身の世界知識が効いています(13:07)。
  • call(arguments:) は async メソッドで、半径 20km 以内の POI を検索して ToolOutput を返します。
  • tool の登録は LanguageModelSession の初期化時に tools: 引数でインスタンスを渡します。同時に prompt 内で「use the tool」と明示的に指示すると呼び出し頻度を上げられます(15:30)。

可用性のハンドリング(16:37。デバイスが Apple Intelligence をサポートしていなかったり、ユーザーが有効化していなかったり、モデルがダウンロード中だったりする可能性があります。3 つの状態にはそれぞれ別の UI を用意します。

struct LandmarkTripView: View {
    let landmark: Landmark
    private let model = SystemLanguageModel.default

    var body: some View {
        switch model.availability {
        case .available:
            generateButton
        case .unavailable(.appleIntelligenceNotEnabled):
            Text("Enable Apple Intelligence in Settings to plan trips.")
        case .unavailable(.modelNotReady):
            Text("Model is still downloading. Please try again later.")
        case .unavailable:
            funFactView // デバイス自体が非対応のため、生成ボタンは隠す
        }
    }
}

ポイント:

  • SystemLanguageModel.default.availability がモデルの状態を表す enum を返すので、開発者は case ごとにフォールバック UI を出し分けます。
  • 動作確認のために実際に Apple Intelligence をオフにする必要はありません。Xcode の scheme に Foundation Models Availability override が用意されており、Device Not Eligible / Apple Intelligence Not Enabled / Model Not Ready の 3 状態をシミュレートできます(17:27)。

ストリーミング出力(20:15respondstreamResponse に置き換えると、返ってくるのは完成された Itinerary ではなく Itinerary.PartiallyGenerated になります——全プロパティが自動的に Optional に変換された型です。

func generateStreaming() async throws {
    let stream = session.streamResponse(
        to: "Generate an itinerary for \(landmark.name).",
        generating: Itinerary.self
    )
    for try await partial in stream {
        partialItinerary = partial // 各フレームはより完成度の高い Itinerary.PartiallyGenerated
    }
}

ポイント:

  • 戻り値は AsyncSequence で、イテレーションのたびに少しずつ完成度の上がったバージョンが手に入ります。最初のフレームには title だけ、次のフレームでは description も追加……というイメージです。
  • PartiallyGenerated 型は自動で Identifiable に準拠するため、SwiftUI の ForEach でそのまま使えて、ID 管理を自前でやる必要はありません(22:53)。
  • View 層では if let title = partial?.title でアンラップし、.contentTransition(.interpolate) を組み合わせれば、テキストがセクションごとに滑らかにフェードインしていく演出になります。

パフォーマンスチューニング(24:24。新しい Foundation Models Instrument は 3 本のトラックを切り出します。Asset Loading(青、モデルのロード時間)、Inference(推論)、Tool Calling(紫)。さらに input token 数も表示してくれます。Naomy は 2 つの手段で初トークンまでの遅延を圧縮しています。

// 最適化 1: ユーザーが landmark の詳細を開いた時点で予熱する
struct LandmarkDescriptionView: View {
    let planner: ItineraryPlanner

    var body: some View {
        descriptionContent
            .task {
                planner.session.prewarm()
            }
    }
}

// 最適化 2: schema の注入をスキップする
let response = try await session.respond(
    to: "Generate an itinerary for \(landmark.name).",
    generating: Itinerary.self,
    options: GenerationOptions(includeSchemaInPrompt: false)
)

ポイント:

  • prewarm() は事前にモデルをメモリに読み込むためのもので、タイミングはユーザーが明確な意図を示した直後(landmark を開いたときなど)にします。説明文を読み終えて Generate を押す頃には、モデルは既に準備完了しています(26:28)。
  • includeSchemaInPrompt: false にすると schema 分の token を節約できますが、次の前提条件のいずれかを満たす必要があります。同じ session で既に同じ型のリクエストを発行済みであるか、instructions に完全な例が含まれていることです。
  • 注意点として、schema の注入を切ると @Guide の説明が失われるため、example は十分に網羅的でなければいけません。Optional プロパティがある場合は、値が入っているケースと nil のケースの両方を例で押さえておく必要があります(28:35)。

重要ポイント

  • 何をやるか: オンデバイス LLM をチャットボックスではなく、業務フィールドの「自動ジェネレーター」として捉える。 なぜやる価値があるか: 従来の方法はモデルに JSON を吐かせ、クライアント側でパースし、フィールドが合わなければクラッシュ……というパターンでした。Guided Generation は生成時の制約をモデルランタイムに委ねるため、型がそのまま契約になります。 どう始めるか: 現状テンプレートで力技で組み立てている機能(おすすめ文言、メニュー分類、行程サマリーなど)を 1 つ選び、ターゲットの構造体に @Generable@Guide を付け、まず #Playground で動かしてから UI に組み込みます。

  • 何をやるか: 重要なフィールドに .contentTransition(.interpolate) とストリーミング生成を組み合わせる。 なぜやる価値があるか: ストリーミングがあると「8 秒待って結果が一気にドン」が「1 秒で 1 行目が出始め、読みながら続きが出てくる」に変わります。体感遅延は一桁単位で下がります。 どう始めるか: respondstreamResponse に差し替え、View を PartiallyGenerated 型に紐付け、フィールドごとに if let でアンラップし、テキストやリストに transition を付与します。

  • 何をやるか: ユーザー行動の節目で prewarm を仕込む。 なぜやる価値があるか: モデルはシステムによってメモリから追い出されることがあり、初回呼び出しではロード待ちが発生します。prewarm はそのコストをユーザーが感じない場所に押し込みます。 どう始めるか: 「ユーザーが意図を表明する」から「実際に生成ボタンを押す」までの待ち時間(詳細を開く、入力を始める、対象カードまでスクロールするなど)を見つけ、.taskonChange の中で session.prewarm() を呼びます。

  • 何をやるか: 可用性ステートを UI のファーストクラスな分岐として扱う。 なぜやる価値があるか: Apple Intelligence は全デバイスにあるわけではないため、「エラーが発生しました」とだけ表示するのはユーザーを追い返すのと同じです。unavailable の各サブステートに合わせて誘導すれば転換率を維持できます。 どう始めるか: 主要な entry view のトップで SystemLanguageModel.default.availability を switch し、3 種類の unavailable それぞれに合った文言を出し分け、Xcode scheme の Foundation Models Availability override で全分岐を実機テストします。

  • 何をやるか: prompt が安定したら includeSchemaInPrompt: false を有効化する。 なぜやる価値があるか: schema token は隠れたコストです。マルチターン対話で同じ schema を毎回送るのは、遅い上にコンテキストの無駄遣いになります。 どう始めるか: まず instructions に完全な Generable の例(Optional プロパティの両ケースをカバー)を書き、その上で GenerationOptions で schema 注入を切り、Foundation Models Instrument で input token 数を比較します。


関連 Session

コメント

GitHub Issues · utterances