Foundation Models は 3 つの条件を同時に要求します:iOS 26 / macOS 26、設定でオンになっている Apple Intelligence、そして対応リストに載っているチップ。私が現在開発中の、AI コンパニオン Luna を備えたジャーナリングアプリ Lanternly のデプロイターゲットは iOS 18 / macOS 15 です。このギャップは埋めようがありません:アプリは、Foundation Models が物理的に存在しないデバイスにもインストールでき、正常に動作しなければならないのです。
したがって AI 機能は、単一の if レベルの「ある・ない」の分岐にはなり得ず、デバイス群の一部で丁寧にオフになるアーキテクチャの層でなければなりません——ビルドを壊すことも、ユーザーを混乱させることもなく。
Lanternly のコードでは、モデルに触れるすべてのサービスで文字どおり同じ三層ゲーティングパターンが繰り返されています:Services/LunaChatService.swift、Services/DailyQuestionService.swift、Services/MonthObservationService.swift、Services/MemoryExtractor.swift。以下では、それがどう構成され、なぜそのように構成されているのかを説明します。
なぜサービス内のゲートはバイナリなのか#
まず意識的に下すべきだった最初の決定:機能サービス自身は、モデルがなぜ利用できないのかを判別しません。その必要がないからです——必要なのは 1 ビットだけ:モデルを呼んでよいか、否か。
static var isAvailable: Bool {
#if canImport(FoundationModels)
if #available(iOS 26, macOS 26, *) {
if case .available = SystemLanguageModel.default.availability { return true }
}
#endif
return false
}まったく同じパターン——if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability——が、LunaChatService.reply(to:)、DailyQuestionService.question(for:)、その他のサービスのすべてのモデル呼び出しの前に置かれています。
デバイスが Apple Intelligence に非対応なのか、設定でオフになっているのか、モデルがまだバックグラウンドでダウンロード中なのか——サービスの反応は常に同じです:モデルを呼ばず、フォールバックに制御を渡す。ビジネスロジックを 3 つの理由への分岐で膨らませるのは余計な結合です。「今日の質問」を生成するサービスが deviceNotEligible について知る必要はありません。
バナーは理由を知っている#
一方、ユーザーにとっては理由の違いが重要です——ここではバイナリゲートではもう足りません。それを担うのが独立したモジュール LunaDiagnostics で、アプリ全体で唯一、.unavailable(reason:) に対する網羅的な switch を行います:
var bannerMessage: String? {
if isSimulator { return nil }
switch SystemLanguageModel.default.availability {
case .available:
return nil
case .unavailable(let reason):
switch reason {
case .deviceNotEligible:
// 「このデバイスではローカル AI 機能が制限されています。Luna はそばにいますが、シンプルな定型文で返答します。」
return "Функции локального ИИ ограничены на этом устройстве. Луна рядом, но отвечает простыми заготовками."
case .appleIntelligenceNotEnabled:
// 「Luna が定型文ではなく生きた言葉で返答できるように、デバイスの設定で Apple Intelligence をオンにしてください。」
return "Чтобы Луна отвечала живыми словами, а не заготовками, включи Apple Intelligence в Настройках устройства."
case .modelNotReady:
// 「ローカルモデルはまだバックグラウンドで準備中です。今のところ Luna は定型文で返答します——少し後に戻ってきてください。」
return "Локальная модель ещё готовится в фоне. Пока Луна отвечает заготовками — вернись чуть позже."
@unknown default:
// 「Luna のローカル機能は現在利用できません——定型文で返答します。」
return "Локальные функции Луны сейчас недоступны — она отвечает заготовками."
}
}
}どの状態にも、ありきたりな「問題が発生しました」ではない、正直な独自の文言があります。非対応デバイス、オフになっているトグル、まだ準備できていないモデル——これらは別々の物語であり、ユーザーには自分がどれに当たるのかを知る権利があります。
バナーが「設定を開く」ボタンを提示するのは、たった 1 つのケース——appleIntelligenceNotEnabled だけです。ユーザーが今すぐ自分で直せる唯一の理由だからです:
var bannerOffersSettings: Bool {
if case .unavailable(.appleIntelligenceNotEnabled) = SystemLanguageModel.default.availability {
return true
}
return false
}シミュレータではバナーはまったく表示されません。そこでは Foundation Models は常に、しかも別の理由で利用不可であり、そのことを実機ユーザーに警告する意味はないからです。バナーは画面の上部で状況を説明します——しかしその瞬間も、Luna 自身はチャットで何かを返答しなければなりません。沈黙という選択肢はないのです。
定型文もまたコンテンツ#
Lanternly のフォールバックは「AI は利用できません」というプレースホルダー文字列ではなく、れっきとしたコンテンツです。モデルが動かなかったときのための、Luna の 5 つの返答バンク:
static var bank: [String] {
[
String(localized: "Спасибо за доверие. Я рядом."), // 「信頼してくれてありがとう。私はそばにいるよ。」
String(localized: "Это звучит важно. Хочешь побыть с этой мыслью ещё немного?"), // 「それは大切なことみたい。その思いにもう少し向き合ってみる?」
String(localized: "Понимаю тебя. Что чувствуешь, когда говоришь это вслух?"), // 「わかるよ。声に出して言ってみて、どんな気持ち?」
String(localized: "Я слушаю. Расскажи, если хочется, ещё."), // 「聞いているよ。よければ、もっと話して。」
String(localized: "Звучит непросто. Хорошо, что ты говоришь это вслух."), // 「大変そうだね。声に出して言えたのはいいことだよ。」
]
}各返答は String(localized:) を経由し、Localizable.xcstrings(ru + en)で翻訳されています。Tests/LocalizationBankTests.swift には、バンク全体とクライシス返答を走査し、すべての文字列にキリル文字の取り残しがない空でない EN 訳が存在することを検証するユニットテストがあります。
バンクの文言は意図的に性別語尾を含みません——モデルの生きたエンジンならプロフィールに応じて「ты записала」か「ты записал」を選べるところで、静的なバンクは中立にとどまらざるを得ないのです。
「今日の質問」も似たロジックです:DailyQuestionService のバンクには 18 問(3 パス × 6)。ウィジェットとメニューバーでは質問は一日中同じでなければならないため、選択は決定論的——年内通算日で決まります:
static func dailyBankQuestion(for path: LifePath, on date: Date = .now,
calendar: Calendar = .current) -> String {
let bank = bank(for: path)
let day = calendar.ordinality(of: .day, in: .year, for: date) ?? 1
return bank[(day - 1) % bank.count]
}一方アプリ内では逆に、UserDefaults による重複防止付きのランダム選択です。質問が何日も連続で繰り返されないようにするためです。同じバンクに対する 2 つの異なる要件——ウィジェットでの安定性とアプリ内での多様性——は、フラグ付きの 1 つの関数ではなく、同じデータの上の 2 つの別関数で満たされています。
常に返答する#
メインのエントリポイント LunaChatService.reply(to:) は、直上のコメントで「常に返答を返す」と明示的にドキュメント化されています——これは宣言ではなく、呼び出しチェーン全体が支えるインバリアントです。優先順位はこうです:まずクライシス応答——決定論的で、モデルを経由せず、意図的にいかなるログにも(デバッグログにさえ)残りません。次に Foundation Models 経由のパス——直接生成か、ユーザーの言語がモデルの supportedLanguages に含まれない場合の pivot 翻訳経由か(このケースは別記事、Luna がロシア語で返答する仕組みのテーマです)。そして両方のパスが失敗したときだけ——定型文バンクです。
モデル呼び出しそのもののレベルで重要なディテール:runFM はエラーを黙って飲み込みません。
do {
let response = try await session.respond(to: prompt)
let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
LunaDiagnostics.shared.lastGenerationError = nil
return text
}
LunaDiagnostics.shared.lastGenerationError = "пустой ответ модели" // 「モデルの空応答」
} catch {
// フォールバックは変えない——本当の原因を記録するだけ(以前は try? が飲み込んでいた)。
LunaDiagnostics.shared.lastGenerationError = String(describing: error)
LunaDiagnostics.shared.logReport()
}
return nilフォールバックはまったく同じままですが、そこに至った理由はもう失われません——LunaDiagnostics.shared.lastGenerationError に残り、モデルの状態、サポート言語、最後の返答のソース(onDevice / pivot / bank / crisis)とともに専用の「AI 診断」画面に表示されます。「なぜ Luna が突然定型文で返答するのかわからない」と「何を直せばいいかわかる」の差は、try? で包まれていないコードちょうど 1 行分の差なのです。
三層パターン#
技術的には、このゲーティング全体は独立しつつも協調する 3 つの層の上に成り立っています。第 1 層はコンパイル時、import の周り:
#if canImport(FoundationModels)
import FoundationModels
#endifこれが必要なのは、プロジェクトをビルドする SDK にフレームワーク自体が存在しない可能性があるからです。第 2 層はランタイム、すべての呼び出し地点で:
if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability {
// モデルは今この瞬間、確実に利用可能
}これは 2 つの異なる条件を同時に捕捉します:個々のデバイスの OS バージョンと、SystemLanguageModel の現在の状態です。第 3 層は、フレームワークの型に実際に触れるプライベートヘルパー自身の上にあります:
@available(iOS 26, macOS 26, *)
private static func runFM(instructions: String, prompt: String) async -> String? { … }ここでの @available 属性は飾りではありません——コンパイラは、スタックの上流で iOS 26/macOS 26 のゲートを通過していないコードからこのヘルパーを呼ぶことを物理的に許しません。「呼び出しを #available で包み忘れた」というミスは、ランタイムのバグからコンパイルエラーに変わります。3 つの層は、コードが存在しない API を「踏み抜き」得る 3 つの異なる瞬間をカバーします:フレームワークのない SDK でのビルド、ユーザーのデバイス上の古い OS、そして間違った場所に呼び出しを書き足してしまう、不注意な未来の自分自身です。
似たようなゲートを作るなら#
もう一度作るとしても必ず繰り返したい一番の判断は、利用不可の理由を機能サービスに持ち込まないことです。サービス側には 1 ビットあれば十分で、「なぜ」の解析は、バナーのすぐそばにある 1 つの診断モジュールに任せます。一方でユーザーに必要なのは具体性です。オフになっているトグル、対象外のデバイス、まだダウンロード中のモデルは、それぞれ別の言葉で伝えるに値しますし、設定ボタンがふさわしいのは、ユーザーが自分で何かを直せる場面だけです。
フォールバックは、仮置きのスタブではなく一人前のコンテンツとして設計する価値があります。ローカライズし、テストでカバーし、文言がニュートラルであるよう気を配る。バンクからの選択にも唯一の正解はありません。ウィジェットには安定性が必要だから日付による決定論的な選択を、チャットには生き生きとした感じが必要だから重複防止付きのランダム性を。これは同じデータに対する 2 つの異なる関数であって、フラグ付きの 1 つの関数ではありません。
最後に 2 つの原則を。「常に返答する」と約束するエントリポイントに、try? でエラーを飲み込む権利はありません——フォールバック自体は同じでも、原因は必ず診断に残します。そしてゲーティングの三層——#if canImport、呼び出し地点の #available、ヘルパー上の @available——は互いの重複ではなく、デバイスに存在しない API へコードが届き得る 3 つの異なる瞬間それぞれに対する保険なのです。



