Saltar a contenido

Límites de los modelos locales para coordinar subagentes: memoria, sampling y tool-calling

TL;DR

Probando deepagents (LangChain) —su capacidad para orquestar varios agentes con varias herramientas— sobre modelos locales, usamos gemma4:12b, qwen3:8b y qwen3.5:9b-mlx vía Ollama como coordinador, en un MacBook Air M5 de 16 GB. Ninguno completó la cadena y lo dejamos. Dos intentos se fueron en 43 y 14 minutos por causas que no tenían nada que ver con lo listo que sea el modelo: memoria mal dimensionada y sampling mal elegido. El tercer bloqueo, el tool-calling, tampoco era del modelo: era del servidor.

Decisión

El portátil de 16 GB no es un entorno de coordinación, ni siquiera para iterar. Y Ollama no es el servidor adecuado para agentes con tool-calling: no expone un punto de control sobre plantilla ni parser. Si se retoma, el siguiente paso es cambiar de servidor (llama-server --jinja o vLLM con --tool-call-parser explícito), no cambiar de modelo.

Esto invalida una receta que publicamos

En Contexto perdido: role: tool en Gemma 4 dimos una receta para arreglar el tool-calling de Gemma 4 editando el TEMPLATE del Modelfile. Con Ollama 0.32.5 ese punto de apoyo ya no existe: el TEMPLATE es inerte para gemma4. Lo verificamos de nuevo para este artículo y está más abajo, con los números.

Qué intentábamos y por qué lo dejamos

El caso de prueba: un agente editor montado con deepagents + LangGraph que delega en cuatro subagentes genre-researcher —cada uno busca en la web y redacta un segmento— y luego debe ensamblar una newsletter y publicarla en HTML. Es un caso de juguete, pero con la forma del problema real: coordinación de subagentes más tool-calling encadenado.

La restricción de partida es dura, y es la que obliga a todo lo demás: los clientes no permiten llamadas a APIs externas, así que no hay modelo hosted que valga — todo corre on-prem o en local.

Nunca conseguimos un run completo. Estos fueron los intentos:

Modelo Rol Qué pasó
gemma4:12b editor No emite tool_calls en el agente real: responde como un chat
qwen3:8b editor Malinterpreta la firma de una herramienta; no completa la cadena
qwen3:8b subagente publisher Fabrica el resultado a mano y declara éxito (fallo silencioso)
qwen3:8b subagente researcher Fiable en tarea corta con 1-2 herramientas
qwen3.5:9b-mlx editor Dos runs abortados (43 min por swap, 14 min por bucle); coordinación nunca medida

Lo único que quedó demostrado en positivo es la última fila y la anterior: un subagente de tarea corta con una o dos herramientas sí funciona con un modelo de 8B. Lo que no conseguimos nunca es el eslabón de arriba, el que coordina.

Los tres bloqueos, por capa

1. El footprint en runtime no es el tamaño en disco

qwen3.5:9b-mlx ocupa 8.9 GB en disco. Cargado, ocupó 15.9 GB. La diferencia es la KV cache, y su tamaño depende de la ventana de contexto: este modelo viene con context length: 262144 (256K) por defecto, y Ollama reserva KV para ese contexto entero.

KV cache

Memoria donde el modelo guarda las claves y valores de atención de los tokens ya procesados, para no recalcularlos en cada token nuevo. Crece con la ventana de contexto configurada, no con lo que realmente usas: reservar 256K de contexto cuesta GB de RAM aunque tu prompt tenga 2.000 tokens.

En una Mac de 16 GB de memoria unificada eso saturó la máquina: 13,7 GB de swap, ~50.000 pageouts y un run de 43 minutos sin terminar. No era el modelo pensando despacio, era el sistema paginando.

El arreglo es capar el contexto al crear el cliente:

model = init_chat_model(
    "ollama:qwen3.5:9b-mlx",
    num_ctx=16384,   # el default del modelo es 262144 (256K)
    temperature=0.6,
)

Con num_ctx=16384 el footprint bajó a ~8,8 GB y el swap desapareció.

Regla práctica

Antes de dar por bueno un modelo, mira ollama show <modelo> y busca su context length. Si viene en cientos de miles de tokens y tu hardware es ajustado, cápalo tú: es la diferencia entre correr y paginar.

2. temperature=0 rompe los modelos de razonamiento

Resuelta la memoria, forzamos temperature=0 buscando un tool-calling más determinista. Fue un error: el run se quedó ~14 minutos en una única generación que no terminaba. El modelo entró en un bucle de repetición de tokens.

El diagnóstico fue por síntomas, porque el log intermedio no se imprime hasta el final: el modelo seguía cargado con el expires_at del keep-alive ya vencido y el runner de Ollama a ~29 % de CPU — señal de una sola llamada al LLM en curso indefinido.

La causa es conocida y está en la documentación del propio fabricante: en los modelos de razonamiento de Qwen el greedy decoding está desaconsejado porque degrada y provoca repeticiones sin fin. Los defaults del modelo (temperature 1, top_k 20, top_p 0.95, presence_penalty 1.5) están precisamente para evitarlo. Corregido a temperature=0.6, el bucle desapareció.

El reflejo de \"temperature=0 para que sea determinista\" es contraproducente

Solo es seguro en modelos que no razonan. En un modelo thinking, poner 0 no te da determinismo: te da una generación infinita. Esto es el mismo eje que ya nos apareció en extracción documental, donde qwen3-vl:8b se gastaba todo el presupuesto de tokens en la traza thinking y dejaba la respuesta vacía. El razonamiento tiene coste oculto y hay que presupuestarlo.

3. El tool-calling lo decide el serving, no el modelo

gemma4:12b no emitía tool_calls en el agente real. En una prueba aislada (una sola herramienta, orden clara) sí llamaba a la herramienta; con el system prompt largo y varias tools se comportaba como un chat, respondiendo con un saludo genérico del tipo "I am ready to assist you…".

La causa no es el número de parámetros: es que Ollama emula el tool-calling metiéndolo en la plantilla de chat de cada modelo, y con Gemma eso es frágil porque su plantilla no tiene formato nativo de tool-calling. vLLM, en cambio, tiene parsers explícitos (--enable-auto-tool-choice --tool-call-parser …). El mismo modelo servido sin el parser adecuado tiene el tool-calling roto de fábrica.

El experimento que cierra la vía del Modelfile

La hipótesis razonable era: si el problema es la plantilla de chat de Gemma, escribamos una plantilla propia en el Modelfile con el formato de tool-calling correcto. Es exactamente lo que recomendaba nuestro artículo anterior.

No funciona. El Modelfile activo de gemma4:12b revela por qué:

TEMPLATE """{{ if .System }}<start_of_turn>system
...
"""
RENDERER gemma4
PARSER gemma4

Cuando RENDERER tiene valor, Ollama bypasea el motor de Go templates y llama a una función compilada específica de la arquitectura. Con PARSER pasa lo mismo: la detección de tool-calls en la salida la hace una máquina de estados hardcoded que busca tags concretos, no configurable desde el Modelfile. PARAMETER solo afecta al sampling.

Verificación 1: Ollama reimpone el renderer y el parser

Creamos un modelo desde un Modelfile con solo las líneas FROM y una TEMPLATE propia — cero líneas RENDERER, PARSER o PARAMETER. Esto es lo que Ollama 0.32.5 guardó:

FROM .../blobs/sha256-1278394b6936...
FROM .../blobs/sha256-675ad6e68101...
TEMPLATE """{{ if .System }}<start_of_turn>system
{{ .System }}<end_of_turn>
{{ end }}{{ range .Messages }}{{ if eq .Role "tool" }}<start_of_turn>tool
{{ .Content }}<end_of_turn>
{{ end }}{{ end }}<start_of_turn>model
RENDERER gemma4
PARSER gemma4
PARAMETER stop <turn|>

RENDERER gemma4 y PARSER gemma4 aparecen solos, y de propina un PARAMETER stop que tampoco escribimos. Ollama detecta la arquitectura por los metadatos del GGUF y reimpone su configuración en el momento de ollama create.

Verificación 2: la plantilla propia es inerte, no solo "convive"

Que las líneas reaparezcan no demuestra por sí solo que la plantilla no se use. Así que la plantilla del test se escribió sin rama para role: user: si mandara la plantilla, un mensaje de usuario tendría que desaparecer del prompt. Misma pregunta a los dos modelos:

Modelo prompt_tokens Respuesta
gemma4:12b (plantilla completa) 32 17 más 25 es 42.
Modelo de prueba (plantilla sin rama user) 32 17 más 25 es 42.

Idénticos: mismo recuento de tokens y misma respuesta, con plantillas radicalmente distintas. El TEMPLATE no interviene en el renderizado. Es un placeholder que se guarda y se muestra, y nada más.

Por qué el recuento de tokens es la prueba y no la respuesta

Es la misma técnica del artículo sobre role: tool: los prompt_tokens te dicen qué recibió de verdad el modelo. Si una plantilla que descarta los mensajes de usuario produce el mismo recuento que una que los incluye, esa plantilla no se está ejecutando.

Nota sobre la evidencia de junio

La plantilla que hay hoy en disco para gemma4:12b es idéntica a la que prescribía nuestro artículo, pero eso no prueba que el upstream la haya corregido: el manifest local no se ha tocado desde el 15 de junio, que es cuando aplicamos la receta nosotros. Da igual para la conclusión: con o sin la plantilla correcta, el renderizado lo hace el renderer compilado.

Conclusión: editar el Modelfile no es una vía válida para controlar el tool-calling de Gemma en Ollama. No es un problema de cómo se escribió la plantilla; es que Ollama no expone un punto de control para desacoplar renderer/parser de la arquitectura. La única forma de tener control real y simétrico sobre plantilla y parser es salir de Ollama.

El techo del hardware, sin ilusiones

Cuatro confusiones que nos costaron tiempo, y que conviene tener claras antes de elegir modelo:

  • MoE compra velocidad, no memoria. Un Mixture-of-Experts como qwen3-coder:30b-a3b (30B totales, 3,3B activos) computa como un ~3B, pero todos los expertos tienen que estar en memoria: el footprint es el de un 30B completo.
  • MLX compra velocidad, no memoria. Es el runtime nativo de Apple Silicon: mejora la ejecución, no el tamaño ni la calidad. En Ollama las builds -mlx pesan más que las GGUF (qwen3.5:9b: 6,6 GB GGUF frente a 8,9 GB MLX).
  • Cuantizar agresivamente daña justo lo que necesitas. Bajar a Q2/Q3 permite meter modelos mayores, pero degrada el seguimiento de instrucciones y el tool-calling estructurado: más grande y más tonto donde importa.
  • Techo real de una Mac de 16 GB: unos 10-11 GB útiles para el modelo, porque macOS y la KV cache se llevan el resto. Máximo práctico ~9-14B, y con num_ctx capado.

Lo que no sabemos

Honestidad sobre los huecos, porque dejamos las pruebas antes de cubrirlos:

  • La coordinación nunca se midió. No sabemos si qwen3.5:9b-mlx con la configuración final habría coordinado los subagentes: los dos runs murieron por entorno antes de llegar. La casilla se queda vacía, no en "no puede".
  • No probamos el servidor alternativo. llama-server --jinja y vLLM son la conclusión lógica del §3, pero son hipótesis: no las ejecutamos.
  • La configuración del servidor GPU está sin verificar. Está pendiente confirmar si los 32 GB de la RTX 5000 son VRAM de la GPU o RAM del sistema. Para inferencia en GPU manda la VRAM, y cualquier cálculo de "qué modelo cabe" depende de ese dato. Mientras no se confirme, no hay recomendación de modelo para ese entorno.

Qué nos llevamos

  1. Los dos bloqueos más caros no fueron del modelo. 43 minutos de swap y 14 de bucle de repetición: memoria mal dimensionada y sampling mal elegido. Antes de concluir "este modelo no sirve", descarta que el entorno esté sabotéandolo.
  2. "Cabe en disco" no es "cabe en RAM". El footprint en runtime lo domina la KV cache y la KV cache la dimensiona num_ctx. En hardware ajustado, capar el contexto no es una optimización: es un requisito.
  3. temperature=0 no es sinónimo de determinismo. En modelos de razonamiento provoca repeticiones infinitas. Usa la temperatura del fabricante y conserva sus penalizaciones.
  4. El tool-calling es una propiedad del serving, no solo del modelo. Ollama lo emula dentro de la plantilla de chat; vLLM tiene parsers explícitos. El mismo modelo, distinto servidor, distinto resultado.
  5. El Modelfile de Ollama no es un punto de control universal. Para arquitecturas con RENDERER/PARSER compilados, el TEMPLATE es inerte y se reimpone al crear el modelo. Verificado con Ollama 0.32.5.
  6. Separar roles por capacidad de modelo es lo que sí funcionó. Un subagente de tarea corta con una o dos herramientas es fiable con un 8B. El coordinador es otro problema.
  7. Un fallo que declara éxito es peor que un error. El subagente publisher fabricó un HTML vacío a mano y anunció "successfully generated 🎉". Si no inspeccionas el artefacto, no te enteras. Esto se trata aparte, en la capa de gestión decide si el modelo sirve.

Anexo: cómo verificarlo en tu máquina (la receta)

  1. Comprueba el contexto por defecto antes de cargar nada: ollama show <modelo> y busca context length. Si son cientos de miles de tokens, cápalo con num_ctx.
  2. Detecta el swap por síntomas, no por la sensación de lentitud. En macOS, Monitor de Actividad → memoria: mira los pageouts y el swap usado. Un run que "va lento" con decenas de miles de pageouts no es un problema de modelo.
  3. Distingue "pensando" de "en bucle". Si el runner de Ollama está a CPU constante y el expires_at del keep-alive ya venció sin que la llamada vuelva, es una única generación que no termina, no progreso.
  4. Comprueba si tu modelo tiene renderer compilado: ollama show --modelfile <modelo> y busca las líneas RENDERER / PARSER. Si están, el TEMPLATE no interviene y no pierdas tiempo editándolo.
  5. Si dudas de si una plantilla se ejecuta, quítale una rama y compara prompt_tokens. Misma pregunta, dos modelos, mismo recuento → la plantilla es inerte.
  6. Prueba el tool-calling aislado antes que en el agente. Una herramienta, orden clara. Si ahí funciona y en el agente no, el problema es la carga de contexto y el número de herramientas, no la capacidad de llamar a una función.

Referencias