Skip to content

12 — observability

Instrumentar un pipeline LLM con OpenTelemetry. Trazas, latencia y error tracking.

El problema

Tu pipeline LLM tiene 3 segundos de latencia. ¿El problema está en el retriever, en el reranker o en la llamada al LLM? Sin trazas, la única forma de saberlo es añadir prints y volver a ejecutar. Con OpenTelemetry, cada etapa genera un span con su duración exacta. En producción, un pipeline sin observabilidad es una caja negra — solo sabes que algo falla, no dónde.

Cómo funciona

  • Span OTel: unidad de trazabilidad. Registra inicio, fin, duración y metadatos de una operación.
  • @trace: decorador que envuelve una función y crea automáticamente un span con su nombre y duración.
  • MockCollector: acumula los spans durante una ejecución y permite verificarlos en tests.
  • Langfuse y Phoenix: plataformas de visualización de trazas LLM. El lab genera trazas compatibles con ambas.
text
@trace("retrieval") → span_start → función → span_end
@trace("generation") → span_start → función → span_end
MockCollector → [span_retrieval, span_generation] → verificar count y latencia

Código paso a paso

1. Decorar funciones con @trace

El decorador intercepta la llamada, mide el tiempo de ejecución y registra el span en el MockCollector activo. No requiere cambios en la lógica de la función.

python
from src.tracer import trace
from src.mock_collector import MockCollector
import src.tracer as tracer_module

@trace("retrieval")
def retrieve(query: str) -> list[str]:
    return buscar_en_vector_db(query)

@trace("generation")
def generate(query: str, context: list[str]) -> str:
    return llamar_llm(query, context)

2. Verificar span_count y latencia total

MockCollector acumula todos los spans durante la ejecución. En los tests se usa como fixture para activarlo antes de la llamada y desactivarlo al terminar.

python
collector = MockCollector()
tracer_module.set_collector(collector)

respuesta = generate("¿Cuál es la política?", retrieve("política"))

spans = collector.get_spans()
assert len(spans) == 2
total_latency = sum(s.duration_ms for s in spans)
assert total_latency < 5000  # ms

3. Inspeccionar spans individuales por nombre

get_spans(name) filtra por nombre de span. Permite verificar cada etapa del pipeline de forma independiente.

python
retrieval_spans = collector.get_spans("retrieval")
generation_spans = collector.get_spans("generation")

assert len(retrieval_spans) == 1
assert retrieval_spans[0].status == "OK"
assert retrieval_spans[0].duration_ms >= 0

Técnicas avanzadas

En producción, necesitas que cada traza sea completa y estructurada para que el equipo de oncall pueda diagnosticar incidencias sin necesitar al desarrollador que escribió el código. El módulo incluye TraceRecord con sus 20 campos, y make_trace_record y validate_trace para construir y validar registros de traza.

python
from src.trace_record import make_trace_record, validate_trace

record = make_trace_record(
    response="Puedes devolver en 30 días.",
    tokens_in=120,
    tokens_out=18,
    latency_ms=342.5,
    model_id="claude-sonnet-4-6",
    prompt_id="return-policy-v2",
    prompt_version="2.1",
    retriever_id="faiss-cosine",
    top_k_docs=5,
    eval_scores={"faithfulness": 0.87, "relevancy": 0.91},
    safety_flags=(),
    pii_flags=(),
)

errors = validate_trace(record)
# errors = [] si todos los campos cumplen los requisitos mínimos
GrupoCampos
Identidadrequest_id, user_segment
Modelomodel_id, model_version
Promptprompt_id, prompt_version
Retrieverretriever_id, retriever_version, top_k_docs, reranker_scores
Respuestaresponse, tokens_in, tokens_out, latency_ms
Seguridadsafety_flags, pii_flags, tool_calls
Errorerror_code, retry_count
Evaleval_scores

Errores comunes

  • Spans sin correlación entre sí. No puedes reconstruir el pipeline completo. Usar el mismo trace_id para todos los spans de una request.
  • Medir solo latencia total. No sabes el cuello de botella. Un span por etapa: retrieval, reranking, generation.
  • Logs no estructurados (print). No son consultables en Langfuse o Phoenix. Usar structured logging con los campos de TraceRecord.
  • No registrar tokens_in/out. No puedes calcular el coste por request. Registrar siempre ambos contadores.

En producción

CampoPor qué importa
trace_idCorrelacionar spans de una request
model_idDetectar regresiones por versión
latency_msSLA y alertas de rendimiento
tokens_in / tokens_outControl de costes
error_typeClasificar incidencias
bash
pytest modules/12-observability/tests/ -m "not slow" -q

Para detectar degradación de latencia en el tiempo, ver módulo 13.

Caso real en producción

Una plataforma de sanidad con asistente de triaje reportaba un p95 de 4.2 segundos sin saber la causa. Tras instrumentar con @trace las tres etapas del pipeline, descubrieron que el reranker tardaba 2.8 segundos — el 67% de la latencia total. El retriever y el generador eran rápidos. La solución fue cachear los resultados del reranker para queries frecuentes, reduciendo el p95 a 1.6 segundos.

Ejercicios

  • 🟢 Añade un cuarto span al pipeline del lab (@trace("postprocessing")). Busca el archivo de test en modules/12-observability/tests/test_observability.py y ejecuta pytest modules/12-observability/tests/ -m "not slow" -q. ¿Sube el número de spans en el collector?
  • 🟡 Implementa un test que verifique que si una etapa tarda más de 2 segundos, el span tiene duration_ms > 2000. Usa un mock con sleep controlado.
  • 🔴 Genera 10 TraceRecord simulando latencia real con varianza. Pásalos por validate_trace y construye un informe con p50, p95 y p99 de latencia por etapa.
22
tests
0.05s
duración
opt.
API key
Avanzado
nivel
bash
pytest modules/12-observability/tests/ \
  -m "not slow" -q

MIT License · GitHub