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