OpenAI への月額課金が 3 万円を超えたとき、私はようやく「個人開発の収益モデルとして、これは持続できないかもしれない」と気づきました。アプリの DAU が増えるほど API 費用も比例して増えていく — 値上げできれば良いのですが、競合は同じ機能を無料で提供しています。利益を確保しようとするほど、ユーザー体験を削るしかなくなる構造でした。
そんなときに Apple が iOS 26 で公開した FoundationModels というフレームワークに目が留まりました。iPhone のなかで動くオンデバイス LLM を、ほんの数行の Swift コードから呼び出せる — しかも従量課金ではなく、ユーザーの端末の電力だけで動きます。サーバーレス LLM、と表現してもよいかもしれません。
ここではRork で生成した SwiftUI プロジェクトに FoundationModels を組み込む手順を、私が実際に 2 本のアプリに導入した経験から整理してお伝えします。最小のテキスト生成から、ストリーミング・構造化出力・ツールコールという本番で必要になる 3 つのパターンまで、動くコードと落とし穴の両方を載せています。
FoundationModels が変えた前提条件 — クラウド LLM との根本的な違い
FoundationModels は、Apple が iPhone・iPad・Mac に内蔵している約 30 億パラメータのオンデバイス LLM を、開発者が直接呼び出せるようにしたフレームワークです。これまでも Apple Intelligence は Writing Tools や Genmoji といった「ユーザー向け機能」を提供していましたが、FoundationModels は「開発者向けの素のモデル API」という位置づけになります。
クラウド LLM との実用上の違いは、コスト構造が根本から変わる点に集約されます。OpenAI や Gemini はトークン単価で課金されますが、FoundationModels はゼロです。ネットワーク通信も発生しないので、機内モードや地下鉄でも同じ品質で動きます。プライバシーポリシーの記述もぐっと軽くなります — ユーザーのデータが端末を離れないからです。
ただし、万能ではありません。私が両者を使い比べて感じたトレードオフは以下のとおりです。
- モデル能力: GPT-5 や Claude Opus と比べると、複雑な推論・長文生成・多言語対応で見劣りします。要約・分類・短い応答生成のように「狭く速く」使う領域が向いています
- 対応デバイス: Apple Intelligence 対応端末(iPhone 15 Pro 以降、A17 Pro / M1 以降の iPad、M シリーズ Mac)が必須です。古い端末ではフォールバックが必要になります
- コンテキスト長: 公開されている上限は約 4096 トークンと、クラウドモデルより短めです。RAG や長文要約は工夫が要ります
「クラウド LLM の代わり」ではなく、「クラウド LLM と組み合わせて費用とプライバシーの両方を最適化する道具」と捉えるのが、私の現時点の立ち位置です。Core ML で独自モデルを動かす設計と組み合わせて使いたい方は、Rork で Core ML カスタムモデルをオンデバイスで動かす完全ガイド も併読すると、選択肢の幅が広がります。
Rork のプロジェクトに FoundationModels を導入する準備
Rork で SwiftUI ネイティブプロジェクトを生成したあとに、FoundationModels を使えるようにする手順は驚くほど短いです。Xcode の Capability 設定や独自フレームワークの追加は不要で、import FoundationModels だけで動き始めます。私が初めて触ったとき、Info.plist に何かを追加し忘れた気がして 30 分ほど探し回ったのですが、結局なにも追加するものはありませんでした。
ただし、絶対に省略してはいけないのが「対応端末判定」です。古い iPhone でアプリが起動した瞬間にクラッシュする、という事故は容易に発生します。次のヘルパーをプロジェクトの起動直後に呼び出して、利用可否を判定するクセをつけてください。
// FoundationModelsAvailability.swift
// アプリ起動直後 or 機能起動時に呼び出して、利用可否で UI を切り替える
import FoundationModels
enum LLMAvailability {
case ready
case appleIntelligenceOff // 設定で無効化されている
case downloading // モデルファイルをダウンロード中
case unsupportedDevice // ハード非対応
case unknown(String)
}
func checkLLMAvailability() -> LLMAvailability {
let model = SystemLanguageModel.default
switch model.availability {
case .available:
return .ready
case .unavailable(let reason):
switch reason {
case .appleIntelligenceNotEnabled:
return .appleIntelligenceOff
case .modelNotReady:
return .downloading
case .deviceNotEligible:
return .unsupportedDevice
@unknown default:
return .unknown(String(describing: reason))
}
}
}
// 期待される動作:
// iPhone 15 Pro / iOS 26+ かつ Apple Intelligence ON → .ready
// iPhone 13 → .unsupportedDevice
// 設定 OFF → .appleIntelligenceOff@unknown default を必ず入れてください。Apple は今後 unavailable の理由を増やす可能性が高く、強い enum マッチをしているとビルドが通っても新しいケースが現実に起きたときに分岐から落ちます。「分岐の網羅性チェック」をコンパイラに任せず、現実のフォールバックも書いておくのが本番アプリの安全策です。
最小実装: 30 行で動くテキスト生成
実装の入り口として、ユーザー入力を受け取って 1 回だけ応答を返す最小コードを示します。私が最初に書いたバージョンはこれより 5 行短かったのですが、エラーハンドリングを抜くと本番で必ず痛い目を見るので、最初から入れておくほうが結局速いです。
// SimpleAskView.swift — 質問を投げて応答を表示する最小サンプル
import SwiftUI
import FoundationModels
struct SimpleAskView: View {
@State private var prompt = ""
@State private var answer = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
TextField("質問を入力", text: $prompt)
.textFieldStyle(.roundedBorder)
Button("送信") { Task { await ask() } }
.disabled(prompt.isEmpty || isLoading)
if let errorMessage { Text(errorMessage).foregroundStyle(.red) }
ScrollView { Text(answer).padding() }
}
.padding()
}
@MainActor
private func ask() async {
isLoading = true
defer { isLoading = false }
errorMessage = nil
do {
let session = LanguageModelSession(instructions: "丁寧な日本語で簡潔に答えてください。")
let response = try await session.respond(to: prompt)
answer = response.content
} catch {
errorMessage = "応答に失敗しました: \(error.localizedDescription)"
}
}
}
// 期待される出力例:
// プロンプト: "Rork とは何ですか?"
// 応答: "Rork は AI を活用してアプリを生成するツールです。アイデアを伝えるだけで…"ポイントは 2 つです。instructions でシステムプロンプトを与えると、応答の口調や言語を安定させられます。私の経験上、これを省略すると英語混じりの応答が混入する確率が上がります。もうひとつ、@MainActor を明示してから UI を書き換えることで、SwiftUI のスレッド警告を出さずに済みます。
ストリーミングで体感速度を 3 倍にする
respond(to:) は応答が完成するまで待ちます。1 〜 2 文の短い応答なら気にならないのですが、要約や説明文を生成する用途では、ユーザーは「考えている時間」を待たされます。私のアプリでは、ストリーミングに切り替えただけで離脱率が体感で 3 割減りました。技術が速くなったわけではなく、心理的な体感速度が変わるのです。
// StreamingAskView.swift — チャンク単位で応答を表示する
import SwiftUI
import FoundationModels
@MainActor
final class StreamingAskViewModel: ObservableObject {
@Published var partial = ""
@Published var isStreaming = false
@Published var errorMessage: String?
private let session = LanguageModelSession(
instructions: "丁寧な日本語で、3 段落以内で答えてください。"
)
func ask(prompt: String) async {
partial = ""
isStreaming = true
errorMessage = nil
defer { isStreaming = false }
do {
let stream = session.streamResponse(to: prompt)
for try await snapshot in stream {
// snapshot.content は累積された全文。差分ではなく全置換でよい
partial = snapshot.content
}
} catch {
errorMessage = "ストリーミングに失敗しました: \(error.localizedDescription)"
}
}
}
// 期待される動作:
// ユーザーが「Rork の長所を教えて」と送信 → 1 文字ずつテキストが追記されるように見える
// 平均で「初回の文字が表示されるまで」が 1.2 秒前後(A17 Pro 実測)for try await snapshot in stream のループでは、snapshot.content に「現時点までの累積文字列」が入っています。差分を組み立てるのは LLM 側の責務ではなく、表示側で全置換してしまうのが一番安定します。私は最初、差分連結ロジックを自分で書いていて、句読点が二重に出るバグに 1 時間ハマりました。ここは Apple 推奨の使い方に従うのが速いです。
ストリーミングを使うときは、@MainActor を ViewModel 全体に付けて、SwiftUI 側で @Published を素直に観測する設計をおすすめします。
@Generable で JSON パースを撲滅する
LLM の出力を構造化データとして扱いたいケースは、本番アプリでは頻発します。たとえば「ユーザーの自由記述から TODO を抽出する」「写真の説明文から色とカテゴリを取り出す」といった用途です。
クラウド LLM では「JSON で答えて」と書いて返ってきた文字列を JSONDecoder で読む、という手順が定番でした。FoundationModels は @Generable マクロを使うと、その面倒を Swift マクロが引き受けてくれます。出力フォーマットの曖昧さもプロンプトエンジニアリングではなく型で抑え込めるので、私は構造化出力が必要な場面では迷わずこれを使っています。
// TaskExtractor.swift — 自由記述から TODO の配列を生成する
import FoundationModels
@Generable
struct ExtractedTask {
@Guide(description: "短いタスク名 (40 字以内)")
let title: String
@Guide(description: "優先度: high / medium / low のいずれか")
let priority: String
@Guide(description: "推奨される所要時間(分)。不明なら nil")
let durationMinutes: Int?
}
@Generable
struct ExtractionResult {
let tasks: [ExtractedTask]
}
func extractTasks(from memo: String) async throws -> [ExtractedTask] {
let session = LanguageModelSession(
instructions: "次のメモからタスクを抽出してください。日本語で返してください。"
)
let result = try await session.respond(
to: memo,
generating: ExtractionResult.self
)
return result.content.tasks
}
// 期待される入力 / 出力:
// 入力: "明日までに発表資料を仕上げる。買い物も。歯医者は来週。"
// 出力:
// ExtractedTask(title: "発表資料を仕上げる", priority: "high", durationMinutes: 90)
// ExtractedTask(title: "買い物", priority: "medium", durationMinutes: nil)
// ExtractedTask(title: "歯医者の予約", priority: "low", durationMinutes: nil)@Guide で項目ごとの説明を与えると、モデルが値を推測する精度が体感でかなり上がります。「優先度は high / medium / low のいずれか」と書いておくだけで、urgent のような勝手な値が混じる確率がほぼゼロになりました。型と自然言語のヒントを併用するのが、@Generable を使いこなす肝です。
返ってきた配列をそのまま SwiftData や Realm に保存すれば、メモから自動で TODO リストが組み上がるアプリの中核がほぼ完成します。SwiftUI と組み合わせる UI 設計は Rork で SwiftUI ネイティブ iOS 開発を加速する完全ガイド も参考になります。
Tool Calling — モデルにアプリの機能を呼ばせる
「天気を教えて」「3 分後にタイマーをセットして」のような自然言語コマンドを、モデルに解釈させてアプリの実機能を呼び出させたい — そんなときに使うのが Tool Calling です。Tool プロトコルに準拠した型を LanguageModelSession に渡しておくと、モデルが必要に応じて自動で呼び出してくれます。
// TimerTool.swift — モデルから呼び出されるタイマーツール
import FoundationModels
struct TimerTool: Tool {
let name = "scheduleTimer"
let description = "ユーザーが指定した秒数後にローカル通知を発火します"
@Generable
struct Arguments {
@Guide(description: "通知までの秒数 (1 〜 86400)")
let seconds: Int
@Guide(description: "通知に表示するタイトル")
let title: String
}
@Generable
struct Output {
let scheduled: Bool
let firesAt: String // ISO 8601
}
func call(arguments: Arguments) async throws -> Output {
guard (1...86400).contains(arguments.seconds) else {
throw NSError(
domain: "TimerTool",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "秒数の範囲外です"]
)
}
let fireDate = Date().addingTimeInterval(TimeInterval(arguments.seconds))
await NotificationScheduler.shared.schedule(at: fireDate, title: arguments.title)
let iso = ISO8601DateFormatter().string(from: fireDate)
return Output(scheduled: true, firesAt: iso)
}
}
// 利用例
let session = LanguageModelSession(tools: [TimerTool()])
let response = try await session.respond(
to: "10 分後に『お風呂を見る』で通知して"
)
// 期待される動作:
// モデルは内部で TimerTool.call(.init(seconds: 600, title: "お風呂を見る")) を呼び、
// Output(scheduled: true, firesAt: "2026-04-26T20:25:00Z") を受け取り、
// 「10 分後に『お風呂を見る』で通知をセットしました」のような自然文を返すツールを設計するときに気をつけているのは、「LLM が壊れた引数を渡してきても、ツール側でガードする」という原則です。上記コードで 1...86400 の範囲チェックを入れているのはそのためです。モデルは大半の場合は妥当な値を渡してきますが、まれに seconds: 0 や巨大な数を入れてくるので、ツール側で例外を投げて失敗を明示してください。LLM はエラー応答を見て自分で修正してくれることが多く、これだけで信頼性が一段上がります。
失敗パターン 3 選とその回避策
私が本番に出してから踏んだ落とし穴を 3 つ共有します。あらかじめ知っておくと、デバッグの時間を節約できます。
1. 対応端末で動かないユーザーが思ったより多い
iPhone 15 Pro 未満や Apple Intelligence を OFF にしているユーザーは、想像より多いです。私のアプリの実測では約 4 割のセッションで .unavailable が返ってきました。先ほどの checkLLMAvailability() を起動時に必ず呼び、未対応の場合は機能 UI ごと隠す or クラウド LLM にフォールバックする実装が必須です。「メニューに項目だけある状態」は審査でも指摘されがちです。
2. プロンプトが長すぎてコンテキスト溢れする
公式に明記されている文脈長は約 4096 トークン。日本語は 1 文字 = 1 〜 2 トークン換算なので、4000 字を超えるプロンプトは溢れます。LanguageModelSession.Error.contextWindowExceeded を catch して、ユーザーに「内容が長すぎます」と伝えるか、要約を入れて再投入するロジックを必ず用意してください。私はサジェストとして「最初の 2000 字だけで再試行する」フォールバックを入れています。
3. Safety フィルタで予期せず拒否される
医療・法律・暴力的な記述などはモデルが回答を拒否します。これ自体は妥当なのですが、「ユーザーの作品プロットの相談」のような正当な用途まで巻き込まれることがあります。response.content が空文字または「お答えできません」系の文言だった場合に備え、UI 側で「別の表現で試してみてください」と案内するか、クラウド LLM への切り替えを許可するスイッチを用意しておくのが現実的です。
クラウド LLM との併用設計 — ルーター 1 つで実装をシンプルに保つ
実用上いちばん良いと感じているパターンは「FoundationModels だけで戦う」でも「クラウド LLM だけ」でもなく、リクエストの性質で振り分ける小さなルーターを置くことです。20 行ほどの Swift で、ランニングコストとユーザー体験のバランスをコントロールできるようになります。
判断基準はシンプルです。プロンプトが短く、タスクが定型で(抽出・分類・要約・書き換え)、端末が FoundationModels に対応していれば端末側へ。それ以外はクラウドへ。実務での目安として、入力 1500 文字以下・1 ターン・既知の用途であれば端末側ルートに乗ります。長文生成・深い推論・最新情報を要するクエリはクラウドが安全です。
// LLMRouter.swift — 入力の性質で端末側 / クラウドを振り分ける
import FoundationModels
enum LLMRoute { case onDevice, cloud }
struct RouterRequest {
let kind: Kind
let inputCharacters: Int
let needsFreshness: Bool
enum Kind { case extract, classify, summarise, rewrite, freeform }
}
func chooseRoute(for request: RouterRequest) -> LLMRoute {
if request.needsFreshness { return .cloud }
if request.kind == .freeform { return .cloud }
if request.inputCharacters > 1500 { return .cloud }
if checkLLMAvailability() != .ready { return .cloud }
return .onDevice
}
// 期待される動作:
// 500 字のメモから TODO を抽出する → .onDevice
// 1000 字の記事を書く → .cloud
// 「昨日 Apple は何を発表した?」 → .cloud(needsFreshness)
// iPhone 13 で実行 → .cloud(availability で弾かれる)ルーターを置く実利は運用面に出ます。UI を変えずに「この機能は端末側へ」「あの機能はクラウドへ」とサーバー設定で動かせるので、Apple が新モデルを出したら境界を引き上げ、クラウド料金が膨らんだら境界を引き下げる、という調整が可能になります。ルートをログに残しておけば、どの機能がどれだけ端末側で完結しているかを後からダッシュボードで確認できます。
注意点が 1 つあります。クラウド用と端末用でプロンプト戦略を二重に持たないこと。同じテンプレートを両方に投げ、振り先だけルーターが選ぶのが、保守の手間を最小化する最短ルートです。プロンプトを 2 系統に分けると、品質改善のたびに 2 回直す羽目になります。
本番投入前の検証チェックリスト
私が App Store に出す前に毎回確認している項目を、そのままチェックリスト化して残します。
- 起動時に
SystemLanguageModel.default.availabilityを確認し、未対応端末で機能 UI を非表示にしているか - ストリーミング中にユーザーが画面遷移したときに、
Taskがキャンセルされて応答破棄されているか(バックグラウンドで走り続けるとバッテリーを浪費します) - 構造化出力 (
@Generable) のフィールドに@Guideを入れて精度を担保しているか - Tool Calling の引数バリデーションをツール側で実装しているか
errorMessageを UI に表示する経路が、最低 1 か所はあるか(無言の失敗が一番信頼を失います)- Apple Intelligence OFF 状態のテストを TestFlight で 1 回走らせたか
- App Store Review に向けた説明文に「FoundationModels を利用しており、ユーザーデータは端末外に送信しません」を含めたか(プライバシー観点で評価されます)
- A/B テストを設計し、FoundationModels バージョンとクラウド LLM バージョンの離脱率・応答品質を実測しているか
iOS 26 のビジュアル刷新と組み合わせる方は Rork で iOS 26 Liquid Glass を実用するガイド も併せて読むと、見た目とインテリジェンスの両面で「iOS 26 らしいアプリ」に仕上げやすくなります。
クラウド LLM とオンデバイス LLM の使い分けを、自分のアプリのユースケースに落とし込む練習に向いています。
結び — まずは TestFlight で 1 機能だけ置き換える
新しい API は、全画面を書き換えてから出すと、ロールバックも検証も大変になります。私の推奨は、まず既存アプリのなかで「短い応答を返す 1 機能」だけを FoundationModels に切り替えて、TestFlight で 1 週間ほど実測することです。離脱率・クラッシュ率・ユーザーフィードバックの 3 つを見れば、自分のアプリにこの API がフィットするかどうかは十分判断できます。
API 課金から解放されるのか、それとも品質低下でユーザーが去るのか — 答えは机の上ではなく、TestFlight にあります。今夜のうちに 1 つだけ機能を選び、明日のビルドで切り替えてみてください。