はじめに
Seekthea という個人開発の RSS リーダーで Apple の Foundation Models framework を本気使いした経験から、すぐ使える実装パターンをまとめました。
「なぜ Apple Intelligence を選ぶのか」「他 AI とどう違うのか」のような俯瞰の話は Zenn の姉妹記事に書いています:
この記事は実装テクに振り切った内容です。
動作環境
- Xcode 26 以降
- iOS 26 / macOS 26 / visionOS 26 以降をターゲット
- iPhone 15 Pro 以降、M1 以降の iPad / Mac、Apple Vision Pro(Apple Intelligence 対応デバイス)
パターン1: 最小コード — まず動かす
import FoundationModels
let session = LanguageModelSession()
let response = try await session.respond(to: "こんにちは")
print(response.content) // String
LanguageModelSession は内部で会話履歴(transcript)を保持するので、同じインスタンスを使い回すと前のプロンプト・応答が文脈として積まれます。単発の分類・抽出タスクで毎回クリーンに動かしたいなら、毎回新しいインスタンスを作る のが楽です。
パターン2: @Generable で構造化出力
文字列パースじゃなく型で受け取れる。これが Foundation Models の最大の魅力。
@Generable
struct CategoryResult {
@Guide(description: "カテゴリのアルファベット1文字")
var category: String
@Guide(description: "重要キーワード(最大5つ)")
var keywords: [String]
@Guide(description: "keywordsの英訳(同じ順序、各1単語の英語)")
var keywordsEn: [String]
}
let response = try await session.respond(to: prompt, generating: CategoryResult.self)
let result: CategoryResult = response.content
// result.category: "A"
// result.keywords: ["AI", "半導体", ...]
// result.keywordsEn: ["AI", "semiconductor", ...]
@Guide(description:) は AI に渡されるフィールドの説明。ここに書く内容で出力が結構変わります。
効果: JSON のパースエラーや想定外フォーマットのトラブルから解放される。
パターン3: アルファベットラベル方式でカテゴリ判定
このパターンに辿り着くまで、Seekthea では 3段階の試行錯誤 がありました。
試した順
第1段階: カテゴリ名で直接分類させる
【カテゴリ】テクノロジー、ビジネス、ライフスタイル、...
【出力】カテゴリ名を1つ
→ 結果: AI が指定したカテゴリ名と微妙に違う表記を返してくる。テック テクノロジ Technology テクノロジー(IT) のように、リストに無い名前で返ってきてマップが詰まる。
第2段階: アルファベットラベル方式
@Generable
struct CategoryResult {
@Guide(description: "カテゴリのアルファベット1文字")
var category: String
}
【カテゴリ】
A. テクノロジー
B. ビジネス
C. ライフスタイル
...
【出力】
- category: 最も適切なカテゴリのアルファベットを1つだけ
→ category: "A" のような 1 文字で返ってくるので、アプリ側で A → テクノロジー のようにマップ。表記ゆれがなくなり一気に安定しました。
private func categoryName(from labelStr: String) -> String? {
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
let trimmed = labelStr.trimmingCharacters(in: .whitespaces).uppercased()
guard let first = trimmed.first else { return nil }
if let idx = alphabet.firstIndex(of: first), idx < userCategories.count {
return userCategories[idx]
}
return nil
}
第3段階: カテゴリに説明を添える
アルファベットラベル方式で表記の問題は解決したものの、判定精度がまだ低い。「テクノロジー」「ビジネス」のような短い単語だけだと、AI がカテゴリの境界を勘違いします。例えば「半導体メーカーの決算」が「ビジネス」じゃなく「テクノロジー」に入ったり、「ゲーム機の発売」が「テクノロジー」に流れたり。
そこで各カテゴリに AI 向けのヒント説明 を持たせ、プロンプトに添えるようにしました(説明文の渡し方は次のパターン5で詳しく説明します)。
結論として何が効いたか
- アルファベットラベル方式: 表記ゆれを根本から潰す(マップする側が強制的に決まった選択肢に倒せる)
-
マップ失敗時のフォールバック: AI が範囲外のラベル("Z" や "category 1" のような変な返答)を返してきた時のため、
nilを返して呼び出し側でハンドリング - カテゴリ説明文: AI が境界を間違えやすい類似カテゴリの分離
カテゴリ名で直接やる方式は、AI が賢くなるほど「気を利かせて」勝手に語を変えてくるので、分類問題は最初から「閉じた選択肢」に倒すのが鉄則 だと痛感しました。
パターン4: マップ失敗時の "" マーカーで無限ループ防止
ある日、Seekthea で「分類処理がやけに遅い」という体感バグに遭遇しました。原因を追ったら、AI がプロンプトの指示を無視してカテゴリ名そのもの("スポーツ" など)を返した時 に、categoryName(from:) が nil を返し、article.aiCategory が nil のまま保存されていた。次のループで「未分類記事を取得」すると同じ記事をまた取ってしまい、同じ記事を延々と再処理する無限ループ になっていたのです。
対策は マップ失敗時に aiCategory = "" を入れて「処理は試した」マーカー にすること。
if let name = categoryName(from: result.category) {
article.aiCategory = name
} else {
article.aiCategory = "" // 「処理は試した」マーカー(無限ループ防止)
}
クエリ側は aiCategory == nil を未処理、aiCategory == "" を「処理失敗」として扱います。
なお Seekthea ではこれと併せて、AI がラベルではなくカテゴリ名を返してきた時のフォールバックも入れています(その文字列が userCategories に含まれていればそれをそのまま採用)。
効果: 永久ループによる体感遅延・バッテリー消費がなくなった。
パターン5: ハルシネーション対策 — 説明文をプロンプトの別セクションに分離
カテゴリ判定の精度を上げようと「テクノロジー(AI、半導体、ソフトウェア開発...)」のように カテゴリ名の括弧内に説明 を入れたところ、Seekthea で凄い regression が発生しました。
「村上宗隆 ホームラン」というスポーツ記事の keywords が
["IT", "AI", "Apple"]になった
原因: カテゴリの説明として渡した「IT」「AI」を、AI が 記事から抽出した語と勘違いしてキーワード欄に書き込んだ。完全にハルシネーションです。
NG パターン(説明と選択肢を同居させる):
カテゴリ「テクノロジー」: AI、半導体、ソフトウェア開発を含む
記事: タイトル「村上宗隆 ホームラン」
→ keywords に「AI」「半導体」が混入する
OK パターン:
【カテゴリ】
A. テクノロジー
B. ゲーム
...
【参考: 各カテゴリの傾向】
- テクノロジー: AI、半導体、ソフトウェア開発を含む
- ゲーム: コンソール、PCゲーム、eスポーツを含む
【記事】
タイトル: Switch 2 の発売日が決定
【出力】
- keywords: 上の記事タイトル・内容に実際に登場する重要語句を日本語で最大5つ抽出(カテゴリ参考の語ではなく、記事本文から)
セクション見出しで区切り、出力指示で「カテゴリ参考の語ではなく」と明示するだけでハルシネーションが大幅に減ります。
効果: カテゴリ説明の語がキーワードに混入する誤抽出がなくなった。
パターン6: Refusal エラーの検知と分岐
ニュースアプリだと安全フィルタによる拒否を一定割合で食らいます。Seekthea で実際に Refusal を返された記事の例:
- 「米 ホルムズ海峡で足止めの船舶退避支援を表明 イランはけん制」(軍事・政治対立)
- 「3D プリンターに検閲ソフトを義務づける法案を「オープンソース文化を破壊する恐れがある」として EFF が批判」(検閲・3D プリンター銃の連想)
エラーは GenerationError.refusal で、デバッグ説明に May contain sensitive content が入ります。catch で判定:
do {
let response = try await session.respond(to: prompt, generating: CategoryResult.self)
// ...
} catch {
if case LanguageModelSession.GenerationError.refusal = error {
// 安全フィルタによる拒否
article.aiClassificationError = "refused"
} else {
// ラベルマップ失敗、session 失敗など
article.aiClassificationError = "other"
}
article.aiCategory = "" // どちらの場合も「処理は試した」マーカー
}
UI 側は aiClassificationError を見て表示を分けます:
| aiCategory | aiClassificationError | 表示 |
|---|---|---|
| nil | nil | (未処理、表示なし) |
| "テクノロジー" | nil | テクノロジー |
| "" | "refused" | 対象外 |
| "" | "other" | 分類失敗 |
「未分類」とまとめると 「まだ処理してない」と誤解 されるので、失敗の理由まで見せるのが UX 的に親切です。
効果: ユーザーが「処理失敗」と「拒否」を見分けられるようになり、「アプリのバグ?」という誤解が減った。
パターン7: 端末非対応のフォールバック
#if canImport(FoundationModels) でビルド時に分岐:
func analyze(article: Article) async {
#if canImport(FoundationModels)
do {
let session = LanguageModelSession()
let response = try await session.respond(to: summaryPrompt(article))
AISummaryCache.shared.set(response.content, for: article.id)
} catch {
applyFallback(article: article)
}
#else
applyFallback(article: article)
#endif
}
private func applyFallback(article: Article) {
// RSS の description や OGP description をそのまま要約代わりに使う
let fallbackSummary = article.leadText ?? article.ogDescription ?? ""
AISummaryCache.shared.set(fallbackSummary, for: article.id)
}
Apple Intelligence 非対応端末(iPhone 15 標準モデル・iPhone 14 以前・M1 未満の Mac など)でも AI なしで使えるコア体験 を保てます。
効果: Apple Intelligence 非対応デバイスのユーザーにもアプリの基本機能を提供できる(要約・分類は劣化版だが動く)。
おまけ: 進捗追跡と最新優先キャンセル
ユーザーが記事を次々開くと、古いほうの AI 処理を待ち続けるのは無駄。最新の記事の処理を優先してキャンセルする仕組み:
@Observable
@MainActor
final class AIProgressTracker {
static let shared = AIProgressTracker()
private var tasks: [UUID: Task<Void, Never>] = [:]
/// 進行中の他記事タスクを全てキャンセルし、新しいタスクを記録
func start(_ id: UUID, task: Task<Void, Never>) {
for (existingId, existingTask) in tasks where existingId != id {
existingTask.cancel()
}
tasks[id] = task
}
func finish(_ id: UUID) {
tasks.removeValue(forKey: id)
}
func isProcessing(_ id: UUID) -> Bool {
tasks[id] != nil
}
}
Task.isCancelled を AI 処理中に確認して途中で抜けるようにすれば、無駄な計算を防げます。
おまけ: 意味類似度は Natural Language framework に任せる
「ユーザーの興味トピックと記事のキーワードがどれくらい近いか」のような ベクトル類似度の計算 は、Apple Intelligence にやらせると重すぎます。
Seekthea では:
- Apple Intelligence で記事のキーワードを 日英ペアで抽出 (パターン2参照)
-
NLEmbeddingで英訳キーワードと興味トピックの cosine similarity を計算
import NaturalLanguage
let embedding = NLEmbedding.wordEmbedding(for: .english)
guard let emb = embedding else { return 0 }
// 記事のキーワード "AI" と興味トピック "machine learning" の意味距離
let distance = emb.distance(between: "AI", and: "machine learning")
// distance が小さいほど意味的に近い (NLDistanceType.cosine)
NLEmbedding は iOS 13 から提供されている事前学習済み埋め込み(英語)で、Apple Intelligence と違って iOS 13+ なら全デバイスで動きます。LLM に類似度判定を問い合わせるのと比べて:
| 項目 | Apple Intelligence | NLEmbedding |
|---|---|---|
| 速度 | 数百ms | マイクロ秒 |
| プロンプト工夫 | 必要 | 不要(ベクトル計算のみ) |
| 対応デバイス | iPhone 15 Pro 以降 | iOS 13 以降の全機種 |
「LLM は LLM が得意な仕事(生成・分類・抽出)」「ベクトル類似度は別の軽量 API」と タスクで API を使い分ける のがオンデバイス AI スタックの基本だと思っています。
まとめ
Apple Intelligence の Foundation Models は、型安全な構造化出力とオンデバイスならではの低レイテンシ・無料が魅力。一方で安全フィルタの保守性とモデルサイズの限界は受け入れる必要があります。
特に有効な使い方:
- 単純な分類・抽出
- 構造化された短い出力
- 短文翻訳
- プライバシー重視のテキスト処理
逆に向いてないケース:
- 複雑な推論や長文生成
- 政治・社会系のニュース処理(Refusal 多発)
- iOS 26 未満も含めた幅広いユーザー対応のみ(フォールバック設計が必須)
ソースコード全体は GitHub で公開しています: