---
title: "Локальний запуск LLM-моделей на Android-смартфоні"
url: "https://romankryvolapov.com/uk/llm-on-android/"
description: "Чим запустити мовну модель просто на Android: LiteRT-LM, llama.cpp, MediaPipe, MLC LLM та ONNX Runtime GenAI — що вибрати, як підготувати модель і код на Kotlin, який правильно зупиняє генерацію."
language: uk
published: 2025-05-13
updated: 2026-11-05
---
Статей на цю тему мало, тож викладу власний досвід. Приклади виросли з мого тестового проєкту; згодом із нього вийшов застосунок, про який є [окрема стаття](/uk/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](/uk/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 .
```
