Cómo implementar observabilidad LLM con distributed tracing en agentes
Aprendé a implementar observabilidad de LLM con distributed tracing y OpenTelemetry para diagnosticar fallas y bajar costos en tus agentes.
Tu agente llama a una API externa para procesar un reembolso y la respuesta tarda el triple o devuelve un formato inválido. Si monitoreás el sistema como un endpoint HTTP común, solo vas a ver un error genérico o un timeout. En este post te explico cómo armar una estrategia de observabilidad de LLM con distributed tracing para encontrar fallas, bajar latencias y auditar costos sin atarte a plataformas cerradas.
Un agente ejecuta un grafo de decisiones probabilísticas con bucles, llamadas a tools, consultas vectoriales y memoria dinámica. Si el flujo falla o alucina y vos solo guardás el input inicial y el output final, no podés aislar el problema. No sabés si falló la búsqueda semántica, si el modelo generó mal los argumentos o si la API externa devolvió un payload roto.
Para operar agentes necesitás distributed tracing granular de inicio a fin.
flowchart TD
subgraph Trace ["Root Trace: Session Workflow"]
direction TB
S1["Span 1: Context Hydration (Postgres / VectorDB)"] --> S2["Span 2: LLM Reasoning (Plan & Tool Selection)"]
S2 --> S3{"¿Requiere Tool?"}
S3 -- "Sí" --> S4["Span 3: Tool Execution (Stripe / Internal API)"]
S4 --> S5["Span 4: Ingest Tool Result into Context"]
S5 --> S2
S3 -- "No (Meta alcanzada)" --> S6["Span 5: Final Synthesis & Output Generation"]
end
Anatomía de un span y propagación de contexto
Para diagnosticar problemas en producción, cada ejecución genera un trace_id único que viaja por todo el ciclo de vida del agente. Cada operación emite un span con atributos estandarizados.
Pensá en el flujo de procesar un reembolso (process_customer_refund):
[Trace: process_customer_refund (14.2s)]
├── [Span: hydrate_user_context (120ms)]
├── [Span: llm_intent_routing (1.4s)] -> tool_calls: [query_stripe_charges]
├── [Span: execute_tool: query_stripe_charges (850ms)]
├── [Span: vector_search: refund_policy (340ms)]
├── [Span: llm_policy_evaluation (4.1s)] -> tool_calls: [execute_refund]
├── [Span: execute_tool: stripe_refund_api (910ms)]
└── [Span: llm_final_response (6.4s)]
Estos son los metadatos necesarios según el tipo de operación:
- Spans de inferencia (LLM):
gen_ai.provider.name(el atributogen_ai.systemquedó deprecado en semconv v1.37),gen_ai.request.model, temperatura,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.cache_read_input_tokens(caching de contexto efímero/prompt caching para calcular costos) ygen_ai.response.finish_reasons. Si usás streaming, medístime_to_first_token(TTFT) con la diferencia entre el envío del request y el primer chunk. En llamadas bloqueantes sin streaming, el TTFT equivale a la latencia total del span. - Spans de tools:
gen_ai.tool.name, argumentos en JSON serializado engen_ai.tool.call.arguments, código de respuesta HTTP/RPC y el payload devuelto al contexto engen_ai.tool.call.result. Ojo:argumentsyresultson datos de contenido; muchas librerías los apagan por defecto por privacidad y tenés que activar la captura de forma explícita. - Spans de retrieval (RAG / memoria): Query embebida, top-k, similarity scores, filtros aplicados e IDs de los chunks inyectados.
Si no podés reconstruir el prompt exacto que recibió el LLM en el paso 3 de una secuencia de 5 llamadas, con sus tokens y respuestas intermedias, estás a ciegas.
Implementación de observabilidad de LLM con OpenTelemetry
Para no quedar atado a un proveedor específico, estructurá la instrumentación con la API estándar de OpenTelemetry (OTel) y las convenciones semánticas oficiales de GenAI.
Este decorador extrae el contexto W3C entrante, mantiene la jerarquía de la traza y guarda los atributos estándar:
import functools
import json
import time
from typing import Any, Callable, Dict
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
tracer = trace.get_tracer("agent.engine", "1.0.0")
def instrument_tool_step(tool_name: str):
def decorator(func: Callable):
@functools.wraps(func)
def wrapper(*args, carrier_context: Dict[str, str] = None, **kwargs) -> Any:
# Propagación de contexto distribuido (W3C standard)
parent_ctx = TraceContextTextMapPropagator().extract(carrier=carrier_context or {})
with tracer.start_as_current_span(
f"execute_tool:{tool_name}",
context=parent_ctx,
kind=trace.SpanKind.INTERNAL
) as span:
# Convenciones semánticas oficiales de OpenTelemetry GenAI
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", tool_name)
span.set_attribute("gen_ai.tool.call.arguments", json.dumps(kwargs))
start_time = time.perf_counter()
try:
result = func(*args, **kwargs)
duration = time.perf_counter() - start_time
span.set_attribute("gen_ai.tool.call.result", json.dumps(result)[:4000]) # Límite de seguridad
span.set_attribute("gen_ai.execute_tool.duration", duration)
span.set_status(Status(StatusCode.OK))
return result
except Exception as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise exc
return wrapper
return decorator
Arquitectura de ingestión y sampling
Guardar cada interacción entera con prompts largos, embeddings y payloads satura el almacenamiento y expone datos personales (PII).
La solución es usar un OTel Collector. Recibe las trazas por OTLP/gRPC, limpia datos sensibles, aplica tail-sampling y manda los datos a los backends elegidos:
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
# Sampling adaptativo en cola
tail_sampling:
decision_wait: 10s
num_traces: 10000
expected_new_traces_per_sec: 2000
policies:
# 1. 100% de traces con errores
- name: sample-errors
type: status_code
status_code: { status_codes: [ ERROR ] }
# 2. 100% de traces lentas (degradación)
- name: sample-latency
type: latency
latency: { threshold_ms: 10000 }
# 3. 5% de traces exitosas para análisis de costos y drifts
- name: sample-success-probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 5.0 }
redaction:
# Sanitización de PII antes de exportar
allow_all_keys: true # conservar los atributos; solo enmascarar los valores que matcheen
blocked_values:
- "(?i)[a-z0-9_.+-]+@[a-z0-9-]+\\.[a-z0-9-.]+" # Emails
- "\\b(?:\\d[ -]*?){13,16}\\b" # Tarjetas de crédito
exporters:
otlp/langfuse:
endpoint: https://cloud.langfuse.com:443
prometheus:
endpoint: 0.0.0.0:8889
clickhouse:
endpoint: tcp://clickhouse:9000?database=otel
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, redaction]
exporters: [otlp/langfuse, clickhouse]
metrics:
receivers: [otlp]
exporters: [prometheus]
Comparativa de backends
| Backend | Ventajas Operativas | Limitaciones y Tradeoffs |
|---|---|---|
| OpenTelemetry + ClickHouse | Control total de costos, retención histórica masiva y soberanía absoluta de datos. | Requiere mantener pipelines propios y diseñar UIs/dashboards ad-hoc. |
| Langfuse / Arize Phoenix | Visualización especializada de árboles de ejecución, debugging paso a paso y testing de prompts. | Introduce una plataforma adicional en la infraestructura fuera del APM central. |
| LangSmith | Trazabilidad nativa si el orquestador usa LangGraph/LangChain. | Acoplamiento de ecosistema y costos de licencia por volumen de eventos. |
| Datadog / New Relic APM | Monitoreo unificado de infraestructura base (K8s, DB, queues) junto al agente. | Costos elevados por ingestión de payloads extensos y herramientas de CI eval poco flexibles. |
De traces en producción a golden datasets
El mayor provecho del tracing aparece cuando alimentás tu suite de pruebas continuas. Es un circuito clave que detallo en cómo armar CI/CD para agentes de IA:
flowchart LR
A["Falla en Producción (Span Error / LLM Judge Fail)"] --> B["Extracción de Trace + Sanitización PII"]
B --> C["Mock de Tools con Payloads Históricos"]
C --> D["Inyección en Golden Dataset"]
D --> E["Regresión en CI: Evaluar nuevo prompt/modelo"]
Para convertir una traza productiva en un test determinista, seguí estos pasos:
- Detectar el incidente: Filtrá trazas con errores en tools o puntajes bajos de evaluadores asíncronos (como un LLM-as-a-judge o validadores semánticos).
- Aislar y mockear: No vuelvas a pegarle a la API real (como Stripe en nuestro ejemplo de reembolsos) para no duplicar operaciones. Tomá los argumentos del span y mockeá la respuesta usando el payload guardado en
gen_ai.tool.call.result. - Validar localmente: Con las tools mockeadas, ejecutá el paso de inferencia para chequear si el nuevo prompt o modelo resuelve bien el ruteo.
Diagnóstico de problemas paso a paso
Si la tasa de éxito de tu agente baja, revisá los spans en este orden:
- Recuperación semántica: Mirá el span de retrieval. Si bajó el similarity score promedio o aumentó su dispersión, revisá el modelo de embeddings, el chunking o el índice vectorial.
- Esquemas de tool calling: Revisá el span de inferencia previo a ejecutar la tool. Si el LLM devolvió un JSON que rompe el schema, forzá la salida con Structured Outputs nativos o sumá validaciones intermedias con reintentos tipados, como explico en la guía sobre tool calling determinista en producción.
- Saturación del contexto: Si el fallo ocurre a partir del cuarto paso, revisá el tamaño de la ventana de contexto. Muchos payloads acumulados confunden al modelo. Cuidado: no borres mensajes al azar. Las APIs exigen que cada
tool_callcoincida con sutool_messagemediante eltool_call_id. Para ahorrar tokens sin romper el historial, resumí solo el contenido de los resultados viejos manteniendo intactos los IDs.
Métricas de servicio para monitorear
- Latencia P50 y P95 por sesión: Tiempo total de la traza, separando la inferencia de las llamadas a APIs externas.
- Costo por resolución exitosa: Total de tokens consumidos (input, output y cacheados) sobre la cantidad de corridas exitosas.
- Tasa de fallas en tools: Proporción de spans de tools con status
ERRORsobre los finalizados conOK. - Profundidad de ejecución (pasos por sesión): Promedio de pasos por corrida. Si sube de golpe, el agente suele estar atrapado en bucles por instrucciones ambiguas en el prompt.
Qué hacer el lunes
- Instrumentá con OTel: Configurá la propagación de contexto W3C y usá las convenciones de OpenTelemetry GenAI para tus modelos y tools.
- Configurá tail-sampling: Levantá un OTel Collector para guardar el 100% de los errores y trazas lentas, y solo un 5% de los casos exitosos para controlar los costos de storage.
- Automatizá la creación de datasets: Armá un pipeline que tome las trazas fallidas sanitizadas, mockee las tools y sume esos casos a tu suite de CI/CD antes de cambiar prompts o modelos.
Un agente sin tracing distribuido es una caja negra que gasta presupuesto sin darte visibilidad de lo que pasa adentro.