Irbisa · Шпаргалка

AI-функции в мобильных приложениях

Инференс на устройстве или на сервере, стриминг токенов, структурированный вывод, задержки, приватность

Senior-разработчик · 17 вопросов

  1. 01

    Как выбрать между моделью на устройстве (Gemini Nano, LiteRT) и серверной моделью для фичи вроде «Суммаризировать переписку»?

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

    interface ThreadSummarizer { suspend fun summarize(text: String): String } class OnDevice(context: Context) : ThreadSummarizer {  private val client = Summarization.getClient(    SummarizerOptions.builder(context)      .setInputType(InputType.ARTICLE)…
  2. 02

    В этом чате на каждом ходе отправляется вся история. Что станет со стоимостью и задержкой к 50-му ходу и как это исправить?

    class ChatViewModel(private val api: ChatApi) : ViewModel() {  private val history = mutableListOf<Message>()   fun send(text: String) = viewModelScope.launch {    history += Message("user", text)    val reply = api.complete(history)   // the whole conversation, every turn    history += Message("assistant", reply)  }}

    Каждый ход заново отправляет и оплачивает всю историю: входные токены растут, расходы растут примерно квадратично, время до первого токена увеличивается, а в итоге запрос переполняет контекстное окно и падает.

  3. 03

    Этот адаптер превращает SSE-стрим токенов во Flow. Что с ним не так?

    fun streamAnswer(client: OkHttpClient, request: Request): Flow<String> = callbackFlow {  val listener = object : EventSourceListener() {    override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {      trySend(data)    }    override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {      close(t)    }  }  EventSources.createFactory(client).newEventSource(request, listener)}

    Он не вызывает awaitClose, поэтому callbackFlow падает в рантайме с IllegalStateException, и никто не отменяет EventSource; кроме того, при нормальном конце стрима flow не завершается, а при заполненном буфере токены молча теряются.

  4. 04

    Найдите баг: английские ответы стримятся нормально, а в русских посреди слова иногда появляется �.

    // The endpoint streams the answer as plain text over chunked HTTP.fun streamAnswer(body: ResponseBody): Flow<String> = flow {  val buf = ByteArray(1024)  body.byteStream().use { input ->    while (true) {      val n = input.read(buf)      if (n == -1) break      emit(String(buf, 0, n, Charsets.UTF_8))    }  }}.flowOn(Dispatchers.IO)

    Каждый сетевой чанк декодируется отдельно, поэтому кириллическая буква или эмодзи, чьи UTF-8 байты попали в два чанка, разрезается пополам, и обе половины превращаются в �.

  5. 05

    Ответ в стриме дёргается по мере прихода токенов, а список постоянно прыгает, пока текст растёт. Что вы поменяете?

    Один токен не равен одному обновлению UI.

    // Deltas arrive far faster than a frame. Coalesce once, at the boundary.val answer: Flow<String> = flow {  val buf = StringBuilder()  api.streamAnswer(prompt).collect { delta -> buf.append(delta); emit(buf.toString()) }}  .flowOn(Dispatchers.Default)          // string building off the main thread…
  6. 06

    Пользователь жмёт «назад» на середине генерации. Что на самом деле должно произойти на клиенте и что серверу всё ещё нужно от вас?

    Отмена обязана дойти до сокета: счётчик останавливает именно закрытие тела ответа.

    class AnswerViewModel(private val repo: AnswerRepo) : ViewModel() {  private var job: Job? = null   fun ask(turnId: String, prompt: String) {    job?.cancel()                      // a new question kills the old stream, and its cost    job = viewModelScope.launch {      // dies with the screen, not with the process…
  7. 07

    Какого времени до первого токена стоит добиваться на экране со стримингом ответа и какие способы скрыть ожидание честные?

    Время до первого токена и есть то число, которое решает: около 100 мс ощущается мгновенным, секунда нормальна, если тап был подтверждён, а после нескольких секунд люди уходят.

    // TTFT is a different metric from total duration. Time the first chunk, not the last.suspend fun ask(prompt: String, sink: (String) -> Unit) {  val started = SystemClock.elapsedRealtime()  var firstAt: Long? = null  var tokens = 0  try {…
  8. 08

    Как запустить свою модель LiteRT (TensorFlow Lite) на Android и что меняет GPU-делегат?

    Загрузите .tflite через memory map в один Interpreter, переиспользуйте его и запускайте вне главного потока.

    class Classifier(context: Context) : Closeable {  private val compat = CompatibilityList()  private val gpu: GpuDelegate? =    if (compat.isDelegateSupportedOnThisDevice) GpuDelegate(compat.bestOptionsForThisDevice) else null   private val interpreter = Interpreter(…
  9. 09

    Что Google Play и App Store требуют от приложения с фичей генеративного ИИ?

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

  10. 10

    Как устроен function calling (tool use) в мобильном ассистенте и какие инструменты должны выполняться на устройстве?

    Модель сама ничего не выполняет: она возвращает имя инструмента с JSON-аргументами, ваш код проверяет их и выполняет вызов, а результат вы отправляете обратно, чтобы модель продолжила.

    @Serializable data class CreateEvent(val title: String, val startEpochMs: Long, val minutes: Int) sealed interface ToolOutcome {  data class Result(val json: String) : ToolOutcome  data class NeedsConfirmation(val prompt: String, val run: suspend () -> String) : ToolOutcome}…
  11. 11

    Почему вызовы LLM должны идти через ваш бэкенд, а не с ключом вендора в приложении, и чем прокси должен владеть помимо скрытия ключа?

    Ключ вендора внутри APK публичен: его может достать кто угодно.

    // WRONG: the client builds the prompt and carries the vendor key.// Anyone with the APK has the key; anyone with the key has your bill.val body = ChatBody(  model = "vendor-large",  messages = listOf(Message("system", SYSTEM_PROMPT), Message("user", userText)),)…
  12. 12

    Ваш ассистент суммаризирует письма и умеет вызывать инструменты. Как защитить его от prompt injection?

    Считайте всё, что читает модель, недоверенным вводом, который может нести инструкции, и исходите из того, что часть инъекций сработает: ограничивайте то, что может сделать захваченная модель, а не надейтесь, что промпт устоит.

  13. 13

    Вы просите у модели JSON и хотите показывать поля по мере их прихода. Что делать с наполовину написанным объектом и как выглядит на экране ещё не сгенерированное поле?

    Разбирайте как финальный только законченный объект, а любой более ранний префикс считайте подсказкой для раскладки.

    @Serializable data class Recipe(val title: String, val minutes: Int, val steps: List<String>) // The streaming mirror: every field nullable, because every field may not exist yet.@Serializable data class RecipeDraft(  val title: String? = null, val minutes: Int? = null, val steps: List<String>? = null,)…
  14. 14

    Найдите баг: пользователь переключается на другое приложение посреди саммари, возвращается, и саммари начинается заново с пустого экрана, а в счёте две генерации.

    class SummaryViewModel(api: AssistantApi, threadId: String) : ViewModel() {  val summary: StateFlow<String> = api.stream(SummarizeRequest(threadId))    .runningFold("") { text, delta -> text + delta }    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "")} @Composablefun SummaryScreen(vm: SummaryViewModel) {  val summary by vm.summary.collectAsStateWithLifecycle()  MarkdownText(summary)}

    WhileSubscribed(5_000) отменяет upstream через 5 с после того, как экран перестал собирать flow, и запускает его заново для следующего подписчика, поэтому каждое возвращение из фона начинает новую платную генерацию с "", даже если саммари уже было готово.

  15. 15

    Приложение убили посреди ответа, и пользователь вернулся через час. Что вы восстанавливаете, что отправляете заново и что нельзя отправлять дважды?

    Храните диалог как долговечные ходы с идентификаторами, а ViewModel считайте их проекцией.

    @Entitydata class TurnEntity(  @PrimaryKey val id: String,        // minted on the client, reused by every retry  val conversationId: String,  val role: String,                  // "user" | "assistant"  val text: String,…
  16. 16

    Модель на устройстве весит 1.2 ГБ, а скачивание приложения столько весить не может. Как эти байты попадают на телефон и когда вы их удаляете?

    Веса относятся к контенту, а не к коду: они едут по платформенному каналу доставки ассетов, версионируются вместе с кодом, который их читает, и являются кешем, который система вправе забрать, поэтому фича обязана работать, пока их нет.

    // Play Asset Delivery: the model is an on-demand asset pack, not part of the APK.class ModelStore(private val packs: AssetPackManager) {  fun localPath(): String? = packs.getPackLocation(PACK)?.assetsPath()   // null = not here yet   fun ensure(activity: Activity, onReady: (String) -> Unit) {    localPath()?.let { return onReady(it) }…
  17. 17

    Модель отвечает по-разному при каждом запуске. Как протестировать ИИ-фичу перед релизом и понять, что она всё ещё работает после?

    Разделите задачу: всё вокруг модели остаётся обычным детерминированным кодом, который тестируют с фейковой моделью, а качество самой модели измеряют статистически набором evals и оценщиками и перепрогоняют при каждой смене промпта или модели.