Локално стартиране на 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 .