Локальный запуск 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 .