Foundation Models в iOS 26: разбор фреймворка для on-device LLM

от автора

Когда я готовился к внутреннему митапу по WWDC 2025 в нашей iOS-команде, нужно было сделать обзор сессий #360 (Discover ML & AI Frameworks) и #265 (Dive Deeper into Writing Tools). Доклад я уже провёл, но при подготовке набралось много заметок, которые в формат презентации не влезли: подводные камни, неочевидные решения, паттерны использования. Эта статья — попытка собрать всё это в одном месте.

Речь пойдёт о Foundation Models Framework: что это, как устроено внутри, как с этим работать в реальном приложении и где у этого фреймворка границы применимости. Я постарался не пересказывать документацию Apple, а сосредоточиться на тех моментах, которые не очевидны при первом знакомстве и о которых я бы хотел узнать раньше, чем начал писать первый прототип.

Зачем вообще понадобился ещё один AI-фреймворк

До iOS 26 у iOS-разработчика, который хотел добавить интеллектуальные функции в приложение, было два рабочих пути. Первый — использовать облачный API: OpenAI, Anthropic, Google, что-то ещё. Это работает, но накладывает ряд ограничений: каждый запрос стоит денег, требуется стабильное интернет-соединение, данные пользователя физически уходят на чужой сервер. Последнее особенно неприятно, если речь о приложениях с медицинскими, финансовыми или просто личными данными. В Европе, например, это сразу же поднимает вопросы соответствия GDPR.

Второй путь — взять готовую модель в формате Core ML и запустить её на устройстве. Здесь приватность не страдает, но появляются другие сложности: нужно подобрать или обучить модель под свою задачу, конвертировать её в Core ML, оптимизировать под Neural Engine, тестировать на разных устройствах с разной производительностью. Для большинства команд без выделенного ML-инженера это слишком дорого.

Foundation Models — третий путь, который закрывает обе проблемы сразу. Apple даёт прямой программный доступ к той же языковой модели, которая уже работает внутри системных AI-функций: Writing Tools, Smart Reply в Сообщениях, генерация Genmoji. Раньше эта модель была закрыта; начиная с iOS 26 её можно использовать в своём приложении.

Облачный AI vs On-device AI

Облачный AI vs On-device AI

Принципиальное отличие от облачных API в том, что весь инференс происходит локально, на специализированном AI-чипе внутри устройства. Это меняет экономику фичи (нет цены за токен), её надёжность (работает в самолёте, в метро, в горах) и архитектуру (не нужно беспокоиться о retry-логике для сетевых ошибок).

Минимальный пример

Прежде чем разбирать архитектуру, посмотрим на самый минимальный рабочий пример. Этот код реально работает в продакшне:

import FoundationModelslet session = LanguageModelSession()let response = try await session.respond(to: "Напиши changelog")print(response.content)

Четыре содержательных строки. Никаких API-ключей, никакой инициализации сети, никакой подписки. Но за этой простотой стоит несколько важных нюансов, которые проявляются ровно тогда, когда вы пытаетесь использовать это в реальном приложении, а не в Playground. О них и пойдёт речь дальше.

Что происходит под капотом

Когда вы вызываете session.respond(to:), запрос проходит через несколько слоёв.

Стек Foundation Models

Стек Foundation Models

Сверху вниз: ваш Swift-код вызывает API фреймворка. Сам фреймворк FoundationModels отвечает за управление сессией, обработку Guided Generation (об этом ниже), вызовы тулов и стриминг. Ниже — собственно языковая модель Apple на ~3 миллиарда параметров. Она же используется и в системных AI-функциях, то есть это не урезанная демоверсия, а та же модель. И, наконец, вычисления выполняются на Neural Engine — отдельном сопроцессоре внутри Apple Silicon, заточенном под нейросетевые операции.

Важный момент про Neural Engine: это не CPU и не GPU. В A17 Pro (iPhone 15 Pro) это 16-ядерный блок производительностью около 18 TOPS — триллионов операций в секунду. Именно благодаря такому железу модель размером в 3B параметров работает на телефоне с приемлемой задержкой. На обычном CPU инференс был бы слишком медленным, на GPU — слишком прожорливым по батарее. Neural Engine — компромисс, оптимизированный именно под inference нейросетей.

Из этого, кстати, следует важное ограничение: фреймворк работает только на устройствах с достаточно современным Neural Engine. Конкретно: iPhone 15 Pro и новее (чип A17 Pro и выше), все Mac на Apple Silicon (M1 и новее), iPad с M-серией чипов. На iPhone 14 и старше API недоступен, и попытка создать LanguageModelSession завершится ошибкой. Об этом стоит помнить и закладывать в архитектуру fallback на облачный API или просто отключение AI-функций для несовместимых устройств.

Разбираем код построчно

Вернёмся к минимальному примеру и разберём каждую строку. Это полезно сделать, потому что каждая из них скрывает за собой выбор, который повлияет на архитектуру приложения.

Первая строка: import FoundationModels

Подключение фреймворка. Звучит банально, но стоит проверить совместимость на этапе сборки и в рантайме. На этапе сборки достаточно поднять минимальный target до iOS 26, иначе линкер просто не найдёт символы. В рантайме нужна проверка LanguageModelSession.isAvailable:

guard LanguageModelSession.isAvailable else {    // Модель недоступна на этом устройстве.    // Здесь должен быть fallback — например, скрыть AI-функцию    // или использовать облачный API.    return}

Этот guard критичен, потому что приложение может оказаться на устройстве, которое формально поддерживает iOS 26, но не имеет достаточно мощного Neural Engine. В моём прототипе я сначала забыл про эту проверку и получил неприятный сюрприз при тестировании на iPhone 14 — приложение крашилось при первом обращении к модели.

Вторая строка: создаём сессию

LanguageModelSession() создаёт объект сессии. И вот здесь начинается самое интересное, потому что сессия — это не просто обёртка над запросом. Это полноценный stateful объект, который хранит историю всех ваших запросов и ответов модели.

Жизненный цикл LanguageModelSession

Жизненный цикл LanguageModelSession

Это важно по двум причинам. Во-первых, модель помнит контекст: если вы в первом запросе сказали «меня зовут Артём», а во втором спросили «как меня зовут», она ответит правильно. Это и есть та самая память диалога, которая в облачных API обычно реализуется ручной передачей истории сообщений в каждый запрос.

Во-вторых — и это куда важнее с практической точки зрения — создание сессии стоит дорого. Под капотом происходит загрузка весов модели в память Neural Engine и выделение буфера под контекстное окно. На моём iPhone 15 Pro это занимало 200–500 миллисекунд. Это значит, что сессию нужно создавать один раз и переиспользовать, а не пересоздавать на каждый запрос.

В прототипе я наступил на эти грабли почти сразу. Сначала функция выглядела так:

// Так делать НЕ нужно: сессия создаётся при каждом вызовеfunc sendMessage(_ text: String) async throws -> String {    let session = LanguageModelSession()    let response = try await session.respond(to: text)    return response.content}

При интенсивном использовании это превращало приложение в кисель: каждое сообщение от пользователя приводило к перезагрузке модели в память. Правильный паттерн — держать сессию как свойство ViewModel или сервиса:

@Observablefinal class ChatViewModel {    // Создаётся один раз при инициализации ViewModel    private let session = LanguageModelSession()        func sendMessage(_ text: String) async throws -> String {        // Сессия переиспользуется для всех запросов        // и помнит весь предыдущий контекст диалога        let response = try await session.respond(to: text)        return response.content    }}

Дополнительный приятный момент: при создании сессии можно задать системный промпт через параметр instructions. Это аналог system message в ChatGPT API — инструкции, которые модель будет учитывать во всех запросах в рамках этой сессии:

let session = LanguageModelSession(    instructions: """    Ты ассистент для iOS-разработчиков.     Отвечай кратко, с примерами кода на Swift.    Не используй markdown-форматирование в ответах.    """)

В моём прототипе чат-ассистента такие инструкции значительно улучшили качество ответов: без них модель часто скатывалась в общие фразы, с ними — давала структурированные ответы со сниппетами кода.

Третья строка: запрос с обработкой ошибок

try await session.respond(to:) — асинхронный запрос к модели. await понятен: мы ждём, пока модель сгенерирует ответ. А вот try — это уже отдельная история. У этого вызова есть три типа ошибок, и каждую из них нужно обработать отдельно, иначе приложение начнёт падать в неожиданных местах.

Первая ошибка — unsupportedLanguage. Возникает, когда модель не может работать с языком запроса. На момент iOS 26 поддерживаются 15 языков, включая русский, но если пользователь, например, напишет на тайском, прилетит именно эта ошибка.

Вторая — contextWindowExceeded. Контекстное окно — это максимальный объём информации, который модель может одновременно держать в памяти при генерации ответа. Когда история сессии становится слишком длинной (например, после двадцатого-тридцатого обмена сообщениями), новый запрос может в это окно не уместиться. Нужно создавать новую сессию, желательно — с кратким резюме предыдущего разговора в instructions.

Третья — guardrailViolation. Внутри модели работает система безопасности, которая блокирует запросы и ответы, нарушающие определённые правила: предложения насилия, генерация запрещённого контента, попытки jailbreak. Это не баг, а намеренная фича: вы получаете built-in safety без необходимости писать собственные фильтры. Но обрабатывать эту ошибку нужно вежливо — пользователь не должен видеть техническое сообщение «guardrail violation», ему нужно мягкое «не могу помочь с этим запросом».

Полный обработчик выглядит так:

do {    let response = try await session.respond(to: userInput)    updateUI(with: response.content)    } catch LanguageModelError.unsupportedLanguage {    showError("Этот язык пока не поддерживается")    } catch LanguageModelError.contextWindowExceeded {    // Сессия переполнена — создаём новую с резюме старой    await resetSessionWithSummary()    } catch LanguageModelError.guardrailViolation {    showError("Не могу помочь с этим запросом, попробуйте переформулировать")    } catch {    // Любые другие непредвиденные ошибки    showError("Что-то пошло не так")}

Отдельно стоит сказать про стриминг. Метод respond(to:) возвращает финальный ответ — то есть пользователь ждёт, пока модель полностью сгенерирует текст. На коротких запросах это незаметно, но если модель генерирует длинный ответ (например, объяснение какого-нибудь концепта), задержка в несколько секунд воспринимается болезненно.

Для таких случаев есть streamResponse(to:), который возвращает AsyncSequence с частями ответа по мере генерации. Это полностью повторяет поведение ChatGPT, где буквы появляются постепенно:

@Observablefinal class StreamingViewModel {    private let session = LanguageModelSession()    var generatedText = ""        func generate(prompt: String) async {        generatedText = ""                do {            // Каждая итерация даёт следующий кусок ответа            for await partial in session.streamResponse(to: prompt) {                await MainActor.run {                    // Обновляем UI по мере поступления                    generatedText += partial.text                }            }        } catch {            // обработка ошибок        }    }}

С точки зрения UX разница огромная. Без стриминга на запрос «объясни SwiftUI» пользователь видит спиннер 3–5 секунд, потом сразу всю простыню текста. Со стримингом — первые слова появляются примерно через 200 мс, и текст «печатается» на глазах. Воспринимается это как «модель думает вслух», а не «приложение висит».

Четвёртая строка: содержимое ответа

response.content — строка с ответом модели. Кроме content объект response содержит ещё несколько полезных полей. finishReason показывает, почему генерация остановилась: .complete означает нормальное завершение, .length — модель упёрлась в лимит токенов и текст обрезан, .stop — попался стоп-токен. usage содержит статистику по токенам: promptTokens, completionTokens и totalTokens. Полезно для отладки и понимания, насколько большие у вас запросы.

Особое внимание стоит уделить случаю finishReason == .length. Если он встречается часто, значит ваши запросы или ожидаемые ответы слишком длинные. Пользователь в этой ситуации видит ответ, оборванный на середине предложения, что выглядит как баг. Лучше детектировать такой случай и либо разбивать запрос на части, либо явно сообщать пользователю, что ответ обрезан.

Структурированный вывод через @Generable

Это, пожалуй, та часть Foundation Models, которая мне нравится больше всего — даже больше, чем сам факт on-device LLM. Потому что она решает реальную головную боль, с которой сталкивался каждый, кто работал с языковыми моделями.

Допустим, мы хотим автоматически анализировать отзывы пользователей в App Store. От модели нам нужны структурированные данные: тональность отзыва, оценка от 1 до 5, список конкретных проблем, краткое резюме. Классический подход — попросить модель вернуть JSON-строку, потом распарсить её на стороне приложения. Выглядит это примерно так:

// Хрупкий подход — просим JSON и парсим вручнуюlet response = try await session.respond(to: """    Проанализируй отзыв: \(userReview)    Верни JSON: { "sentiment": "...", "rating": ..., "issues": [...] }""")let data = response.content.data(using: .utf8)!let json = try JSONSerialization.jsonObject(with: data) // может упасть// Дальше — приведение типов, проверка полей, обработка ошибок...

С такой схемой связан целый букет проблем. Модель может вернуть невалидный JSON — например, с одинарными кавычками вместо двойных или с trailing comma. Может забыть обязательное поле или, наоборот, добавить лишнее. Может перепутать тип значения: вернуть rating как строку, а не как число. Может добавить пояснительный текст вокруг JSON, который ломает парсинг. Любой из этих случаев становится багом в продакшне.

Foundation Models решает эту проблему через макрос @Generable и технику под названием constrained decoding.

Как работает @Generable

Как работает @Generable

Идея простая: описываем нужную структуру как обычный Swift-тип, помечаем @Generable, каждое поле аннотируем @Guide с описанием на естественном языке. Дальше передаём этот тип в respond(to:generating:), и модель сама заполняет его:

@Generablestruct AppReview {    @Guide("Тональность отзыва: positive, negative или neutral")    var sentiment: String        @Guide("Оценка от 1 до 5, где 5 — отлично")    var rating: Int        @Guide("Список конкретных проблем, упомянутых в отзыве. Пустой массив, если проблем нет.")    var issues: [String]        @Guide("Краткое резюме в одном предложении")    var summary: String}// Использование:let review = try await session.respond(    to: "Проанализируй отзыв: \(userReview)",    generating: AppReview.self)// review — уже готовый Swift-объект, никакого парсингаprint(review.sentiment)  // "negative"print(review.rating)     // 2print(review.issues)     // ["вылетает при загрузке", "запутанная навигация"]

Что здесь происходит. Когда мы пишем generating: AppReview.self, компилятор Swift через макрос @Generable генерирует схему, описывающую структуру типа: какие у него поля, какие у них типы, какие есть ограничения. Эта схема передаётся в модель не как часть промпта, а как ограничение на процесс генерации.

И вот тут самое интересное: модель в процессе генерации ограничена только теми токенами, которые валидны для текущей позиции в схеме. Если она сейчас генерирует значение для поля rating: Int, она физически не может вернуть слово или дробное число — следующий токен может быть только цифрой. Это и называется constrained decoding.

У такого подхода два преимущества. Первое — гарантия валидности: модель никогда не вернёт «битый» результат, потому что невалидный токен ей просто недоступен. Второе, неожиданное — скорость: чем меньше пространство возможных токенов на каждом шаге, тем меньше вычислений нужно. В сессии #286 Apple это явно подчеркнули: Guided Generation одновременно повышает точность и ускоряет инференс.

С качеством формулировок в @Guide есть свой нюанс. Чем точнее описание, тем точнее результат. Сравните:

// Размытое описание@Guide("оценка")var rating: Int// Конкретное описание@Guide("Оценка от 1 до 5, где 1 — очень плохо, 5 — отлично. Учитывай общий тон отзыва.")var rating: Int

В первом случае модель может вернуть оценку в любом диапазоне — 0, 100, что угодно. Во втором случае она получает контекст: 1 — плохо, 5 — отлично, нужно учитывать тон. По сути @Guide — это инструкция модели на естественном языке, привязанная к конкретному полю. Это то же самое, что промпт, только более точный и локальный.

Структуры можно вкладывать и комбинировать с enum:

@Generableenum Severity {    case critical, high, medium, low}@Generablestruct BugReport {    @Guide("Краткое описание проблемы одним предложением")    var title: String        @Guide("Шаги для воспроизведения проблемы")    var reproSteps: [String]        @Guide("Критичность проблемы")    var severity: Severity        @Guide("Предполагаемая причина, если очевидна. nil если не очевидна.")    var probableCause: String?}

В моём прототипе бага-трекера такая структура заменила штук пятьдесят строк парсинг-кода и обработки ошибок. И, что более важно, исчезли все баги вида «иногда краш на пустом массиве».

Tool Calling: подключаем модель к реальным данным

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

Tool Calling — механизм, который решает эту проблему. Вы описываете функции (тулы), которые умеет вызвать ваш код, и модель сама решает, когда и с какими аргументами их использовать.

Tool Calling — как модель вызывает твои функции

Tool Calling — как модель вызывает твои функции

Рассмотрим на конкретном примере. Пишем тул для поиска ресторанов:

struct RestaurantSearchTool: Tool {    // Имя тула — модель использует его внутренне    let name = "searchRestaurants"        // Описание для модели: когда использовать этот тул    let description = "Ищет рестораны по параметрам и возвращает список подходящих"        // Аргументы описываются через @Generable    @Generable    struct Arguments {        @Guide("Тип кухни: italian, japanese, russian и т.д.")        var cuisine: String                @Guide("Количество гостей")        var guests: Int                @Guide("Дата в формате YYYY-MM-DD")        var date: String                @Guide("Минимальный рейтинг от 1 до 5, по умолчанию 4")        var minRating: Double?    }        // Сама функция: получает аргументы, возвращает результат    func call(arguments: Arguments) async throws -> ToolOutput {        let results = await RestaurantAPI.search(            cuisine: arguments.cuisine,            guests: arguments.guests,            date: arguments.date,            minRating: arguments.minRating ?? 4.0        )        let text = results.map { "\($0.name) — \($0.rating)⭐" }            .joined(separator: "\n")        return ToolOutput(text)    }}

Подключаем тул к сессии и пишем человеческий запрос:

let session = LanguageModelSession(tools: [RestaurantSearchTool()])let answer = try await session.respond(    to: "Найди итальянский ресторан на 4 человека на следующую пятницу")print(answer.content)

Что происходит внутри. Модель получает запрос, видит, что у неё есть тул searchRestaurants, читает его описание, понимает, что это именно то, что нужно. Дальше она самостоятельно извлекает параметры из запроса пользователя: italian для кухни, 4 для количества гостей, и преобразует «следующую пятницу» в конкретную дату. Вызывает call(arguments:), получает результат, и формирует финальный ответ для пользователя.

Чем это принципиально отличается от классического подхода с интентами и регулярными выражениями. Раньше, чтобы реализовать такую функциональность, нужно было писать сложную логику разбора намерений пользователя: парсить дату из «следующая пятница», извлекать тип кухни через keyword matching или NLP, обрабатывать варианты формулировок. С Tool Calling вы просто описываете, что умеет ваше приложение, а модель сама разбирается, как интерпретировать запрос. Это куда декларативнее.

В одну сессию можно передать несколько тулов, и модель будет выбирать нужный в зависимости от контекста, или комбинировать их:

let session = LanguageModelSession(tools: [    RestaurantSearchTool(),    WeatherTool(),    CalendarTool(),])// На запрос "Найди ресторан на пятницу если погода будет хорошей"// модель может сначала вызвать WeatherTool, проверить прогноз,// потом CalendarTool для уточнения даты, потом RestaurantSearchTool

Дополнительная мелочь, которая мне понравилась: ToolOutput поддерживает указание источника данных через ToolOutput.Source. Если ваш тул возвращает данные из конкретного API, можно передать ссылку и название источника, и модель включит это в финальный ответ. Получается встроенный fact-checking — пользователь видит не просто «вот ресторан», а «вот ресторан, источник такой-то».

Когда использовать что: выбор фреймворка под задачу

Foundation Models — мощный инструмент, но не единственный AI-фреймворк в iOS. И это, пожалуй, самая важная мысль, которую я хочу донести в этой статье: не нужно тащить LLM туда, где задачу решает более простой и подходящий инструмент.

Какой Apple AI-фреймворк выбрать

Какой Apple AI-фреймворк выбрать

Принцип, которого я придерживаюсь: использовать наивысший по уровню абстракции API, который решает задачу. Чем выше уровень — тем меньше кода нужно написать, тем лучше интеграция с системой, тем меньше ML-ответственности на команде.

Если задача — позволить пользователю отредактировать текст с помощью AI (переписать, исправить ошибки, сократить), то самый правильный инструмент — Writing Tools. Это системная функция Apple, доступная через контекстное меню в любом текстовом поле. Если в вашем приложении используется стандартный UITextView с UITextInteraction, Writing Tools уже работают — без единой строки кода с вашей стороны. Apple сделала эту интеграцию автоматической.

Если у вас кастомный текстовый редактор (например, Markdown-редактор с собственным движком), для интеграции Writing Tools есть Coordinator API, добавленный в iOS 26. Через UIWritingToolsCoordinator можно получить полноценную интеграцию с анимацией переписывания, проверкой орфографии прямо в строке текста и follow-up запросами от пользователя:

class CustomEditor: UIView {    var writingToolsCoordinator: UIWritingToolsCoordinator?        override func awakeFromNib() {        super.awakeFromNib()        // Создаём координатор и привязываем к своему движку        let coordinator = UIWritingToolsCoordinator(delegate: self)        self.writingToolsCoordinator = coordinator    }}extension CustomEditor: UIWritingToolsCoordinatorDelegate {    func writingToolsCoordinator(        _ coordinator: UIWritingToolsCoordinator,        replace originalText: String,        with newText: String,        in range: NSRange    ) {        // Применяем переписанный текст к своему движку.        // Анимация переписывания добавится автоматически.        myTextStorage.replaceCharacters(in: range, with: newText)    }}

Если задача — предложить пользователю готовые варианты ответа в чате (как это сделано в Сообщениях), то это Smart Reply API. Модель анализирует контекст переписки и возвращает 3–5 подходящих коротких ответов.

Если нужно проанализировать изображение — распознать объекты, прочитать текст, найти таблицы — это Vision Framework. В iOS 26, кстати, в Vision появился важный обновлённый API: VNRecognizeDocumentRequest, который понимает структуру документа целиком — таблицы, списки, заголовки, параграфы — а не просто распознаёт отдельные строки текста, как делал старый OCR.

Если задача — распознавание речи, особенно длинных записей вроде митингов или лекций, в iOS 26 рекомендуется использовать новый SpeechAnalyzer вместо устаревшего SFSpeechRecognizer. Новый API оптимизирован именно под длинные аудио, поддерживает streaming через AsyncSequence и работает с distant speaker (когда говорящий находится не вплотную к микрофону).

И только если задача — что-то более кастомное и связанное с текстом: анализировать отзывы, извлекать структурированные данные из произвольного текста, генерировать персонализированный контент, реализовать собственного чат-ассистента — тогда Foundation Models. Это правильный инструмент именно для гибкой текстовой логики, которую нельзя свести к фиксированной системной функции.

Что не стоит ожидать от модели на 3B параметров

Foundation Models — это компактная модель. По размеру она примерно в 500 раз меньше GPT-4. И этот факт нужно учитывать при выборе задач, которые ей поручать.

Что эта модель делает хорошо: классифицирует короткий текст, извлекает структурированную информацию из произвольного текста, переписывает короткие фрагменты, делает выжимку (summarization), отвечает на простые вопросы по контексту, который ей дали в промпте. То есть всё, что я называю «текстовая логика среднего уровня».

Что она делает плохо или не делает совсем. Математические вычисления — даже простую арифметику она часто проваливает. Это в принципе известное ограничение всех языковых моделей, и Foundation Models здесь не исключение. Для вычислений используйте обычный код. Длинные многошаговые рассуждения — модель путается, теряет промежуточные результаты, контекстное окно переполняется. Лучше разбивать сложную задачу на цепочку коротких запросов, где каждый следующий получает результат предыдущего как контекст. Фактические вопросы о реальном мире, особенно о свежих событиях, — здесь модель будет галлюцинировать с уверенным видом. Для таких случаев нужен Tool Calling с подключением к надёжному источнику данных.

Под промптинг такой модели нужно адаптироваться. Несколько практических наблюдений из прототипа.

Первое — указывать ожидаемый объём ответа явно. «Объясни SwiftUI» даёт расплывчатый длинный текст. «Объясни SwiftUI в трёх пунктах, каждый не длиннее двух предложений» — конкретный и применимый ответ.

Второе — указывать формат, если нужен конкретный. «Опиши шаги» может дать что угодно. «Опиши шаги в виде нумерованного списка, не более 5 пунктов» — структурированный ответ. Хотя для жёстко структурированных задач, конечно, лучше использовать @Generable.

Третье — разбивать сложные задачи. Если хочется одним запросом сделать ревью кода, придумать рефакторинг, написать тесты и составить документацию, модель растеряется. Гораздо лучше работает цепочка: сначала спросить про баги, потом про рефакторинг с учётом найденных багов, потом про тесты под рефакторенный код.

Для итерации по промптам в Xcode 26 есть удобный инструмент — макрос #playground, который исполняет код прямо в IDE без перекомпиляции основного проекта:

#playground {    let session = LanguageModelSession()    let result = try await session.respond(to: "Твой промпт здесь")    print(result.content)}// Cmd+Return — запуск, результат виден сразу

Когда дорабатываешь промпт, такой workflow реально экономит время. Меняешь формулировку — нажимаешь Cmd+Return — видишь, как меняется ответ. Без необходимости запускать симулятор и проходить путь до экрана с моделью.

Пример полной интеграции в SwiftUI

Соберём всё рассмотренное в одну рабочую ViewModel. Это код, который можно скопировать в реальный проект и доработать под свою задачу:

@Observablefinal class AIAssistantViewModel {    // Сессия создаётся один раз — при создании ViewModel    private let session = LanguageModelSession(        instructions: "Ты помощник для iOS-разработчиков. Отвечай кратко, с примерами кода."    )        var response = ""    var isLoading = false    var errorMessage: String?        func ask(_ prompt: String) async {        // Проверяем, что модель доступна на этом устройстве        guard LanguageModelSession.isAvailable else {            errorMessage = "AI недоступен на этом устройстве"            return        }                isLoading = true        errorMessage = nil        response = ""                do {            // Используем стриминг для лучшего UX            for await partial in session.streamResponse(to: prompt) {                await MainActor.run {                    response += partial.text                    isLoading = false  // первый кусок пришёл — спиннер убираем                }            }        } catch LanguageModelError.unsupportedLanguage {            errorMessage = "Этот язык пока не поддерживается"        } catch LanguageModelError.contextWindowExceeded {            // В реальном приложении здесь стоит создать новую сессию            // с резюме предыдущего разговора в instructions            errorMessage = "Разговор слишком длинный, начни заново"        } catch LanguageModelError.guardrailViolation {            errorMessage = "Не могу помочь с этим запросом"        } catch {            errorMessage = "Что-то пошло не так. Попробуй ещё раз."        }                isLoading = false    }}

SwiftUI-вью, которая использует эту ViewModel:

struct AIAssistantView: View {    @State private var viewModel = AIAssistantViewModel()    @State private var input = ""        var body: some View {        VStack(spacing: 16) {            ScrollView {                if viewModel.isLoading {                    ProgressView()                        .frame(maxWidth: .infinity)                }                Text(viewModel.response)                    .frame(maxWidth: .infinity, alignment: .leading)                    .animation(.easeInOut, value: viewModel.response)            }                        if let error = viewModel.errorMessage {                Text(error)                    .foregroundStyle(.red)                    .font(.caption)            }                        HStack {                TextField("Спроси что-нибудь...", text: $input)                    .textFieldStyle(.roundedBorder)                Button("Отправить") {                    let prompt = input                    input = ""                    Task { await viewModel.ask(prompt) }                }                .disabled(input.isEmpty || viewModel.isLoading)            }        }        .padding()    }}

Этот код реализует базового чат-ассистента: вводишь сообщение, видишь стриминг ответа, ошибки обрабатываются и показываются пользователю в человеческом виде. От продакшн-готового решения его отделяет ещё несколько шагов: обработка длинных диалогов с автоматическим резюмированием, сохранение истории между запусками приложения, более тонкая работа с UI во время стриминга. Но базовая структура — именно такая.

Подводные камни, которые я нашёл в прототипе

Несколько наблюдений из практики, которые могут сэкономить кому-то время.

Контекстное окно конечно, и его исчерпание приходит неожиданно. На моих типичных коротких диалогах оно держалось 20–30 обменов сообщениями, дальше начинался contextWindowExceeded. Если приложение предполагает долгие разговоры, нужен механизм автоматического резюмирования старой части истории и создания новой сессии с этим резюме в instructions. Без такого механизма пользователь получит непонятную ошибку в середине разговора.

Скорость генерации зависит от устройства. На моём iPhone 15 Pro модель генерировала примерно 30–50 токенов в секунду на коротких ответах. На более новых устройствах должно быть быстрее, но числа сильно зависят от загруженности системы — если параллельно идёт другой Neural Engine workload, скорость падает. Это аргумент за стриминг — он сглаживает восприятие задержки, даже если общее время генерации не уменьшается.

guardrailViolation иногда срабатывает на удивительных вещах. Например, в моих тестах модель отказывалась обсуждать некоторые медицинские темы даже в общем образовательном контексте. С этим придётся жить — встроенные guardrails переопределить нельзя. Если ваш use case плотно завязан на пограничные темы, возможно, Foundation Models не подойдёт.

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

Отладка ошибок неудобная. Если что-то идёт не так внутри модели, наружу прилетает довольно общее сообщение об ошибке. В iOS 26 нет отдельного debug-режима для модели, который показал бы, например, как именно модель интерпретировала промпт. Это, видимо, обратная сторона приватности — но при разработке иногда хочется большей наблюдаемости. Помогает только итерация через #playground.

Что в итоге

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

Для таких задач это, пожалуй, лучшее, что есть на iOS сейчас. Три строки кода до базовой работы, @Generable для type-safe структурированного вывода без парсинга JSON, Tool Calling для подключения к реальным данным, грамотная обработка ошибок и safety из коробки. Всё на устройстве, всё бесплатно.

В моём прототипе чат-ассистента для iOS-разработчиков Foundation Models покрыл, по моим оценкам, процентов 80 от того, что я ожидал получить от облачного API. Остальные 20 — это случаи, требующие свежих фактических данных или более глубокого рассуждения, и для них пришлось бы либо подключать тулы с внешними API, либо переходить на гибридную архитектуру с облачным fallback.

Главный совет, который я бы дал себе перед началом работы: не пытайтесь использовать Foundation Models для всего подряд. Сначала посмотрите, не закрывается ли ваша задача более высокоуровневым API — Writing Tools, Smart Reply, Vision. Если закрывается — берите его, выйдет меньше кода и лучше системная интеграция. И только если действительно нужна гибкая текстовая логика — тогда Foundation Models с правильно подобранным @Generable и Tool Calling.

Полезные ссылки для тех, кто хочет углубиться:

Если у вас уже есть опыт работы с Foundation Models — поделитесь, на какие подводные камни наткнулись вы. Особенно интересно, как решаете проблему с переполнением контекстного окна в долгих диалогах: пробовал несколько подходов с автоматическим резюмированием, но ни один пока не дал стабильно хороший результат.

ссылка на оригинал статьи https://habr.com/ru/articles/1035022/