Saltar al contenido principal
intermediatePart 7

Trazado a nivel de componente: depuración de llamadas a herramientas del agente

· 13 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
Langfuse+
0/8
🎯 Skill path0/8 earned
AI evals & observability

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.

Reproducibilidad

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):

~/agent-tracing/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):

~/agent-tracing/Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir requests
COPY agent.py .
CMD ["python", "agent.py"]
~/agent-tracing/docker-compose.yml
services:
agent:
build: .
env_file: .env
docker compose run --rm --build agent

El razonamiento del modelo y su elección de herramienta impresos como dos bloques separados 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.

WEC Inference soporta llamadas a herramientas — y expone el razonamiento

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:

~/agent-tracing/agent.py (v2, adiciones)
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

El bucle completo del agente: razonamiento, acción, resultado de la herramienta, luego la respuesta final fundamentada 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:

~/agent-tracing/agent.py (v3, decoradores)
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.

La lista de trazas de Langfuse mostrando una traza de ejecución y una traza de 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:

El árbol de trazado de Langfuse: ejecución, dos generaciones de call_model y el span de search_docs Figura 4. El turno del agente como un árbol: runcall_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:

El nodo call_model mostrando reasoning_content y tool_calls en Langfuse 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:

El terminal mostrando un rastreo de error HTTP 422 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:

El trazado roto: call_model verde, search_docs rojo con un error 422 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

El trazado corregido: todos los nodos verdes, respuesta final producida 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:

~/agent-tracing/agent.py (v4, dos herramientas + despacho)
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:

Traza de Langfuse donde el agente eligió correctamente get_pricing 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
Una ejecución exitosa — con una respuesta completamente incorrecta
[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:

El trazado de fallo silencioso: get_pricing elegido pero search_docs ejecutado, Corrección 0.00 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.

Habilidad desbloqueada 🏅

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_docs faltante. Solo las funciones decoradas con @observe se 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 de update_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_calls del 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.

Finished this tutorial?
Mark it complete to earn Trace & debug agent tool calls on your skill path.

¿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

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.