Índice de la sección
Observabilidad y trazas
Qué registrar en cada llamada al modelo y a cada herramienta, cómo enlazarlo con IDs en una traza y qué métricas sacar para operar un agente.
Roadmap- Definir qué campos registrar por petición, por llamada al modelo y por llamada a herramienta.
- Implementar trazas estructuradas con trace_id, span_id y parent_id usando contextvars.
- Derivar de las trazas métricas de coste, latencia, pasos y errores, y decidir alertas.
- Aplicar redacción de datos personales y una política de retención a lo que registras.
Requiere: Depurar un agente leyendo sus trazas · El coste de multiplicar agentes
Al terminar esta lección cada petición a tu agente dejará una traza completa: qué preguntó el usuario, qué llamadas al modelo hubo, qué herramientas se ejecutaron, cuánto tardó y cuánto costó cada paso. Sabrás qué métricas sacar de ahí y qué alertas poner.
La idea
La Observabilidad es la capacidad de responder preguntas sobre tu sistema en producción sin tener que desplegar código nuevo para averiguarlo. En un agente las preguntas típicas son: ¿por qué respondió esto?, ¿por qué tardó veinte segundos?, ¿por qué esta conversación costó diez veces más que la media?, ¿desde cuándo falla esta herramienta?
Para responderlas necesitas trazas: un registro estructurado de cada petición como un árbol de tramos (spans). La raíz es la petición del usuario. Debajo cuelgan las llamadas al modelo y las herramientas. En un sistema multiagente, cada subagente es una rama con sus propios tramos.
flowchart TD P[peticion trace_id=t1] --> M1[modelo paso 1] P --> H1[herramienta buscar_pedido] P --> M2[modelo paso 2] P --> S[subagente] S --> M3[modelo sub paso 1] S --> H2[herramienta buscar_docs]
Todos los tramos comparten trace_id. Cada uno tiene su span_id y el parent_id de quien lo creó. Con esos tres campos reconstruyes el árbol en cualquier herramienta de análisis.
Qué registrar
| Nivel | Campos |
|---|---|
| Petición | trace_id, usuario (seudonimizado), canal, versión del prompt y del agente, resultado final, duración total, coste total |
| Llamada al modelo | span_id, parent_id, modelo, stop_reason, usage completo, duración, ID de la respuesta, ID de la petición del proveedor, número de paso |
| Herramienta | span_id, parent_id, nombre, argumentos (redactados), tamaño del resultado, is_error, mensaje de error, duración |
| Decisiones humanas | quién aprobó o rechazó, cuándo, qué se le mostró (Humano en el bucle) |
Tres campos se olvidan a menudo y luego se echan de menos:
stop_reason. Un aumento demax_tokensindica respuestas cortadas; uno derefusal, peticiones que el modelo rechaza. Ninguno lanza una excepción, así que solo los ves si los registras.- El ID de la petición del proveedor. Si tienes que abrir una incidencia con el proveedor, es lo que te pedirán. El SDK de Python lo expone en la respuesta como
_request_id. - La versión del prompt. Sin ella no puedes relacionar un cambio de comportamiento con un despliegue (Despliegue).
Trazas con contextvars
No necesitas una plataforma para empezar. Una línea JSON por tramo, escrita en la salida estándar o en un archivo, se puede enviar después a cualquier sistema de registros. contextvars propaga el tramo actual a través de funciones y de tareas de asyncio sin pasarlo como argumento.
import contextvars, json, sys, time, uuid
from contextlib import contextmanager
_traza = contextvars.ContextVar("traza", default=None)
_tramo = contextvars.ContextVar("tramo", default=None)
def emitir(registro: dict) -> None:
sys.stdout.write(json.dumps(registro, ensure_ascii=False, default=str) + "\n")
@contextmanager
def tramo(tipo: str, **atributos):
trace_id = _traza.get() or uuid.uuid4().hex
span_id, parent_id = uuid.uuid4().hex[:16], _tramo.get()
t_traza, t_tramo = _traza.set(trace_id), _tramo.set(span_id)
datos = {"tipo": tipo, **atributos}
inicio = time.perf_counter()
try:
yield datos # quien usa el tramo añade campos a este diccionario
except Exception as e:
datos["error"] = repr(e)
raise
finally:
emitir({"trace_id": trace_id, "span_id": span_id, "parent_id": parent_id,
"ms": round((time.perf_counter() - inicio) * 1000), **datos})
_tramo.reset(t_tramo); _traza.reset(t_traza)Y así se usa dentro del bucle del agente:
import anthropic
from trazas import tramo
MODEL = "claude-sonnet-5-5"
PROMPT_VERSION = "soporte-v14"
client = anthropic.Anthropic()
def llamar_modelo(paso: int, **kwargs):
with tramo("modelo", modelo=MODEL, paso=paso) as t:
resp = client.messages.create(model=MODEL, **kwargs)
u = resp.usage
t.update(stop_reason=resp.stop_reason, message_id=resp.id,
request_id=getattr(resp, "_request_id", None),
input_tokens=u.input_tokens, output_tokens=u.output_tokens,
cache_creation=u.cache_creation_input_tokens or 0,
cache_read=u.cache_read_input_tokens or 0)
return resp
def llamar_herramienta(nombre: str, entrada: dict, ejecutar) -> tuple[str, bool]:
with tramo("herramienta", nombre=nombre, entrada=redactar(entrada)) as t:
try:
salida, error = ejecutar(nombre, entrada), False
except Exception as e:
salida, error = f"Error: {e}", True
t.update(is_error=error, bytes_resultado=len(salida))
return salida, error
def atender(usuario_id: str, pregunta: str) -> str:
with tramo("peticion", usuario=seudonimo(usuario_id), prompt=PROMPT_VERSION) as t:
respuesta = bucle_agente(pregunta) # usa llamar_modelo y llamar_herramienta
t["resultado"] = "ok"
return respuestaredactar y seudonimo son tuyas: la primera quita o enmascara datos personales de los argumentos; la segunda convierte el ID del usuario en un hash con sal para poder agrupar sin identificar. Con subagentes en paralelo, cada tarea de asyncio hereda una copia del contexto, así que sus tramos cuelgan del tramo que las lanzó sin hacer nada más.
# salida de ejemplo
{"trace_id": "9f2c…", "span_id": "a1b2…", "parent_id": "77de…", "ms": 2140, "tipo": "modelo", "modelo": "claude-sonnet-5-5", "paso": 1, "stop_reason": "tool_use", "input_tokens": 3120, "output_tokens": 88, "cache_creation": 0, "cache_read": 2900}
{"trace_id": "9f2c…", "span_id": "c3d4…", "parent_id": "77de…", "ms": 180, "tipo": "herramienta", "nombre": "buscar_pedido", "entrada": {"pedido_id": "4512"}, "is_error": false, "bytes_resultado": 412}Cuando el volumen crezca, OpenTelemetry es el estándar abierto para trazas y la mayoría de plataformas de observabilidad lo aceptan. El modelo de datos es el mismo: tramos con IDs y atributos.
Métricas que salen de las trazas
Agregando los tramos obtienes lo necesario para operar:
- Coste por petición (media y percentil alto), por versión de prompt y por tipo de petición. Sale de
usagey de tus precios (Control de costes y prompt caching). - Latencia total y del primer token, percentiles 50 y 95. Los usuarios recuerdan las esperas largas, y la media las esconde (Latencia: streaming, modelo adecuado y menos pasos).
- Pasos por petición y su distribución. Una cola larga indica bucles que no convergen.
- Errores de herramienta por nombre. Un salto repentino suele ser un cambio en un sistema externo.
- Distribución de
stop_reason, con atención amax_tokensyrefusal. - Tasa de caché:
cache_readfrente al total de entrada.
Pon alertas sobre pocas cosas y bien elegidas: coste por hora por encima de un presupuesto, tasa de errores de una herramienta, percentil 95 de latencia y aumento de max_tokens. Cada alerta debe llevar a una acción concreta; si nadie sabe qué hacer cuando salta, sobra.
De la traza a la eval
Las trazas cierran el círculo con Nivel 80 · Evals. Cuando un usuario se queja o una alerta salta, buscas la traza, entiendes el fallo y la conviertes en un caso de tu conjunto de prueba. Si tu formato de trazas coincide con el de la trayectoria de tus evals (Evals de trayectoria: cómo llegó, no solo qué dijo), el paso es casi automático.
Errores frecuentes
- Registrar solo errores y no poder explicar una respuesta que no falló pero estaba mal.
- No registrar
usagey estimar el coste con la factura mensual. - Usar IDs distintos en cada servicio y no poder unir los tramos de una misma petición.
- Guardar prompts completos con datos personales sin redacción ni retención.
- Mirar solo medias de latencia y coste.
Ejercicio
EjercicioPistas
Agrupa con itertools.groupby tras ordenar por trace_id, o carga las líneas en un DataFrame. Para redactar, empieza por expresiones regulares de correos y teléfonos y amplía según tus datos.
Resumen
- Una traza es un árbol de tramos con
trace_id,span_idyparent_id. - Registra por llamada al modelo
stop_reason,usage, duración e IDs; por herramienta, nombre, argumentos redactados, error y duración. contextvarspropaga la traza por funciones y tareas deasynciosin tocar firmas.- Saca de las trazas coste, latencia, pasos, errores y
stop_reason, y alerta solo sobre lo accionable. - Redacta datos personales y fija una retención.