Локальний запуск LLM-моделей на Android-смартфоні
Статей на цю тему мало, тож викладу власний досвід. Приклади виросли з мого тестового проєкту; згодом із нього вийшов застосунок, про який є окрема стаття.
Огляд написано в середині 2025 року, і відтоді багато що змінилося. Google перевів MediaPipe LLM Inference API в режим лише підтримки і рекомендує переходити на LiteRT-LM. Я інтегрував LiteRT-LM у свій застосунок, і він дуже швидкий, тож тепер у нього власний розділ, а код і кроки збірки в інших розділах виправлено й оновлено.
Який рушій вибрати
- LiteRT-LM — з нього я б починав сьогодні: у моєму застосунку він дуже швидкий, Google використовує його у власних продуктах, а API сам зберігає стан діалогу. Моделі потрібні у форматі
.litertlm. - llama.cpp — вибір, коли потрібна конкретна модель:
.gguf— найпоширеніший формат, і на Hugging Face таких моделей тисячі. - MediaPipe досі працює, але новий проєкт я б на ньому не починав: нові можливості й оптимізації тепер ідуть у LiteRT-LM.
- MLC LLM та ONNX Runtime GenAI залишилися для мене експериментами. На Android обидва працювали повільно, а їхні моделі складаються з безлічі файлів, які незручно завантажувати й зберігати.
Решту я в застосунок не вбудовував, тож тут лише посилання. Звичайний LiteRT — рівнем нижче за MediaPipe: для LLM токенізатор доводиться додавати вручну, і переваг у цьому я не побачив. ExecuTorch — рантайм PyTorch для мобільних пристроїв, що замінив PyTorch Mobile, який більше не розвивається; до Android він підключається як AAR з Maven Central. Qualcomm AI Hub — хмарний сервіс, який оптимізує моделі під чипи Qualcomm і вимірює їх на реальних пристроях.
Як влаштовані приклади
Усі рушії нижче сховані за одним маленьким інтерфейсом, тож у коді залишається тільки те, чим вони відрізняються:
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 — це кінець відповіді, виняток — помилка рушія, а зупинка — звичайне скасування:
import androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport kotlinx.coroutines.Jobimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.catchimport kotlinx.coroutines.flow.launchInimport kotlinx.coroutines.flow.onCompletionimport 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 (посібник із Kotlin) — рушій Google для запуску LLM поверх LiteRT; його використовують Chrome, Chromebook Plus і Pixel Watch. Я інтегрував його у свій застосунок, і він дуже швидкий. Готові моделі .litertlm є в litert-community на Hugging Face: від Gemma 3 1B до Gemma 4 E4B.
Як додати у свій застосунок
implementation "com.google.ai.edge.litertlm:litertlm-android:0.19.0"Бібліотеку зібрано свіжим Kotlin, тому модулю, який її використовує, потрібен Kotlin 2.3 або новіший. Для GPU застосунок має явно запросити нативні бібліотеки — усередині <application>:
<uses-native-library android:name="libvndksupport.so" android:required="false" /><uses-native-library android:name="libOpenCL.so" android:required="false" />Код
import com.google.ai.edge.litertlm.Backendimport com.google.ai.edge.litertlm.ConversationConfigimport com.google.ai.edge.litertlm.Engineimport com.google.ai.edge.litertlm.EngineConfigimport com.google.ai.edge.litertlm.SamplerConfigimport kotlinx.coroutines.CancellationExceptionimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.conflateimport kotlinx.coroutines.flow.flowimport kotlinx.coroutines.flow.flowOnimport 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 — мабуть, найпопулярніший спосіб. Як він влаштований усередині, розібрано в статті про llama.cpp. У мобільних застосунках його часто використовують через обгортку для React Native llama.rn.
Застосунки на llama.cpp і схожих рушіях: PocketPal AI, MNN від Alibaba (у нього власний рушій, а не llama.cpp), LMPlayground.
Як додати у свій застосунок
Завантажуємо приклад llama.android і дивимося, як там усе влаштовано. У вашому проєкті структура, найпевніше, буде іншою, тож зверніть увагу на шлях у головному CMakeLists.txt. У моєму випадку всі компоненти лежать у теці llama_cpp:
add_subdirectory(llama_cpp build-llama)MediaPipe
MediaPipe (GitHub) запускає моделі у форматі TFLite (файли .tflite і .task). Готові моделі є в litert-community на Hugging Face і на Kaggle. Його класи для LLM тепер позначено як застарілі на користь LiteRT-LM.
Модель PyTorch можна сконвертувати через litert-torch, але я цього не робив. У TFLite статичні форми тензорів (batch = 1), тому розмір KV-кешу фіксується під час конвертації: саме це означає ekv2048 в імені на кшталт Gemma3-1B-IT_multi-prefill-seq_q4_ekv2048.task.
Як додати у свій застосунок
У MediaPipe є різні task для різних завдань, для LLM потрібен tasks-genai (Maven):
implementation "com.google.mediapipe:tasks-genai:0.10.35"Код
Уся складність тут у зупинці. generateResponseAsync повертає звичайний SettableFuture, тому future.cancel() скасовує лише обгортку: рушій далі рахує й тримає глобальне блокування, яке знімає тільки фінальний done=true. До цього моменту будь-який виклик на сесії кидає IllegalStateException — і close(), і наступний addQueryChunk(). По-справжньому рушій зупиняє cancelGenerateResponseAsync(), що з'явився у версії 0.10.24.
Сесія зберігає контекст діалогу, тому живе стільки ж, скільки діалог, у межах ліміту maxTokens.
import android.content.Contextimport com.google.mediapipe.tasks.genai.llminference.LlmInferenceimport com.google.mediapipe.tasks.genai.llminference.LlmInferenceSessionimport com.google.mediapipe.tasks.genai.llminference.ProgressListenerimport java.util.concurrent.Futureimport java.util.concurrent.TimeUnitimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.channels.awaitCloseimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.callbackFlowimport kotlinx.coroutines.flow.conflateimport 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 компілює модель під конкретний пристрій. Підтримка Android на той момент була експериментальною, обчислення йдуть через OpenCL. Документація: CLI, Android SDK, компіляція моделей. Готові бібліотеки й демо-застосунок — у binary-mlc-llm-libs.
Підготовка
Готові моделі з mlc-ai відкриті, акаунт для них не потрібен. Акаунт на Hugging Face і access token потрібні лише для того, щоб завантажити закриті оригінали на кшталт Gemma від Google і скомпілювати їх самостійно.
На комп'ютері мають бути встановлені інструменти — перевіряємо в PowerShell і ставимо те, чого бракує:
git --versiongit lfs --versionrustup --versioncargo --versionninja --versioncmake --versionjavac -versionjar --helpjavadoc --helpКоманда mlc_llm постачається Python-пакетом. Документація рекомендує середовище conda, а на Windows ще й ставити cmake і ninja з conda:
conda activate your-environmentconda install -c conda-forge cmake ninja git git-lfs zstdpython -m pip install --pre -U -f https://mlc.ai/wheels mlc-llm-nightly-cpu mlc-ai-nightly-cpuКлонуємо mlc-llm разом із підмодулями: серед них рантайм TVM, від якого залежить збірка. Архів не підійде:
git clone --recursive https://github.com/mlc-ai/mlc-llmЗадаємо змінні середовища для скрипта mlc_llm, наприклад у PowerShell. У JAVA_HOME не має бути пробілів — збірка на Windows із ними не справляється, тому JBR з Android Studio копіюємо в шлях без пробілів:
$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. Під час клонування Git спитає пароль — це access token:
git clone https://huggingface.co/google/gemma-3-1b-it-qat-q4_0-unquantizedДалі всі команди запускаємо з теки з моделлю.
Крок 1 — конвертація ваг:
mlc_llm convert_weight . --quantization q4f16_0 -o ..\RecompiledКоманда переводить ваги з вихідного формату (safetensors або PyTorch .bin) у формат MLC і заодно квантує їх. q4f16_0 означає: ваги зберігаються в 4 бітах, масштабні коефіцієнти — у float16. Суфікс — це розкладка ваг, і на телефонах вона важлива: на GPU Adreno моделі з розкладкою _1 вішають системний інтерфейс на 20–50 секунд під час першого prefill. Це відома проблема, якої немає в розкладки _0. Крапка — поточна тека з вагами, -o — куди зберегти результат.
Крок 2 — конфігурація:
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:
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; ваги залишаються там, і застосунок завантажує їх сам:
{ "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, а доставити їх на пристрій доведеться самому:
{ "device": "android", "model_list": [ { "model": "../Recompiled", "model_id": "gemma-3-1b-it-q4f16_0-MLC", "estimated_vram_bytes": 3000000000, "bundle_weight": true } ]}Докладніше — у документації з пакування бібліотек і ваг. З теки з цим файлом запускаємо:
mlc_llm packageКоманда створює dist/lib/mlc4j — Gradle-модуль із рантаймом і бібліотекою моделі. Підключаємо його в settings.gradle і додаємо в застосунок implementation project(':mlc4j'):
include ':mlc4j'project(':mlc4j').projectDir = file('dist/lib/mlc4j')Застосунок із targetSdk 31 (Android 12) і вище не отримує вендорську бібліотеку на кшталт OpenCL, доки не оголосить її. Офіційний застосунок MLCChat оголошує такі, усередині <application>:
<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(). Нічого не губиться, бо історія на боці застосунку.
import ai.mlc.mlcllm.MLCEngineimport ai.mlc.mlcllm.OpenAIProtocol.ChatCompletionMessageimport ai.mlc.mlcllm.OpenAIProtocol.ChatCompletionRoleimport ai.mlc.mlcllm.OpenAIProtocol.StreamOptionsimport android.util.Logimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.conflateimport kotlinx.coroutines.flow.flowimport 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 — розширення ONNX Runtime для генеративних моделей (Java API).
Модель для нього — не один файл .onnx, а тека: файли ONNX, genai_config.json і файли токенізатора, і SimpleGenAI приймає шлях до цієї теки. Тому onnx-community на Hugging Face, попри назву, зазвичай не допомагає: більшість його репозиторіїв — експорт для Transformers.js без genai_config.json. Офіційний приклад для Android запускає Phi-3 mini, а інші моделі можна сконвертувати через model builder.
Як додати у свій застосунок
Бібліотека — це AAR зі сторінки релізів плюс сам ONNX Runtime з Maven, як в офіційному прикладі:
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 не діє.
import ai.onnxruntime.genai.SimpleGenAIimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.ensureActiveimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.channelFlowimport kotlinx.coroutines.flow.conflateimport 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. Копіювання чіпає тільки свою теку й виконується один раз на кожну версію моделей:
import android.content.Contextimport android.content.res.AssetManagerimport 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. Ваги відстежуємо за розширенням, а бібліотеки рушія за текою: загальне правило *.jar захопить і gradle/wrapper/gradle-wrapper.jar, і клон без LFS більше не зможе запустити Gradle.
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 .