Trazado a nivel de componente: depuración de llamadas a herramientas del agente
Un agente que llama a herramientas hace dos cosas muy diferentes: razona ("Debería buscar en la documentación") y actúa (realmente llama a la herramienta). Cuando algo sale mal, la primera pregunta siempre es qué capa falló — ¿pensó mal o se rompió la acción? Un registro plano no puede responder eso. Un trazado puede.
En este tutorial, construirás un pequeño agente que llama a herramientas en WEC Inference, lo instrumentarás con Langfuse para que cada paso de razonamiento y cada llamada a la herramienta se conviertan en un nodo inspeccionable, y luego depurarás dos fallos reales del árbol de trazado — incluyendo el peor tipo: una respuesta incorrecta y confiada que nunca lanza un error. Cada comando, error y captura de pantalla a continuación proviene de una ejecución real.
Construido en una instancia de WEC con Docker, contra WEC Inference (Qwen3.5-122B) y un Langfuse autoalojado del
tutorial de observabilidad. Las herramientas del agente incluyen el servicio RAG del
tutorial de RAG — así que esta pieza une toda la serie. Todas las llamadas al modelo permanecen en WEC; nada sale hacia una API de terceros.
Lo que construirás
Las líneas punteadas son el punto: cada paso se informa a Langfuse, por lo que el razonamiento del agente y sus acciones se presentan como nodos separados e inspeccionables.
Requisitos previos: una instancia de WEC con Docker, una clave API de WEC Inference, un Langfuse en funcionamiento (clave pública + clave secreta, URL del host) y una herramienta que el agente pueda llamar — aquí el servicio RAG /ask. Todos los comandos se ejecutan en ~/agent-tracing.
Paso 1 — Ver las dos capas (sin trazado aún)
Regla: nunca instrumentes antes de haber visto el comportamiento en bruto. Así que v1 hace lo mínimo — pregunta al modelo una cuestión con una definición de herramienta, y muestra lo que regresa. Configura el espacio de trabajo y la clave de WEC:
mkdir -p ~/agent-tracing && cd ~/agent-tracing
grep '^WEC_API_KEY=' ~/evolution-api/.env > .env
agent.py (v1):
import os, requests
WEC_URL = "https://inference.wiline.com/v1/chat/completions"
WEC_API_KEY = os.environ["WEC_API_KEY"]
MODEL = "Qwen3.5-122B"
TOOLS = [{
"type": "function",
"function": {
"name": "search_docs",
"description": "Buscar en la documentación de WEC una respuesta a una pregunta.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}]
def call_model(messages):
r = requests.post(WEC_URL,
headers={"Authorization": f"Bearer {WEC_API_KEY}"},
json={"model": MODEL, "messages": messages, "tools": TOOLS, "tool_choice": "auto"},
timeout=120)
r.raise_for_status()
return r.json()["choices"][0]["message"]
if __name__ == "__main__":
msg = call_model([{"role": "user", "content": "¿Cómo creo una instancia de cómputo en WEC?"}])
print("=== CAPA DE RAZONAMIENTO (lo que pensó) ===")
print(msg.get("reasoning_content") or "(ninguno)")
print("\n=== CAPA DE ACCIÓN (lo que decidió hacer) ===")
for tc in msg.get("tool_calls") or []:
print(f"herramienta: {tc['function']['name']} args: {tc['function']['arguments']}")
Ejecuta en un contenedor (mismo patrón que el resto de la serie):
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir requests
COPY agent.py .
CMD ["python", "agent.py"]
services:
agent:
build: .
env_file: .env
docker compose run --rm --build agent
Figura 1. WEC Inference devuelve las dos capas en una respuesta: reasoning_content
(el pensamiento) y tool_calls (la decisión). No tenemos que inferir la división — la API nos la entrega.
Qwen3.5-122B devuelve finish_reason: tool_calls con un arreglo tool_calls adecuado, más un campo reasoning_content que contiene la cadena de pensamiento del modelo antes de actuar. Ese campo de razonamiento es exactamente lo que hace visible la separación entre razonamiento y acción más adelante.
Paso 2 — Cerrar el bucle: ejecutar la herramienta, obtener la respuesta
v1 decidió buscar pero no lo hizo. Ahora llama realmente a la herramienta (el servicio RAG /ask), alimenta el resultado de vuelta y deja que el modelo escriba la respuesta final. Agrega la herramienta + el bucle:
import json
RAG_URL = "http://<tu-ip-vm>:8000/ask" # el servicio RAG = nuestra única herramienta
def search_docs(query):
r = requests.post(RAG_URL, json={"question": query}, timeout=120)
r.raise_for_status()
return r.json()["answer"]
def run(user_input):
messages = [{"role": "user", "content": user_input}]
while True:
msg = call_model(messages)
if not msg.get("tool_calls"):
print("\n=== RESPUESTA FINAL ===\n" + (msg.get("content") or ""))
return msg.get("content")
messages.append({"role": "assistant", "content": msg.get("content") or "",
"tool_calls": msg["tool_calls"]})
for tc in msg["tool_calls"]:
args = json.loads(tc["function"]["arguments"])
print(f"[razonamiento] {(msg.get('reasoning_content') or '').strip()[:140]}")
print(f"[acción] {tc['function']['name']}({args})")
result = search_docs(args["query"])
print(f"[resultado de la herramienta] {result[:140]}...")
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
docker compose run --rm --build agent
Figura 2. El bucle completo — el modelo razona, llama a search_docs, obtiene el resultado de RAG y escribe una respuesta fundamentada. Funciona. Pero un terminal que funciona no te dice nada sobre si el razonamiento fue sólido — eso es lo que arreglamos a continuación.
Paso 3 — Agregar Langfuse: las capas se convierten en un árbol de trazado
El terminal aplana todo en un solo flujo. Langfuse convierte cada función en un nodo para que puedas ver la estructura. Reutiliza las claves de Langfuse del tutorial de observabilidad (mismo proyecto que tus trazas de RAG):
grep -E '^LANGFUSE_(PUBLIC_KEY|SECRET_KEY|HOST)=' ~/rag-service/.env >> .env
Agrega el SDK (pip install ... langfuse) y decora tres funciones — esa es toda la instrumentación. @observe envuelve una función como un span; as_type="generation" marca las llamadas LLM para que Langfuse capture modelo, tokens y costo:
from langfuse import observe, get_client
@observe(as_type="generation")
def call_model(messages):
...
data = r.json()
get_client().update_current_generation(model=MODEL, usage_details=data.get("usage"))
return data["choices"][0]["message"]
@observe()
def search_docs(query):
...
@observe()
def run(user_input):
...
if __name__ == "__main__":
run("¿Cómo creo una instancia de cómputo en WEC?")
get_client().flush() # script de corta duración: empujar trazas antes de salir
docker compose run --rm --build agent
Abre Langfuse → Trazado → Trazas. Lo primero que notarás: hay dos trazas por pregunta.
Figura 3. Trazado distribuido, gratis: la traza run de tu agente y la propia traza ask del servicio RAG son separadas — cada servicio se instrumenta a sí mismo. Ver ambas es cómo sigues una solicitud a través de los límites del servicio.
Abre la más nueva run:
Figura 4. El turno del agente como un árbol: run → call_model (decidir) → search_docs (actuar) → call_model (respuesta). Langfuse también capturó latencia, uso de tokens (801→306) y una puntuación de corrección — todo por tres decoradores.
Haz clic en el primer call_model y abre su salida:
Figura 5. La capa de razonamiento, capturada: reasoning_content ("Debería buscar en la documentación de WEC…") se encuentra justo al lado de los tool_calls que produjo. Este es el registro contra el que depuras.
Paso 4 — Romperlo (de la manera ruidosa), luego depurar desde el trazado
Planta un error realista: la API de RAG espera {"question": ...}, pero es natural enviar {"query": ...} porque el parámetro de la herramienta se llama query. Una clave incorrecta:
sed -i 's/{"question": query}/{"query": query}/' agent.py
docker compose run --rm --build agent
Se bloquea:
Figura 6. El terminal te da un rastreo de pila — te dice dónde se rompió el código. No te dice si el agente pensó correctamente. Para eso, ve al trazado.
Abre la run fallida y haz clic en el span rojo search_docs:
Figura 7. Todo el diagnóstico en una imagen: call_model es verde (el razonamiento fue correcto — eligió correctamente buscar), search_docs es rojo, murió en 0.04s, y el panel muestra el 422 más la carga útil exacta que envió. Un fallo instantáneo es un rechazo, no un tiempo de espera — apuntando directamente a una mala solicitud.
Arregla la línea y confirma el verde:
sed -i 's/{"query": query}/{"question": query}/' agent.py
docker compose run --rm --build agent
Figura 8. De vuelta al verde — search_docs tiene éxito y se produce la respuesta final. Diagnosticaste y verificaste la corrección desde el trazado.
Ese fue el fallo ruidoso — se bloqueó, así que lo habrías atrapado eventualmente incluso sin un trazado. El peligroso es el siguiente.
Paso 5 — Una segunda herramienta, para que el agente tenga que elegir
Los agentes reales tienen más de una herramienta, y las decisiones interesantes ocurren cuando el modelo elige entre ellas. Agrega una herramienta get_pricing junto a search_docs, y enruta las llamadas a través de una tabla de despacho indexada por el nombre de la herramienta:
TOOLS = [
{"type": "function", "function": {
"name": "search_docs",
"description": "Buscar en la documentación de WEC preguntas sobre cómo hacer y configurar.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {
"name": "get_pricing",
"description": "Obtener el precio de un recurso de WEC (por ejemplo, 'instancia de cómputo', 'almacenamiento en bloque', 'inferencia').",
"parameters": {"type": "object", "properties": {"resource": {"type": "string"}}, "required": ["resource"]}}},
]
PRICES = {
"instancia de cómputo": "$0.03 / hora-vCPU",
"almacenamiento en bloque": "$0.10 / GB-mes",
"inferencia": "$0.50 por 1M tokens",
}
@observe()
def get_pricing(resource):
return PRICES.get(resource.lower().strip(), f"No se encontró precio para '{resource}'.")
TOOL_FUNCS = {"search_docs": search_docs, "get_pricing": get_pricing}
# en run(), despachar por la herramienta que el modelo realmente eligió:
for tc in msg["tool_calls"]:
name = tc["function"]["name"]
args = json.loads(tc["function"]["arguments"])
print(f"[acción] {name}({args})")
result = str(TOOL_FUNCS[name](**args))
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
Haz una pregunta de precio (run("¿Cuánto cuesta una instancia de cómputo en WEC?")) y ejecútala. El agente debería elegir get_pricing, no search_docs:
Figura 9. Selección genuina de herramientas: dado dos herramientas, el agente eligió get_pricing y respondió $0.03/hora-vCPU, Corrección 1.00. La elección en sí misma es ahora algo que puedes ver en el trazado.
Paso 6 — El fallo silencioso (por qué el trazado se gana su lugar)
Ahora un error que no se bloquea. Alguien refactoriza el despacho y codifica en duro la antigua herramienta única, olvidando la nueva — así que cada llamada ejecuta search_docs sin importar lo que eligió el modelo:
sed -i 's/result = str(TOOL_FUNCS\[name\](\*\*args))/result = str(search_docs(list(args.values())[0])) # ERROR/' agent.py
docker compose run --rm --build agent
[action] get_pricing({'resource': 'instancia de cómputo'})
=== RESPUESTA FINAL ===
Basado en la información de precios disponible, la categoría de instancia de cómputo más cara en WEC es $1,586.77. ...
Lee eso cuidadosamente. El modelo eligió get_pricing (correcto). La respuesta es confiada, detallada — y completamente incorrecta (el precio real es $0.03/hora-vCPU). No hay error, ni rastreo. Un registro se vería perfectamente saludable. Este es el modo de fallo que se envía a producción y miente silenciosamente a los usuarios.
El trazado es lo único que lo atrapa. Abre la run:
Figura 10. Tres indicios que un registro no puede mostrar: (1) el span ejecutado es search_docs — no hay span de get_pricing, a pesar de que el modelo lo eligió: decisión ≠ acción; (2) el nodo de razonamiento puntuó Corrección 0.00 — el juez LLM marcó automáticamente la respuesta incorrecta; (3) la salida muestra al modelo confundido por un resultado de herramienta que no coincidía con lo que pidió. Arregla el despacho de vuelta a TOOL_FUNCS[name](**args) y el precio es correcto nuevamente.
Instrumentaste un agente que llama a herramientas para que su razonamiento y sus acciones sean nodos de trazado separados e inspeccionables — y depuraste dos fallos reales desde el trazado: el ruido del bloqueo y la respuesta silenciosa, confiada pero incorrecta que ningún rastreo de pila revelaría jamás.
Solución de problemas
- No aparece trazado. Un script corto sale antes de que el SDK se vacíe. Llama a
get_client().flush()antes de que el proceso termine (ya está en v3). - Span de
search_docsfaltante. Solo las funciones decoradas con@observese convierten en nodos — decora la función, no el sitio de llamada. - La generación no muestra tokens/costo. Pasa
usage_details=data.get("usage")a través deupdate_current_generation; sin ello, Langfuse no puede calcular el costo. - Las trazas del agente y de la herramienta parecen desconectadas. Esperado — cada servicio se traza a sí mismo. Conéctalos más tarde con propagación de trazas si necesitas una vista única entre servicios.
- Una respuesta incorrecta silenciosa sin error. Compara los
tool_callsdel modelo (lo que eligió) contra los spans ejecutados (lo que realmente se ejecutó). Un desajuste es un error de despacho/ruteo — y una puntuación de corrección en el trazado lo señalará automáticamente.
¿Qué sigue?
Ahora puedes ver a un agente pensar, actuar y fallar silenciosamente — y atraparlo. Apunta la misma instrumentación a un agente real de múltiples pasos, agrega puntuación de corrección automatizada en cada trazado, y tendrás una observabilidad de producción que revela fallos silenciosos antes de que lo hagan tus usuarios.
Desmontaje
cd ~/agent-tracing && docker compose down --rmi local
