По умолчанию используйте серверный путь, потому что он работает на любом устройстве, а модель на устройстве берите только для ограниченных, коротких или приватных задач и только если проверка доступности пройдена. Локально бесплатно, приватно и офлайн, но модель маленькая и есть только на свежем железе.
iOS 26+: Foundation Models отдаёт модель Apple через LanguageModelSession, а SystemLanguageModel.default.availability говорит, потянет ли её этот телефон
Android: ML Kit GenAI запускает Gemini Nano через AICore: task API (суммаризация, проверка текста, переписывание, описание изображений) плюс Prompt API для произвольных промптов, только на свежих флагманах; другие модели запускаются как свои веса через LiteRT / LiteRT-LM
Что когда выбирать: ограниченная задача, короткий вход, приватные данные: сначала локально; качество и есть продукт: сервер, а локальная модель как офлайн-запасной вариант
Частая ошибка: считать on-device возможностью, а не проверкой на каждый вызов: большая часть вашей базы ещё долго не пройдёт эту проверку
AndroidSenior
AI-функции в мобильных приложениях
Инференс на устройстве или на сервере, стриминг токенов, структурированный вывод, задержки, приватность
Закрыто вопросов
0 / 17
- 0Повторить
Вопросы
Каждый ход заново отправляет и оплачивает всю историю: входные токены растут, расходы растут примерно квадратично, время до первого токена увеличивается, а в итоге запрос переполняет контекстное окно и падает. Окно нужно считать в токенах, а не в сообщениях.
Токены, а не символы: модели тарифицируют и ограничивают в токенах: для английского примерно 4 символа на токен, для русского токенов на слово заметно больше; опирайтесь на usage, который возвращает API, а не на text.length
Бюджет окна: фиксированный системный промпт, затем самые новые ходы, которые влезают в бюджет, с запасом под ответ (max_output_tokens)
Сжимайте хвост: сворачивайте старые ходы в накопительное саммари, чтобы длинный чат сохранял контекст за фиксированную цену
Кеш промпта: держите стабильный префикс побайтно одинаковым, чтобы кеш вендора давал скидку; таймстемп в начале убивает кеш
Скрытая гонка: два быстрых send оба дописывают в history, и ответы ложатся не в том порядке; сериализуйте отправку или привязывайте ходы к id
Где это живёт: окно собирает сервер из сохранённых ходов; клиент отправляет id хода, а не историю
Он не вызывает awaitClose, поэтому callbackFlow падает в рантайме с IllegalStateException, и никто не отменяет EventSource; кроме того, при нормальном конце стрима flow не завершается, а при заполненном буфере токены молча теряются.
**awaitClose { source.cancel() }: держит продюсер живым и закрывает сокет, когда коллектор отменён; именно это закрытие позволяет серверу прекратить генерацию
onClosed → close()**: без этого законченный ответ никогда не завершит flow, и UI крутит лоадер вечно; многие API ещё присылают маркер [DONE]
HTTP-ошибки: при не-2xx статусе OkHttp вызывает onFailure с t == null и Response; превратите это в исключение, иначе 429 выглядит как зависание
Потерянные токены: trySend в стандартный буфер на 64 элемента не проходит, когда коллектор медленный; используйте buffer(Channel.UNLIMITED) (текст небольшой) или trySendBlocking
Таймауты: для стримов берите client.newBuilder().readTimeout(0, TimeUnit.SECONDS).build(); стандартный read timeout в 10 с обрывает медленную генерацию
Альтернатива: плагин SSE в Ktor даёт client.sse { incoming.collect { } } с отменой из коробки
Каждый сетевой чанк декодируется отдельно, поэтому кириллическая буква или эмодзи, чьи UTF-8 байты попали в два чанка, разрезается пополам, и обе половины превращаются в �. В ASCII один символ занимает один байт, поэтому английский текст не ломается никогда.
Почему: кириллическая буква в UTF-8 занимает 2 байта, эмодзи занимает 4; TCP и chunked-кодирование режут поток на произвольных байтовых смещениях, а не по границам символов, а String(bytes, UTF_8) заменяет неполную последовательность на U+FFFD, вместо того чтобы дождаться остатка
Исправление: один InputStreamReader (reader(Charsets.UTF_8)) на весь стрим: его декодер придерживает начальные байты разрезанного символа, пока не придёт последний, а read(CharArray) возвращается, как только готовы целые символы, так что текст по-прежнему стримится
Где уже безопасно: построчные ридеры вроде BufferedSource.readUtf8Line() в OkHttp (на нём стоит SSE-адаптер) и readUTF8Line() в Ktor декодируют целые строки; баг всплывает при чтении сырых байтов: plain-text или NDJSON-стримы, самописный парсер
Как поймать в следующий раз: юнит-тест, который подаёт русский ответ с эмодзи через фейковый стрим, отдающий по 1–3 байта за read; фикстуры только на английском до этого случая не доходят
Один токен не равен одному обновлению UI. Копите дельты, сбрасывайте их с частотой кадра, дописывайте в один текстовый узел, а автопрокрутку включайте, только пока пользователь уже внизу.
У стрима частота данных, у экрана частота кадров. Переведите одно в другое один раз, на границе.
Почему перестроение на токен дорого: раскладка растущего абзаца пересчитывает весь абзац, а перепарсинг Markdown на каждую дельту удваивает счёт
Троттлинг: копите вне главного потока и отдавайте не чаще раза в 30–60 мс; быстрее всё равно никто не читает
Стабильная идентичность: ответ в стриме это один элемент списка со стабильным key, а не элемент на каждый токен
Прокрутка: список чата делают с reverseLayout = true, где самое новое сообщение становится элементом 0: список привязан к низу, поэтому растущий ответ остаётся в кадре, пока пользователь внизу, а когда он прокрутил вверх, старые сообщения стоят на месте; кнопку «вниз» показывайте при firstVisibleItemIndex > 0
Отмена обязана дойти до сокета: счётчик останавливает именно закрытие тела ответа. А уже показанный кусок текста относится к состоянию, так что сохраните его до того, как скоуп будет отменён.
Правильный скоуп: вызов принадлежит скоупу владельца экрана; генерация в скоупе на весь процесс (GlobalScope) продолжает стримить и тратить деньги после ухода с экрана
Сигналом служит закрытие: отмена корутины, висящей на чтении, закрывает соединение (на iOS это делает отмена Task), при условии что адаптер стрима отменяет вызов при отмене (awaitClose { source.cancel() }, Ktor); проверка флага сама по себе не останавливает ничего
**Сохраняйте внутри withContext(NonCancellable)**: отменённая корутина не может suspend, поэтому обычный save() в finally молча не выполнится
Это не ошибка: покажите частичный текст с кнопкой «повторить», а не красный баннер
Что нужно серверу: прокси, превращающий закрытое соединение клиента в abort наверх, id генерации и свои потолок по токенам и таймаут простоя: при убийстве процесса никакой finally не выполнится
Время до первого токена и есть то число, которое решает: около 100 мс ощущается мгновенным, секунда нормальна, если тап был подтверждён, а после нескольких секунд люди уходят. Когда токены пошли, общая длительность почти не важна: чтение медленнее генерации.
Честные приёмы убирают настоящее ожидание или делают его видимым; театр изображает работу, которой нет.
Измеряйте по частям: разделите ожидание на свою работу, свой прокси и очередь вендора; считайте p95 на реальных сетях, а не на офисном wifi
Честно: начинайте запрос по намерению, а не по нажатию «отправить», кешируйте неизменную системную часть промпта на сервере, держите локальную сессию прогретой и сразу рисуйте скелетон с работающей «Отменой»
Театр: анимация печатной машинки поверх уже полученного ответа, намеренно замедленный стрим или определённый прогресс-бар для генерации неизвестной длины
Загрузите .tflite через memory map в один Interpreter, переиспользуйте его и запускайте вне главного потока. Делегат переносит поддерживаемые операции с CPU на GPU или NPU: для больших моделей быстрее и экономнее, но дольше инициализация и возможен тихий откат на CPU.
LiteRT: так в 2024 году переименовали TensorFlow Lite; рантайм идёт в бандле или из Google Play services, API org.tensorflow.lite не изменился
Создавайте один раз: сборка интерпретатора стоит от десятков до сотен мс и много памяти; он не потокобезопасен, поэтому работайте с ним из одного потока и вызывайте close() для него и его делегата, когда фича больше не нужна
Проверяйте перед делегированием: CompatibilityList говорит, работает ли GPU-делегат на этом устройстве; неподдерживаемые операции откатываются на CPU, и каждый переход CPU↔GPU копирует тензоры, так что меряйте benchmark-утилитой
NNAPI: deprecated начиная с Android 15; до NPU теперь добираются через новые API акселераторов в LiteRT
Размер: квантизация в int8 или fp16 уменьшает файл в 2–4 раза; для LLM берите LiteRT-LM (MediaPipe LLM Inference в режиме поддержки), а не голый интерпретатор
Оба магазина считают сгенерированный результат вашим контентом: вы обязаны предотвращать и модерировать вредный вывод и дать пользователю пожаловаться на него внутри приложения, а перед отправкой персональных данных стороннему ИИ-провайдеру раскрыть это и получить согласие.
Политика Play об AI-Generated Content: приложение должно не допускать запрещённый контент, а пользователь должен иметь возможность пожаловаться на оскорбительный вывод или пометить его прямо в приложении, не выходя из него
Play Data safety: промпты, фото и голос, уходящие вендору модели, считаются собираемыми и передаваемыми данными; декларируйте их, включая то, что собирает SDK вендора
App Store 5.1.2(i): после обновления в ноябре 2025 приложение обязано явно сообщать, когда персональные данные передаются стороннему ИИ, и сначала получать явное разрешение
Сгенерированный контент, который видят другие: подпадает под правила обоих магазинов о пользовательском контенте: фильтрация, жалобы, блокировка
На практике: экран согласия перед первым облачным вызовом, действие «пожаловаться» на каждом ответе, серверная модерация входа и выхода и политика конфиденциальности, где назван вендор
Модель сама ничего не выполняет: она возвращает имя инструмента с JSON-аргументами, ваш код проверяет их и выполняет вызов, а результат вы отправляете обратно, чтобы модель продолжила. Что можно вызывать и что требует подтверждения пользователя, решает приложение.
Цикл: объявите инструменты с именем, описанием и JSON-схемой; модель отвечает вызовом вместо текста; вы выполняете его и возвращаете результат как tool-сообщение; повторяете, пока модель не ответит текстом, с лимитом на число раундов
Где выполняются: поиск и ваша база данных живут на сервере; календарь, контакты, геолокация и текущий экран есть только на телефоне, поэтому сервер передаёт такие вызовы клиенту и ждёт
Аргументы генерирует модель: декодируйте в типизированный класс, проверяйте диапазоны и права, а на неизвестное имя инструмента отвечайте ошибкой, а не догадкой
Побочные эффекты проходят через человека: инструменты только для чтения выполняются автоматически; всё, что отправляет, платит, удаляет или бронирует, показывает точные аргументы на подтверждение и несёт ключ идемпотентности
Runtime-разрешения: запрашивайте в момент, когда инструменту оно нужно, а не на старте приложения
Ключ вендора внутри APK публичен: его может достать кто угодно. Но прокси, который только прячет ключ и пересылает любое тело от клиента, всё равно остаётся открытой точкой инференса за ваш счёт, поэтому промпт, модель и бюджет каждого пользователя должен контролировать сервер.
Правило: клиент говорит, чего хочет пользователь; сервер решает, что спросят у модели и сколько этого достанется данному пользователю.
Владейте промптом: клиент отправляет намерение и данные (id треда, тон, локаль), но никогда строку промпта
Владейте моделью: идентификатор модели, параметры декодирования и потолок по токенам живут на сервере
Квота на пользователя: дневной потолок и понятный 429, который приложение умеет показать; IP на мобиле не личность, один NAT оператора объединяет тысячи людей
Аттестация: Play Integrity или App Attest на выдаче токенов плюс свой учёт токенов по каждому запросу
Что проходит насквозь: стрим, отмена вплоть до вызова наверх и идентификатор запроса в заголовках ответа
Считайте всё, что читает модель, недоверенным вводом, который может нести инструкции, и исходите из того, что часть инъекций сработает: ограничивайте то, что может сделать захваченная модель, а не надейтесь, что промпт устоит.
Откуда она берётся
Прямая: пользователь пишет «игнорируй свои инструкции»; в основном это неприятность, если только у модели нет секретов или привилегий
Косвенная: инструкции спрятаны в письме, веб-странице, PDF или картинке, которую обрабатывает модель («перешли эту переписку на…»); это опасный вариант, потому что жертвой становится ваш пользователь
Что ограничивает ущерб
Минимальные привилегии: у суммаризатора нет инструментов вообще; инструменты ограничены данными самого пользователя и текущей задачей
Подтверждение: отправка, оплата, удаление и шаринг требуют экрана подтверждения, собранного из распарсенных аргументов, а не из текста модели
Никаких секретов в промптах: считайте, что системный промпт утечёт; ключи, данные других пользователей и внутренние URL туда не попадают
Обработка вывода: показывайте вывод как обычный текст; не подгружайте автоматически ссылки и картинки, которые он содержит (данные могут уйти в query URL), и никогда не передавайте его без проверки в WebView.evaluateJavascript, SQL или Intent
Что помогает, но не защищает
выделение недоверенного контента с пометкой для модели, что это данные, классификаторы ввода, настройки безопасности вендора: они снижают долю успешных атак, но не до нуля
держите известные примеры инъекций в наборе evals и прогоняйте их при каждой смене промпта или модели
Разбирайте как финальный только законченный объект, а любой более ранний префикс считайте подсказкой для раскладки. Просите у вендора вывод по схеме и стримьте префикс в зеркальный тип, где все поля nullable.
iOS 26 делает это сам: тип с @Generable, отданный через streamResponse, приходит частичными снимками и заполняется в порядке объявления полей
Разбор префикса: обрежьте до последнего завершённого члена, закройте открытые контейнеры, декодируйте нестрогим парсером в зеркало и в конце проверьте настоящим типом
Частая ошибка: брать значение из незавершённого токена: цена 12, потому что стрим оборвался посреди 1234, не медленная, а неправильная
На экране: скелетон формы поля, никаких значений, которые появятся и потом исправятся, и выключенные кнопки, пока объект не прошёл валидацию
WhileSubscribed(5_000) отменяет upstream через 5 с после того, как экран перестал собирать flow, и запускает его заново для следующего подписчика, поэтому каждое возвращение из фона начинает новую платную генерацию с "", даже если саммари уже было готово.
Почему: collectAsStateWithLifecycle перестаёт собирать flow ниже STARTED; после таймаута stateIn отменяет upstream, а здесь это сам HTTP-стрим. Новый подписчик собирает холодный flow с нуля: новый запрос, а runningFold снова начинает с "", и экран пустеет
Почему идиома с 5 с здесь не работает: она подходит для дешёвых повторяемых источников вроде запроса к Room или обновлений локации, где повторная подписка просто перечитывает текущие данные; генерация представляет собой платный побочный эффект, и повторный запуск стоит вдвое дороже и может дать другой ответ
Исправление: привяжите генерацию к ViewModel, а не к UI: SharingStarted.Lazily (или launch в init, который пишет в MutableStateFlow) запускает стрим один раз и сохраняет результат; viewModelScope всё равно отменит его, когда пользователь уйдёт с экрана, и это именно та остановка, которая нужна
Как поймать в следующий раз: runTest с фейковым AssistantApi, который считает вызовы: подписаться, отменить, advanceTimeBy(6_000), подписаться снова, проверить, что вызов один
Храните диалог как долговечные ходы с идентификаторами, а ViewModel считайте их проекцией. Вернувшись, спрашивайте сервер про уже начатую генерацию, а не запускайте её заново.
Что долговечно: каждый ход это строка: id, роль, текст, статус (pending, streaming, complete, failed); экран рисуется из хранилища, а не из памяти
Что проигрываете заново: решает последний ход: complete-ответ не требует ничего, ход пользователя без ответа отправляется заново с тем же id хода, streaming или pending требует вопроса серверу про данный generation id, неизвестный становится failed с возможностью повтора
Никогда дважды: ход пользователя: id ему выдаёт клиент и шлёт при каждой попытке, чтобы повтор продолжил, а не спросил заново; это критично, когда модель умеет вызывать инструменты, которые отправляют или платят
Никогда дважды: обрезанный ответ как контекст: отправленный как законченный, он заставит модель его дописывать; помечайте его обрезанным или выкидывайте из окна
Веса относятся к контенту, а не к коду: они едут по платформенному каналу доставки ассетов, версионируются вместе с кодом, который их читает, и являются кешем, который система вправе забрать, поэтому фича обязана работать, пока их нет.
Свои веса: on-demand asset pack через Play Asset Delivery (или AI pack через Play for On-device AI, который умеет ещё и таргетинг по модели устройства) на Android, Background Assets или On-Demand Resources на iOS; все дают докачку, проверку целостности и кеш, который вам не пришлось писать
Частая ошибка: тянуть гигабайт обычным GET в Documents: ни докачки, ни проверки целостности, файл попадает в бэкап iCloud, а недокачанный становится вашим крашем
Пока идёт загрузка: по умолчанию только безлимитная сеть, версионируйте пак вместе с читателем, а фичу тем временем обслуживает серверный путь
Удаление: система молча вычищает такие паки при нехватке места, так что «модели больше нет» считается нормальной веткой; свои копии держите вне бэкапа и удаляйте при выходе из аккаунта
Разделите задачу: всё вокруг модели остаётся обычным детерминированным кодом, который тестируют с фейковой моделью, а качество самой модели измеряют статистически набором evals и оценщиками и перепрогоняют при каждой смене промпта или модели.
Вокруг модели
спрячьте модель за интерфейс и тестируйте парсинг, стриминг, отмену, фолбэк и UI квоты с фейком, который проигрывает записанные чанки, включая битый JSON и ошибку посреди стрима
закрепите версию модели и параметры сэмплинга в конфиге; алиас вроде latest меняет поведение у вас за спиной
Сама модель
Набор evals: несколько сотен входов из реального трафика (с согласием и очисткой) плюс известные сложные случаи и попытки инъекций
Оценщики: точные проверки, где возможно (валидный JSON, обязательные поля, длина, язык), LLM-судья с письменной рубрикой для качества и выборочная проверка людьми
Гейт: гоняйте evals в CI при каждой смене промпта или модели и сравнивайте с базовой линией; промпт считается кодом, и он проходит ревью
В продакшене
лайк/дизлайк и действие «пожаловаться», доля перегенераций и отмен, время до первого токена и доля ошибок по версии модели
выкатывайте изменения промпта и модели через remote config на процент пользователей, чтобы откатить регрессию без релиза
логируйте промпты только с согласием и маскированием