thamu _

Industrialiser la GenAI avec MLflow 3.x : tracing, evaluation et optimisation des prompts

par Thamu Mnyulwa 12 min de lecture

Les systemes de Generative AI ne tombent pas en panne comme les logiciels classiques.

Dans un service HTTP traditionnel, un incident de production revient souvent a des questions connues : la requete a-t-elle expire ? La base de donnees a-t-elle renvoye une erreur ? Une dependance a-t-elle repondu avec un 500 ? Un nouveau deploiement a-t-il change le comportement ?

Avec une application Retrieval-Augmented Generation (RAG) ou un workflow agentique, la surface de panne est plus large. La reponse finale peut etre bien formee et pourtant fausse. La recherche vectorielle peut recuperer un contexte non pertinent. Un outil peut etre appele dans le mauvais ordre. Un agent peut repeter le meme raisonnement plusieurs fois, augmenter la latence et le cout, puis retourner une reponse confiante qui n’est jamais fondee sur les documents recuperes.

C’est pour cette raison que la GenAI en production demande plus que des logs d’API. Elle demande une visibilite sur le chemin d’execution et un harnais d’evaluation capable de noter a la fois la reponse finale et les etapes intermediaires qui l’ont produite.

MLflow 3.x est interessant parce qu’il rassemble ces briques dans un meme workflow : tracing compatible OpenTelemetry, evaluation LLM-as-a-judge, scorers personnalises conscients des traces, versioning des prompts et sampling pour la production. Cet article decrit l’architecture que j’utiliserais pour faire passer une application GenAI d’une demo a un systeme que l’on peut deboguer, mesurer et ameliorer de maniere continue.

Pourquoi les logs plats ne suffisent pas pour les agents

Une requete RAG ou agentique n’est pas une operation unique. C’est un graphe d’operations :

  • Recevoir la question utilisateur.
  • Recuperer du contexte depuis une base vectorielle.
  • Construire le prompt.
  • Appeler un modele de chat.
  • Eventuellement appeler des outils.
  • Eventuellement rappeler le modele.
  • Retourner la reponse finale.

Un logging classique peut indiquer qu’une requete a pris six secondes. Il montre rarement si la base vectorielle a consomme cinq secondes, si l’appel LLM etait couteux a cause d’un prompt trop charge, ou si l’agent a appele un outil inutile.

MLflow Tracing modelise la requete comme une hierarchie de spans dans une trace. La trace represente l’execution complete. Chaque span represente une unite de travail significative : retrieval, appel d’outil, appel de modele, formatage du prompt ou operation agent racine.

C’est important, car les problemes de qualite naissent souvent avant le dernier appel au modele. Si le contexte recupere est mauvais, la reponse peut etre mauvaise meme si le modele suit correctement les instructions. Si un outil echoue silencieusement, le modele peut inventer l’information manquante. Si l’agent boucle inutilement, la reponse peut etre correcte mais trop lente et trop chere a servir.

OpenTelemetry comme contrat d’observabilite

MLflow Tracing est compatible avec OpenTelemetry et prend en charge les GenAI Semantic Conventions. Concretement, les donnees de trace ne sont pas enfermees dans une interface proprietaire. MLflow peut ingerer des traces OpenTelemetry via un endpoint OTLP comme /v1/traces, et les traces generees par MLflow peuvent etre exportees vers des plateformes compatibles OpenTelemetry.

C’est crucial dans des deploiements reels. L’application GenAI peut etre en Python, tandis qu’un gateway ou un service de retrieval est en Go, Java ou Rust. L’organisation utilise peut-etre deja Datadog, Grafana, Prometheus ou une autre stack d’observabilite. OpenTelemetry fournit un protocole commun a l’equipe plateforme, tandis que MLflow ajoute les metadonnees specifiques a la GenAI : prompts, noms de modeles, utilisation des tokens, sorties de retriever, entrees d’outils et feedback d’evaluation.

L’idee centrale est simple : les traces deviennent le substrat commun de debogage, pas un artefact reserve aux notebooks.

Les types de spans donnent du sens a la trace

La forme de la trace compte, mais le sens de chaque span compte tout autant. MLflow prend en charge des types de spans comme :

Type de spanCe qu’il representePourquoi c’est utile
AGENTAgent autonome ou orchestration de haut niveauRegroupe le chemin de decision complet
CHAINSuite coordonnee de sous-etapesUtile pour le formatage du prompt, le routage ou les pipelines
CHAT_MODEL ou spans LLMInvocation d’un modeleUtile pour inspecter tokens, latence et prompts
TOOLFonction, API, calculateur, base de donnees ou action externeUtile pour verifier la correction et l’efficacite des outils
RETRIEVERRecuperation de contexte, souvent depuis une base vectorielleNecessaire aux juges RAG qui inspectent les documents
EMBEDDINGConversion texte-vers-vecteurUtile pour diagnostiquer le pipeline de retrieval
RERANKERReordonnancement des contextes recuperesUtile pour comprendre les problemes de classement

Le span RETRIEVER est particulierement important. Les juges RAG de MLflow attendent que les spans de retrieval sortent des documents structures. Chaque document doit porter page_content et des metadonnees comme doc_uri ou chunk_id. Sans cette structure, un evaluateur ne peut pas separer de maniere fiable ce que le modele savait de ce qu’il a invente.

Un agent RAG trace

L’exemple suivant montre un petit agent RAG instrumente manuellement. L’objectif n’est pas de construire un assistant financier complet. L’objectif est de montrer ou placer les frontieres d’observabilite.

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

Quelques details sont importants pour la production :

  • Le retriever retourne des objets Document, afin que les evaluateurs RAG puissent inspecter le contexte.
  • La conversion de devise est un span TOOL, donc ses echecs et sa latence sont visibles separement de l’appel au modele.
  • La construction du prompt est un span CHAIN, ce qui aide a detecter les templates trop lourds.
  • L’appel OpenAI est capture automatiquement via mlflow.openai.autolog().
  • request_preview et response_preview gardent la liste des traces lisible.

Le decorateur @mlflow.trace enregistre aussi les exceptions comme evenements de span lorsque le code trace leve une exception. Cela donne un chemin de diagnostic utile : l’agent peut retourner un message sur pour l’utilisateur, tandis que le span de l’outil echoue montre toujours l’erreur reelle.

Concurrence et services distribues

Les systemes GenAI de production ne sont presque jamais des notebooks mono-thread. Ils lancent plusieurs retrievals, utilisent des appels async ou traversent plusieurs microservices.

MLflow utilise le mecanisme Python ContextVar pour le contexte de tracing. Les taches async heritent du contexte par defaut, ce qui rend asyncio pertinent pour les workloads GenAI I/O-bound. Les threads sont differents : le contexte ne traverse pas automatiquement les frontieres de thread. Avec ThreadPoolExecutor, il faut copier le contexte avec contextvars.copy_context() et executer le worker dans ce contexte.

Pour les microservices, MLflow prend en charge le distributed tracing via les headers W3C TraceContext. Le service appelant injecte les headers avec mlflow.tracing.get_tracing_context_headers_for_http_request(). Le service recepteur les extrait avec mlflow.tracing.set_tracing_context_from_http_request_headers(). Les deux services doivent ecrire vers le meme tracking server et le meme experiment si l’on veut obtenir une trace connectee.

C’est la difference entre “le routeur a appele le service agent” et “je peux voir le routeur, l’agent, le retriever et le tool call dans un seul arbre d’execution”.

L’evaluation commence la ou le tracing s’arrete

L’observabilite repond a la question : que s’est-il passe ?

L’evaluation repond a la question : etait-ce bon ?

Pour la GenAI, les metriques lexicales comme exact match, BLEU ou ROUGE sont souvent trop limitees. Un modele peut paraphraser correctement et echouer sur le chevauchement lexical. Il peut aussi produire un texte fluide qui n’est pas fonde sur le contexte recupere. Le harnais mlflow.genai.evaluate() prend en charge les LLM judges, les scorers personnalises, les evaluation datasets, les traces et les comparaisons de prompts.

Un enregistrement d’evaluation contient generalement :

  • inputs : les donnees passees a l’application.
  • outputs : des sorties deja calculees si l’on veut scorer sans regenerer.
  • expectations : faits attendus, comportements requis, guidelines ou tool calls attendus.
  • traces : produites pendant l’evaluation via predict_fn ou fournies depuis le trafic historique.

Deux modes deviennent utiles :

  • Evaluation directe : executer l’application sur le dataset, generer les traces, puis scorer.
  • Evaluation de reponses ou de traces : scorer des sorties existantes ou des traces de production sans rappeler le modele.

Le second mode est important pour la CI et le controle des couts.

Les juges RAG separent retrieval et generation

Pour les systemes RAG, trois questions doivent etre evaluees separement :

QuestionJuge MLflow
Les documents recuperes sont-ils pertinents ?RetrievalRelevance
Les documents contiennent-ils assez d’information ?RetrievalSufficiency
La reponse finale reste-t-elle fondee sur le contexte ?RetrievalGroundedness

Cette separation est utile en production. Si RetrievalSufficiency echoue, le modele n’avait peut-etre jamais les preuves necessaires. Si RetrievalGroundedness echoue alors que la sufficiency passe, le modele avait les preuves mais a quand meme invente des affirmations. Ce ne sont pas les memes problemes d’engineering.

Le detail essentiel : ces juges exigent des traces avec au moins un span RETRIEVER. La trace n’est pas seulement un outil d’observabilite. Elle devient une entree d’evaluation.

Ajouter des scorers deterministes conscients des traces

Les LLM judges sont puissants, mais ils coutent de l’argent, ajoutent de la latence et portent leur propre incertitude. Pour une logique metier stricte, un scorer code est souvent meilleur.

Par exemple, si un agent financier doit interroger la base vectorielle avant de repondre, inutile de demander a un LLM de le deviner. On parse la trace et on le verifie directement.

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}.",
    )

C’est ici que l’evaluation consciente des traces devient utile. On ne juge plus seulement la reponse. On juge le chemin d’execution.

L’optimisation des prompts doit etre versionnee

Les changements de prompts doivent etre traites comme des changements logiciels. Si une version de prompt ameliore le ton mais augmente le taux d’hallucination, ce n’est pas une amelioration.

La MLflow Prompt Registry permet d’enregistrer des prompts avec mlflow.genai.register_prompt(), de charger des versions precises avec mlflow.genai.load_prompt() et d’evaluer des variantes de prompt sur le meme dataset. Cela donne une boucle propre :

  1. Enregistrer la version 1 du prompt.
  2. Executer l’evaluation.
  3. Enregistrer la version 2 avec un message de commit clair.
  4. Executer le meme dataset d’evaluation.
  5. Comparer les metriques et les traces.

La discipline importante consiste a garder le dataset stable lorsque l’on compare des versions. Sinon, on ne sait pas si le prompt s’est ameliore ou si le test a change.

Configuration de production

Tracer toutes les requetes indefiniment est rarement le bon comportement par defaut. Il faut assez de visibilite pour deboguer et ameliorer, mais pas tellement de telemetrie que le tracing devient le goulot d’etranglement.

MLflow prend en charge le logging asynchrone et le 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

Pour les chemins critiques, on peut surcharger le sampling sur la fonction tracee :

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

Pour les chemins a fort volume, on reduit le sampling. Pour les chemins financiers, de conformite ou sensibles a la securite, on trace tout.

MLflow fournit aussi le package mlflow-tracing pour les environnements de production qui veulent le SDK de tracing sans installer le package MLflow complet. C’est utile pour des conteneurs plus petits, des demarrages plus rapides et moins de conflits de dependances.

Le modele operationnel

Le pattern de production que j’utiliserais :

  1. Instrumenter l’application pour que chaque requete RAG ou agentique cree une trace significative.
  2. Utiliser les bons types de spans, surtout RETRIEVER et TOOL.
  3. Garder les sorties du retriever structurees sous forme de documents.
  4. Construire un evaluation dataset avec les faits et comportements attendus.
  5. Combiner LLM judges et scorers deterministes conscients des traces.
  6. Versionner les prompts et evaluer chaque changement sur le meme dataset.
  7. Utiliser sampling et logging asynchrone en production.
  8. Exploiter le feedback humain et les traces de production pour enrichir le dataset.

Le changement central est celui-ci : la trace devient l’unite de travail du GenAI engineering. Elle est l’artefact de debogage, l’entree d’evaluation, le journal d’optimisation des prompts et le signal de monitoring.

Si l’on regarde seulement la reponse finale, on rate le systeme qui l’a creee. MLflow 3.x donne un moyen d’observer et de scorer ce systeme directement.

Sources

Share