Как научить Apple Intelligence говорить по-русски#
Foundation Models — фреймворк, который в iOS 26 открыл разработчикам прямой доступ к той же on-device модели, что стоит за Apple Intelligence. Один Swift-класс — LanguageModelSession — и в приложении появляется генерация текста без облака, без API-ключей и без данных пользователя, покидающих устройство. Звучит как решение любой задачи с текстом.
Кроме одной: модель не говорит по-русски. Не «говорит плохо» — не говорит вообще, официально, на уровне supportedLanguages.
Я столкнулся с этим при разработке Lanternly — дневника с AI-компаньоном по имени Луна, который сейчас в активной разработке. Идея приложения строится на том, что ИИ работает полностью на устройстве: без логина, без сервера, без отправки записей куда бы то ни было. Русский язык в такой архитектуре — не второстепенная деталь, а вопрос «работает ли продукт вообще для русскоязычного пользователя». Ниже — как я решил это на уровне архитектуры, с реальным кодом.
Проблема: у Foundation Models нет русского#
Модель, лежащая в основе Apple Intelligence, обучена как мультиязычная — это видно по исследовательским публикациям Apple. Но то, что модель «видела» язык на этапе обучения, и то, что Apple официально выкатила его как поддерживаемый в потребительском фреймворке — разные вещи. Публично заявленный список поддерживаемых языков Apple Intelligence на середину 2026 года включает английский, французский, немецкий, итальянский, португальский (Бразилия), испанский, японский, корейский и упрощённый китайский, плюс ещё несколько локалей уровня датского, шведского, турецкого — итого около двух десятков. Русского там нет.
Дело не в качестве генерации — дело в том, что SystemLanguageModel официально не объявляет русский поддерживаемым языком, а значит, любой вызов с расчётом на прямую русскоязычную генерацию либо тихо деградирует в качестве, либо вовсе не гарантирован. На форумах разработчиков это давняя тема: пользователи периодически спрашивают в Apple Discussions, планируется ли русский, и получают в ответ то, что получают на любом community-форуме — отсутствие официальной информации и предположения (в том числе о том, что уход 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 }
}Это простая функция, но она держит на себе всю развилку архитектуры: от неё зависит, пойдёт ли реплика по короткому пути (прямая генерация) или по длинному (перевод туда и обратно). Здесь же встаёт вторая практическая проблема — определение языка пользователя по короткому тексту. NLLanguageRecognizer из NaturalLanguage на коротких фразах ненадёжен: кириллицу он не так уж редко принимает за болгарский, украинский или македонский. Решение — сузить кандидатов до реально обрабатываемых языков и задать приоры:
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 хранится полноценный русскоязычный системный промпт как источник истины (verbatim). Не потому что он сейчас где-то вызывается напрямую — а потому что в день, когда Apple добавит русский в supportedLanguages, приложению не придётся переписывать логику тона и характера компаньона: она уже готова и ждёт своей ветки условия.
Путь B: pivot-перевод — обход ограничения на устройстве#
Когда язык не поддерживается напрямую, включается второй путь: генерация всё равно происходит on-device, но не на языке пользователя, а на английском — с переводом на входе и на выходе.
@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)
}Три этапа — три on-device вызова вместо одного, и это ощутимо: пайплайн заметно медленнее прямой генерации. Но для незакрытого списком языка это единственный способ дать содержательный, а не заготовленный ответ, не покидая устройство ни на одном из шагов.
Реальный код в Lanternly чуть более отказоустойчив, чем упрощённая версия выше: если перевод самого сообщения прошёл, а перевод сопутствующего контекста (истории диалога) — нет, приложение не роняет весь ответ в фолбэк, а отвечает моделью без истории диалога. Диалог не обрывается из-за частичного сбоя перевода — это осознанный компромисс между полнотой контекста и живостью ответа.
Translation framework: перевод на устройстве, но не бесплатно#
Ключевая деталь, которую легко упустить: Translation — тоже полностью on-device фреймворк (появился ещё в iOS 18 на WWDC 2024), но он не поставляется «со всеми языками из коробки». Перевод конкретной пары языков работает, только если на устройстве уже скачан соответствующий языковой пакет — общесистемный ресурс, единый для всех приложений. Если пользователь никогда не открывал системный переводчик и не скачивал пакет ru↔en, первый вызов TranslationSession либо инициирует загрузку (в SwiftUI через .translationTask система сама предложит скачать), либо просто вернёт отказ, если приложение не в позиции его запрашивать.
Из этого следует практическое правило для пайплайна: пивот-перевод не может тихо предполагать успех. Если пакет не скачан — перевод завершится неудачей, и это нормальный, ожидаемый исход, а не редкий крайний случай.
Честная диагностика вместо тихого фолбэка#
Самая дорогая ошибка в подобной архитектуре — не сам факт наличия фолбэка, а его непрозрачность. Ранняя версия подобного кода в Lanternly оборачивала вызов модели в try? — это удобно, но означает, что при сбое (модель отказала, перевод недоступен, языковой пакет не скачан) причина исчезает молча, и остаётся только один сигнал: «ответ пришёл из банка заготовок». Диагностировать это на реальном устройстве пользователя невозможно.
Текущая версия фиксирует реальную причину на каждом шаге:
do {
let response = try await session.respond(to: prompt)
return response.content
} catch {
// НЕ try? — фиксируем настоящую причину, а не глотаем её молча.
Diagnostics.shared.lastGenerationError = String(describing: error)
return nil
}Плюс отдельный диагностический слой, который в любой момент может ответить на вопрос «а почему Луна сейчас отвечает заготовками, а не сама»: доступна ли модель, поддерживается ли язык, симулятор это или реальное устройство, какая была последняя ошибка генерации. Это не для пользователя в проде — это разница между «непонятно, почему не работает» и «понятно, что нужно почистить или проверить» на этапе разработки и поддержки.
Важная деталь про приватность: сама диагностика никогда не логирует содержимое сообщений — только технические метаданные (код языка, тип ошибки, источник ответа). А диагностический слой явно отключается для сообщений, попавших под кризис-протокол — ни при каких обстоятельствах такие обращения не должны оставлять след даже в отладочном логе устройства.
Коротко все три сценария выглядят так:
| Путь A (прямой) | Путь B (pivot) | Банк (фолбэк) | |
|---|---|---|---|
| Когда срабатывает | Язык есть в supportedLanguages | Язык не поддерживается, но перевод доступен | Модель недоступна, языковой пакет не скачан, любой шаг упал |
| On-device вызовов | 1 (генерация) | до 3 (перевод → генерация → перевод) | 0 |
| Требования | Apple Intelligence включён, iOS 26+ | + скачанный пакет перевода ru↔en | ничего |
| Задержка | минимальная | заметно выше (два перевода поверх генерации) | мгновенно |
| Приватность | 100% на устройстве | 100% на устройстве | на устройстве (статичный текст) |
| Если не сработало | переход на банк | переход на банк | не бывает — всегда есть ответ |
Что забрать из этого паттерна#
Этот подход не специфичен для дневника с ИИ-компаньоном — это универсальный паттерн для любого on-device LLM-фичи в приложении с аудиторией за пределами официально поддерживаемых языков модели: определять язык с сужением кандидатов и приорами, проверять поддержку в рантайме (а не хардкодить список), переводить локально через системный языковой пакет как временный мост, и — самое главное — не глотать ошибки молча там, где фолбэк маскирует реальную причину сбоя.
Русский рано или поздно, вероятно, окажется в supportedLanguages — Apple регулярно расширяет список, и в исследовательских публикациях компании русский уже фигурирует среди языков, на которых обучается сама модель. До этого момента pivot-перевод — не костыль, а рабочая архитектура, которая держит приложение честным перед пользователем: оно либо отвечает содержательно на устройстве, либо явно говорит через диагностику, почему сегодня — нет.



