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 и ги измерва на реални устройства.

Как са устроени примерите

Всички двигатели по-долу са скрити зад един малък интерфейс, така че в кода остава само това, с което се различават:

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 (ръководство за Kotlin) е двигателят на Google за стартиране на LLM върху LiteRT; използват го Chrome, Chromebook Plus и Pixel Watch. Интегрирах го в своето приложение и той е много бърз. Готови модели .litertlm има в litert-community в Hugging Face: от Gemma 3 1B до Gemma 4 E4B.

Как да го добавите към своето приложение

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 е може би най-популярният начин. Как е устроен отвътре, е разгледано в статията за llama.cpp. В мобилните приложения често се използва чрез обвивката за React Native llama.rn.

Приложения на llama.cpp и близки двигатели: PocketPal AI, MNN на Alibaba (има собствен двигател, а не llama.cpp), LMPlayground.

Как да го добавите към своето приложение

Изтегляме примера llama.android и разглеждаме как е устроено всичко. В проекта ви структурата най-вероятно ще е различна, затова обърнете внимание на пътя в главния CMakeLists.txt. При мен всички компоненти са в папката llama_cpp:

CMake
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):

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

Подготовка

Готовите модели от mlc-ai са отворени, акаунт за тях не е нужен. Акаунт в Hugging Face и 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, от който зависи билдът. Архив не става:

Shell
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. При клонирането Git ще поиска парола – това е access token:

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

Нататък всички команди изпълняваме от папката с модела.

Стъпка 1 – конвертиране на теглата:

Shell
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 – конфигурация:

Shell
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:

Shell
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
}
]
}

Повече подробности – в документацията за пакетиране на библиотеки и тегла. От папката с този файл изпълняваме:

Shell
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 е разширение на 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, както в официалния пример:

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. Теглата следим по разширение, а библиотеките на двигателя – по папка: общото правило *.jar ще засегне и gradle/wrapper/gradle-wrapper.jar, и клонинг без LFS вече няма да може да стартира Gradle.

Shell
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 .