Foundation Models в Swift: локальный LLM Apple Intelligence, @Generable и tools в iOS 26

Полное руководство по фреймворку Foundation Models в iOS 26: подключение к on-device LLM, структурированный вывод через @Generable и @Guide, стриминг ответов, инструменты (tools), управление контекстным окном в 4096 токенов и обработка GenerationError.

Foundation Models iOS 26: on-device LLM-гайд

В iOS 26 Apple впервые открыла разработчикам прямой доступ к локальной языковой модели — той самой, что лежит в основе Apple Intelligence. Звучит как маркетинг, но на деле всё довольно прозаично: фреймворк Foundation Models позволяет вызывать ~3-миллиардную on-device LLM из Swift буквально парой строк. Без API-ключей, без счетов за токены, в самолёте на эшелоне 12 000 — везде работает.

В этом гайде пройдём путь от первого respond(to:) до production-сценариев: @Generable, @Guide, инструменты, стриминг, обработка GenerationError и новый API управления контекстом, который Apple подкинула в iOS 26.4. Поехали.

Что такое Foundation Models и зачем он нужен

Foundation Models — это Swift-фреймворк, дающий доступ к той самой on-device LLM, что обслуживает системные функции вроде Writing Tools, Genmoji и Smart Reply. В отличие от облачных провайдеров (OpenAI, Anthropic, Google), здесь:

  • модель выполняется полностью на устройстве — данные физически не покидают iPhone или Mac;
  • нет токенных биллингов, ключей и rate-limits;
  • работает офлайн, в режиме полёта, в фоне;
  • интегрирована в систему через стандартные Swift-типы и макросы.

За такую приватность приходится платить компромиссами. Модель меньше облачных аналогов, контекстное окно ограничено 4096 токенами, она не годится для код-генерации, сложных вычислений или фактологических вопросов о мире (cutoff обучения — октябрь 2023). Зато она отлично справляется с суммаризацией, извлечением сущностей, классификацией, переписыванием, генерацией коротких сценариев — теми задачами, ради которых раньше приходилось гонять данные в облако.

Честно говоря, после пары недель экспериментов отношение у меня поменялось: это не «GPT в кармане», а очень узкоспециализированный инструмент. Когда задача попадает в его нишу — он удивляет скоростью и приватностью. Когда не попадает — лучше даже не пробовать.

Требования и поддерживаемые устройства

Перед тем как писать код, проверьте, что у вас:

  • Xcode 26 (или новее) и SDK iOS 26 / macOS 26 / iPadOS 26 / visionOS 26;
  • устройство с поддержкой Apple Intelligence: iPhone 15 Pro / Pro Max или любой iPhone 16/17 на A17 Pro и выше; Mac на M1 и новее; iPad на M1+; Apple Vision Pro;
  • в настройках устройства включён Apple Intelligence (модель скачивается отдельно, ~2–3 ГБ).

Перед использованием модели всегда проверяйте её доступность. Это первое правило production-кода — без вариантов.

import FoundationModels

let model = SystemLanguageModel.default

switch model.availability {
case .available:
    // Готовы вызывать модель
    break
case .unavailable(.deviceNotEligible):
    // Старое устройство — нужен fallback
    break
case .unavailable(.appleIntelligenceNotEnabled):
    // Пользователь не включил Apple Intelligence в настройках
    break
case .unavailable(.modelNotReady):
    // Модель ещё скачивается
    break
case .unavailable(let other):
    print("Недоступно: \(other)")
}

Никогда не предполагайте, что модель доступна. Это не Foundation API уровня String — это сервис, который физически может отсутствовать на устройстве пользователя. И, судя по статистике распространения Apple Intelligence, в обозримом будущем не появится у заметной части аудитории.

Первый запрос: LanguageModelSession

Все взаимодействия идут через LanguageModelSession — объект, который хранит историю диалога (transcript) и опции генерации. Простейший «Hello, world»:

import FoundationModels

func askModel() async throws -> String {
    let session = LanguageModelSession()
    let response = try await session.respond(to: "Назови три преимущества SwiftUI перед UIKit одной строкой каждое.")
    return response.content
}

Сессия запоминает предыдущие сообщения, поэтому повторный respond(to:) на той же сессии будет учитывать предыдущий контекст. Если хотите чистый запрос — создавайте новую сессию.

Системные инструкции

Чтобы задать модели роль и стиль, передавайте instructions при создании сессии:

let session = LanguageModelSession(instructions: """
Ты — лаконичный технический редактор. Отвечай на русском.
Никогда не используй маркетинговые штампы вроде «революционный» или «мощный».
""")

Системные инструкции эффективнее, чем повторение требований в каждом prompt. Они занимают часть контекста один раз, а не на каждый запрос — и при коротком окне в 4096 токенов это очень ощутимая экономия.

Структурированный вывод: @Generable и @Guide

Вот ради этого, по-моему, и стоит вообще смотреть в сторону Foundation Models. Главная фишка фреймворка — guided generation. Вместо того чтобы парсить «грязный» JSON из строки и молиться, чтобы модель не забыла закрыть кавычку, вы объявляете обычный Swift-тип, помечаете его @Generable — и получаете уже типизированный объект. Это работает не магией промптинга, а constrained decoding на уровне токенов: модель буквально не может сгенерировать токен, который сломал бы вашу схему.

import FoundationModels

@Generable
struct Recipe: Equatable {
    @Guide(description: "Название блюда, не более 5 слов")
    let title: String

    @Guide(description: "Краткое описание в 1–2 предложения")
    let summary: String

    @Guide(.count(5))
    let ingredients: [String]

    @Guide(.range(1...60))
    let prepMinutes: Int
}

func generateRecipe() async throws -> Recipe {
    let session = LanguageModelSession()
    let response = try await session.respond(
        to: "Придумай простой рецепт пасты с кабачками для будней.",
        generating: Recipe.self
    )
    return response.content
}

Что здесь происходит:

  • Макрос @Generable на этапе компиляции генерирует JSON-схему типа и инициализатор, способный её разобрать.
  • @Guide(description:) добавляет в схему пояснения для модели — фактически локализованные подсказки.
  • @Guide(.count(n)), @Guide(.range(...)), @Guide(.pattern(...)) и подобные генераторы накладывают жёсткие ограничения: длина массива, диапазон чисел, regex для строк.
  • Все сохранённые свойства типа сами должны быть Generable. Базовые типы (String, Int, Double, Bool, Array, Optional, перечисления без ассоциированных значений) — уже Generable.

Порядок свойств имеет значение

А вот это меня в первый раз застало врасплох. Модель генерирует поля последовательно, в порядке объявления. Это критично, когда одно поле логически зависит от другого. Допустим, вы хотите сначала список тезисов, а потом резюме на их основе:

@Generable
struct ArticleAnalysis {
    @Guide(.count(3))
    let keyPoints: [String]   // Сначала генерируем тезисы

    let summary: String       // Потом резюме, опираясь на них
}

Если поменять поля местами, качество резюме упадёт — модель будет писать summary «вслепую», ещё не сгенерировав тезисы. Ошибка не выскочит, всё скомпилируется, просто на выходе будет каша. Имейте в виду.

Перечисления как ограниченный выбор

Generable-перечисления — мощный способ заставить модель выбирать только из заданного множества:

@Generable
enum Sentiment: String {
    case positive, neutral, negative
}

@Generable
struct ReviewClassification {
    let sentiment: Sentiment
    @Guide(.range(1...5))
    let stars: Int
    let reasoning: String
}

Никакого «модель вернула positiv вместо positive» — на уровне декодирования таких токенов просто не существует. После многолетнего ковыряния регулярок по выхлопу LLM это, признаться, ощущается как чудо.

Стриминг: реактивный UI на async-последовательностях

Дожидаться полного ответа на длинной генерации — плохой UX, и точка. Foundation Models поддерживает стриминг через streamResponse, причём, в отличие от облачных API, он стримит не сырые токены, а частично собранные структурированные снимки: экземпляры специального типа T.PartiallyGenerated, у которых поля заполняются по мере генерации.

import SwiftUI
import FoundationModels

@Observable
final class RecipeViewModel {
    var partial: Recipe.PartiallyGenerated?
    var isGenerating = false

    func generate(prompt: String) async {
        isGenerating = true
        defer { isGenerating = false }

        let session = LanguageModelSession()
        do {
            let stream = session.streamResponse(
                generating: Recipe.self,
                prompt: { prompt }
            )
            for try await snapshot in stream {
                partial = snapshot
            }
        } catch {
            print("Ошибка генерации: \(error)")
        }
    }
}

struct RecipeView: View {
    @State private var viewModel = RecipeViewModel()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            if let title = viewModel.partial?.title {
                Text(title).font(.title2.bold())
            }
            if let summary = viewModel.partial?.summary {
                Text(summary).foregroundStyle(.secondary)
            }
            if let ingredients = viewModel.partial?.ingredients {
                ForEach(ingredients, id: \.self) { Text("• \($0)") }
            }
        }
        .animation(.snappy, value: viewModel.partial)
        .task { await viewModel.generate(prompt: "Паста с кабачками для будней") }
    }
}

SwiftUI сам анимирует появление полей по мере их прихода — пользователь видит «живую» генерацию, как в ChatGPT, но с типизированным выводом. Эффект, на мой вкус, даже приятнее: текст не сыпется буква за буквой, а проявляется блоками.

Tools: даём модели руки

Сама по себе LLM знает только то, что было в её обучающих данных (cutoff октябрь 2023). Чтобы модель могла обращаться к актуальным данным — Apple Health, Core Location, базе приложения, REST API — используйте tools. Это Swift-типы, реализующие протокол Tool; вы регистрируете их в сессии, и модель сама решает, когда их вызвать.

import FoundationModels
import CoreLocation

final class WeatherTool: Tool {
    let name = "get_weather"
    let description = "Возвращает текущую погоду по названию города на русском."

    @Generable
    struct Arguments {
        @Guide(description: "Название города в именительном падеже")
        let city: String
    }

    @Generable
    struct Output {
        let temperatureC: Double
        let condition: String
    }

    func call(arguments: Arguments) async throws -> Output {
        // Здесь — реальный вызов WeatherKit или вашего API
        return Output(temperatureC: 12.4, condition: "облачно")
    }
}

func askWithTools() async throws {
    let session = LanguageModelSession(
        tools: [WeatherTool()],
        instructions: "Если нужна актуальная погода, всегда вызывай get_weather."
    )
    let response = try await session.respond(to: "Что мне надеть в Москве сегодня?")
    print(response.content)
}

Несколько важных моментов:

  • Имя инструмента (name) и его description попадают в prompt автоматически. Потратьте время на их формулировку — относитесь к этому как к хорошему docstring.
  • Arguments и возвращаемый тип должны быть @Generable.
  • Модель может вызвать инструмент несколько раз за один respond(to:), если задача комплексная.
  • Все вызовы инструментов фиксируются в session.transcript — удобно для отладки и аналитики.

Управление контекстным окном (4096 токенов)

А вот тут начинается боль. Главное практическое ограничение модели — 4096 токенов на ввод и вывод вместе. Это примерно 3000 слов на английском, заметно меньше на русском (русские тексты дороже по токенам — кириллица режется на куски почти посимвольно). Превысите лимит — получите GenerationError.exceededContextWindowSize, и сессия будет «отравлена»: продолжить в ней нельзя, нужно создать новую.

В iOS 26.4 (март 2026) Apple наконец-то добавила два долгожданных API для проактивного управления контекстом:

let model = SystemLanguageModel.default

// Сколько токенов всего доступно (зависит от устройства и версии модели)
let total = model.contextSize

// Сколько токенов займёт строка — измеряем ДО отправки
let prompt = "Очень длинный текст для суммаризации..."
let tokens = try model.tokenCount(for: prompt)

if tokens > total - 512 { // Резервируем место под ответ
    // Слишком длинно — режем или суммаризируем порциями
}

Стратегии работы с лимитом:

  • Map-reduce-суммаризация: длинный текст режется на куски, каждый суммаризируется отдельной сессией, а потом результаты сводятся ещё одним проходом.
  • Сжатие истории: в чат-сценариях периодически просите модель пересказать предыдущие N сообщений одним абзацем и стартуйте новую сессию с этим summary в инструкциях.
  • Минимальные Generable-схемы: не включайте в один тип всё, что может понадобиться когда-либо. Для каждого экрана — свой компактный @Generable-тип.
  • Лимит на ответ: в GenerationOptions можно задать maximumResponseTokens, чтобы модель не растягивала вывод.
let options = GenerationOptions(
    temperature: 0.7,
    maximumResponseTokens: 300
)
let response = try await session.respond(
    to: "Суммаризируй в 3 предложения",
    options: options
)

Обработка ошибок

Foundation Models бросает LanguageModelSession.GenerationError с богатым набором кейсов. Минимум, который должен ловить production-код:

do {
    let response = try await session.respond(to: prompt, generating: Recipe.self)
    handle(response.content)
} catch let error as LanguageModelSession.GenerationError {
    switch error {
    case .exceededContextWindowSize:
        // Сессия отравлена — создаём новую
        await restartSession()
    case .assetsUnavailable:
        // Модель ещё качается или удалена
        showFallbackUI()
    case .guardrailViolation:
        // Сработал safety-фильтр Apple — переформулируйте prompt
        showSafeAlternativePrompt()
    case .unsupportedLanguageOrLocale:
        // Модель не поддерживает выбранный язык
        switchToEnglishOrFallback()
    case .decodingFailure:
        // Структурный вывод не удалось разобрать (редко при @Generable)
        retryWithSimplerSchema()
    case .rateLimited:
        // Слишком частые запросы
        try? await Task.sleep(for: .seconds(1))
    @unknown default:
        log(error)
    }
} catch {
    log(error)
}

Отдельно стоят ошибки инструментов — LanguageModelSession.ToolCallError. Они оборачивают ошибку, выброшенную из вашего Tool.call(arguments:), и доступны при итерации стрима транскрипта.

Безопасность и guardrails

Apple встроила в Foundation Models два слоя guardrails: один проверяет ввод (prompt injection, недопустимый контент), второй — вывод (опасные инструкции, медицинские/юридические утверждения). При срабатывании вы получаете .guardrailViolation.

Что это значит на практике:

  • Никогда не вставляйте в prompt сырой пользовательский ввод без экранирования контекста — оборачивайте его в кавычки или префиксы и давайте модели чёткие инструкции, как с ним обращаться.
  • Не пытайтесь обходить guardrails — это нарушает App Review Guidelines и приведёт к реджекту.
  • Для UGC-сценариев показывайте пользователю понятное сообщение при срабатывании фильтра, а не технический stack trace.

Производительность и батарея

On-device — не значит бесплатно. Каждый вызов модели — это секунды на Neural Engine и заметный расход батареи. Из практики:

  • Кешируйте результаты для одинаковых входов. Хеш prompt + версия модели — отличный ключ.
  • Прогревайте сессию в фоне до того, как пользователь нажмёт кнопку: session.prewarm() загружает модель в память и драматически ускоряет первый ответ.
  • Не дёргайте модель в цикле на каждое нажатие клавиши — debounce минимум 300–500 мс.
  • Используйте maximumResponseTokens везде, где это уместно. Короче ответ — меньше вычислений.
  • Для повторяющихся запросов с одинаковыми инструкциями переиспользуйте сессию — она держит KV-кэш в памяти.
final class AISummarizer {
    private let session: LanguageModelSession

    init() {
        session = LanguageModelSession(instructions: "Суммаризуй в 2 предложения.")
        Task { await session.prewarm() }
    }

    func summarize(_ text: String) async throws -> String {
        try await session.respond(to: text).content
    }
}

Когда не стоит использовать Foundation Models

Apple явно перечисляет сценарии, где локальная модель — плохой выбор:

  • Кодогенерация. Модель меньше Copilot/Claude и не натренирована специально на код.
  • Точные вычисления и математика. Используйте Tool, который вызывает обычный Swift-расчёт.
  • Фактологические Q&A о мире. Cutoff октябрь 2023; для актуальных фактов нужны tools, ходящие в API.
  • Длинные контексты. 4096 токенов — это не «суммаризировать книгу за один проход».
  • Тонкая работа с редкими языками. Качество вне «больших» языков заметно ниже.

Для всего этого правильнее использовать облачную LLM или специализированные модели, а Foundation Models оставить для приватных, лёгких, латентность-критичных задач. Не пытайтесь забить гвоздь микроскопом — он не сломается, но будет очень обидно.

FAQ

Какие устройства поддерживают Foundation Models?

Только устройства с поддержкой Apple Intelligence: iPhone 15 Pro/Pro Max, вся линейка iPhone 16/17, iPad на M1 и новее, Mac на M1+, Apple Vision Pro. На остальных SystemLanguageModel.default.availability вернёт .unavailable(.deviceNotEligible), и вам нужно реализовать fallback (например, облачную модель или просто отключение фичи).

Можно ли использовать Foundation Models бесплатно и без интернета?

Да. Модель выполняется локально, плата за токены отсутствует, интернет не требуется. Единственное условие — устройство пользователя должно поддерживать Apple Intelligence и иметь его включённым в настройках. После первого включения модель скачивается один раз (~2–3 ГБ) и далее работает офлайн.

Чем @Generable отличается от обычного парсинга JSON из ответа LLM?

@Generable — это constrained decoding на уровне токенов: фреймворк ограничивает множество допустимых следующих токенов схемой вашего типа, поэтому модель физически не может сгенерировать невалидный JSON. В отличие от подхода «попроси LLM вернуть JSON и парси», здесь не бывает обрезанных кавычек, лишних запятых или галлюцинированных полей. Декодирование в Swift-тип происходит автоматически — без JSONDecoder и опциональных полей-костылей.

Что делать, если контекст превысил 4096 токенов?

Сессия после GenerationError.exceededContextWindowSize восстановлению не подлежит — нужно создать новую LanguageModelSession. Чтобы предотвратить ошибку, в iOS 26.4 используйте SystemLanguageModel.default.tokenCount(for:) для измерения prompt'а заранее и стратегию map-reduce: режьте длинный вход на куски, суммаризуйте каждый отдельной сессией, а финальное резюме собирайте ещё одним проходом.

Как Foundation Models соотносится с App Intents и Apple Intelligence?

Это разные слои стека. App Intents описывают действия вашего приложения, чтобы Siri и Spotlight могли их вызывать. Foundation Models даёт прямой доступ к языковой модели внутри вашего приложения — для генерации текста, классификации, извлечения данных. Apple Intelligence — это зонтичный термин для всех системных AI-фич (Writing Tools, Genmoji, Image Playground и т.д.), которые внутри используют ту же модель. App Intents и Foundation Models часто комбинируют: интент ловит запрос пользователя, а Foundation Models внутри генерирует ответ.

Что дальше

Foundation Models — не «волшебная кнопка ИИ», а инструмент с конкретной нишей: приватные, быстрые, оффлайновые языковые задачи прямо на устройстве. Освоив @Generable, стриминг и tools, вы получаете слой, на котором можно построить умные ассистенты, генерацию контента, классификаторы и парсеры — без единого запроса в облако.

Следующий шаг простой: попробуйте переписать одну фичу в вашем приложении, которая раньше требовала запроса к облачной LLM, на локальную модель. Замерьте латентность, расход батареи и качество. В большинстве UI-сценариев результат вас приятно удивит — а заодно вы перестанете платить за токены там, где это никогда и не было нужно.

Об авторе Daniel Okafor

Daniel is a former Spotify iOS engineer (2019-2024) who worked on the Now Playing surface and the in-app podcast player. He shipped the SwiftUI rewrite of the lyrics view to over 600 million users and contributed several fixes upstream to swift-collections. His writing tends toward the unglamorous corners of iOS work: build-time regressions in Xcode 16, why SwiftData still isn't ready for production sync scenarios, and how to instrument a real app with os_signpost without drowning in noise. He spent two years before Spotify at a fintech startup in Berlin building a banking app on top of Solaris API. Daniel now freelances out of Lisbon and maintains a small open-source library for type-safe deep links in SwiftUI. He has 9 years of native iOS experience.