← todos los posts
observabilidad·20 de septiembre de 2026·7 min

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 atributo gen_ai.system quedó 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) y gen_ai.response.finish_reasons. Si usás streaming, medís time_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 en gen_ai.tool.call.arguments, código de respuesta HTTP/RPC y el payload devuelto al contexto en gen_ai.tool.call.result. Ojo: arguments y result son 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:

  1. 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).
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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_call coincida con su tool_message mediante el tool_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 ERROR sobre los finalizados con OK.
  • 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

  1. Instrumentá con OTel: Configurá la propagación de contexto W3C y usá las convenciones de OpenTelemetry GenAI para tus modelos y tools.
  2. 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.
  3. 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.