はじめに
デザインと技術でプロダクトを開発するフェンリルから、iOSのFoundation Modelsを生かした実践的な実装テクニックを紹介します。
先日、Appleのエバンジェリストの方々をお迎えし、フェンリル大阪本社でセミナーを開催しました。 ワークショップを通じて、Foundation Modelsの活用方法を直接教わりました。
セミナーは、Foundation Modelsの基本的な使い方から@Generableを用いた実装のアイデア共有まで、5時間のハンズオンで1つのサンプルアプリを作り上げる、非常に密度の濃い内容でした。 また、アプリを作る中で生まれた疑問をその場で質問できることも、大変貴重な経験でした。
本記事は、その経験を踏まえて改めて自分でゼロからアプリを組み立てる中で得た、Foundation Modelsの実践的な使いどころを整理したものです。
まず、Foundation Modelsの基本的な使い方(@Generableで型を定義してsession.respond()で生成)は、WWDC25のセッションや公式ドキュメントで理解している方が多いと思います。 一方で、自分でセッションを用いたフローを組み込んだ方は少ないのではないかと感じます。
今回は、記事URLを共有するとAIがフラッシュカードを自動生成するiOSアプリ「ArticleFlash」を開発する中で得た、Foundation Modelsの実践的なテクニックを4つ紹介します。
作ったアプリ
まず、作ったアプリの概要を紹介します。
SafariやGoogleなどのウェブブラウザで記事を読んだ後、
共有ボタンを押すとアプリが表示されます。
アプリを選択すると、記事の内容をクイズ形式に変換したカードが生成されます。
アプリ内で1週間ごとに作成したカードがクイズ形式で出題されるため、
学んだことを自動的に復習できます。
……というアプリです。
それでは、このアプリでFoundation Modelsをどう使ったのか、4つのテクニックを順に見ていきます。
1. Streaming APIでAIの応答をリアルタイムに出力する
まず、カード生成部分からです。 Foundation Modelを勉強したての頃はsession.respond()で結果を受け取ることに慣れますが、これでは3枚のカードがすべて生成されるまで処理を待つ必要があります。
そこで、session.streamResponse()を使うと、生成途中の結果を逐次的に受け取れます。 これにより、カードが1枚完成するごとにアニメーションを交えて画面を順次更新する、ストリーミングUIを実現できます。
let stream = session.streamResponse( // ココ!
to: prompt,
generating: FlashcardCandidates.self
)
for try await snapshot in stream {
if let cards = snapshot.content.cards {
let validCards = cards.compactMap { candidate -> FlashcardCandidate? in
guard let q = candidate.question, let a = candidate.answer else { return nil }
return FlashcardCandidate(question: q, answer: a, tags: candidate.tags?.compactMap(\.self) ?? [])
}
if validCards.count > latestCards.count {
latestCards = validCards
onUpdate(latestCards)
}
}
}
なお、for try await snapshot in streamのsnapshotはResponseStream.Snapshot型です。 そのため、中身にアクセスするにはsnapshot.contentを経由する必要があります。
snapshot → ResponseStream<FlashcardCandidates>.Snapshot
snapshot.content → FlashcardCandidates.PartiallyGenerated
snapshot.content.cards → [FlashcardCandidate.PartiallyGenerated]?
snapshot.content.cards?[i].question → String? ← 全部Optional
@Generable構造体のプロパティは、ストリーミング中はすべてOptionalになるため、nilの場合とそうでない場合で処理を明確に書き分ける必要があります。
Apple公式サンプルの FoundationModelsTripPlanner も同様にpartialResponse.content経由で Itinerary.PartiallyGenerated?を受け取っているので、このパターンがおそらく標準的な実装手順です。
2. @Guideとinstructionsのトークン効率を最適化
次に、記事の内容を実際に読み込ませることを考えます。
Foundation Modelsのコンテキストウィンドウには入出力合計で4,096トークンの制限があり、この中に以下がすべて詰め込まれます。
-
instructions(システムプロンプト) -
@Generableのスキーマ - ユーザープロンプト
- 記事テキスト
- Foundation Modelsの出力
そのため、最初は少し長い記事を入れるとexceeded model context window sizeとなり、正常に動作しませんでした。そこで、良い例と悪い例を1行ずつに圧縮すると、品質を維持しつつトークン消費を大幅に削減できます。
// Before: 約1,200トークン(34行)
private static let instructions = """
あなたは学習カード生成の専門家です。
与えられた記事から、記憶に残りやすいQ&Aフラッシュカードを日本語で3つ生成してください。
### ルール
- 質問は「〜とは何か」「〜の違いは」のような具体的な問い形式にする
...(中略・3パターンの良い例 + 3パターンの悪い例)
"""
// After: 約100トークン(6行)
private static let instructions = """
記事からQ&Aカードを日本語で3つ生成。ルール:
- 質問は「〜とは何か」形式で自己完結。答えは30字以内、キーワード文頭配置
- 3つは異なる要点をカバー。タグは技術名を1〜3個
良い例: Q:@Stateと@Bindingの違いは? A:@Stateはビュー内部所有、@Bindingは親からの参照
悪い例: Q:この記事について説明して(漠然)/ A:〜を理解することが重要です(曖昧)
"""
これにより、約1,100トークン節約できました🙌
見落としがちな@Guideのコスト
@Guideのdescriptionは@Generableマクロをスキーマとしてプロンプトに自動注入します。つまり、descriptionを長く書くほど、トークン制限を圧迫します。
// Before: 約220トークン消費
@Guide(description: """
記事の核心を突く具体的な質問。以下の条件を満たすこと:
- 「〜とは何か」「〜の役割は」のような具体的な問い形式
- 答えを知らない人が読んでも質問の意味が分かる自己完結した文
- 「〜について説明して」のような漠然とした質問は避ける
""")
var question: String
// After: 約30トークン消費
@Guide(description: "記事の核心を問う具体的な質問。自己完結した1文")
var question: String
@Guideのdescriptionを短くしても生成品質が著しく落ちるわけではありません。instructions側でルール・例を明示していれば、@Guideは最小限のキーワードで十分です。
最終的なトークン配分
| 項目 | トークン数 |
|---|---|
instructions |
~100 |
@Generableスキーマ |
~200 |
| プロンプト文 | ~20 |
| 記事テキスト | ~600 |
| 出力(3カード) | ~600 |
| 合計 | ~1,520 |
日本語はトークン消費が激しいことで有名ですが、公式ドキュメントによると1文字 ≈ 1トークンとあります😱 そのため、日本語でトークンを消費するアプリでは、テキストの絞り込みが特に有効です。
3. 「4,096トークン」制限を再帰的な要約で突破する
前段でトークンの節約について述べましたが、記事が日本語の場合は4,096トークンが一気に消費されます。 かといってそのまま入力すると、後半にある重要な情報を取りこぼしてしまいます。
そこで、Foundation Modelsを呼び出す前にヒューリスティックなアプローチでテキストを圧縮し、それでも長い場合だけ再度要約する二段構えにしました。
ヒューリスティックなアプローチ
記事の構造を利用し、AIを使わずに重要な情報を絞り込みます。
private static func extractKeySentences(from text: String, limit: Int) -> String {
let paragraphs = text.components(separatedBy: "\n")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var result: [String] = []
var totalLength = 0
for paragraph in paragraphs {
if paragraph.count < 60, !paragraph.contains("。") {
// 見出し(短くて句点なし)→ そのまま採用
result.append(paragraph)
totalLength += paragraph.count
} else {
// 段落の先頭文(トピックセンテンス)を抽出
if let first = firstSentence(of: paragraph) {
result.append(first)
totalLength += first.count
}
}
if totalLength > limit * 2 { break }
}
return result.joined(separator: "\n")
}
「見出しは短くて句点がない」「段落の先頭文が最も重要」という日本語記事の構造的特徴を利用しています。
@Generableを要約にも活用
ヒューリスティックだけでは不十分な場合、Foundation Modelsで要約します。ここでも@Generableが活躍します。
@Generable
struct Summary: Sendable {
@Guide(description: "要点を箇条書きで5〜8個")
var keyPoints: [String]
}
private static func aiSummarize(_ text: String, title: String) async throws -> String {
let safeInput = String(text.prefix(800))
let session = LanguageModelSession(
instructions: "テキストの要点を日本語で5〜8個、各1文で抽出"
)
let response = try await session.respond(to: safeInput, generating: Summary.self)
return response.content.keyPoints
.prefix(8)
.map { "・\($0)" }
.joined(separator: "\n")
}
@Generableは「生成」だけでなく「抽出・要約」にも使えます。 [String]型で要点を構造化データとして取り出せるのは@Generableならではの強みです。
トークン制限超過への対策
実際に運用すると、エッジケースでトークン制限を超えることがあります。そこで、2段階のガードレールを設け、制限を超えることを防ぎます。
// 1. 記事本文を600文字以内に圧縮
let condensedText = try await ArticleSummarizer.condense(bodyText, title: title)
// 2. Foundation Modelに入力するテキストを強制的に600字以内に制限
let safeText = String(article.bodyText.prefix(600))
ちなみにこの「分割から各セクションを新セッションで要約、結合、反復」のフローは、LanguageModelSession公式ドキュメントでも長文処理の推奨手順として挙げられているアプローチです。
コツは、サブタスクごとに新しいLanguageModelSessionを作成することです。同一セッションを使い回すと前のセッションの内容が積み重なり、結果的にトークンが枯渇します。
4. 文字列の完全一致ではなく、意味合いによる正誤判定の実装
最後に、ユーザーが回答を入力したときの正誤判定について解説します。 意味としておおむね合っていれば正解としたいため、完全な文字列一致判定は使えません。
これは意味的には正解ですが、文字列比較では不正解になってしまいます。
そこで、@Generableで判定結果の型を定義し、AIによる意味的な正誤判定を実行します。
@Generable
struct JudgeResult: Sendable {
@Guide(description: "判定理由を1文で具体的に")
var reason: String
@Guide(description: "正解ならtrue。キーワードや概念が含まれていれば表現違いでも正解")
var isCorrect: Bool
}
ポイントは2つあります:
-
Bool 型のプロパティにも
@Guideを付けて判定基準を明示する。「意味的に同じなら正解」という基準がなければ、モデルは厳密な文字列比較に偏りがちです。 - reason を isCorrect より先に宣言する。@Generable は宣言順にプロパティを生成するため、先に理由を出力させると結論を急いで間違うことを防げます。 公式ドキュメントでも "Make sure the reasoning field is the first property" と明記されている定石です。
instructionsへのFew-Shot判定例の包含
private static let instructions = """
回答の正誤を判定。日本語で出力。
基準: キーワード・概念が含まれれば表現違いでも正解。部分的でも核心が欠ければ不正解。
例: 「ビュー内部所有」≒「ビュー自身が持つ」→正解 / 「4096」≒「約4000」→正解
"""
また、同義語の判定例と数値の近似判定例(「4096」と「約4000」)を含めると、判定精度が安定します。
フォールバック
Foundation Modelsが利用できない環境では、キーワードの一致率で判定します。 今回は簡易的な実装にしたため、本格的に対応する場合はCore MLの使用を検討しています。
private static func fallbackJudge(correctAnswer: String, userAnswer: String) -> JudgeResult {
let keywords = normalizedAnswer
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { $0.count >= 2 }
let matchCount = keywords.filter { normalizedUser.contains($0) }.count
let matchRatio = keywords.isEmpty ? 0.0 : Double(matchCount) / Double(keywords.count)
let isCorrect = matchRatio >= 0.4
return JudgeResult(
isCorrect: isCorrect,
reason: isCorrect ? "キーワードが一致" : "主要なキーワードが不足"
)
}
まとめ
実際にアプリへ組み込む中で、Foundation Modelsの強みを肌で感じることができました。
instructions・@Guide・入力テキスト量のバランス調整は確かに腕の見せどころです。しかし、それを乗り越えればオンデバイスLLMならではの特性を発揮できます。
例えば、下記のような優れたエンジニア体験を、Appleプラットフォームの上で自然に提供できます。
- オンデバイスで完結するため、ユーザーが読んだ記事の内容を外部サーバーに送らずに済む
- 追加課金なしで動くため、サブスクや従量課金を一切気にせずAI機能を組み込める
-
@Generableによる型安全な出力のおかげで、文章生成だけでなく判定・要約タスクにも自然に応用できる点が快適
Foundation Modelsの可能性をさらに掘り下げていきたいと、今回の開発を通じて改めて感じました。