Roman Kryvolapov Engineering Blog

Локальний запуск 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.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 (посібник із 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.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 — мабуть, найпопулярніший спосіб. Як він влаштований усередині, розібрано в статті про 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.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 компілює модель під конкретний пристрій. Підтримка Android на той момент була експериментальною, обчислення йдуть через OpenCL. Документація: CLI, Android SDK, компіляція моделей. Готові бібліотеки й демо-застосунок — у binary-mlc-llm-libs.

Підготовка

Готові моделі з mlc-ai відкриті, акаунт для них не потрібен. Акаунт на Hugging Face і access token потрібні лише для того, щоб завантажити закриті оригінали на кшталт Gemma від Google і скомпілювати їх самостійно.

На комп'ютері мають бути встановлені інструменти — перевіряємо в PowerShell і ставимо те, чого бракує:

Terminal window
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:

Terminal window
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, від якого залежить збірка. Архів не підійде:

Terminal window
git clone --recursive https://github.com/mlc-ai/mlc-llm

Задаємо змінні середовища для скрипта mlc_llm, наприклад у PowerShell. У JAVA_HOME не має бути пробілів — збірка на Windows із ними не справляється, тому JBR з Android Studio копіюємо в шлях без пробілів:

Terminal window
$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:

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

Далі всі команди запускаємо з теки з моделлю.

Крок 1 — конвертація ваг:

Terminal window
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 — конфігурація:

Terminal window
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:

Terminal window
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
}
]
}

Докладніше — у документації з пакування бібліотек і ваг. З теки з цим файлом запускаємо:

Terminal window
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.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 — розширення 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.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. Копіювання чіпає тільки свою теку й виконується один раз на кожну версію моделей:

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. Ваги відстежуємо за розширенням, а бібліотеки рушія за текою: загальне правило *.jar захопить і gradle/wrapper/gradle-wrapper.jar, і клон без LFS більше не зможе запустити Gradle.

Terminal window
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 .