---
title: "Локальный запуск LLM моделей на Android смартфоне"
url: "https://romankryvolapov.com/ru/llm-on-android/"
description: "Чем запустить языковую модель прямо на Android: LiteRT-LM, llama.cpp, MediaPipe, MLC LLM и ONNX Runtime GenAI — что выбрать, как подготовить модель и код на Kotlin, который правильно останавливает генерацию."
language: ru
published: 2025-05-13
updated: 2026-11-05
---
Статей по этой теме мало, поэтому изложу свой опыт. Примеры выросли из моего тестового проекта; позже из него получилось приложение, о котором есть [отдельная статья](/ru/offline-ai-launcher/).

Обзор написан в середине 2025 года, и с тех пор многое изменилось. Google перевёл MediaPipe LLM Inference API в [режим только поддержки](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference) и рекомендует переходить на LiteRT-LM. Я интегрировал LiteRT-LM в своё приложение, и он очень быстрый, поэтому теперь у него свой раздел, а код и шаги сборки в остальных разделах исправлены и обновлены.

## Какой движок выбрать

- **LiteRT-LM** — с него я бы начинал сегодня: в моём приложении он очень быстрый, Google использует его в собственных продуктах, а API сам хранит состояние диалога. Модели нужны в формате `.litertlm`.
- **llama.cpp** — выбор, когда нужна конкретная модель: `.gguf` — самый распространённый формат, и на Hugging Face таких моделей тысячи.
- **MediaPipe** по-прежнему работает, но новый проект я бы на нём не начинал: новые возможности и оптимизации теперь идут в LiteRT-LM.
- **MLC LLM и ONNX Runtime GenAI** остались для меня экспериментами. На Android оба работали медленно, а их модели состоят из множества файлов, которые неудобно скачивать и хранить.

Остальное я в приложение не встраивал, поэтому здесь только ссылки. Обычный [LiteRT](https://ai.google.dev/edge/litert) — уровнем ниже MediaPipe: для LLM токенизатор приходится добавлять вручную, и преимуществ в этом я не увидел. [ExecuTorch](https://github.com/pytorch/executorch) — рантайм PyTorch для мобильных устройств, заменивший PyTorch Mobile, который больше не развивается; к Android он подключается как AAR из Maven Central. [Qualcomm AI Hub](https://aihub.qualcomm.com) — облачный сервис, который оптимизирует модели под чипы Qualcomm и замеряет их на реальных устройствах.

## Как устроены примеры

Все движки ниже спрятаны за одним маленьким интерфейсом, так что в коде остаётся только то, чем они различаются:

```kotlin
import kotlinx.coroutines.flow.Flow

interface LocalChat : AutoCloseable {
    /**
     * Emits the answer accumulated so far and completes when the answer ends.
     * Cancelling the collection stops the generation. One call at a time.
     */
    fun send(message: String): Flow<String>
}
```

`Flow` отдаёт весь ответ на текущий момент, а не отдельные куски: медленный получатель просто пропускает промежуточные состояния и всё равно получает последнее — для этого в каждой реализации стоит `conflate()`. Конец `Flow` — это конец ответа, исключение — ошибка движка, а остановка — обычная отмена:

```kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach

class ChatViewModel(private val chat: LocalChat) : ViewModel() {

    val answer = MutableStateFlow("")
    val error = MutableStateFlow<Throwable?>(null)
    val isGenerating = MutableStateFlow(false)

    private var generation: Job? = null

    fun send(message: String) {
        isGenerating.value = true
        generation = chat.send(message)
            .onEach { answer.value = it }
            .catch { error.value = it }
            .onCompletion { isGenerating.value = false }
            .launchIn(viewModelScope)
    }

    fun stop() {
        generation?.cancel()
    }
}
```

Загрузка модели занимает секунды и блокирует поток, поэтому классы ниже создаются не в главном потоке, например внутри `withContext(Dispatchers.IO)`.

## LiteRT-LM

[LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) ([руководство по Kotlin](https://ai.google.dev/edge/litert-lm/android)) — движок Google для запуска LLM поверх LiteRT; его используют Chrome, Chromebook Plus и Pixel Watch. Я интегрировал его в своё приложение, и он очень быстрый. Готовые модели `.litertlm` есть в [litert-community](https://huggingface.co/litert-community) на Hugging Face: от [Gemma 3 1B](https://huggingface.co/litert-community/Gemma3-1B-IT) до [Gemma 4 E4B](https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm).

### Как добавить в своё приложение

```groovy
implementation "com.google.ai.edge.litertlm:litertlm-android:0.19.0"
```

Библиотека собрана свежим Kotlin, поэтому модулю, который её использует, нужен Kotlin 2.3 или новее. Для GPU приложение должно явно запросить нативные библиотеки — внутри `<application>`:

```xml
<uses-native-library android:name="libvndksupport.so" android:required="false" />
<uses-native-library android:name="libOpenCL.so" android:required="false" />
```

### Код

```kotlin
import com.google.ai.edge.litertlm.Backend
import com.google.ai.edge.litertlm.ConversationConfig
import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.EngineConfig
import com.google.ai.edge.litertlm.SamplerConfig
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion

class LiteRtLmChat(modelPath: String, cacheDir: String) : LocalChat {

    private val engine = Engine(
        EngineConfig(
            modelPath = modelPath,
            backend = Backend.GPU(),
            cacheDir = cacheDir, // speeds up every load after the first one
        )
    ).apply { initialize() }

    // The conversation keeps the dialogue history itself.
    private val conversation = engine.createConversation(
        ConversationConfig(samplerConfig = SamplerConfig(topK = 40, topP = 0.95, temperature = 0.8))
    )

    override fun send(message: String): Flow<String> = flow {
        val answer = StringBuilder()
        conversation.sendMessageAsync(message).collect { chunk ->
            answer.append(chunk.toString())
            emit(answer.toString())
        }
    }.onCompletion { cause ->
        // Cancelling the collection does not reach the engine.
        if (cause is CancellationException) conversation.cancelProcess()
    }.conflate().flowOn(Dispatchers.IO)

    override fun close() {
        conversation.close()
        engine.close()
    }
}
```

Две вещи, о которых руководство прямо не говорит. `Flow` библиотеки не останавливает движок при отмене, поэтому `cancelProcess()` приходится вызывать явно. И отмена не откатывает внутреннее состояние диалога: в исходниках библиотеки это пока запрос на доработку.

## llama.cpp

[llama.cpp](https://github.com/ggml-org/llama.cpp) — пожалуй, самый популярный способ. Как он устроен внутри, разобрано в [статье про llama.cpp](/ru/llama-cpp/). В мобильных приложениях его часто используют через обёртку для React Native [llama.rn](https://github.com/mybigday/llama.rn).

Приложения на llama.cpp и близких движках: [PocketPal AI](https://github.com/a-ghorbani/pocketpal-ai), [MNN](https://github.com/alibaba/MNN) от Alibaba (у него свой движок, а не llama.cpp), [LMPlayground](https://github.com/andriydruk/LMPlayground).

### Как добавить в своё приложение

Скачиваем пример [llama.android](https://github.com/ggml-org/llama.cpp/tree/master/examples/llama.android) и смотрим, как там всё устроено. В вашем проекте структура, скорее всего, будет другой, поэтому обратите внимание на путь в главном `CMakeLists.txt`. В моём случае все компоненты лежат в папке `llama_cpp`:

```cmake
add_subdirectory(llama_cpp build-llama)
```

## MediaPipe

[MediaPipe](https://ai.google.dev/edge/mediapipe/solutions/guide) ([GitHub](https://github.com/google-ai-edge/mediapipe)) запускает модели в формате TFLite (файлы `.tflite` и `.task`). Готовые модели есть в [litert-community](https://huggingface.co/litert-community) на Hugging Face и на [Kaggle](https://www.kaggle.com/models/google/gemma-3/tfLite). Его классы для LLM теперь помечены как устаревшие в пользу LiteRT-LM.

Модель PyTorch можно сконвертировать через [litert-torch](https://github.com/google-ai-edge/litert-torch), но я этого не делал. У TFLite статические формы тензоров (batch = 1), поэтому размер KV-кэша фиксируется при конвертации: именно это значит `ekv2048` в имени вроде `Gemma3-1B-IT_multi-prefill-seq_q4_ekv2048.task`.

### Как добавить в своё приложение

У MediaPipe есть разные task для разных задач, для LLM нужен `tasks-genai` ([Maven](https://mvnrepository.com/artifact/com.google.mediapipe)):

```groovy
implementation "com.google.mediapipe:tasks-genai:0.10.35"
```

### Код

Вся сложность здесь в остановке. `generateResponseAsync` возвращает обычный `SettableFuture`, поэтому `future.cancel()` отменяет только обёртку: движок продолжает считать и держит глобальную блокировку, которую снимает только финальный `done=true`. До этого момента любой вызов на сессии бросает `IllegalStateException` — и `close()`, и следующий `addQueryChunk()`. По-настоящему движок останавливает `cancelGenerateResponseAsync()`, появившийся в версии 0.10.24.

Сессия хранит контекст диалога, поэтому живёт столько же, сколько диалог, в пределах лимита `maxTokens`.

```kotlin
import android.content.Context
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession
import com.google.mediapipe.tasks.genai.llminference.ProgressListener
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn

class MediaPipeChat(
    context: Context,
    modelPath: String,
    backend: LlmInference.Backend = LlmInference.Backend.GPU,
) : LocalChat {

    private val inference = LlmInference.createFromOptions(
        context,
        LlmInference.LlmInferenceOptions.builder()
            .setModelPath(modelPath)
            .setMaxTokens(1024)
            .setMaxTopK(64)
            .setPreferredBackend(backend)
            .build(),
    )

    private val session = LlmInferenceSession.createFromOptions(
        inference,
        LlmInferenceSession.LlmInferenceSessionOptions.builder()
            .setTopK(40)
            .setTopP(1.0f)
            .setTemperature(0.8f)
            .build(),
    )

    private var generation: Future<String>? = null

    override fun send(message: String): Flow<String> = callbackFlow {
        awaitPreviousGeneration()
        val answer = StringBuilder()
        session.addQueryChunk(message)
        val future = session.generateResponseAsync(ProgressListener { partial, done ->
            answer.append(partial)
            trySend(answer.toString())
            if (done) close()
        })
        generation = future
        awaitClose {
            if (!future.isDone) session.cancelGenerateResponseAsync()
        }
    }.conflate().flowOn(Dispatchers.IO)

    override fun close() {
        awaitPreviousGeneration()
        session.close()
        inference.close()
    }

    // After a cancel the engine stays busy until its final done=true.
    private fun awaitPreviousGeneration() {
        runCatching { generation?.get(30, TimeUnit.SECONDS) }
    }
}
```

## MLC LLM

[MLC LLM](https://github.com/mlc-ai/mlc-llm) компилирует модель под конкретное устройство. Поддержка Android на тот момент была экспериментальной, вычисления идут через [OpenCL](https://en.wikipedia.org/wiki/OpenCL). Документация: [CLI](https://llm.mlc.ai/docs/deploy/cli.html), [Android SDK](https://llm.mlc.ai/docs/deploy/android.html), [компиляция моделей](https://llm.mlc.ai/docs/compilation/compile_models.html). Готовые библиотеки и демо-приложение — в [binary-mlc-llm-libs](https://github.com/mlc-ai/binary-mlc-llm-libs/releases).

### Подготовка

Готовые модели из [mlc-ai](https://huggingface.co/mlc-ai) открыты, аккаунт для них не нужен. Аккаунт на [Hugging Face](https://huggingface.co) и access token нужны, только чтобы скачать закрытые оригиналы вроде Gemma от Google и скомпилировать их самостоятельно.

На компьютере должны быть установлены инструменты — проверяем в PowerShell и ставим недостающее:

```powershell
git --version
git lfs --version
rustup --version
cargo --version
ninja --version
cmake --version
javac -version
jar --help
javadoc --help
```

Команда `mlc_llm` поставляется Python-пакетом. Документация рекомендует окружение conda, а на Windows ещё и ставить cmake и ninja из conda:

```powershell
conda activate your-environment
conda install -c conda-forge cmake ninja git git-lfs zstd
python -m pip install --pre -U -f https://mlc.ai/wheels mlc-llm-nightly-cpu mlc-ai-nightly-cpu
```

Клонируем mlc-llm вместе с подмодулями: среди них рантайм TVM, от которого зависит сборка. Архив не подойдёт:

```bash
git clone --recursive https://github.com/mlc-ai/mlc-llm
```

Задаём переменные окружения для скрипта `mlc_llm`, например в PowerShell. В `JAVA_HOME` не должно быть пробелов — сборка на Windows с ними не справляется, поэтому JBR из Android Studio копируем в путь без пробелов:

```powershell
$env:TVM_NDK_CC="C:\Users\--userName--\AppData\Local\Android\Sdk\ndk\27.0.11718014\toolchains\llvm\prebuilt\windows-x86_64\bin\aarch64-linux-android24-clang"
$env:ANDROID_NDK="C:\Users\--userName--\AppData\Local\Android\Sdk\ndk\27.0.11718014"

$env:MLC_LLM_SOURCE_DIR="C:\--path-to-mlc-llm-folder--\mlc-llm"
$env:TVM_SOURCE_DIR="C:\--path-to-mlc-llm-folder--\mlc-llm\3rdparty\tvm"

$env:JAVA_HOME="C:\--path-without-spaces--\jbr"
$env:Path="$env:JAVA_HOME\bin;$env:Path"
$env:PATH += ";C:\Users\--userName--\.cargo\bin"
```

### Компиляция модели

Берём модель, например [google/gemma-3-1b-it-qat-q4_0-unquantized](https://huggingface.co/google/gemma-3-1b-it-qat-q4_0-unquantized). При клонировании Git спросит пароль — это access token:

```bash
git clone https://huggingface.co/google/gemma-3-1b-it-qat-q4_0-unquantized
```

Дальше все команды запускаем из папки с моделью.

**Шаг 1 — конвертация весов:**

```bash
mlc_llm convert_weight . --quantization q4f16_0 -o ..\Recompiled
```

Команда переводит веса из исходного формата (safetensors или PyTorch `.bin`) в формат MLC и заодно квантует их. `q4f16_0` значит: веса хранятся в 4 битах, масштабные коэффициенты — в float16. Суффикс — это раскладка весов, и на телефонах она важна: на GPU Adreno модели с раскладкой `_1` вешают системный интерфейс на 20–50 секунд при первом prefill. Это [известная проблема](https://llm.mlc.ai/docs/deploy/android.html#known-issues), которой нет у раскладки `_0`. Точка — текущая папка с весами, `-o` — куда сохранить результат.

**Шаг 2 — конфигурация:**

```bash
mlc_llm gen_config . --quantization q4f16_0 --conv-template gemma3_instruction --context-window-size 768 -o ..\Recompiled
```

Генерирует `mlc-chat-config.json`: архитектуру, расположение весов, настройки токенизатора и параметры чата. Квантование указывается то же, что на первом шаге. `--conv-template` — шаблон диалога, по которому движок собирает промпт из реплик; он должен соответствовать модели, для Gemma 3 это `gemma3_instruction`. `--context-window-size` — размер контекстного окна, его подбирают под модель и память устройства.

**Шаг 3 (необязательный) — компиляция под Android:**

```bash
mlc_llm compile ..\Recompiled\mlc-chat-config.json --device android -o ..\Recompiled\android.tar
```

Собирает в архив `android.tar` библиотеку модели — скомпилированный код модели под ABI arm64-v8a. Рантайма в нём нет. Удобнее следующий путь: он собирает и то и другое в модуль для проекта.

### Генерация библиотеки для приложения

Создаём файл `mlc-package-config.json` с описанием модели. На готовую модель ссылаемся по адресу на Hugging Face; веса остаются там, и приложение скачивает их само:

```json
{
  "device": "android",
  "model_list": [
    {
      "model": "HF://mlc-ai/Phi-3.5-mini-instruct-q4f16_0-MLC",
      "model_id": "Phi-3.5-mini-instruct-q4f16_0-MLC",
      "estimated_vram_bytes": 4250586449
    }
  ]
}
```

Для локально скомпилированной модели нужен `bundle_weight`: без него `mlc_llm package` отвергает локальный путь. Веса тогда попадают в `dist/bundle`, а доставить их на устройство предстоит самому:

```json
{
  "device": "android",
  "model_list": [
    {
      "model": "../Recompiled",
      "model_id": "gemma-3-1b-it-q4f16_0-MLC",
      "estimated_vram_bytes": 3000000000,
      "bundle_weight": true
    }
  ]
}
```

Подробнее — в документации по [упаковке библиотек и весов](https://llm.mlc.ai/docs/compilation/package_libraries_and_weights.html). Из папки с этим файлом запускаем:

```bash
mlc_llm package
```

Команда создаёт `dist/lib/mlc4j` — Gradle-модуль с рантаймом и библиотекой модели. Подключаем его в `settings.gradle` и добавляем в приложение `implementation project(':mlc4j')`:

```groovy
include ':mlc4j'
project(':mlc4j').projectDir = file('dist/lib/mlc4j')
```

Приложение с targetSdk 31 (Android 12) и выше не получает вендорскую библиотеку вроде OpenCL, пока не объявит её. Официальное приложение MLCChat объявляет такие, внутри `<application>`:

```xml
<uses-native-library android:name="libOpenCL.so" android:required="false" />
<uses-native-library android:name="libOpenCL-pixel.so" android:required="false" />
```

### Код

`modelPath` — папка с весами на устройстве. `modelLib` — значение `model_lib` из `dist/lib/mlc4j/src/main/assets/mlc-app-config.json`, имя с хешем вроде `gemma3_text_q4f16_0_<hash>`.

Промпт в формате Gemma вручную собирать не нужно: движок сам оборачивает реплики в шаблон, заданный через `--conv-template`, и сам знает стоп-токены модели. Если передать уже размеченный промпт, разметка получится двойной. API совместим с OpenAI, поэтому каждый запрос несёт весь диалог, а история живёт в приложении. `create` принимает и привычные `max_tokens`, `temperature`, `top_p`, `seed`, `frequency_penalty` и `presence_penalty`.

Отменить отдельный запрос SDK не умеет: у `MLCEngine` есть только `reload`, `reset` и `unload`. Если просто перестать читать канал, движок продолжит генерировать, поэтому остановка идёт через `reset()`. Ничего не теряется, потому что история на стороне приложения.

```kotlin
import ai.mlc.mlcllm.MLCEngine
import ai.mlc.mlcllm.OpenAIProtocol.ChatCompletionMessage
import ai.mlc.mlcllm.OpenAIProtocol.ChatCompletionRole
import ai.mlc.mlcllm.OpenAIProtocol.StreamOptions
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn

class MlcChat : LocalChat {

    // One per process: the constructor starts two background threads that cannot be stopped.
    private val engine = MLCEngine()
    private val history = mutableListOf<ChatCompletionMessage>()

    // Also switches models.
    fun load(modelPath: String, modelLib: String) {
        engine.unload()
        engine.reload(modelPath, modelLib)
        history.clear()
    }

    override fun send(message: String): Flow<String> = flow {
        history += ChatCompletionMessage(role = ChatCompletionRole.user, content = message)
        val answer = StringBuilder()
        var finished = false
        try {
            val responses = engine.chat.completions.create(
                messages = history,
                stream_options = StreamOptions(include_usage = true),
            )
            for (response in responses) {
                response.choices.firstOrNull()?.delta?.content?.asText()?.let { chunk ->
                    answer.append(chunk)
                    emit(answer.toString())
                }
                // The last message carries the statistics: this is where to read the real speed.
                response.usage?.extra?.let { stats ->
                    Log.d("MlcChat", "prefill ${stats.prefill_tokens_per_s}, decode ${stats.decode_tokens_per_s} tok/s")
                }
            }
            finished = true
        } finally {
            if (!finished) engine.reset()
            if (answer.isNotEmpty()) {
                history += ChatCompletionMessage(role = ChatCompletionRole.assistant, content = answer.toString())
            } else {
                history.removeAt(history.lastIndex)
            }
        }
    }.conflate().flowOn(Dispatchers.IO)

    override fun close() = engine.unload()
}
```

## ONNX Runtime GenAI

[onnxruntime-genai](https://github.com/microsoft/onnxruntime-genai) — расширение ONNX Runtime для генеративных моделей ([Java API](https://onnxruntime.ai/docs/genai/api/java.html)).

Модель для него — не один файл `.onnx`, а папка: файлы ONNX, `genai_config.json` и файлы токенизатора, и `SimpleGenAI` принимает путь к этой папке. Поэтому [onnx-community](https://huggingface.co/onnx-community) на Hugging Face, несмотря на название, обычно не помогает: большинство его репозиториев — экспорт для Transformers.js без `genai_config.json`. Официальный [пример для Android](https://github.com/microsoft/onnxruntime-inference-examples/tree/main/mobile/examples/phi-3/android) запускает [Phi-3 mini](https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-onnx), а другие модели можно сконвертировать через [model builder](https://onnxruntime.ai/docs/genai/howto/build-model.html).

### Как добавить в своё приложение

Библиотека — это AAR со страницы [релизов](https://github.com/microsoft/onnxruntime-genai/releases) плюс сам ONNX Runtime из Maven, как в официальном примере:

```groovy
implementation "com.microsoft.onnxruntime:onnxruntime-android:latest.release"
implementation files("libs/onnxruntime-genai-android-0.19.0.aar")
```

Отдельно про `genai_config.json`: он используется во время работы, и в нём есть важный параметр `past_present_share_buffer` — способ использования буфера KV-кэша. У меня с `true` большинство моделей не запускалось.

### Код

`SimpleGenAI` не хранит состояние и не применяет шаблон чата модели, поэтому весь диалог каждый раз уходит в него целиком и уже размеченным. `max_length` ограничивает промпт и ответ вместе, так что маленькое значение обрежет ответ, а `temperature` без `do_sample` не действует.

```kotlin
import ai.onnxruntime.genai.SimpleGenAI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn

class OnnxChat(modelDir: String) : LocalChat {

    private val genAi = SimpleGenAI(modelDir)
    private val dialogue = StringBuilder()

    override fun send(message: String): Flow<String> = channelFlow {
        // The markup is Phi-3's; another model needs its own.
        dialogue.append("<|user|>\n").append(message).append("<|end|>\n<|assistant|>\n")
        val answer = StringBuilder()
        try {
            genAi.createGeneratorParams().use { params ->
                params.setSearchOption("max_length", 2048.0)
                params.setSearchOption("do_sample", true)
                params.setSearchOption("temperature", 0.7)
                // The answer is assembled from the chunks: what generate() returns
                // is the whole sequence decoded, with the prompt at the start of it.
                genAi.generate(params, dialogue.toString()) { chunk ->
                    ensureActive() // an exception is the only way to stop generate() from inside
                    answer.append(chunk)
                    trySend(answer.toString())
                }
            }
        } finally {
            dialogue.append(answer).append("<|end|>\n")
        }
    }.conflate().flowOn(Dispatchers.IO)

    override fun close() = genAi.close()
}
```

## Модели внутри приложения

Для экспериментов модель можно положить в `assets` и скопировать в папку файлов приложения при первом запуске. Это только для экспериментов: при таких размерах лимиты Google Play оставляют Play Asset Delivery или скачивание при первом запуске, и моё приложение скачивает модели с Hugging Face. Копирование трогает только свою папку и выполняется один раз на каждую версию моделей:

```kotlin
import android.content.Context
import android.content.res.AssetManager
import java.io.File

fun copyModelsFromAssets(context: Context, modelsVersion: Int, assetsDir: String = "models"): File {
    val target = File(context.filesDir, assetsDir)
    // The marker is written last, so an interrupted copy starts over on the next launch.
    val marker = File(target, ".copied-$modelsVersion")
    if (marker.exists()) return target
    target.deleteRecursively()
    copyAssets(context.assets, assetsDir, target)
    marker.createNewFile()
    return target
}

private fun copyAssets(assets: AssetManager, path: String, target: File) {
    val children = assets.list(path).orEmpty()
    if (children.isEmpty()) {
        target.parentFile?.mkdirs()
        assets.open(path).use { input -> target.outputStream().use { input.copyTo(it) } }
    } else {
        children.forEach { copyAssets(assets, "$path/$it", File(target, it)) }
    }
}
```

Если хотите хранить модели в git-репозитории, нужна поддержка больших файлов — [Git LFS](https://git-lfs.com). Веса отслеживаем по расширению, а библиотеки движка по папке: общее правило `*.jar` захватит и `gradle/wrapper/gradle-wrapper.jar`, и клон без LFS больше не сможет запустить Gradle.

```bash
git lfs install

git lfs track "*.bin"
git lfs track "*.task"
git lfs track "*.litertlm"
git lfs track "*.tflite"
git lfs track "*.onnx"
git lfs track "*.onnx.data"
git lfs track "*.safetensors"
git lfs track "*.gguf"
git lfs track "dist/lib/mlc4j/output/**"

cat .gitattributes

git add .
```
