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 .