Rastreando el paso de recuperación: seis herramientas de observabilidad comparadas en un agente RAG
Un agente RAG rastreado con seis herramientas de observabilidad de LLM: Opik, MLflow, Langfuse, Phoenix, AcruxCore y Helicone, comparando decoradores, OpenTelemetry y proxies.
Fuentes: Tracing a RAG pipeline: how six LLM observability tools handle the step that isn’t a model call
La generación mejorada con recuperación de información es un proceso en dos pasos, y solo uno de ellos implica una llamada al modelo. Para ver cómo los herramientas de observabilidad registran la otra mitad, se instrumentó al mismo agente pequeño a través de seis plataformas: Opik, MLflow, Langfuse, Phoenix, AcruxCore y Helicone.
Por qué la recuperación de información escapa al envoltorio habitual
Es sencillo observar una sola llamada al modelo: basta modificar el cliente de OpenAI para que cada chat.completions.create() aparezca como un intervalo de tiempo. Agregar la recuperación de información parece ser un paso menor. Se obtiene el contexto, se llama al modelo, y esa misma modificación debería seguir cubriendo todo. En la práctica, solo cubre la mitad.
Dicho de otro modo, un pipeline RAG consta al menos de dos elementos separados que deben rastrearse. Uno es el paso de recuperación, del cual no se da cuenta ningún wrapper del cliente. El otro es el paso de generación, que se asemeja a cualquier llamada a un modelo ordinario. El experimento se centró directamente en esa intersección, manteniendo todo lo demás constante entre ejecuciones.
Los hallazgos en resumen
- Los decoradores son los más utilizados. Opik, MLflow y Langfuse convierten cada función en un paso rastreable con una sola línea adicional, sin modificar su cuerpo.
- OpenTelemetry funciona en dos entornos. Tanto Phoenix como AcruxCore aceptan OTLP, por lo que un span creado manualmente puede enviarse a cualquiera de ellos cambiando el endpoint. Se ejecutó el mismo pipeline en ambos.
RERANKER, GUARDRAIL y EVALUATOR, contados a partir del código del SDK y no de la documentación.Qué es un tipo de rango
Imagínese una traza como la lista de etapas por las que pasa una solicitud. Cada etapa es un span que contiene un nombre, tiempos de inicio y finalización, así como sus datos de entrada y salida. Esto revela qué se ejecutó y cuánto tiempo duró, pero no la naturaleza de la tarea: un span etiquetado como step_2 con una duración de 900 ms podría ser igualmente una búsqueda SQL, una llamada a un LLM o una solicitud HTTP. Diferenciarlos es la función del tipo de span, una etiqueta que lo determina. Cada herramienta define un conjunto cerrado de categorías, y cada span recibe una.
Tomemos como ejemplo al agente RAG aquí. Una solicitud genera tres spans:
┌────────────────┬────────────────────────────────┬────────────────┐
│ Span name │ What it did │ Span type │
├────────────────┼────────────────────────────────┼────────────────┤
│ rag-agent │ the whole request │ agent or chain │
├────────────────┼────────────────────────────────┼────────────────┤
│ search_docs │ queried Chroma for four chunks │ retrieval │
├────────────────┼────────────────────────────────┼────────────────┤
│ ChatCompletion │ called gpt-4o-mini │ llm │
└────────────────┴────────────────────────────────┴────────────────┘
Aquí, search_docs es simplemente un nombre elegido por el desarrollador, mientras que la recuperación proviene de la lista de valores permitidos por el SDK. Un visor muestra esto como un chip de Recuperación antes de search_docs y un chip de LLM antes de la completación.
El tipo es importante, y no solo el nombre, porque la herramienta actúa en función de él. Elige el ícono y el diseño; así, un segmento de recuperación muestra los documentos obtenidos, mientras que un segmento de modelo muestra el conteo de tokens y el costo. Permite la filtración, por lo que una pregunta como “¿cuáles pasos de recuperación tardaron más de un segundo?” funciona en todos los registros, incluso cuando los desarrolladores nombraron sus funciones de manera diferente. Además, impulsa el análisis: cuánta latencia proviene de la recuperación frente a la generación se convierte en una consulta en la columna de tipo.
La longitud de los vocabularios varía. AcruxCore permite siete: llm, tool, retrieval, embedding, agent, chain y other. MLflow permite quince, entre ellos RERANKER, GUARDRAIL, EVALUATOR, PARSER y MEMORY. Opik solo permite cuatro: general, tool, llm y guardrail.
El agente en pruebas
El agente es intencionadamente sencillo. Indexa cinco páginas de la documentación de OpenTelemetry, divididas en fragmentos de aproximadamente 1,000 caracteres, las incrusta con text-embedding-3-small y las almacena en una colección Chroma en memoria. Una única pregunta fija sobre qué es un span y cómo los spans padre e hijo forman un trace obtiene cuatro fragmentos, y gpt-4o-mini escribe la respuesta a partir de ellos.
Ninguna de las seis plataformas posee los documentos, a propósito. Si el agente respondiera a preguntas sobre una de las herramientas que se comparan, la prueba sería circular, y el texto de la respuesta aparecería en todas las capturas de pantalla.
def retrieve_context(collection, question: str, top_k: int = 4) -> str:
[query_embedding] = embed_texts([question])
results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
chunks = results["documents"][0]
sources = [m["source"] for m in results["metadatas"][0]]
return "\n\n".join(f"[{src}]\n{chunk}" for src, chunk in zip(sources, chunks))
Esta función, el paso de particionamiento y la llamada a embedding no cambian en las seis ejecuciones. La única variable es la instrumentación que los rodea. Cada herramienta se ejecutó en una máquina local: cinco autohospedadas en Docker, además de AcruxCore. LangSmith y W&B Weave se omitieron porque existen únicamente como servicios alojados. A juzgar por su SDK, LangSmith sería un cuarto ejemplo de decorador con siete tipos de ejecución, aunque al no observarse rastro alguno, no hay nada más que afirmar al respecto.
Patrón 1: el decorador
Aquí es donde las herramientas convergen, y con razón: el cuerpo de la función permanece exactamente como está escrito. El código que ya existe simplemente recibe anotaciones. La forma de Opik coincide con su documentación:
@opik.track(name="search_docs", project_name=PROJECT_NAME)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
MLflow tiene la misma forma con un tipo de rango adjunto:
@mlflow.trace(name="search_docs", span_type=SpanType.RETRIEVER)
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
Langfuse ofrece @observe, algo que es fácil pasar por alto al encontrarse primero con sus ejemplos de gestor de contexto:
@observe(name="search_docs", as_type="retriever")
def search_docs(collection, query: str) -> str:
return rag_core.retrieve_context(collection, query)
La llamada al modelo se anida automáticamente en los tres casos. Cada uno proporciona un cliente de OpenAI modificado (track_openai(), mlflow.openai.autolog() y langfuse.openai), por lo que el período de generación depende de la función decorada que se esté ejecutando en ese momento. Por lo tanto, un único decorador genera un árbol de dos niveles de profundidad.
Langfuse produjo el resultado más ordenado. El período de búsqueda search_docs de tipo retriever duró 1,05 s, y el período de generación que capturó por sí solo duró 4,14 s, con los costos y totales de tokens sumados. En MLflow, las mismas dos etapas muestran un tiempo de recuperación de 0,47 s y un período de Completions capturado de 3,37 s. Opik también dibuja un árbol correcto, salvo por una peculiaridad estética: el período de trazado y el período raíz comparten el mismo nombre, por lo que rag-agent aparece listado dos veces encima de search_docs.
Patrón 2: un gestor de contexto del proveedor
Langfuse también expone el objeto span en sí, que es la herramienta a utilizar cuando los límites de un span no coinciden con los límites de una función:
with langfuse.start_as_current_observation(name="search_docs", as_type="span") as span:
context = retrieve_context(collection, QUESTION)
span.update(input={"query": QUESTION}, output={"context": context})
Lo que se gana es precisión, ya que la entrada y la salida pueden establecerse en el instante exacto y un rango puede abarcar parte de una función. Lo que se pierde es la separación, porque el rastreador de código termina dentro de la lógica de negocio. Cuando toda una función es la unidad, @observe logra el mismo resultado sin esa desventaja.
Patrón 3: OpenTelemetry estándar
Phoenix no cuenta con decoradores propios. Se utiliza directamente el rastreador de OpenTelemetry, y el instrumentador OpenInference envuelve automáticamente al cliente de modo que la llamada de generación sigue estando anidada:
with tracer.start_as_current_span("search_docs") as span:
context = retrieve_context(collection, QUESTION)
span.set_attribute("output.value", context)
La ventaja es que nada de lo que hay aquí pertenece a Phoenix. Se trata de intervalos OTel estándar, por lo que la misma instrumentación puede alimentar a cualquier backend compatible con OTLP una vez que se cambie el endpoint. Esa afirmación fue puesta a prueba, ya que la portabilidad suele ser cierta en teoría pero falla en la práctica. AcruxCore también habla OTLP, por lo que se diseñó el mismo flujo para él: el mismo cuerpo de with tracer.start_as_current_span(...), el mismo instrumentador y un nuevo endpoint.
tracer_provider = otel.register(service_name="rag-agent-observability")
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
Cada plataforma cuenta con una función auxiliar register() que combina un TracerProvider, un BatchSpanProcessor y un exportador OTLP en una sola llamada. Ninguna de estas funciones auxiliares es obligatoria, y los pipelines creados manualmente funcionan con ambos. Los spans se enviaron correctamente anidados. El atributo RETRIEVER de OpenInference pasó a ser de tipo Retrieval, y el span del modelo capturado automáticamente enumeró el modelo y 1,341 tokens.
Por lo tanto, la portabilidad se mantiene, y ese es el mejor motivo para escribir instrumentación en OTel puro: los spans se escriben una sola vez, y el hecho de determinar qué backend los recibirá se convierte en una cuestión de configuración. El costo se incurre desde el principio: una llamada a register(), un instrumentador y un objeto tracer preceden al primer span, lo cual representa más estructura de soporte que la que necesitaría cualquier decorador para esos mismos dos spans.
Patrón 4: ingestión fuera de banda
Además de OTLP, AcruxCore ofrece una quinta vía que los demás herramientas no poseen. En lugar de observar el proceso, el código describe los spans y los publica. El identificador de traza une todo: al pasarlo a la pasarela, el span del modelo escrito en el lado del servidor se une a la misma traza. Las dos vías se complementan entre sí.
reported = await hub.traces.ingest({
"name": "rag-agent",
"spans": [{
"spanId": "search_docs", "name": "search_docs", "kind": "retrieval",
"input": {"query": QUESTION}, "output": {"context": context},
"startTime": started, "endTime": _now(),
}],
}, wait=False) # buffered, returns at once
result = await hub.gateway.chat(
"openai/gpt-4o-mini", messages, provider=PROVIDER,
trace={"trace_id": reported.trace_id}, # generation lands in the same trace
)
Requiere menos llamadas que la creación de un rastreador, y las marcas de tiempo son establecidas por quien realiza la llamada, lo cual es adecuado para trabajos que ya han finalizado y no dejan una pila de llamadas activa a la que adjuntarse.
El problema está en la bandera wait=False. En el modo por defecto, ingest() espera su solicitud POST y devuelve un identificador de rastreo generado por el servidor, por lo que la solicitud realiza un viaje de ida y vuelta por la red antes de que comience la llamada al modelo. Con wait=False, el registro se almacena en un búfer local del SDK y el identificador se crea en el lado del cliente. No se confirma nada en el lugar donde se realiza la llamada, y una transmisión fallida se registra como una advertencia y se descarta, de manera similar a un exportador OTel. La decisión consiste en elegir entre una respuesta lenta y ruidosa en caso de fallo o una respuesta rápida y silenciosa en caso de fallo. Cualquiera de las opciones es defendible, siempre y cuando la elección sea deliberada.
Patrón 5: estar dentro de la ruta de la solicitud
Dos de las herramientas se encuentran dentro de la propia llamada, y como consecuencia el segmento del modelo se genera de forma gratuita. Helicone lo muestra de la manera más clara: basta cambiar base_url para que apunte a ella y la completación se registra sin necesidad de código SDK alguno. La pasarela AcruxCore adopta la misma estrategia. Una llamada enrutada a través de gateway.chat() tiene su segmento llm generado por el servidor, por lo que el cliente no necesita instrumentación adicional.
Esta opción presenta una ventaja y una limitación, comunes a ambas. Todo lo que se encuentra en la ruta de la solicitud solo muestra tráfico HTTP dirigido a un proveedor de modelos. Una consulta de Chroma no genera dicho tráfico, por lo que permanece invisible independientemente de la herramienta utilizada.
Se diferencian en lo que los acompaña. La pasarela AcruxCore acepta un identificador de traza, lo que permite que un span registrado a través de otra ruta, OTel o ingest(), funcione como padre para que el span del modelo escrito en el servidor quede dentro de la misma traza. Helicone solo ofrece el proxy y no incluye un SDK para envolver una función arbitraria. Esto deja dos opciones: omitir la recuperación de la traza o inventar una finalización falsa para incluirla, y esta última llena la telemetría con información ficticia. Por lo tanto, la afirmación precisa es más restringida que la de que los proxies no pueden rastrear la recuperación: un proxy por sí solo no puede hacerlo, y su importancia depende de si existe una segunda ruta.
Una advertencia sobre la implementación, no relacionada con el diseño: la versión autohospedada de Helicone utilizada en esta prueba respondió a su endpoint de registro manual con un código 500 debido a la ausencia de una variable S3_REGION, lo que impidió registrar incluso la mitad del modelo. Ese es un defecto de esta imagen en particular y no del enfoque en sí.
Qué se puede considerar un span
Dos spans demuestran que el sistema funciona, pero no pueden distinguir entre un reordenador y un mecanismo de protección medio año después. Por lo tanto, los valores permitidos se obtuvieron de cada SDK instalado y no de sus documentaciones.
MLflow lidera esta categoría con una gran ventaja. RERANKER, GUARDRAIL, EVALUATOR, PARSER y MEMORY son funciones integradas, mientras que Opik cuenta con cuatro tipos y clasifica todo lo demás como metadatos. Los siete tipos de AcruxCore manejan RAG, pero sin un nivel de detalle mayor. Phoenix hereda su lista de la convención de OpenInference, por lo que es común en todas las herramientas que siguen ese estándar y no constituye una característica exclusiva de Phoenix.
Esta es la fila que con mayor probabilidad será malinterpretada a partir de las páginas de marketing. Se pueden verificar cualquiera de estas listas contra las versiones instaladas en pocas líneas de código:
from mlflow.entities import SpanType
from openinference.semconv.trace import OpenInferenceSpanKindValues
print([v for k, v in vars(SpanType).items() if isinstance(v, str) and not k.startswith("_")])
print([e.value for e in OpenInferenceSpanKindValues])
Langfuse pone un cuidado especial. Su ObservationTypeLiteral, que se encuentra en langfuse._client.span, está compuesto por tipos Literal anidados, por lo que typing.get_args devuelve alias internos en lugar de cadenas y el resultado debe ser aplanado de forma recursiva. Esto explica por qué las cifras indicadas en su documentación a menudo difieren de las del SDK.
Elegir
- MLflow es adecuado para equipos que desean semántica precisa de inmediato y evaluación posterior. Su vocabulario es con mucho el más amplio, aunque su estructura es más pesada que la de una herramienta de trazado pura.
- Opik genera un árbol correcto esa misma tarde, con la menor complejidad y solo cuatro tipos de span.
- Langfuse ofrece tanto un decorador como una solución manual con tipado estricto, a costa de tener dos conjuntos de documentación.
- Phoenix es adecuado para equipos que podrían cambiar sus backends o que ya utilizan OTel. No requiere API de proveedores y necesita la mayor cantidad de configuración antes de que aparezca el primer span.
- AcruxCore es adecuado cuando el span del modelo debe generarse automáticamente mientras aún se necesita un registro para la recuperación de datos. Cuenta con tres rutas componibles, un vocabulario más pequeño que el de MLflow, Phoenix o Langfuse, y una función de ingestión con búfer que falla de forma silenciosa.
- Helicone es adecuado para pipelines que en realidad consisten únicamente en una llamada al modelo. La captura no requiere código, y no existe lugar donde registrar pasos que no sean del modelo.
La verdadera lección aquí es que no existe un ranking. Al preguntarse si una herramienta realiza el seguimiento de las recuperaciones, resultó haber cinco preguntas separadas, y cada respuesta representa una decisión de diseño que conlleva un costo visible: una línea de código, un bloque with, trabajo de configuración, un salto en la red o un punto ciego. Elija el costo que cause menos daño y examine el vocabulario de intervalos antes de tomar una decisión, porque allí es donde aparecen las sorpresas más tarde.
Leer más
- Cuando el panel es verde y el agente está equivocado: el seguimiento del estado — Cómo los fallos del agente se ocultan en el estado transmitido entre pasos funcionales, y qué tipo de seguimiento, restricciones en tiempo de ejecución, registros de enrutamiento y evaluaciones son necesarios para capturarlos.
- Trazado de un agente Voice RAG con LiveKit, Qdrant y MLflow — Crea un asistente de hipotecas de voz a voz utilizando LiveKit, LlamaIndex y Qdrant, con trazas de OpenTelemetry en MLflow que muestran cada paso de recuperación y de procesamiento del LLM.
- LLMOps en la práctica: Evaluación, observabilidad, coste y gobernanza para la IA — Qué se necesita para mantener una aplicación de LLM fiable en producción: versionado de prompts, conjuntos de evaluación, trazado, métricas de alucinaciones, seguimiento de costes y gobernanza.