Gemma local era demasiado lento con AIdaemon hasta que arreglé llama.cpp y el tamaño del prompt
Ejecuto AIdaemon en mi Mac la mayoría de los días. Es el demonio de agente autoalojado que construí en Rust. Durante meses, el backend de LLM fue OpenRouter, además de Gemini, cuyo nivel gratuito era generoso. Una vez que se agotó, pagaba unos pocos dólares al mes por algo que podía alojar yo mismo. Solo quería probar la inferencia local con la familia Gemma de Google sin añadir un tiempo de ejecución que aún no usara.
Tenía llama.cpp instalado a través de Homebrew y un Gemma 4 26B MoE GGUF en disco (unsloth/gemma-4-26B-A4B-it, Q4_K_M), unos dieciséis gigabytes, en un M4 Pro con 48 GB de memoria unificada. Ollama habría sido el camino fácil. Lo omití a propósito, ya que de todos modos envuelve llama.cpp y quería las banderas de rendimiento directamente.
La pila terminó viéndose así.
Telegram / Slack → AIdaemon → llama-server (API compatible con OpenAI) → Gemma 4 26B GGUF
AIdaemon no carga los pesos del modelo. Habla con cualquier cosa que parezca la API de chat de OpenAI. El llama-server de llama.cpp encaja en esa forma.
Ponerlo en marcha en general
El primer obstáculo fueron los puertos. AIdaemon ya reserva el 8080 para comprobaciones de estado y devoluciones de llamada de OAuth. llama-server usa el mismo puerto por defecto. Puse la inferencia en el 8081.
El segundo obstáculo fue el modo de pensamiento. Gemma 4 viene con el razonamiento/pensamiento activado en la plantilla de chat. llama-server registró thinking = 1 al inicio. Las respuestas aterrizaron en reasoning_content mientras que content volvía vacío. AIdaemon lee content. Desde Telegram parecía que el modelo se había quedado en silencio.
La solución fue una bandera.
llama-server \
-m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--jinja \
--reasoning off \
-c 16384 \
-ngl 99 \
--alias gemma-4-26b \
--host 127.0.0.1 \
--port 8081
--jinja es importante para la plantilla de Gemma 4. --reasoning off es importante para AIdaemon. Sin él, estás depurando el agente cuando el modelo en realidad está respondiendo en un campo que nada lee.
Y lo mantendría desactivado incluso sin ese error. El pensamiento consume una gran cantidad de tokens adicionales antes de cada respuesta, lo que supone una latencia real en un modelo local, y un agente recupera parte de ese razonamiento de forma gratuita trabajando en una tarea a través de llamadas a herramientas con retroalimentación real en lugar de un largo monólogo interno. Estoy intercambiando un poco de margen de razonamiento profundo por velocidad, lo que para un asistente local rápido es la decisión correcta, y puedo volver a activarlo para la tarea rara que realmente lo necesite.
Por el lado de AIdaemon, el bloque del proveedor apuntaba al servidor local.
[provider]
api_key = "local"
base_url = "http://127.0.0.1:8081/v1"
kind = "openai_compatible"
max_tokens = 4096
[provider.models]
default = "gemma-4-26b"
fallback = []
Mantuve OpenRouter como una entrada [[provider.fallbacks]] para que un llama-server inactivo no dejara inutilizable el demonio. El nombre del modelo local debe coincidir con --alias en llama-server, no con el slug del repositorio de Hugging Face.
Prueba de humo antes de tocar Telegram.
curl http://127.0.0.1:8081/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemma-4-26b","messages":[{"role":"user","content":"Say hi."}],"max_tokens":50}'
Si content está vacío y reasoning_content está lleno, el pensamiento sigue activado.
Por qué todavía se sentía lento
Los mensajes simples estaban bien. El trabajo del agente no. Enviaba algo normal en Telegram y esperaba. Y esperaba.
El registro de llama-server contaba la historia. Prompts de alrededor de 14.500 tokens. Eso no es un error tipográfico y no es un único mensaje de usuario largo. En un modelo de contexto 16k, AIdaemon presupuesta mensajes más esquemas de herramientas a aproximadamente 14.8k y reserva el resto para la salida. Me estaba acercando al límite en cada turno.
Algunas cosas llenan esa carga útil.
- Prompt del sistema. Reglas de operación, barreras de seguridad, lista de especialistas, contexto del canal. Una plantilla estática grande. En iteraciones posteriores del bucle, omite la guía de herramientas markdown, pero el prompt principal sigue siendo de miles de tokens.
- Esquemas JSON de herramientas, enviados por separado de los mensajes de chat en cada llamada a LLM. Con mi instalación completa, eso son aproximadamente de 35 a 40 herramientas integradas, más cualquier herramienta MCP que coincida con el turno. Nombres, parámetros, campos requeridos, enums. Las descripciones suman, incluso después de la compactación.
- Historial de conversación. Giros recientes colapsados, resumen de sesión opcional y resultados completos de herramientas para la interacción actual. Algunas salidas voluminosas de
terminaloread_filepueden rivalizar con el costo del esquema. - Memoria, y no todo tu almacén de hechos volcado. Un pequeño pin de hechos críticos e instrucciones para recuperarlo a través de herramientas de memoria cuando sea necesario.
La primera iteración de un turno es peor. El prompt del sistema todavía incluye la documentación de herramientas markdown y los esquemas JSON se envían en paralelo. Las interfaces de chat envían una burbuja. Los demonios de agente envían un manual de operación más un catálogo de herramientas más lo que sea que devolvió el último comando.
La inferencia local tiene dos velocidades y se comportan de manera muy diferente.
- Prefill es el modelo leyendo tu prompt. El tiempo escala con el tamaño del prompt. Aquí es donde los flujos de trabajo del agente perjudican.
- Generación es el modelo escribiendo la respuesta. El rendimiento se mantiene aproximadamente plano, ya sea que el prompt fuera corto o largo.
En mi máquina, esa distinción importaba más que cualquier bandera individual de llama.cpp.
Velocidad de inferencia en mi M4 Pro
Hardware para estos números. Apple M4 Pro, 48 GB de memoria unificada, gemma-4-26B-A4B-it-UD-Q4_K_M.gguf (Unsloth Q4_K_M), llama.cpp build 9140, descarga completa de Metal (-ngl 99), configuración optimizada de ranura única a continuación. Gemma 4 26B es MoE, aproximadamente 4B parámetros activos en el momento de la inferencia. La generación se siente más cercana a un modelo de tamaño mediano. El prefill todavía recorre todo el prompt.
Extraje los tiempos del campo timings de llama-server en la respuesta JSON compatible con OpenAI después de un modelo caliente. El mismo servidor que se ejecuta hoy.
| Tamaño del prompt | Tokens de entrada | Tiempo de pared del prefill | tok/s de prefill | tok/s de generación |
|---|---|---|---|---|
| Chat corto (caliente) | 49 | 0.2 s | ~230 | ~48 |
| Giro de agente pequeño | ~1.000 | 1.6 s | ~650 | ~44 |
| Contexto mediano | ~5.000 | 6.2 s | ~630 | ~43 |
| Contexto grande | ~10.000 | 8.9 s | ~550 | ~40 |
| Giro real de AIdaemon | ~14.500 | 8.4 s | ~480 | ~35 |
La generación se mantuvo cerca de 40 a 48 tok/s en general. El prefill dominó. Un prompt de agente de ~14.5k tokens tardó aproximadamente ocho segundos y medio en procesar la entrada antes del primer token de salida. Eso no es un servidor colgado. Esa es la fase de lectura que el modelo está terminando.
Cálculo rápido para un salto de LLM de agente en esta configuración.
- ~14.5k prefill a ~480 tok/s ≈ 8 s antes de que algo regrese
- Respuesta de ~200 tokens a ~40 tok/s ≈ 5 s de generación
- Un salto ≈ 13 s como mínimo, antes de la ejecución de la herramienta o un segundo salto
Un bucle de agente de tres iteraciones con llamadas a herramientas puede fácilmente sentarse en más de cuarenta segundos de tiempo de modelo solo. Telegram se siente roto mucho antes de que el hardware esté realmente luchando.
Compara eso con un prompt de chat tonto. "Di hola en una frase" en la configuración predeterminada de llama-server de cuatro ranuras midió aproximadamente 48 tok/s de prefill y 52 tok/s de generación en la misma máquina. Después de cambiar a --parallel 1 y las banderas de lote/caché, la misma prueba curl corta saltó a aproximadamente 127 tok/s de prefill con la generación todavía alrededor de 57 tok/s. La optimización del servidor principalmente movió la aguja en el prefill para prompts pequeños y la sobrecarga de memoria. No borró el impuesto de ocho segundos en un contexto de agente de 14k.
La configuración predeterminada de llama-server empeoró el caso del agente, y la perilla que me sorprendió fue --parallel.
llama-server no ejecuta una conversación a la vez internamente. Mantiene ranuras separadas. Cada ranura es una ventana de contexto completa con su propia caché KV en memoria. Cuando llega una solicitud, el servidor elige una ranura, carga tu prompt en ella y genera a partir de ahí. Una segunda solicitud puede usar una ranura diferente al mismo tiempo sin borrar la primera conversación.
--parallel establece cuántas ranuras existen. Si lo omites, las compilaciones recientes de llama.cpp eligen auto, que en mi Mac significó cuatro ranuras. El inicio registró n_parallel is set to auto, using n_parallel = 4 y initializing slots, n_slots = 4.
Cuatro ranuras tienen sentido cuando una GPU sirve a varios clientes. Una interfaz de navegador, una prueba curl, tal vez un segundo usuario. El servidor puede gestionar chats concurrentes.
Dentro de un chat, AIdaemon es mayormente serial. Telegram, Slack y Discord ponen en cola los mensajes por sesión para que no tengas dos bucles de agente compitiendo por el mismo hilo. Pero esa no es toda la imagen. Una meta de cron programada puede generar un líder de tarea en segundo plano mientras chateas en Telegram. Una segunda meta puede hacer lo mismo. Slack y Telegram son sesiones diferentes, por lo que ambas pueden acceder al modelo a la vez si estás activo en ambas.
Para mi configuración, esa superposición era rara. Un chat de Telegram, un puñado de comprobaciones programadas, no suelen ser en el mismo segundo. El --parallel 4 predeterminado todavía significaba que tres ranuras permanecían inactivas la mayor parte del tiempo mientras reservaban caché KV y RAM de caché de prompt. Vi que la caché de prompt crecía más allá de tres gigabytes durante las pruebas. Cuando bajé a --parallel 1, las solicitudes concurrentes de AIdaemon no se rompieron. llama-server las pone en cola y ejecuta una a la vez. Esperas tu turno en lugar de compartir memoria de GPU a través de carriles vacíos.
Si ejecutas varias metas programadas a la vez de forma rutinaria, o vives en Telegram mientras cron se dispara cada minuto, prueba --parallel 2 o 3 en lugar de 1. Intercambias algo de velocidad de solicitud única por no serializar cada salto superpuesto. Haz coincidir el número de ranuras con cuántas llamadas a LLM realmente superpones, no con el valor predeterminado de cuatro.
Establecer --parallel 1 lo colapsó a una ranura. Los registros mostraron n_slots = 1. Todo el presupuesto de caché KV fue para el único giro del agente que realmente estaba ejecutando.
Banderas de llama-server que realmente ayudaron
Reinicié llama-server con un perfil de una sola ranura y un solo usuario.
llama-server \
-m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--jinja \
--reasoning off \
-c 16384 \
-ngl 99 \
--parallel 1 \
--flash-attn on \
--cache-type-k q8_0 \
--cache-type-v q8_0 \
--cache-ram 1024 \
-b 4096 \
-ub 1024 \
--prio 2 \
--alias gemma-4-26b \
--host 127.0.0.1 \
--port 8081
--parallel 1 fue la mayor ganancia en el lado de la inferencia para mi configuración. No porque el modelo se volviera más inteligente. Porque dejé de pagar por tres carriles de conversación vacíos que nunca usé. Eso se mantuvo hasta que comencé a reutilizar la caché entre giros y los trabajos en segundo plano se convirtieron en el problema. Más sobre eso a continuación.
--flash-attn on y los tipos de caché KV q8_0 ayudaron en Apple Silicon. Limitar --cache-ram detuvo la caché de prompt de crecer desmesuradamente durante sesiones largas. Tamaños de lote más grandes (-b 4096, -ub 1024) aceleraron el prefill en prompts grandes. --prio 2 empujó el proceso hacia arriba en el planificador. Pequeña cosa, pero cuando estás iterando en la configuración, ayuda.
En prompts cortos, el prefill pasó de aproximadamente 48 tok/s a 127 tok/s. La generación se mantuvo alrededor de 57 tok/s. Eso confirmó que la optimización del servidor valió la pena. También confirmó algo más. A ~14k tokens todavía estás mirando más de ocho segundos de prefill sin importar qué. La siguiente palanca tenía que ser el tamaño del prompt.
Reducir lo que envía AIdaemon
La optimización del servidor por sí sola no borra un prefill de ocho segundos cuando estás fijado cerca de 15k tokens en cada salto. La otra mitad fue enseñar a AIdaemon a respetar una ventana de 16k cuando el modelo es local, y a compactar lo que envía antes de la llamada en lugar de esperar que el servidor lo soporte.
Añadí un presupuesto por modelo en config.toml.
[state.context_window.model_budgets]
gemma-4-26b = 16384
Ese número debe coincidir con -c en llama-server. Si AIdaemon piensa que tiene 128k tokens pero el servidor solo tiene 16k, estás pagando por trabajo que se trunca o falla de forma extraña.
En el código, la fase de construcción del mensaje ejecuta fit_tool_definitions_to_budget() antes de cada llamada a LLM. Nunca omite herramientas. Recorta metadatos en etapas. Las descripciones se acortan, las anotaciones de esquema y los ejemplos se eliminan, hasta que las herramientas serializadas encajan en el presupuesto restante después de contar el prompt del sistema y el historial. Hay un segundo pase después de que se ensambla el prompt completo, porque esas inserciones pueden consumir el margen que pensabas que te quedaba.
El agente todavía expone todas las herramientas. Simplemente deja de enviar texto de esquema de longitud de ensayo que el modelo local no necesita para elegir terminal sobre read_file. En un modelo en la nube de 48k o 128k, es posible que nunca lo notes. En 16k local, es la diferencia entre un salto utilizable y ocho segundos de silencio.
También eliminé reasoning_effort en el proveedor local. Eso es para modelos de pensamiento en la nube. La ruta de pensamiento de Gemma es diferente y ya la hemos deshabilitado en llama-server.
Eso hace que Gemma local sea utilizable. No significa que 14k tokens sea el objetivo. Todavía estoy buscando dónde el prompt puede reducirse aún más. Documentación de herramientas duplicada en la primera iteración, un prompt del sistema más ligero cuando el presupuesto del modelo es pequeño, filtrado de herramientas más inteligente para que las ejecuciones locales no lleven un catálogo del tamaño de la nube. La compactación fue la solución que me desbloqueó; la siguiente ronda se trata de enviar cada pieza de contexto una vez.
La verdadera solución fue reutilizar el prompt, no solo reducirlo
Reducir el prompt ayudó, pero todavía pagaba un prefill en cada turno. Entonces hizo clic. El modelo no debería tener que releer los mismos 15.000 tokens dos veces. Casi todo el prompt de un agente es idéntico de un turno a otro. El prompt del sistema, los esquemas de herramientas, los mensajes más antiguos. Solo el nuevo mensaje del usuario y el último resultado de la herramienta cambian, y se encuentran al final.
llama.cpp ya sabe cómo aprovechar eso. Mantiene la caché KV del turno anterior. Si el inicio de tu próximo prompt es byte por byte idéntico al último, reutiliza ese trabajo en caché y salta directamente a los nuevos tokens. Eso es un inicio en caliente y es rápido. Si algo cerca del principio difiere, incluso un solo token, no puede confiar en el resto, por lo que descarta la caché y lee todo de nuevo. Ese es un inicio en frío y es el camino lento que había estado golpeando en cada turno.
El problema era que AIdaemon seguía cambiando la parte frontal del prompt sin querer. Una marca de tiempo que avanzaba, un bloque de memoria que se reordenaba, un turno antiguo que se resumía un poco diferente. Pequeñas ediciones, pero aterrizaban cerca del principio, por lo que la caché nunca coincidía y cada turno se volvía frío. La solución fue hacer que la parte frontal fuera aburrida. Un bloque de sistema estable, y giros antiguos congelados en una forma fija en el momento en que salen de la ventana activa, nunca más reescritos. Después de eso, los primeros 15.000 tokens de cada prompt eran idénticos al turno anterior, y llama.cpp finalmente pudo reutilizarlos.
Eso también cambió mi opinión sobre --parallel. Una sola ranura era la más rápida para una solicitud aislada, pero AIdaemon realiza trabajos de memoria y resumen en segundo plano en el mismo servidor, y cada uno de esos trabajos seguía aterrizando en la ranura de mi chat y borrando la caché que intentaba mantener caliente. Así que pasé a --parallel 2, fijé mi conversación a una ranura y envié los trabajos en segundo plano a la otra. Ahora el mantenimiento se procesa en su propio carril y mi chat se mantiene caliente.
La bandera que nadie menciona
Prompt estable, mi propia ranura, y cada nuevo turno todavía estaba frío. Casi me rindo. Luego leí el registro de llama-server una vez más.
forcing full prompt re-processing due to lack of cache data
(likely due to SWA or hybrid/recurrent memory)
SWA significa atención de ventana deslizante. En la mayoría de sus capas, Gemma 4 solo mira una ventana de tokens recientes en lugar de todo el historial. Eso es parte de lo que hace que un modelo de 26B sea tan barato de ejecutar. La trampa es que, por defecto, llama.cpp solo almacena esa pequeña ventana, por lo que en el momento en que un nuevo turno cambia las posiciones de los tokens, no queda nada para reutilizar, y comienza de nuevo. Todo mi cuidadoso trabajo de estabilidad de prompts no pudo sobrevivir a un esquema de atención que descarta la mayor parte de su propia caché.
Una bandera lo arregló.
llama-server \
-m ~/models/llm/gemma-4-26b/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--jinja --reasoning off \
-c 131072 \
-ngl 99 \
--parallel 2 \
--swa-full \
--flash-attn on \
--cache-type-k q8_0 --cache-type-v q8_0 \
--cache-ram 12288 \
-b 4096 -ub 1024 \
--alias gemma-4-26b --host 127.0.0.1 --port 8081
--swa-full le dice a llama.cpp que mantenga una caché de tamaño completo para las capas con ventana en lugar de la porción. Cuesta más memoria, bastante más en un modelo que tiene la mayoría de capas con ventana, pero tengo 48 GB y una conversación que finalmente se mantiene caliente. En Gemma, esa única bandera es la diferencia total entre reutilizar la caché entre giros y releer el prompt cada vez. Sin ella, la estabilidad del prompt y la fijación de ranuras te compran casi nada.
Los números se movieron como quería. Un seguimiento que solía releer unos 15.000 tokens y se detenía durante unos treinta segundos, ahora relee unos 1.300 y responde en un par. Mismo modelo, mismo hardware, misma respuesta, aproximadamente un noventa por ciento menos de trabajo por turno.
Solo encontré esto porque AIdaemon me lo dijo
Nada de esto se pudo encontrar por intuición. "Parece lento" no es un informe de errores. Lo que lo hizo manejable es que AIdaemon registra la anatomía de cada llamada al modelo. El tamaño del prompt, cuántos tokens de entrada se sirvieron de la caché frente a los leídos frescos, una huella digital de cada parte del prompt y qué trabajo en segundo plano se ejecutó cuándo.
El número de caché frente a fresco fue la clave. En un turno que debería haber estado caliente, ver el recuento fresco saltar de nuevo a quince mil significaba que la caché se había roto, y las huellas digitales por sección mostraban exactamente qué parte del prompt había cambiado para romperla. Así es como capté primero la rotación del prompt, luego los trabajos en segundo plano robando la ranura, luego SWA. Tres culpables diferentes escondidos detrás del mismo síntoma de una respuesta lenta. Sin esa telemetría, habría estado cambiando banderas al azar.
Si te llevas una cosa de esto, haz que tu agente local sea observable. El modelo es una caja negra y el servidor es mayormente una caja negra. Tu propio demonio es el único lugar que controlas, así que haz que te diga qué envió y qué se reutilizó en cada llamada.
Lo que le diría a alguien más que intente esto
Empieza con el modelo que ya tienes. Usé Gemma 4 26B MoE porque el GGUF ya estaba descargado. La variante unificada de 12B está en mi lista a continuación. Menor contexto, menos RAM, probablemente más rápido para uso intensivo de chat.
Haz coincidir tres números. llama-server -c, AIdaemon model_budgets, y lo que realmente esperas en un turno de agente ocupado. Deberían coincidir.
Observa los registros. tail -f ~/.aidaemon/llama-server.log muestra los recuentos de tokens del prompt y el comportamiento de las ranuras. Si ves prefills de miles de tokens en cada turno, arregla el contexto del agente antes de comprar hardware más rápido.
Mantén una copia de seguridad en la nube mientras optimizas. Local-first con OpenRouter (o lo que sea que ya pagues) como respaldo significa que puedes reiniciar llama-server veinte veces sin perder Telegram.
Ejecuta llama-server antes que AIdaemon. El demonio se inicia bien sin él y luego recurre a un respaldo o falla en el primer mensaje. Olvidé eso una vez.
En macOS, ejecuto AIdaemon bajo launchd con caffeinate -i para que el modo de suspensión inactiva no mate una sesión de agente larga. llama-server sigue siendo manual a menos que le des su propio plist. Vale la pena hacerlo si este se convierte en tu conductor diario.