marc mayol
← Volver al blog

Gemma en Android: cómo ejecutar un LLM on-device con LiteRT

Gemma en Android: cómo ejecutar un LLM on-device con LiteRT

Este artículo es la parte práctica de mi guía para ejecutar un LLM en local. Aquí voy al grano. Meto Gemma en un móvil Android y hago que funcione sin conexión, sin que tus datos salgan del teléfono, que es lo que de verdad importa y lo que conté en el artículo sobre Shadow AI.

Lo hice para Balùa, mi app que mira la foto de un plato y te dice qué alimentos hay. Fine-tuneé Gemma para que devolviera un JSON con los alimentos, lo exporté al formato de LiteRT y lo ejecuté en un Pixel 8 Pro. Funcionó, con matices. Te cuento los pasos, los errores y lo que tardaba.

Cuando busqué cómo hacerlo, todo lo que encontraba estaba en inglés y casi nada contaba los fallos, ni siquiera los issues #994 y #1039 de litert-torch, que daban por imposible exportar Gemma 3n con visión. Así que aquí van, en español.

¿Qué necesitas para ejecutar Gemma en Android?

Necesitas tres cosas: un modelo Gemma en formato .litertlm, un runtime en la app que lo cargue (LiteRT-LM, o MediaPipe LLM Inference, que es el que usé yo) y un móvil Android arm64 con al menos 6 GB de RAM. Si no vas a fine-tunear, descarga el modelo ya convertido por Google y te ahorras la parte difícil.

Las tres piezas de Gemma en Android, en orden:

Mi recomendación es simple. Si el modelo oficial te sirve, úsalo: el de Gemma 3n E2B en int4 identificaba comida en mi Pixel sin tocar nada. Lo que viene en la siguiente sección solo hace falta si has entrenado tu propia versión.

¿Cómo exporto Gemma al formato .litertlm?

Se exporta con litert-torch, que solo tiene versiones para Linux y macOS. En Windows, usa WSL. La clave es fijar transformers en la 4.56.2, dar mucha memoria de intercambio a WSL y tener en la carpeta del modelo el tokenizer SentencePiece y el special_tokens_map.json con eoi_token. Con eso, un solo comando.

Los issues de litert-torch (#994 y #1039) daban por roto exportar Gemma 3n con visión. Se puede. Este es el entorno que me funcionó, tal cual está en el cookbook:

uv venv exportenv --python 3.12
UV_LINK_MODE=copy uv pip install 'litert-torch==0.9.1' 'transformers==4.56.2' pillow torchvision timm

La versión de transformers es lo más importante. Con la 5.x tuve que parchear litert-torch porque el modelo cambió de estructura por dentro. El export terminaba sin errores y el modelo salía roto: generaba cosas como df online df online.... Con la 4.56.2 no hacen falta esos parches.

Un detalle de uv: usa enlaces duros. Si reutilizas un entorno, reinstala con UV_LINK_MODE=copy para no arrastrar parches de otro.

Memoria. El paso que crea el módulo MLIR con un modelo de unos 11 GB se quedaba sin memoria con 31 GB de RAM y WSL lo mataba con un Killed. Lo arreglé dando a WSL 28 GB y 72 GB de swap en .wslconfig, y reiniciando con wsl --shutdown.

Dos ficheros que tienen que estar en la carpeta del modelo de Gemma 3n:

  1. tokenizer.model (SentencePiece, unos 4,7 MB). El tokenizer rápido de Gemma 3n solo trae tokenizer.json, y entonces MediaPipe falla con SentencePiece tokenizer is not found in the model. Descárgalo de google/gemma-3n-E2B-it.
  2. special_tokens_map.json con eoi_token. Si falta, el export peta con KeyError: 'eoi_token'. Cópialo del modelo base.

Y el comando para exportar Gemma 3n con visión:

litert-torch export_hf /ruta/merged_gemma3n /out \
  --task=image_text_to_text --export_vision_encoder \
  --quantization_recipe=dynamic_wi4_afp32

Tarda unos 30 minutos y sale un model.litertlm de unos 2,7 GB con visión. Ojo con el nombre de la receta de cuantización, que no es intuitivo:

RecetaQué haceCuándo usarla
dynamic_wi4_afp32Pesos en 4 bitsPrimera prueba, menor tamaño
dynamic_wi8_afp32Pesos en 8 bits, unas 2 veces más grandeSi int4 degrada la salida
weight_only_wi4_afp32 / wi8Solo cuantiza pesosAlternativa a las dinámicas
static_wi8_ai8 / ai16Pesos y activacionesCuando necesites exprimir más

dynamic_int4 no existe, aunque sea lo primero que escribes. La de 4 bits se llama dynamic_wi4_afp32.

¿Cómo integro el modelo en la app?

Copia el .litertlm a la carpeta externa de la app, nunca a /data/local/tmp, y cárgalo con el runtime desde un hilo en segundo plano. Con MediaPipe, el backend de GPU me cerraba la app. Forcé CPU. Deja maxTokens en 1024 como mínimo, porque solo la imagen ya consume unos 258 tokens.

El modelo no cabe en el APK. Durante el desarrollo lo subo con adb push a Android/data/<paquete>/files/, que es la carpeta que devuelve getExternalFilesDir. La app la lee sin permisos especiales. Desde /data/local/tmp la app no puede leerlo y falla sin avisar claro.

La configuración de MediaPipe que usé, del cookbook:

LlmInference.LlmInferenceOptions.builder()
    .setModelPath(path)                       // en getExternalFilesDir, NO /data/local/tmp
    .setMaxTokens(1024)                        // input imagen ~258 tokens; no bajar
    .setPreferredBackend(LlmInference.Backend.CPU)   // el backend GPU CASCA (SIGSEGV)
    ...

Tres problemas que me encontré en el Pixel 8 Pro:

Si empiezas hoy, mira LiteRT-LM antes que MediaPipe. Según la documentación oficial, configuras un EngineConfig con la ruta del modelo y el backend (con uno aparte para la visión), inicializas el Engine fuera del hilo principal y conversas con createConversation(). Yo no lo he probado aún en Balùa, así que no te puedo contar sus fallos.

Un consejo de diseño: pon el analizador detrás de una interfaz. En Balùa, si no encontraba el .litertlm, la app caía a un analizador simulado. Así pude desarrollar la interfaz sin tener el modelo de 2,7 GB en el móvil y, más tarde, cambiar de Gemma a Qwen sin tocar el resto. Es lo que cuento en arquitectura hexagonal y LLMs.

¿Qué rendimiento puedo esperar?

Depende de la tarea. Para texto, Google publica para Gemma 4 E2B unos 47 tokens por segundo en CPU y 52 en GPU en un Galaxy S26 Ultra. Para una foto con Gemma 3n en CPU, en mi Pixel 8 Pro, cada análisis tardaba varios minutos, con la CPU al 365-421 % y unos 3 GB de RAM.

Estas son las cifras de Google para Gemma 4 E2B con LiteRT-LM, sacadas de su ficha en Hugging Face (1.024 tokens de entrada, 256 de salida):

DispositivoBackendPrefill (tok/s)Decode (tok/s)Primer tokenMemoria
Galaxy S26 UltraCPU55746,91,8 s1.733 MB
Galaxy S26 UltraGPU3.80852,10,3 s676 MB
Mi Pixel 8 Pro, Gemma 3n con fotoCPU (MediaPipe)n/dn/dvarios minutos por foto~3 GB

La última fila es mía y no es comparable del todo: otro modelo (Gemma 3n), otro runtime (MediaPipe), un Pixel 8 Pro frente a un Galaxy S26 Ultra y, sobre todo, una foto como entrada. Codificar la imagen pesa mucho. Pero es lo que viví, y sirve para no prometer respuestas instantáneas en una app multimodal.

¿Qué falla y cómo lo detectas?

Lo que más falla es la conversión. El export termina bien y el modelo sale roto. Para detectarlo, compáralo con tu modelo en bf16 en el PC y con el .litertlm oficial de Google, siempre con dos imágenes distintas: si dos fotos diferentes dan la misma respuesta, el codificador de visión se ha roto.

Este fue mi caso. Con el export ya arreglado, mi Gemma 3n fine-tuneado devolvía un JSON impecable. Pero con dos fotos distintas daba la misma lista de alimentos, con las mismas raciones. Aislé el problema así:

PruebaResultadoQué me dijo
Mi modelo en bf16 en el PCPerfectoEl fine-tuning y el prompt estaban bien
.litertlm oficial de Google en el PixelVeía: comida → alimentos, retrato → “Hombre”El runtime estaba bien
Mi modelo exportado, int4Alimentos inventados, palabras corruptasLa cuantización degradaba
Mi modelo exportado, int8JSON limpio, pero igual para cualquier fotoLa visión se perdía en la conversión

Tampoco había plan B dentro de MediaPipe: su soporte de LoRA es solo para Gemma 2B, Gemma-2 2B y Phi-2, solo en GPU y sin visión.

Conclusión: el fine-tuning en bf16 funcionaba. Desplegar Gemma 3n multimodal con LoRA fusionado y la visión intacta estaba bloqueado por el tooling, a fecha de mis pruebas.

¿Con qué me quedé al final?

Para Balùa acabé cambiando de modelo y de runtime: Qwen2.5-VL de 3B en GGUF, con llama.cpp y GPU por Vulkan. Esa ruta sí conservó la visión. Gemma sigue siendo buena opción, eso sí, si te vale el modelo oficial o si tu caso es solo texto.

Con Qwen, la carga y la primera foto en el Pixel bajaron a unos 3 minutos, frente a unos 10 en CPU, y las siguientes fotos a unos 2 min 30 s. El modelo ocupa 1,8 GB más 1,3 GB del proyector de visión.

En junio también entrené Gemma 4 E2B, pero entonces MediaPipe no lo soportaba. Hoy Google ya publica Gemma 4 en .litertlm y LiteRT-LM lo ejecuta en Android. Es lo siguiente que quiero probar.

Si estás decidiendo si te compensa ejecutar un modelo en el dispositivo, qué tamaño elegir o qué cuantización usar, empieza por la guía de IA on-device. Y las recetas completas, con los errores exactos, están en el On-Device AI Cookbook.