Contexto perdido: role: tool en Gemma 4 con Ollama y LiteLLM
Nota para el equipo
Sale del proyecto para Avicena, con nuestro agente local sobre Gemma 4. La conclusión no es "Gemma no sirve para agentes", sino "según cómo se empaquete el modelo y cómo lo enrute el proxy, el contexto de las herramientas se pierde en silencio, y hay que medir los tokens para verlo".
El Paso A de este artículo ya no funciona (julio de 2026)
Escrito en junio de 2026. La receta del Paso A —arreglar la plantilla editando el
TEMPLATE del Modelfile— ha dejado de ser válida: en Ollama 0.32.5, gemma4 trae
RENDERER/PARSER compilados por arquitectura, el TEMPLATE es inerte y Ollama
reimpone su configuración al hacer ollama create, aunque quites esas líneas del
Modelfile.
El Paso B (LiteLLM por el /v1 de Ollama) sigue siendo válido: es un problema de
otra capa.
Detalle del experimento y qué hacer en su lugar: límites de los modelos locales para coordinar subagentes.
TL;DR
Nuestro agente local enrutaba bien pero devolvía respuestas vacías: llamaba a la
herramienta correcta y luego se quedaba mudo, o repetía la misma llamada en bucle. La
causa no era una, eran dos capas distintas que descartaban en silencio los mensajes
role: tool.
El arreglo
Lo corregimos en origen, sin parchear el agente: plantilla correcta en el
Modelfile (capa Ollama) + ruteo de LiteLLM por el endpoint compatible-OpenAI de
Ollama (/v1) en lugar del provider ollama_chat/.
Lo no-obvio
Arreglar solo la plantilla no basta: aun con ella correcta, el handler ollama_chat
de LiteLLM seguía dejando fuera el role: tool. Eran dos fallos encadenados, y el
segundo solo se ve después de tapar el primero.
El síntoma
El agente identificaba la herramienta, generaba el tool_calls, nuestro código
ejecutaba la función y le devolvía el resultado con la estructura estándar de OpenAI
(role: "tool" + tool_call_id). A partir de ahí, pared:
- En preguntas RAG y de listado:
draft_responsevacío. - En preguntas SQL: llamaba a
get_sheet_schemay, en vez de encadenar aquery_sheet_data, volvía a llamar aget_sheet_schemay paraba.
El routing_acierto parecía decente, pero engañaba: el agente elegía bien la herramienta
y luego no tenía nada que decir. Sin respuesta, ninguna otra métrica importa.
La pista: el recuento de tokens
En vez de seguir mirando el código del agente, miramos cuántos tokens le llegaban de verdad al modelo. Enviamos el mismo contexto RAG (~1200 tokens) de dos formas:
| Forma de envío del contexto | prompt_tokens |
Respuesta |
|---|---|---|
Como role: tool (lo que hace el agente) |
63 | "necesito que me proporciones los informes" |
El mismo texto como role: user |
1231 | respuesta real con los datos |
El modelo no alucinaba ni fallaba: nunca recibía el contenido de la herramienta. Se lo estábamos enviando, pero alguien lo tiraba por el camino.
La causa: dos capas que descartan el contexto
Capa 1: la plantilla-stub del Modelfile (Ollama)
gemma4:e4b, gemma4:12b y gemma4:latest venían empaquetados con:
{{ .Prompt }} es el campo legacy para texto plano (/api/generate). No recorre el
historial ({{ range .Messages }}) ni tiene rama para el rol tool. Cuando LiteLLM llama
a /api/chat con un array de mensajes, Ollama usa esa plantilla para construir el prompt,
y al no saber qué hacer con un rol estructurado como tool, lo descarta en silencio.
El problema es el empaquetado, no Gemma
No es de la familia Gemma en sí (el gemma3 oficial trae una plantilla de chat
correcta), sino del empaquetado concreto con el que se importaron estos pesos.
Matiz de julio de 2026: para el tool-calling el diagnóstico se quedó corto. No es solo empaquetado arreglable — con renderer/parser compilados por arquitectura, el tool-calling de Gemma en Ollama no es reparable desde el Modelfile.
Capa 2: el handler ollama_chat de LiteLLM
Una vez arreglada la plantilla, lo verificamos directo contra Ollama y funcionaba (el
role: tool se renderizaba). Pero vía LiteLLM seguía perdiéndose. Mismo payload OpenAI a
ambos endpoints:
| Endpoint | prompt_tokens |
Resultado |
|---|---|---|
Ollama /v1 directo (compatible-OpenAI) |
235 | responde con los datos ✅ |
LiteLLM /v1 con provider ollama_chat/ |
53 | "no veo resultados" ❌ |
El traductor de ollama_chat no pasa el rol tool a la API nativa de Ollama. No
"corrompe metadatos": directamente lo deja fuera.
Precisión: solo ollama_chat/
Solo verificamos el provider ollama_chat/. El antiguo ollama/ (text-completion) sí
dejaba pasar el contenido de tool (aplanaba todo a texto), pero a cambio no soporta
tool-calling nativo, que es lo que necesita el nodo plan.
La solución (cada arreglo en su capa)
El principio rector: no parchear el agente. Disfrazar los role: tool de role: user
en nuestro código habría tapado el síntoma y dejado deuda. Arreglamos donde estaba roto.
Paso A: plantilla correcta en el Modelfile
Obsoleto desde Ollama 0.32.5 — se conserva como registro
Esto funcionaba en junio de 2026, con modelos empaquetados con TEMPLATE {{ .Prompt }}
y sin renderer nativo. Hoy gemma4 trae RENDERER gemma4 / PARSER gemma4, el
TEMPLATE no interviene en el renderizado y las líneas se reimponen solas al crear el
modelo. No apliques esta receta: no rompe nada, simplemente no hace nada. La
alternativa es salir de Ollama (llama-server --jinja, o vLLM con
--tool-call-parser), como se explica en
límites de los modelos locales para coordinar subagentes.
Exportar el Modelfile actual, sustituir la plantilla y crear de nuevo el modelo a partir del Modelfile corregido:
ollama show gemma4:e4b --modelfile > Modelfile # conserva la línea FROM (hash real)
# sustituir TEMPLATE {{ .Prompt }} por la plantilla con {{ range .Messages }} y rama tool
ollama create gemma4:e4b -f ./Modelfile # in-place: no re-descarga pesos
El Modelfile exportado tiene, justo debajo del FROM, la plantilla errónea
TEMPLATE {{ .Prompt }}. Esa línea debe sustituirse por:
TEMPLATE """{{ if .System }}<start_of_turn>system
{{ .System }}<end_of_turn>
{{ end }}{{ range .Messages }}{{ if eq .Role "user" }}<start_of_turn>user
{{ .Content }}<end_of_turn>
{{ else if eq .Role "assistant" }}<start_of_turn>model
{{ if .FunctionCalls }}<start_of_turn>call
{{ range .FunctionCalls }}{{ .Name }}
{{ .Arguments }}{{ end }}<end_of_turn>
{{ else }}{{ .Content }}<end_of_turn>
{{ end }}{{ else if eq .Role "tool" }}<start_of_turn>tool
{{ .Content }}<end_of_turn>
{{ end }}{{ end }}<start_of_turn>model
"""
La nueva plantilla recorre los mensajes y añade una rama para cada rol, incluido
<start_of_turn>tool ... <end_of_turn>.
Paso B: LiteLLM por el /v1 de Ollama
Cambiamos la configuración en litellm/config.yaml para que los modelos locales pasen de
ollama_chat/ a openai/, apuntando al endpoint compatible-OpenAI de Ollama:
- model_name: gemma-e4b
litellm_params:
model: openai/gemma4:e4b # "openai/" → LiteLLM no aplica su traductor de ollama
api_base: "http://host.docker.internal:11434/v1"
api_key: "ollama" # requerido por el cliente; Ollama lo ignora
Verificado que Ollama /v1 hace las dos cosas que necesita el agente: emite tool_calls
(nodo plan, finish_reason: tool_calls) y respeta el role: tool (nodo synthesize).
Es además el mismo provider que ya usaban los modelos remotos.
Resultado
Verificado end-to-end vía LiteLLM tras los dos arreglos:
| Categoría | Antes | Después |
|---|---|---|
list_documents |
vacía | ✅ 16 documentos reales |
search_documents (RAG) |
vacía | ✅ respuesta con citas y fuentes |
query_sheet_data (leer el role:tool) |
"no veo resultados" | ✅ datos reales |
Commits: a9843c9 (provider LiteLLM) · plantillas Modelfile aplicadas en local.
Qué nos llevamos
- Si el agente elige bien la herramienta pero responde vacío, compara
prompt_tokenscon y sin proxy. No hace falta tocar el código del agente: envía el mismo payload a Ollama directo y a LiteLLM y mira qué número devuelve cada uno. Si caen los tokens, algo en el camino está comiendo contexto. - Aísla cambiando una sola variable cada vez: el rol del mensaje, o el endpoint. Eso solo ya señala al culpable, sin teorizar sobre el código.
- Arregla en la capa rota, no en el agente. Un workaround en el agente esconde bugs reales de infraestructura y los convierte en deuda invisible.
- Una etapa que descarta contexto sin lanzar error es la más peligrosa: una plantilla de Modelfile rota no aparece en ningún log; solo se ve "responde algo raro". Por eso el recuento de tokens, y no los logs, fue lo que destapó el caso.
Anexo: aplicarlo al resto de la familia Gemma 4 (la receta)
Anexo obsoleto — no aplicar
Depende del Paso A, que ya no tiene efecto en Ollama 0.32.5. Se conserva como registro de lo que hicimos en junio de 2026.
El problema afecta a toda la familia, así que hay que aplicar el mismo fix a cada modelo
descargado. Se pueden trabajar desde la misma carpeta renombrando los archivos
intermedios para no pisar el FROM de cada uno:
# Versión de 12B
ollama show gemma4:12b --modelfile > Modelfile-12b
# [Abrir Modelfile-12b, dejar su FROM y pegar el TEMPLATE multilínea de arriba]
ollama create gemma4:12b -f ./Modelfile-12b
# Versión por defecto
ollama show gemma4:latest --modelfile > Modelfile-latest
# [Abrir Modelfile-latest, dejar su FROM y pegar el TEMPLATE multilínea de arriba]
ollama create gemma4:latest -f ./Modelfile-latest
El fix está confirmado como necesario en e2b, e4b, 12b y 26b. El 31b está
pendiente de verificar: asumir que también lo necesita hasta comprobar lo contrario.
¿Podemos hacer todo esto en una única carpeta?
Sí. Al renombrar los archivos intermedios (Modelfile-12b, Modelfile-latest),
podemos corregir todos los modelos en el mismo directorio sin miedo a machacar el
FROM, que contiene el identificador único (hash) de cada modelo.
¿Tengo que guardar esta carpeta de Modelfiles para siempre?
No. En cuanto la terminal devuelva el mensaje de success, puedes borrar la carpeta.
ollama create absorbe el archivo de texto y guarda la configuración de forma
persistente.
Estos modelos pesan muchos gigabytes, ¿se duplica el espacio en disco?
Cero bytes extra de pesos. Ollama gestiona el almacenamiento mediante capas de contenido fijas (blobs). Al recrear el modelo con el mismo nombre, deja intacto el blob de los pesos y solo cambia el "puntero" para asignarle la nueva plantilla.