Foundation Models 同时要求三个条件:iOS 26 / macOS 26、在设置中开启的 Apple Intelligence,以及兼容列表里的芯片。我目前正在开发的 Lanternly——一款带 AI 伴侣 Luna 的日记应用——部署目标是 iOS 18 / macOS 15。这道鸿沟无法弥合:应用必须能在物理上不存在 Foundation Models 的设备上安装并正常运行。
这意味着 AI 功能不能是单个 if 层面的"有 / 没有"分叉,它必须是一个架构层——在一部分设备上干净利落地关闭自己,而不是搞坏构建,也不让用户困惑。
在 Lanternly 的代码里,同一个三层门控模式几乎在每一个触碰模型的服务中重复出现:Services/LunaChatService.swift、Services/DailyQuestionService.swift、Services/MonthObservationService.swift、Services/MemoryExtractor.swift。下面讲讲它是怎么构建的,以及为什么恰恰要这样构建。
为什么服务里的门控是二元的#
第一个值得有意识做出的决定:功能服务本身不去分辨模型为什么不可用。它们不需要——它们只需要一个比特:能不能调用模型。
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、在设置中被关闭,还是模型还在后台下载——服务的反应都是同一个:不调用模型,把控制权交给兜底方案。为三种原因写分支去膨胀业务逻辑,只会带来多余的耦合:生成"每日一问"的服务,不该知道 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 "Локальные функции Луны сейчас недоступны — она отвечает заготовками."
}
}
}每种状态都有自己诚实的措辞,而不是笼统的"出了点问题":不符合条件的设备、被关掉的开关、还没准备好的模型——这是三个不同的故事,用户有权知道自己遇到的是哪一个。
横幅只在一种情况下提供"打开设置"按钮——appleIntelligenceNotEnabled,因为这是用户此刻唯一能自己修复的原因:
var bannerOffersSettings: Bool {
if case .unavailable(.appleIntelligenceNotEnabled) = SystemLanguageModel.default.availability {
return true
}
return false
}在模拟器里横幅根本不显示:那里 Foundation Models 永远不可用,而且原因另有其事,没有必要为此去警告真机上的用户。横幅在屏幕顶部把情况解释清楚——但此刻 Luna 自己仍然必须在聊天里回应点什么,沉默不是选项。
预设回复也是内容#
Lanternly 的兜底方案不是一句"AI 不可用"的占位字符串,而是货真价实的内容。一个包含五条 Luna 回复的库,用于模型没能给出结果的情况:
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 做防重复的随机选取,避免问题一天接一天地重复。对同一个题库的两种不同要求——小组件要稳定、应用内要多样——由建立在同一份数据之上的两个不同函数来满足,而不是一个带开关参数的函数。
永远有回应#
主入口 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? 包住的代码。
三层模式#
从技术上讲,整套门控立在三个相互独立却彼此协调的层之上。第一层在编译期,围绕 import:
#if canImport(FoundationModels)
import FoundationModels
#endif它之所以必要,是因为构建项目所用的 SDK 里可能根本没有这个框架。第二层在运行时,位于每一个调用点:
if #available(iOS 26, macOS 26, *), case .available = SystemLanguageModel.default.availability {
// 此刻模型确定可用
}它同时捕获两个不同的条件:具体设备上的系统版本,以及 SystemLanguageModel 的当前状态。第三层落在真正触碰框架类型的私有辅助函数本身上:
@available(iOS 26, macOS 26, *)
private static func runFM(instructions: String, prompt: String) async -> String? { … }这里的 @available 属性不是装饰——编译器在物理上就不允许从调用栈上游没有通过 iOS 26/macOS 26 门控的代码里调用这样的辅助函数。"忘了把调用包进 #available"这个失误,从运行时 bug 变成了编译错误。三个层覆盖了代码可能"踩到"缺失 API 的三个不同时刻:用没有该框架的 SDK 构建、用户设备上的旧系统,以及粗心的未来的自己在错误的位置补上一次调用。
如果你要构建类似的门控#
如果重来一次,我最想重复的决定是:不把不可用的原因带进功能服务——对它们来说一个比特就够了,"为什么"的分析就让它住在唯一的诊断模块里,紧挨着横幅提示。而用户需要的是具体信息:被关掉的开关、不符合条件的设备、还在下载中的模型,各自值得不同的措辞;设置按钮也只应出现在用户自己能修复问题的地方。
兜底方案值得当作完整的内容来设计,而不是占位符——本地化、用测试覆盖、留意措辞的中性。从回复库里挑选这件事同样没有唯一正确的答案:小组件需要稳定,所以按日期做确定性选取;聊天需要鲜活,所以用带防重复的随机。这是同一份数据配上两个不同的函数——而不是一个函数加一个标志位。
最后是两条原则。一个承诺"永远有回应"的入口点,没有权利用 try? 吞掉错误——兜底方案保持不变,但原因必须沉淀到诊断里。而门控的三层——#if canImport、调用点的 #available、辅助函数上的 @available——不是互相重复,而是为代码可能触及设备上不存在的 API 的三个不同时刻各上一道保险。



