Saltar a contenido

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_response vacío.
  • En preguntas SQL: llamaba a get_sheet_schema y, en vez de encadenar a query_sheet_data, volvía a llamar a get_sheet_schema y 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:

TEMPLATE {{ .Prompt }}

{{ .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

  1. Si el agente elige bien la herramienta pero responde vacío, compara prompt_tokens con 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.
  2. 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.
  3. 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.
  4. 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.