让Apple Intelligence学会说俄语#
Foundation Models 是从 iOS 26 开始让开发者直接调用 Apple Intelligence 背后同一个端侧模型的框架。只需要一个 Swift 类型 LanguageModelSession,应用就能在不往返云端、不需要 API 密钥、用户数据也绝不离开设备的情况下生成文本。听起来几乎可以解决任何文本类需求。
但有一个例外:这个模型不会说俄语。不是"说得不好"——而是在 supportedLanguages 这个层面上,官方根本不支持。
我在开发Lanternly时遇到了这个问题——这是一款带有名叫"Luna"的 AI 伙伴的日记应用,目前仍在积极开发中。这款应用的前提是 AI 完全在设备端运行:无需登录、无需服务器、日记内容永远不会离开手机。在这样的架构里,俄语不是一个可有可无的语言选项,而是决定产品对俄语用户来说能不能用的关键。下面结合真实代码,讲讲我在架构层面是怎么解决这个问题的。
问题所在:Foundation Models 没有俄语#
支撑 Apple Intelligence 的模型是按多语言方式训练的——Apple 自己的研究论文对此说得很清楚。但一个语言出现在训练数据里,和这个语言在面向消费者的框架中被正式宣布为"支持",是两回事。截至 2026 年年中,Apple Intelligence 官方公布的语言列表覆盖英语、法语、德语、意大利语、葡萄牙语(巴西)、西班牙语、日语、韩语和简体中文,再加上荷兰语、瑞典语、土耳其语等少数几个语言区域设置,总共大约二十种。俄语不在其中。
这与生成质量无关——问题在于 SystemLanguageModel 官方并没有把俄语声明为受支持语言,所以任何假定可以直接用俄语生成的调用,要么悄悄降级,要么根本无法保证能正常工作。这在开发者论坛上是个反复出现的话题:总有人在 Apple Discussions 上询问俄语支持是否有计划,得到的回答也和任何社区论坛上一样——没有官方信息,只有各种猜测(包括一种说法认为,Apple 退出俄罗斯零售市场可能降低了这门语言的优先级)。无论哪种回答,都不改变一个实际需求:应用仍然需要用俄语回应用户。
对工程实现来说,有一个细节很关键:这份语言列表不是常量。Apple 明确要求开发者在运行时检查,而不是写死在代码里,因为受支持语言的集合可能随版本更新而变化。这直接影响了 Lanternly 的架构设计——代码的写法保证了,一旦 Apple 哪天加入俄语支持,应用会自动切换到直接生成路径,不需要改一行代码。
不要猜测:在运行时检查 supportedLanguages#
第一个架构决策是:永远不要在应用里写死一份"受支持"语言列表,而是直接向模型查询。
@available(iOS 26, macOS 26, *)
private static func fmSupports(_ lang: Locale.Language) -> Bool {
guard let code = lang.languageCode else { return false }
return SystemLanguageModel.default.supportedLanguages
.contains { $0.languageCode == code }
}这是个很小的函数,但整个架构的分支逻辑都压在它身上——它决定一条回复走短路径(直接生成)还是长路径(往返翻译)。紧挨着它的是第二个实际问题:从一段短文本判断用户使用的语言。NaturalLanguage 框架里的 NLLanguageRecognizer 在处理短句时并不可靠——把西里尔字母误判为保加利亚语、乌克兰语或马其顿语的情况并不少见。解决办法是把候选语言范围收窄到真正能处理的语言,并设置先验概率:
private static func languageOf(_ text: String) -> Locale.Language {
let recognizer = NLLanguageRecognizer()
recognizer.languageConstraints = candidateLanguages() // 模型支持的语言 + ru + 设备语言
recognizer.languageHints = [.russian: 0.5, .english: 0.35]
recognizer.processString(text)
if let lang = recognizer.dominantLanguage {
return Locale.Language(identifier: lang.rawValue)
}
return Locale.current.language
}没有这个限制,一句俄语的简短问候完全有可能被判定为保加利亚语——后续所有逻辑都会走进错误的分支。
路径 A:语言被直接支持时#
如果 fmSupports 返回 true,流程就很简单:创建一个带有回复语言指令的 LanguageModelSession,调用一次 respond(to:)。对于英语、西班牙语、日语等所有官方支持的语言,Lanternly 用的正是这条路径——不需要翻译,延迟最低。
俄语这里值得单独一提:尽管官方并不支持,Lanternly 的代码库里仍然完整保留了一份逐字的俄语系统提示词,作为"事实来源"。这并不是因为它今天在哪里被直接调用,而是因为一旦 Apple 把俄语加入 supportedLanguages,应用就不需要重写伙伴的语气和人格逻辑——它已经准备好了,只等条件分支触发的那一天。
路径 B:pivot 翻译——在设备端绕开限制#
当语言不被直接支持时,第二条路径启动:生成仍然发生在设备端,但用的是英语而不是用户的语言,输入和输出各经过一次翻译。
@available(iOS 26, macOS 26, *)
private static func pivotReply(userText: String, userLang: Locale.Language,
history: [ChatMessage]) async -> String? {
let english = Locale.Language(identifier: "en")
// 1. 把输入翻译成英语——需要已下载的语言包。
guard let userEN = await Translator.translate(userText, from: userLang, to: english) else {
return nil // 语言包未下载——不伪造结果,如实返回 nil
}
// 2. 用英语生成——在模型官方支持的范围内。
guard let replyEN = await runFM(instructions: systemPromptEN, prompt: userEN) else {
return nil
}
// 3. 把回复翻译回用户的语言。
return await Translator.translate(replyEN, from: english, to: userLang)
}三个阶段,三次端侧调用而不是一次——比直接生成明显更慢。但对于列表之外的语言,这是唯一能给出真实生成的回复、而不是套话,同时每一步都不离开设备的方法。
Lanternly 生产环境中的代码比上面这个简化版本更有韧性一些:如果消息本身翻译成功,但伴随的上下文(聊天历史)翻译失败,应用不会把整个回复都丢进兜底话术库,而是不带历史记录、直接用模型生成回复。翻译的部分失败不会让整个对话中断——这是在"上下文完整性"和"回复的鲜活度"之间刻意做出的权衡。
Translation 框架:端侧运行,但并非无条件可用#
有一个容易被忽略的细节:Translation 同样是完全端侧的框架(早在 2024 年 WWDC、iOS 18 时就已推出),但它并不会把所有语言对都内置好。某一对语言的翻译能否工作,取决于对应的语言包是否已经下载到设备上——这是一个系统级、所有应用共享的资源。如果用户从未打开过系统翻译功能,也没有下载过 ru↔en 语言包,第一次调用 TranslationSession 要么会触发下载(在 SwiftUI 中,.translationTask 会让系统主动提示下载),要么在应用无法发起请求的情况下直接失败。
由此得出一条实践规则:pivot 翻译管线永远不能默默假设翻译会成功。如果语言包没有下载,翻译就会失败——这是正常且可预期的结果,而不是罕见的边缘情况。
用诚实的诊断代替沉默的兜底#
这类架构中最昂贵的错误,不在于有兜底方案本身,而在于兜底方案的不透明。Lanternly 早期版本的类似代码曾用 try? 包裹模型调用——写起来方便,但这意味着一旦出错(模型拒绝生成、翻译不可用、语言包缺失),真正的原因会悄无声息地消失,只剩下一个信号:"这条回复来自兜底话术库"。在用户的真实设备上,这种情况根本无法诊断。
现在的版本会在每一步都记录真实原因:
do {
let response = try await session.respond(to: prompt)
return response.content
} catch {
// 不用 try?——记录真正的原因,而不是悄悄吞掉它。
Diagnostics.shared.lastGenerationError = String(describing: error)
return nil
}此外还有一个专门的诊断层,随时可以回答"为什么 Luna 现在用套话回复,而不是自己生成一条"这个问题:模型是否可用、语言是否受支持、当前是模拟器还是真机、最近一次生成错误是什么。这不是给生产环境里的终端用户看的——它带来的区别是:在开发和维护阶段,"不清楚为什么不工作"和"清楚该修什么"之间的差异。
这里有一个隐私上的重要细节:诊断层从不记录消息内容本身——只记录语言代码、错误类型、回复来源这类技术元数据。而且对于被危机检测协议拦截的消息,诊断功能会被显式关闭——这类交互在任何情况下都不应该留下痕迹,哪怕是设备上的调试日志也不例外。
把三条路径放在一起对比,大致是这样:
| 路径 A(直接) | 路径 B(pivot) | 兜底话术库 | |
|---|---|---|---|
| 触发条件 | 语言在 supportedLanguages 中 | 语言不受支持,但翻译可用 | 模型不可用、语言包缺失、任一步骤失败 |
| 端侧调用次数 | 1 次(生成) | 最多 3 次(翻译→生成→翻译) | 0 次 |
| 前提条件 | 已开启 Apple Intelligence,iOS 26+ | 加上已下载的 ru↔en 翻译语言包 | 无 |
| 延迟 | 最低 | 明显更高(生成之外还有两次翻译) | 即时 |
| 隐私 | 100% 端侧 | 100% 端侧 | 端侧(静态文本) |
| 失败时 | 转入兜底话术库 | 转入兜底话术库 | 不会失败——总能返回一条回复 |
这个模式能带来什么启发#
这套方法并不局限于带 AI 伙伴的日记应用——它是一个通用模式,适用于任何服务对象在模型官方支持语言之外的端侧 LLM 功能:用收窄的候选集合加先验概率来检测语言、在运行时检查支持情况而不是写死列表、把系统本地语言包当作临时桥梁使用,以及最重要的一点——绝不在兜底方案会掩盖真实原因的地方,悄悄吞掉错误。
俄语迟早很可能会出现在 supportedLanguages 里——Apple 一直在持续扩展这份列表,而根据 Apple 自己的研究,底层模型的训练语言中已经包含俄语。在那之前,pivot 翻译不是权宜之计,而是一套让应用对用户保持诚实的可用架构:要么在端侧给出有意义的回复,要么通过诊断清楚地告诉你,今天为什么没能做到。



