Accueil / Articles

Suivre l’étape de récupération : comparaison de six outils d’observabilité sur un agent RAG

Un agent RAG analysé à l’aide de six outils d’observabilité LLM : Opik, MLflow, Langfuse, Phoenix, AcruxCore et Helicone, en comparant les décorateurs, OpenTelemetry et les proxies.

Éditeur : REACTAPP.TOP10 min de lecture2399 mots

Sources: Tracing a RAG pipeline: how six LLM observability tools handle the step that isn’t a model call

La génération augmentée par récupération est un processus en deux étapes, et seule l’une d’elles implique une appel à un modèle. Pour voir comment les outils d’observabilité enregistrent l’autre moitié du processus, le même petit agent a été équipé d’outils d’analyse à travers six plateformes : Opik, MLflow, Langfuse, Phoenix, AcruxCore et Helicone.

Pourquoi la récupération échappe au wrapper habituel

Un appel à un modèle unique est facile à observer : il suffit de modifier le client OpenAI, et chaque chat.completions.create() apparaît alors comme une unité d’analyse. Ajouter la récupération semble être une étape mineure. Le contexte est récupéré, le modèle est appelé, et le même patch devrait toujours couvrir tout le processus. En pratique, il ne couvre que la moitié.

En d’autres termes, un pipeline RAG comprend au moins deux éléments distincts à suivre. Le premier est l’étape de récupération, que aucun wrapper client ne détecte. Le second est l’étape de génération, qui ressemble à n’importe quelle appel à un modèle ordinaire. L’expérience visait précisément cette zone de transition, en maintenant tout le reste constant entre les exécutions.

Les résultats en bref

  • Les décorateurs dominent. Opik, MLflow et Langfuse transforment chacun une fonction en une étape traçable en ajoutant une seule ligne, sans modifier le corps de la fonction.
  • OpenTelemetry fonctionne en deux endroits. Phoenix et AcruxCore prennent tous deux en charge OTLP, de sorte qu’un span manuellement créé peut être envoyé à l’un ou à l’autre en changeant simplement l’endpoint. Le même pipeline a été exécuté sur les deux.
  • Deux produits sont impliqués dans le chemin de la requête. Le proxy d’Helicone et le gateway d’AcruxCore enregistrent l’intervalle du modèle sans aucun code client, mais aucun d’eux ne détecte une étape qui ne contacte jamais un fournisseur de modèle.
  • Helicone ne dispose d’aucune solution pour la récupération, car son produit se résume au proxy et il n’existe aucun SDK permettant d’enregistrer cette étape.
  • MLflow propose le vocabulaire le plus riche en types d’intervalle, avec quinze types parmi lesquels RERANKER, GUARDRAIL et EVALUATOR, comptés à partir de la source du SDK et non de la documentation.
  • Qu’est-ce qu’un type d’intervalle

    Considérez une trace comme la liste des étapes par lesquelles passe une requête. Chaque étape est un élément portant un nom, des horaires de début et de fin, ainsi que ses entrées et sorties. Cela révèle ce qui a été exécuté et combien de temps cela a duré, mais pas la nature de la tâche : un élément étiqueté step_2 d’une durée de 900 ms pourrait tout aussi bien correspondre à une recherche SQL, à une invocation d’un LLM ou à une requête HTTP. Distinguer ces cas relève du type d’élément, une étiquette qui le définit. Chaque outil définit un ensemble fermé de catégories, et chaque élément en reçoit une.

    Prenons l’agent RAG ici. Une requête génère trois éléments :

    ┌────────────────┬────────────────────────────────┬────────────────┐
    │   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            │
    └────────────────┴────────────────────────────────┴────────────────┘
    

    Ici, search_docs n’est qu’un nom choisi par le développeur, tandis que la récupération provient de la liste de valeurs autorisées par le SDK. Un afficheur le représente par une icône de récupération avant search_docs et une icône d’LLM avant la complétion.

    Le type est important, et non seulement le nom, car l’outil agit en fonction de lui. Il choisit l’icône et le layout : ainsi, une zone de récupération affiche les documents obtenus, tandis qu’une zone de modèle affiche le nombre de tokens et le coût. Cela permet le filtrage, de sorte qu’une question comme « quels étapes de récupération ont duré plus d’une seconde » fonctionne pour toutes les traces, même lorsque les développeurs ont donné des noms différents à leurs fonctions. De plus, cela permet des analyses : savoir quelle partie de la latence provient de la récupération et quelle partie de la génération devient une interrogation sur la colonne du type.

    La longueur des vocabulaires varie. AcruxCore en permet sept : llm, tool, retrieval, embedding, agent, chain et other. MLflow en autorise quinze, parmi lesquels RERANKER, GUARDRAIL, EVALUATOR, PARSER et MEMORY. Opik n’en permet que quatre : general, tool, llm et guardrail.

    L’agent en test

    Cet agent est délibérément simple. Il indexe cinq pages de documentation OpenTelemetry, divisées en fragments d’environ 1 000 caractères, les incorpore à l’aide de text-embedding-3-small et les stocke dans une collection Chroma en mémoire. Une seule question fixe concernant la définition d’un span ainsi que la manière dont les spans parents et enfants forment un trace récupère quatre de ces fragments, et gpt-4o-mini rédige la réponse à partir d’eux.

    Aucune des six plateformes ne possède délibérément les documents. Si l’agent répondait aux questions concernant l’un des outils comparés, le test deviendrait circulaire, et le texte de réponse apparaîtrait sur chaque capture d’écran.

    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))
    

    Ce fonction, l’étape de segmentation en blocs et l’appel d’incorporation ne changent pas au cours des six exécutions. La seule variable est l’instrumentation qui les entoure. Chaque outil a été exécuté sur une machine locale : cinq hébergés en Docker, ainsi que AcruxCore. LangSmith et W&B Weave ont été exclus car ils n’existent que sous forme de services hébergés. D’après son SDK, LangSmith pourrait constituer un quatrième exemple de décorateur avec sept types d’exécution, mais comme aucun trace n’a été observée, rien de plus ne peut être affirmé à son sujet.

    Pattern 1 : le décorateur

    C’est ici que les outils convergent, et c’est logique : le corps de la fonction reste exactement tel qu’il a été écrit. Le code déjà existant est simplement annoté. La forme proposée par Opik correspond à sa documentation :

    @opik.track(name="search_docs", project_name=PROJECT_NAME)
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    MLflow a la même forme avec un type de plage associé :

    @mlflow.trace(name="search_docs", span_type=SpanType.RETRIEVER)
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    Langfuse propose @observe, facile à négliger lorsqu’on rencontre d’abord ses exemples de gestionnaire de contexte :

    @observe(name="search_docs", as_type="retriever")
    def search_docs(collection, query: str) -> str:
        return rag_core.retrieve_context(collection, query)
    

    Les appels au modèle s’encadrent automatiquement dans les trois approches. Chacune fournit un client OpenAI modifié (track_openai(), mlflow.openai.autolog() et langfuse.openai), de sorte que la phase de génération dépend de la fonction décorée qui est actuellement en exécution. Un seul décorateur permet donc d’obtenir un arbre de deux niveaux de profondeur.

    Langfuse a produit le résultat le plus ordonné. La phase de recherche search_docs de type retriever a duré 1,05 s, tandis que la phase de génération qu’elle a enregistrée par elle-même a duré 4,14 s, avec des totaux de coûts et de tokens intégrés. Dans MLflow, les mêmes deux étapes montrent une recherche en 0,47 s et la phase Completions enregistrée en 3,37 s. Opik dessine également un arbre correct, à l’exception d’une particularité esthétique : les phases trace et racine partagent le même nom, ce qui fait que rag-agent apparaît deux fois au-dessus de search_docs.

    Modèle 2 : un gestionnaire de contexte fourni par un fournisseur

    Langfuse expose également l’objet span lui-même, qui est l’outil à utiliser lorsque les limites d’un span ne correspondent pas aux limites d’une fonction :

    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})
    

    On gagne en précision, car l’entrée et la sortie peuvent être définies au moment exact et une plage peut couvrir une partie d’une fonction. Ce qui est perdu, c’est la séparation, car le code de suivi se retrouve à l’intérieur de la logique métier. Lorsqu’une fonction entière constitue l’unité, @observe obtient le même résultat sans cet inconvénient.

    Pattern 3 : OpenTelemetry standard

    Phoenix ne dispose d’aucun décorateur propre. Le traceur OpenTelemetry est utilisé directement, et l’instrumentateur OpenInference enrobe automatiquement le client de sorte que l’appel de génération reste imbriqué :

    with tracer.start_as_current_span("search_docs") as span:
        context = retrieve_context(collection, QUESTION)
        span.set_attribute("output.value", context)
    

    L’avantage est que rien ici ne appartient à Phoenix. Il s’agit de segments OTel standard, de sorte que la même instrumentation peut alimenter n’importe quel backend compatible OTLP une fois l’endpoint modifié. Cette affirmation a fait l’objet d’une vérification approfondie, car la portabilité est souvent théoriquement vraie mais défaillante en pratique. AcruxCore parle également OTLP, c’est pourquoi le même pipeline a été adapté pour lui : même code with tracer.start_as_current_span(...), même outil d’instrumentation, nouveau endpoint.

    tracer_provider = otel.register(service_name="rag-agent-observability")
    OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
    

    Chaque plateforme dispose d’une aide register() qui regroupe un TracerProvider, un BatchSpanProcessor ainsi qu’un exporteur OTLP en une seule appel. Aucune de ces aides n’est obligatoire, et les pipelines personnalisés fonctionnent avec les deux. Les spans sont transmis avec un encadrement correct. L’attribut RETRIEVER d’OpenInference est devenu un type de Retrieval, et le span du modèle capturé automatiquement indiquait le modèle ainsi que 1 341 tokens.

    Ainsi, la portabilité est assurée, ce qui constitue la meilleure raison d’écrire des outils d’instrumentation en OTel pur : les spans sont écrits une seule fois, et le choix du backend qui les reçoit devient alors une question de configuration. Le coût est engagé dès le départ : une appel register(), un outil d’instrumentation et un objet traceur précèdent toujours le premier span, ce qui représente plus de structure que tout décorateur nécessaire pour ces mêmes deux spans.

    Pattern 4 : ingestion hors bande

    En plus d’OTLP, AcruxCore propose une cinquième méthode que les autres outils ne disposent pas. Au lieu d’observer le processus, le code décrit les segments et les publie. L’identifiant de suivi relie tout ensemble : en le transmettant au gateway, le segment du modèle généré du côté serveur rejoint le même suivi. Ces deux méthodes se complètent mutuellement.

    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
    )
    

    Il nécessite moins d’appels que la création d’un traceur, et les timestamps sont définis par l’appelant, ce qui convient aux tâches déjà terminées qui ne laissent pas de pile d’appels en cours à joindre.

    Le problème réside dans le flag wait=False. En mode par défaut, ingest() attend sa requête POST et renvoie un identifiant de trace généré par le serveur, de sorte que la requête effectue un aller-retour réseau avant même que l’appel au modèle ne commence. Avec wait=False, l’information est stockée dans un buffer local du SDK et l’identifiant est créé du côté du client. Aucune confirmation n’est envoyée depuis l’endroit où l’appel a été effectué, et toute tentative d’envoi échouée est enregistrée comme avertissement puis ignorée, tout comme c’est le cas avec un exportateur OTel. Il s’agit de choisir entre une réaction lente et bruyante en cas d’échec ou une réaction rapide et silencieuse en cas d’échec. Chacune des deux options est justifiable, à condition que le choix soit délibéré.

    Pattern 5 : intégration dans le chemin de la requête

    Deux des outils sont intégrés directement à l’appel lui-même, ce qui permet d’obtenir gratuitement la donnée correspondant au modèle. Helicone le montre de la manière la plus claire : il suffit de modifier base_url pour qu’il pointe vers cet outil, et la complétion est enregistrée sans aucun code SDK. Le gateway AcruxCore adopte la même approche. Un appel acheminé via gateway.chat() voit sa donnée llm générée par le serveur, de sorte que le client n’a pas besoin d’aucun outil d’instrumentation.

    Cette approche présente un avantage et une limite communs aux deux cas. Tout ce qui se trouve dans le chemin de la requête ne représente que du trafic HTTP destiné à un fournisseur de modèles. Une requête Chroma ne génère pas ce type de trafic, ce qui la rend invisible quel que soit l’outil utilisé.

    Ils diffèrent par ce qui les accompagne. Le gateway AcruxCore prend un identifiant de trace, ce qui permet à une étape enregistrée via une autre route, OTel ou ingest(), de servir de parent afin que l’étape du modèle écrite par le serveur se trouve dans la même trace. Helicone ne propose que le proxy, sans SDK permettant d’encapsuler une fonction arbitraire. Cela laisse deux options : omettre la récupération depuis la trace ou inventer une fin fictive pour la transporter, et cette dernière remplit les données de télémétrie de fausses informations. L’affirmation précise est donc plus restreinte que l’idée selon laquelle les proxies ne peuvent pas tracer la récupération : un proxy seul ne peut pas le faire, et l’importance de ce fait dépend de l’existence d’une deuxième route.

    Un bémol lié à la mise en œuvre, sans rapport avec la conception : la version Helicone auto-hébergée utilisée dans ce test a renvoyé une erreur 500 depuis son point d’accès aux journaux manuels, car une variable S3_REGION manquait, ce qui a empêché l’enregistrement même de la moitié du modèle. Il s’agit d’un défaut de cette image en particulier et non de l’approche globale.

    Ce qu’on peut appeler une période

    Deux périodes montrent que le fonctionnement est correct, mais elles ne permettent pas de distinguer un réordonneur d’un mécanisme de protection six mois plus tard. Les valeurs autorisées ont donc été extraites de chaque SDK installé et non de ses documents officiels.

    MLflow domine largement cette catégorie. RERANKER, GUARDRAIL, EVALUATOR, PARSER et MEMORY sont tous intégrés nativement, tandis qu’Opik ne propose que quatre types et classe tout le reste dans la métadonnée. Les sept types d’AcruxCore gèrent le RAG mais sans plus de précision. Phoenix hérite sa liste de la convention OpenInference, ce qui en fait une caractéristique commune à tous les outils suivant cette norme et non pas une fonctionnalité propre à Phoenix.

    C’est la catégorie la plus susceptible d’être mal interprétée à partir des pages de marketing. Il est possible de vérifier n’importe quelle liste par rapport aux versions installées en quelques lignes de code :

    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 prend des précautions supplémentaires. Son ObservationTypeLiteral, présent dans langfuse._client.span, est constitué de types Literal imbriqués, de sorte que typing.get_args génère des alias internes plutôt que des chaînes de caractères, et le résultat doit être aplati de manière récursive. Cela explique pourquoi les chiffres indiqués dans sa documentation diffèrent souvent de ceux du SDK.

    Choix

    • MLflow convient aux équipes qui souhaitent une sémantique précise dès maintenant et une évaluation ultérieure. Son vocabulaire est de loin le plus vaste, bien que l’ensemble des outils soit plus lourd qu’un outil de suivi pur.
    • Opik permet d’obtenir un arbre correct dès le même après-midi, avec le moins de formalités possibles et seulement quatre types de spans.
    • Langfuse offre à la fois un décorateur et une solution manuelle grâce à un typage strict, au prix de deux ensembles de documentation.
  • Phoenix convient aux équipes qui pourraient changer de backend ou qui utilisent déjà OTel. Il ne nécessite aucune API de fournisseur et exige le plus de configuration avant que le premier span n’apparaisse.
  • AcruxCore est adapté lorsque le span du modèle doit être généré automatiquement tandis que la récupération nécessite encore un enregistrement. Il dispose de trois chemins composables, d’un vocabulaire plus restreint que celui de MLflow, Phoenix ou Langfuse, ainsi qu’un système d’ingestion tamponné qui échoue silencieusement.
  • Helicone convient à un pipeline qui n’est en réalité qu’une simple appel à un modèle. La capture ne nécessite aucun code, et il n’y a aucun endroit où enregistrer une étape non liée au modèle.
  • La véritable leçon ici est l’absence de classement. La question de savoir si un outil permet de tracer les récupérations s’est avérée comporter cinq questions distinctes, et chaque réponse représente une décision de conception ayant un coût visible : une ligne de code, un bloc with, des tâches de configuration, un saut réseau ou un point aveugle. Choisissez le coût le moins élevé et examinez le vocabulaire utilisé pour définir les périodes avant de vous engager, car c’est là que les surprises apparaissent tardivement.