thamu _

Generative AI mit MLflow 3.x produktionsreif machen: Tracing, Evaluation und Prompt-Optimierung

von Thamu Mnyulwa 10 Min. Lesezeit

Generative-AI-Systeme scheitern anders als klassische Software.

Bei einem normalen HTTP-Service laesst sich ein Produktionsproblem oft auf bekannte Fragen reduzieren: Ist die Anfrage abgelaufen? Hat die Datenbank einen Fehler geliefert? Hat ein externes System mit 500 geantwortet? Hat ein neues Release das Verhalten veraendert?

Bei einer Retrieval-Augmented-Generation-Anwendung (RAG) oder einem Agenten-Workflow ist die Fehlerflaeche groesser. Die finale Antwort kann sauber formuliert und trotzdem falsch sein. Die Vektorsuche kann irrelevanten Kontext liefern. Ein Tool kann in der falschen Reihenfolge aufgerufen werden. Ein Agent kann denselben Denkschritt mehrfach wiederholen, Kosten und Latenz erhoehen und am Ende eine selbstbewusste Antwort ausgeben, die nie durch die Quellen gedeckt war.

Deshalb braucht produktive GenAI mehr als API-Logs. Sie braucht Sichtbarkeit in den Ausfuehrungspfad und eine Evaluation, die sowohl die finale Antwort als auch die Zwischenschritte bewertet.

MLflow 3.x ist spannend, weil es diese Teile in einem Workflow zusammenbringt: OpenTelemetry-kompatibles Tracing, LLM-as-a-Judge-Evaluation, eigene trace-bewusste Scorer, Prompt-Versionierung und Sampling fuer den Betrieb. Dieser Artikel beschreibt eine Architektur, mit der ich eine GenAI-Anwendung von der Demo in ein System ueberfuehren wuerde, das debuggt, gemessen und systematisch verbessert werden kann.

Warum flache Logs fuer Agenten nicht reichen

Eine RAG- oder Agentenanfrage ist nicht eine einzelne Operation. Sie ist ein Graph aus Operationen:

  • Nutzerfrage empfangen.
  • Kontext aus einer Vektordatenbank abrufen.
  • Prompt zusammenbauen.
  • Chat-Modell aufrufen.
  • Bei Bedarf Tools aufrufen.
  • Bei Bedarf das Modell erneut aufrufen.
  • Finale Antwort zurueckgeben.

Klassisches Logging kann zeigen, dass eine Anfrage sechs Sekunden gedauert hat. Es zeigt selten, ob die Vektordatenbank fuenf Sekunden davon verbraucht hat, ob der LLM-Aufruf teuer war, weil zu viel Kontext im Prompt lag, oder ob der Agent ein unnoetiges Tool aufgerufen hat.

MLflow Tracing modelliert eine Anfrage als Hierarchie von Spans innerhalb eines Traces. Der Trace ist die gesamte Ausfuehrung. Jeder Span ist ein sinnvoller Arbeitsschritt: Retriever, Tool, Modellaufruf, Prompt-Formatierung oder Root-Agent.

Das ist wichtig, weil Qualitaetsprobleme oft vor dem finalen Modellaufruf entstehen. Wenn der Kontext schlecht ist, kann die Antwort schlecht sein, obwohl das Modell korrekt instruiert wurde. Wenn ein Tool still fehlschlaegt, kann das Modell die Luecke erfinden. Wenn ein Agent unnoetig rotiert, kann die Antwort korrekt, aber zu langsam und zu teuer sein.

OpenTelemetry als Observability-Vertrag

MLflow Tracing ist mit OpenTelemetry kompatibel und unterstuetzt GenAI Semantic Conventions. Praktisch bedeutet das: Trace-Daten sind nicht in einer proprietaeren UI gefangen. MLflow kann OpenTelemetry-Traces ueber einen OTLP-Endpunkt wie /v1/traces aufnehmen, und MLflow-Traces koennen in OpenTelemetry-kompatible Plattformen exportiert werden.

Das ist fuer reale Deployments wichtig. Die GenAI-App kann in Python geschrieben sein, waehrend Gateway oder Retrieval-Service in Go, Java oder Rust laufen. Das Unternehmen nutzt vielleicht bereits Datadog, Grafana, Prometheus oder einen anderen Observability-Stack. OpenTelemetry gibt dem Plattformteam ein gemeinsames Protokoll, waehrend MLflow die GenAI-spezifischen Metadaten ergaenzt: Prompts, Modellnamen, Token-Nutzung, Retriever-Outputs, Tool-Inputs und Evaluation Feedback.

Die Grundidee ist einfach: Traces sind das gemeinsame Debugging-Substrat, kein Notebook-Artefakt.

Span-Typen geben dem Trace Bedeutung

Die Form des Traces ist wichtig, aber die Bedeutung jedes Spans ebenso. MLflow unterstuetzt Span-Typen wie:

Span-TypBedeutungWarum es wichtig ist
AGENTAutonomer Agent oder Top-Level-OrchestrierungGruppiert den gesamten Entscheidungsweg
CHAINKoordinierte Folge von TeiloperationenGut fuer Prompt-Formatierung, Routing oder Pipeline-Schritte
CHAT_MODEL oder LLM-SpansModellaufrufGut fuer Tokens, Latenz und Prompt-Inspektion
TOOLFunktion, API, Rechner, Datenbankaufruf oder externe AktionGut fuer Tool-Korrektheit und Effizienz
RETRIEVERKontextabruf, meist aus einer VektordatenbankErforderlich fuer RAG-Judges, die Dokumente pruefen
EMBEDDINGText-zu-Vektor-KonvertierungGut fuer Retrieval-Diagnostik
RERANKERNeuordnung abgerufener KontexteGut fuer Ranking-Fehler

Der RETRIEVER-Span ist besonders wichtig. MLflow RAG-Judges erwarten, dass Retriever-Spans Dokumente strukturiert ausgeben. Jedes Dokument sollte page_content und Metadaten wie doc_uri oder chunk_id tragen. Ohne diese Struktur kann ein Evaluator nicht verlaesslich trennen, was das Modell wusste und was es erfunden hat.

Ein getracter RAG-Agent

Das folgende Beispiel zeigt einen kleinen manuell instrumentierten RAG-Agenten. Es geht nicht darum, einen vollstaendigen Finanzassistenten zu bauen. Es geht darum, sinnvolle Observability-Grenzen zu zeigen.

import time
from typing import List

import mlflow
import openai
from mlflow.entities import Document, SpanType

mlflow.openai.autolog()
client = openai.OpenAI()


@mlflow.trace(name="vector_database_query", span_type=SpanType.RETRIEVER)
def retrieve_financial_documents(query: str) -> List[Document]:
    span = mlflow.get_current_active_span()
    span.set_attributes({"search_strategy": "hybrid", "top_k": 2})

    time.sleep(0.3)
    raw_results = [
        {
            "content": "Q3 revenue increased by 14% to $1.2B.",
            "uri": "s3://reports/q3_earnings.pdf",
        },
        {
            "content": "Operating margins expanded after reduced cloud compute costs.",
            "uri": "s3://reports/q3_margins.pdf",
        },
    ]

    documents = [
        Document(page_content=row["content"], metadata={"doc_uri": row["uri"]})
        for row in raw_results
    ]

    span.set_outputs(documents)
    return documents


@mlflow.trace(name="currency_conversion_api", span_type=SpanType.TOOL)
def convert_currency(amount_usd: float, target_currency: str) -> float:
    if target_currency not in {"EUR", "GBP", "JPY"}:
        raise ValueError(f"Unsupported target currency: {target_currency}")

    conversion_rate = 0.92
    return amount_usd * conversion_rate


@mlflow.trace(name="financial_analysis_agent", span_type=SpanType.AGENT)
def financial_agent(user_query: str, target_currency: str = "USD") -> str:
    mlflow.update_current_trace(
        request_preview=user_query,
        tags={"environment": "production", "agent_version": "2.1.0"},
    )

    docs = retrieve_financial_documents(user_query)
    context = "\n\n".join(
        f"Source: {doc.metadata['doc_uri']}\n{doc.page_content}"
        for doc in docs
    )

    with mlflow.start_span(name="prompt_formatting", span_type=SpanType.CHAIN) as span:
        system_prompt = (
            "You are a financial analyst. Answer using only the provided context.\n\n"
            f"Context:\n{context}"
        )
        span.set_inputs({"document_count": len(docs)})
        span.set_outputs({"context_length": len(context)})

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_query},
        ],
        temperature=0.0,
    )

    final_text = response.choices[0].message.content or ""

    if target_currency != "USD":
        try:
            converted = convert_currency(1_200_000_000, target_currency)
            final_text += (
                f"\n\nNote: $1.2B USD is approximately "
                f"{converted:,.2f} {target_currency}."
            )
        except ValueError as exc:
            final_text += f"\n\nNote: currency conversion failed: {exc}"

    mlflow.update_current_trace(response_preview=final_text[:300])
    return final_text

Ein paar Details sind fuer Produktion relevant:

  • Der Retriever gibt Document-Objekte zurueck, damit RAG-Evaluatoren den Kontext pruefen koennen.
  • Die Waehrungsumrechnung ist ein TOOL-Span, also getrennt vom Modellaufruf sichtbar.
  • Die Prompt-Konstruktion ist ein CHAIN-Span, damit zu grosse Prompt-Templates auffallen.
  • Der OpenAI-Aufruf wird durch mlflow.openai.autolog() automatisch erfasst.
  • request_preview und response_preview halten die Trace-Liste lesbar.

Der @mlflow.trace-Decorator speichert auch Exceptions als Span-Events, wenn getracter Code eine Exception wirft. Dadurch kann der Agent eine nutzersichere Antwort zurueckgeben, waehrend der fehlgeschlagene Tool-Span trotzdem den echten Fehler zeigt.

Nebenlaeufigkeit und verteilte Services

Produktive GenAI-Systeme sind selten einzelne Notebook-Funktionen. Sie starten parallele Retrievals, nutzen Async-I/O oder rufen mehrere Microservices auf.

MLflow verwendet Python ContextVar fuer den Tracing-Kontext. Async-Tasks erben den Kontext standardmaessig, wodurch asyncio gut fuer I/O-lastige GenAI-Workloads passt. Threads sind anders: Kontext wird nicht automatisch ueber Thread-Grenzen propagiert. Wer ThreadPoolExecutor nutzt, sollte den Kontext mit contextvars.copy_context() kopieren und den Worker in diesem Kontext ausfuehren.

Fuer Microservices unterstuetzt MLflow Distributed Tracing ueber W3C TraceContext Header. Der Caller erzeugt Header mit mlflow.tracing.get_tracing_context_headers_for_http_request(). Der empfangende Service liest sie mit mlflow.tracing.set_tracing_context_from_http_request_headers(). Beide Services muessen in denselben Tracking Server und dasselbe Experiment schreiben, wenn ein zusammenhaengender Trace entstehen soll.

Das ist der Unterschied zwischen “der Router hat den Agent-Service aufgerufen” und “ich sehe Router, Agent, Retriever und Tool Call in einem Ausfuehrungsbaum”.

Evaluation beginnt dort, wo Tracing aufhoert

Observability beantwortet: Was ist passiert?

Evaluation beantwortet: War es gut?

Fuer GenAI sind lexikalische Metriken wie Exact Match, BLEU oder ROUGE oft zu eng. Ein Modell kann korrekt paraphrasieren und trotzdem an Wortueberlappung scheitern. Es kann aber auch fluessig schreiben und dennoch nicht durch den abgerufenen Kontext gedeckt sein. MLflow mlflow.genai.evaluate() unterstuetzt LLM-Judges, Custom Scorers, Evaluation Datasets, Traces und Prompt-Vergleiche.

Ein Evaluation Record enthaelt typischerweise:

  • inputs: Daten, die an die App gehen.
  • outputs: optional vorab berechnete Antworten.
  • expectations: Ground Truth, Leitlinien oder erwartete Tool Calls.
  • Traces: entweder waehrend der Evaluation erzeugt oder aus historischem Traffic geladen.

Dadurch entstehen zwei Modi:

  • Direkte Evaluation: App ueber das Dataset laufen lassen, Traces erzeugen und bewerten.
  • Antwort- oder Trace-Evaluation: bestehende Outputs oder Produktionstraces bewerten, ohne das Modell erneut aufzurufen.

Der zweite Modus ist wichtig fuer CI und Kostenkontrolle.

RAG-Judges trennen Retrieval von Generation

Bei RAG-Systemen sollten drei Fragen getrennt bewertet werden:

FrageMLflow Judge
Waren die abgerufenen Dokumente relevant?RetrievalRelevance
Enthielten die Dokumente genug Information?RetrievalSufficiency
Blieb die finale Antwort durch den Kontext gedeckt?RetrievalGroundedness

Diese Trennung ist operativ hilfreich. Wenn RetrievalSufficiency fehlschlaegt, hatte das Modell vielleicht nie genug Evidenz. Wenn RetrievalGroundedness fehlschlaegt, obwohl Sufficiency besteht, hatte das Modell die Evidenz und hat trotzdem etwas erfunden. Das sind unterschiedliche Engineering-Probleme.

Wichtig ist: Diese Judges brauchen Traces mit mindestens einem RETRIEVER-Span. Der Trace ist nicht nur Observability. Er wird zur Evaluationseingabe.

Deterministische trace-bewusste Scorer ergaenzen

LLM-Judges sind stark, aber sie kosten Geld, brauchen Zeit und bringen eigene Unsicherheit mit. Fuer harte Business-Regeln ist ein codebasierter Scorer oft besser.

Wenn ein Finanzagent vor der Antwort zwingend die Vektordatenbank abfragen muss, sollte man das nicht von einem LLM interpretieren lassen. Man parst den Trace direkt.

import mlflow
from mlflow.entities import Feedback, SpanType
from mlflow.genai import scorer
from mlflow.genai.scorers import Guidelines, RetrievalGroundedness, RetrievalSufficiency


@scorer
def required_tool_was_called(*, trace, expectations):
    required_tool = (expectations or {}).get("required_tool")
    if not required_tool:
        return Feedback(value=True, rationale="No required tool configured.")

    tool_spans = trace.search_spans(span_type=SpanType.TOOL)
    retriever_spans = trace.search_spans(span_type=SpanType.RETRIEVER)
    called = [span.name for span in [*tool_spans, *retriever_spans]]

    if required_tool in called:
        return Feedback(
            value=True,
            rationale=f"Required tool was called: {required_tool}.",
        )

    return Feedback(
        value=False,
        rationale=f"Expected {required_tool}, but saw {called}.",
    )

Hier liegt der Wert trace-bewusster Evaluation: Man bewertet nicht nur die Antwort, sondern auch den Pfad, der zu ihr gefuehrt hat.

Prompt-Optimierung muss versioniert werden

Prompt-Aenderungen sollten wie Software-Aenderungen behandelt werden. Wenn eine Prompt-Version den Ton verbessert, aber mehr Halluzinationen erzeugt, ist sie keine Verbesserung.

Die MLflow Prompt Registry erlaubt es, Prompts mit mlflow.genai.register_prompt() zu registrieren, bestimmte Versionen mit mlflow.genai.load_prompt() zu laden und Prompt-Varianten gegen dasselbe Dataset zu evaluieren. Daraus entsteht ein sauberer Loop:

  1. Prompt Version 1 registrieren.
  2. Evaluation ausfuehren.
  3. Prompt Version 2 mit klarer Commit Message registrieren.
  4. Dasselbe Evaluation Dataset erneut ausfuehren.
  5. Metriken und Trace-Outputs vergleichen.

Wichtig ist, dass das Dataset stabil bleibt. Sonst weiss man nicht, ob der Prompt besser wurde oder ob sich der Test veraendert hat.

Produktionskonfiguration

Jede Anfrage dauerhaft voll zu tracen ist selten die richtige Standardeinstellung. Man braucht genug Sichtbarkeit, um zu debuggen und zu verbessern, aber nicht so viel Telemetrie, dass Tracing selbst zum Bottleneck wird.

MLflow unterstuetzt asynchrones Logging und Sampling:

export MLFLOW_TRACKING_URI="http://your-mlflow-server:5000"
export MLFLOW_EXPERIMENT_NAME="production-genai-app"
export MLFLOW_ASYNC_TRACE_LOGGING_MAX_WORKERS=10
export MLFLOW_ASYNC_TRACE_LOGGING_MAX_QUEUE_SIZE=1000
export MLFLOW_TRACE_SAMPLING_RATIO=0.1

Fuer kritische Pfade kann Sampling am getracten Funktionsaufruf ueberschrieben werden:

@mlflow.trace(sampling_ratio_override=1.0)
def payment_or_compliance_agent(request):
    return run_agent(request)

Fuer High-Volume-Endpunkte senkt man die Sampling Rate. Fuer finanzielle, regulatorische oder sicherheitskritische Pfade traced man alles.

MLflow bietet ausserdem das Paket mlflow-tracing fuer Produktionsumgebungen, in denen man das Tracing SDK ohne das volle MLflow-Paket installieren moechte. Das hilft bei kleineren Containern, schnelleren Cold Starts und weniger Dependency-Konflikten.

Das Betriebsmodell

Das Produktionsmuster, das ich verwenden wuerde:

  1. Jede RAG- oder Agentenanfrage erzeugt einen sinnvollen Trace.
  2. Span-Typen werden korrekt gesetzt, besonders RETRIEVER und TOOL.
  3. Retriever-Outputs bleiben als Dokumente strukturiert.
  4. Ein Evaluation Dataset enthaelt erwartete Fakten und erwartetes Verhalten.
  5. LLM-Judges werden mit deterministischen trace-bewussten Scorern kombiniert.
  6. Prompts werden versioniert und gegen dasselbe Dataset evaluiert.
  7. Produktion nutzt Sampling und asynchrones Logging.
  8. Human Feedback und Produktionstraces erweitern das Evaluation Dataset.

Der Kern ist: Der Trace wird zur Einheit des GenAI-Engineerings. Er ist Debugging-Artefakt, Evaluationseingabe, Prompt-Optimierungsnachweis und Produktionssignal.

Wenn wir nur die finale Antwort betrachten, uebersehen wir das System, das sie erzeugt hat. MLflow 3.x gibt uns eine Moeglichkeit, dieses System direkt zu beobachten und zu bewerten.

Quellen

Share