Startseite / Artikel

Nachverfolgung des Abrufschritts: Sechs Überwachungstools im Vergleich anhand eines RAG-Agenten

Ein RAG-Agent wurde mit sechs LLM-Überwachungstools untersucht: Opik, MLflow, Langfuse, Phoenix, AcruxCore und Helicone, wobei Decorators, OpenTelemetry und Proxys verglichen wurden.

Verlag: REACTAPP.TOP10 Min. Lesezeit2399 Wörter

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

Retrieval-augmented Generation ist ein zweistufiger Prozess, wobei nur einer der Schritte eine Aufrufung des Modells beinhaltet. Um zu sehen, wie Überwachungstools die andere Hälfte aufzeichnen, wurde derselbe kleine Agent über sechs Plattformen instrumentiert: Opik, MLflow, Langfuse, Phoenix, AcruxCore und Helicone.

Warum die Datenabfrage den üblichen Wrapper umgeht

Einen einzelnen Modellaufruf ist einfach zu überwachen: Man modifiziert den OpenAI-Client, und jeder chat.completions.create()-Aufruf erscheint als eigenständiger Zeitraum. Die Hinzufügung der Datenabfrage scheint ein kleiner Schritt zu sein. Der Kontext wird abgerufen, das Modell wird aufgerufen, und derselbe Patch sollte eigentlich alles abdecken. In der Praxis deckt er jedoch nur die Hälfte ab.

Anders ausgedrückt handelt es sich bei einem RAG-Pipeline um mindestens zwei getrennte Komponenten, die nachverfolgt werden müssen. Die eine ist der Abrufschritt, den keine Client-Wrapper bemerken. Die andere ist der Generierungsschritt, der einer gewöhnlichen Modellaufruf ähnelt. Das Experiment konzentrierte sich direkt auf diese Schnittstelle, wobei alles andere zwischen den Durchläufen unverändert blieb.

Zusammenfassung der Erkenntnisse

  • Decorator dominieren. Opik, MLflow und Langfuse verwandeln jeweils eine Funktion in einen nachverfolgbaren Schritt, indem sie eine zusätzliche Zeile hinzufügen, ohne den eigentlichen Code zu verändern.
  • OpenTelemetry funktioniert an zwei Stellen. Sowohl Phoenix als auch AcruxCore unterstützen OTLP, sodass ein manuell erstellter Span durch Änderung des Endpunkts an beide gesendet werden kann. Derselbe Pipeline wurde gegen beide Systeme ausgeführt.
  • Zwei Produkte befinden sich im Anfragenpfad. Helicons Proxy sowie AcruxCores Gateway erfassen den Modell-Ablauf ohne Client-Code, doch keines von beiden bemerkt einen Schritt, der niemals einen Modellanbieter kontaktiert.
  • Helicon hat keine Lösung für das Abrufen, da der Proxy sein einziges Produkt ist und es kein SDK gibt, um diesen Schritt zu protokollieren.
  • MLflow bietet das umfangreichste Vokabular für Ablaufschritte, insgesamt fünfzehn Typen darunter RERANKER, GUARDRAIL und EVALUATOR, die aus der SDK-Quelle und nicht aus der Dokumentation erfasst werden.
  • Was ein Ablaufschrittstyp ist

    Betrachten Sie einen Trace als die Liste der Phasen, durch die eine Anfrage ging. Jede Phase ist ein Span mit einem Namen, Start- und Endzeit sowie Eingabe- und Ausgabedaten. Dadurch wird sichtbar, was ausgeführt wurde und wie lange es dauerte, doch nicht die Art der Aufgabe: Ein Span mit dem Label step_2, der 900 ms dauert, könnte genauso gut eine SQL-Abfrage, einen Aufruf eines LLMs oder einen HTTP-Abfragen sein. Die Unterscheidung dazwischen obliegt dem Span-Typ, einem Tag, der dies klärt. Jedes Tool definiert eine abgeschlossene Menge an Kategorien, und jeder Span erhält eine davon.

    Nehmen wir hier den RAG-Agenten. Eine Anfrage erzeugt drei 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            │
    └────────────────┴────────────────────────────────┴────────────────┘
    

    Hier ist search_docs einfach ein Name, den der Entwickler gewählt hat, während die Abrufmethode aus der Liste der vom SDK zulässigen Werte stammt. Ein Anzeiger zeigt dies als „Retrieval“-Chip vor search_docs und als „LLM“-Chip vor der Vervollständigung an.

    Die Art ist wichtig – nicht nur der Name –, denn das Tool handelt danach. Es wählt das Icon und die Anordnung aus; so zeigt ein Abrufbereich die heruntergeladenen Dokumente an, während ein Modellbereich die Anzahl der Token sowie die Kosten anzeigt. Es ermöglicht das Filtern, sodass eine Frage wie „Welche Abrufschritte haben länger als eine Sekunde gedauert?“ bei allen Datensätzen funktioniert, selbst wenn Entwickler ihre Funktionen anders benannt haben. Zudem unterstützt es die Analyse: Wie viel Latenz entsteht durch das Abrufen im Vergleich zur Erstellung, wird über die Spalte „Typ“ abgefragt.

    Die Wortschatzlisten unterscheiden sich in ihrer Länge. AcruxCore unterstützt sieben Arten: llm, tool, retrieval, embedding, agent, chain und other. MLflow bietet fünfzehn Arten, darunter RERANKER, GUARDRAIL, EVALUATOR, PARSER und MEMORY. Opik erlaubt nur vier Arten: general, tool, llm und guardrail.

    Der getestete Agent

    Der Agent ist absichtlich einfach gestrickt. Er indiziert fünf Seiten der OpenTelemetry-Dokumentation, die in etwa 1.000 Zeichen lange Abschnitte unterteilt sind, embeddet sie mit text-embedding-3-small und speichert sie in einer in-Memory-Chroma-Kollektion. Eine feste Frage dazu, was ein Span ist und wie Eltern- und Kind-Spans einen Trace bilden, ruft vier dieser Abschnitte ab, und gpt-4o-mini formuliert die Antwort daraus.

    Keine der sechs Plattformen besitzt die Dokumente absichtlich. Wenn der Agent Fragen zu einem der verglichenen Tools beantwortet hätte, wäre der Test zirkulär, und der Antworttext würde in jedem Screenshot erscheinen.

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

    Diese Funktion, der Chunking-Schritt sowie der Embedding-Aufruf bleiben bei den sechs Ausführungen unverändert. Die einzige Variable ist die um sie herum implementierte Instrumentierung. Jedes Tool wurde auf einem lokalen Rechner ausgeführt: fünf in Docker selbst gehostet, plus AcruxCore. LangSmith und W&B Weave wurden weggelassen, da sie nur als gehostete Dienste existieren. Nach seinem SDK wäre LangSmith ein weiteres Beispiel für einen Dekorator mit sieben Ausführungstypen, doch da keine Spuren davon beobachtet wurden, lässt sich dazu nichts weiter sagen.

    Muster 1: Der Dekorator

    Hier treffen die Tools aufeinander – und das ist durchaus sinnvoll: Der Funktionskörper bleibt genau so, wie er geschrieben ist. Bereits vorhandener Code wird lediglich annotiert. Opiks Form entspricht seinen Dokumentationen:

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

    MLflow hat die gleiche Struktur mit einem angehängten Span-Typ:

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

    Langfuse bietet @observe an, das leicht übersehen wird, wenn man zuerst mit den Beispielen für Context-Manager konfrontiert wird:

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

    Der Aufruf des Modells ist in allen drei Fällen automatisch verschachtelt. Jeder liefert einen angepassten OpenAI-Client (track_openai(), mlflow.openai.autolog() und langfuse.openai), sodass die Generierungsphase von der jeweils aktuell ausgeführten dekorierten Funktion abhängt. Ein einziger Dekorator erzeugt daher einen Baum mit zwei Tiefenebenen.

    Langfuse lieferte das übersichtlichste Ergebnis. Die Suchphase mit dem Typ retriever-typed search_docs dauerte 1,05 Sekunden, und die von Langfuse selbst erfasste Generierungsphase dauerte 4,14 Sekunden, wobei Kosten und Tokenzahlen zusammengefasst wurden. In MLflow zeigen die beiden gleichen Phasen eine Suchzeit von 0,47 Sekunden sowie eine erfasste Completions-Phase von 3,37 Sekunden. Opik zeichnet ebenfalls einen korrekten Baum, abgesehen von einer optischen Besonderheit: Die Trace- und Root-Phase teilen sich denselben Namen, wodurch rag-agent zweimal unter search_docs aufgeführt wird.

    Muster 2: Ein Kontextmanager des Anbieters

    Langfuse stellt außerdem das span-Objekt selbst zur Verfügung, welches das geeignete Werkzeug ist, wenn die Grenzen eines spans nicht mit den Grenzen einer Funktion übereinstimmen:

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

    Was gewonnen wird, ist Präzision, da Eingabe und Ausgabe genau im richtigen Moment festgelegt werden können und ein Zeitraum einen Teil einer Funktion abdecken kann. Was verloren geht, ist die Trennung, da der Tracing-Code in der Geschäftslogik landet. Wenn eine ganze Funktion die Einheit ist, erreicht @observe dasselbe Ergebnis ohne diesen Nachteil.

    Muster 3: Standard OpenTelemetry

    Phoenix verfügt über keinen eigenen Dekorator. Der OpenTelemetry-Tracer wird direkt verwendet, und der OpenInference-Instrumentor umhüllt den Client automatisch, sodass der Aufruf zur Generierung weiterhin eingebettet bleibt:

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

    Der Vorteil ist, dass hier nichts Phoenix gehört. Es handelt sich um standardmäßige OTel-Span-Strukturen, sodass dieselbe Instrumentierung jeden OTLP-kompatiblen Backend-Server versorgen kann, sobald der Endpunkt geändert wird. Diese Behauptung wurde gründlich überprüft, denn Portabilität gilt oft nur auf dem Papier, ist in der Praxis jedoch oft nicht gegeben. AcruxCore spricht ebenfalls OTLP, weshalb derselbe Pipeline darauf abgestimmt wurde: identischer with tracer.start_as_current_span(...)-Code, identischer Instrumentator, neuer Endpunkt.

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

    Jede Plattform verfügt über einen register()-Hilfsfunktion, der einen TracerProvider, einen BatchSpanProcessor sowie einen OTLP-Exporter in einen einzigen Aufruf zusammenfasst. Keiner dieser Hilfsfunktionen ist zwingend erforderlich, und selbst erstellte Pipelines funktionieren mit beiden. Die Spans wurden korrekt verschachtelt übermittelt. Das RETRIEVER-Attribut von OpenInference wurde zu einer Retrieval-Kategorie, und der automatisch erfasste Modellspan enthält Angaben zum Modell sowie 1.341 Tokens.

    Dadurch bleibt die Portabilität gewährleistet – und das ist der beste Grund, Instrumentierung in reinem OTel zu schreiben: Die Spans werden nur einmal geschrieben, und welche Backend-Plattform sie erhält, ist lediglich eine Frage der Einstellungen. Die Kosten entstehen bereits zu Beginn – ein register()-Aufruf, ein Instrumentierungsmodul sowie ein Tracer-Objekt gehen dem ersten Span voraus, was mehr Struktur erfordert als jeder Dekorator für dieselben beiden Spans.

    Muster 4: asynchrone Eingabe

    Neben OTLP bietet AcruxCore einen fünften Ansatz, den die anderen Tools nicht haben. Anstatt den Prozess zu überwachen, beschreibt der Code die Spannungen und stellt sie bereit. Die Trace-ID verbindet alles miteinander: Wenn man sie an das Gateway weiterleitet, wird die auf der Serverseite erstellte Modell-Spannung derselben Trace zugeordnet. Die beiden Ansätze ergänzen sich gegenseitig.

    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
    )
    

    Es sind weniger Aufrufe erforderlich als bei der Erstellung eines Tracers, und die Zeitstempel werden vom Aufrufer festgelegt, was sich gut für Arbeiten eignet, die bereits abgeschlossen sind und keinen aktiven Aufrufstapel mehr bieten, an den man sich anhängen könnte.

    Das Problem ist die Flagge wait=False. Im Standardmodus wartet ingest() auf den POST-Anfragen und gibt einen vom Server generierten Trace-Id zurück, sodass die Anfrage vor Beginn des Modellaufrufs eine Netzwerk-Rundreise durchführt. Bei wait=False landet der Span in einem lokalen SDK-Puffer und der Id wird auf der Client-Seite erstellt. Am Aufrufort wird nichts bestätigt, und ein fehlgeschlagener Send-Vorgang wird als Warnung protokolliert und verworfen, ähnlich wie bei einem OTel-Exporter. Es muss sich entscheiden zwischen langsamer und lauter Reaktion bei Fehlern oder schneller und stummer Reaktion bei Fehlern. Beides ist vertretbar, solange die Wahl bewusst getroffen wird.

    Muster 5: Im Anfragepfad verbleiben

    Zwei der Tools befinden sich innerhalb des Anrufs selbst, wodurch der Modell-Teil kostenlos zur Verfügung steht. Helicone zeigt dies am deutlichsten: Wenn man base_url so ändert, dass er auf dieses Tool verweist, wird die Vervollständigung ohne jeglichen SDK-Code aufgezeichnet. Der AcruxCore-Gateway verfolgt denselben Ansatz. Ein über gateway.chat() gerouteter Anruf hat seinen llm-Teil, der vom Server erstellt wird, sodass der Client keine zusätzlichen Instrumentierungen benötigt.

    Diese Vorgehensweise hat einen Vorteil und eine Einschränkung, die beide Ansätze teilen. Alles im Anfragepfad erkennt lediglich HTTP-Verkehr, der an einen Modellanbieter gerichtet ist. Eine Chroma-Anfrage sendet keinen solchen Verkehr, wodurch sie unabhängig vom verwendeten Tool unsichtbar bleibt.

    Sie unterscheiden sich darin, was sie begleitet. Die AcruxCore-Gateway-Plattform nimmt eine Trace-ID entgegen, wodurch ein über einen anderen Weg, OTel oder ingest() aufgezeichneter Span als Elternspan fungieren kann, sodass das vom Server erstellte Modellspan in derselben Trace landet. Helicone bietet lediglich einen Proxy an und kein SDK, um eine beliebige Funktion zu umhüllen. Damit bleiben zwei Optionen: Entweder wird die Abrufung aus der Trace weggelassen oder eine falsche Abschlussinformation erzeugt, um sie mitzuschicken – letzteres füllt die Telemetriedaten mit Fiktionen. Die genaue Aussage ist daher enger gefasst als die Behauptung, Proxys könnten keine Abrufungen nachverfolgen: Ein Proxy allein kann dies nicht, und die Bedeutung davon hängt davon ab, ob ein zweiter Weg vorhanden ist.

    Eine Einschränkung bei der Implementierung, die nichts mit dem Design zu tun hat: Die selbst gehostete Helicone-Version in diesem Test antwortete auf ihren Endpunkt für manuelle Protokollierung mit einem Fehlercode 500, da die Variable S3_REGION fehlte – dadurch wurde sogar die Aufzeichnung der Modellkomponente verhindert. Das ist ein Mangel dieser spezifischen Version und nicht des Ansatzes insgesamt.

    Was als „Span“ bezeichnet werden kann

    Zwei „Spans“ zeigen, dass die Systemkomponenten funktionieren, doch sie können nach einem halben Jahr keinen Unterschied zwischen einem Neurangierer und einer Schutzbalkenkomponente erkennen. Die zulässigen Werte wurden daher aus jedem installierten SDK entnommen und nicht aus dessen Dokumentation.

    MLflow führt in dieser Kategorie mit großem Abstand. RERANKER, GUARDRAIL, EVALUATOR, PARSER und MEMORY sind alle bereits integriert, während Opik vier Typen bietet und alles Weitere in Metadaten ablegt. Die sieben Typen von AcruxCore behandeln RAG, aber nicht in größerer Detailliertheit. Phoenix übernimmt seine Liste aus der OpenInference-Vorgabe, sodass sie bei allen Tools gilt, die diesem Standard folgen – es handelt sich dabei nicht um eine spezielle Phoenix-Funktion.

    Das ist die Zeile, die am ehesten aufgrund von Marketingseiten falsch eingeschätzt wird. Jede dieser Listen kann in wenigen Zeilen mit den installierten Versionen abgeglichen werden:

    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 geht besonders sorgfältig vor. Sein ObservationTypeLiteral, der sich in langfuse._client.span befindet, wird aus verschachtelten Literal-Typen zusammengesetzt, wodurch typing.get_args innere Aliase anstelle von Zeichenketten liefert und das Ergebnis rekursiv vereinfacht werden muss. Das erklärt, warum die Angaben in seiner Dokumentation oft nicht mit denen des SDK übereinstimmen.

    Auswahl

    • MLflow eignet sich für Teams, die jetzt präzise Semantik und eine spätere Bewertung wünschen. Sein Wortschatz ist weitaus umfangreicher, doch der Stack ist aufwändiger als bei einem reinen Tracing-Tool.
    • Opik liefert bereits am selben Nachmittag einen korrekten Baum, mit minimalen Formalitäten und nur vier Span-Typen.
    • Langfuse bietet sowohl einen Dekorator als auch eine manuelle Lösungsmöglichkeit durch starke Typisierung – zum Preis von zwei Dokumentationssets.
  • Phoenix eignet sich für Teams, die ihre Backend-Systeme ändern möchten oder bereits OTel nutzen. Er benötigt keine Anbieter-API und erfordert die meisten Einstellungen, bevor der erste Datenabschnitt angezeigt wird.
  • AcruxCore ist geeignet, wenn der Modell-Datenabschnitt automatisch erstellt werden soll, während zur Abfrage weiterhin ein Datensatz erforderlich ist. Er verfügt über drei kombinierbare Routen, ein kleineres Vokabular als MLflow, Phoenix oder Langfuse sowie einen gepufferten Eingabeprozess, der fehlerfrei abläuft, falls etwas schiefgeht.
  • Helicone passt zu Pipelines, die tatsächlich nur aus einem Modellaufruf bestehen. Für die Datenerfassung ist kein Code nötig, und ein nicht-modellerischer Schritt hat keinen Ort, an dem er aufgezeichnet werden könnte.
  • Die eigentliche Lektion hier ist, dass es keine Rangliste gibt. Die Frage, ob ein Tool die Ablaufverfolgung ermöglicht, entpuppte sich als fünf separate Fragen, und jede Antwort ist eine Gestaltungsentscheidung mit sichtbaren Kosten: eine Zeile Code, ein with-Block, Aufwandsarbeiten, ein Netzwerksprung oder ein Blindpunkt. Wählen Sie die kostengünstigste Lösung und prüfen Sie das Vokabular vor dem Einsatz, denn genau dort tauchen späte Überraschungen auf.