Accueil / Articles

Création d’une boucle d’amélioration des agents avec les outils d’évaluation de LangGraph et LangSmith.

Une boucle étape par étape de LangGraph et LangSmith : collecte des traces, trois niveaux d’évaluation, regroupement des échecs, ensembles de données de régression hors ligne et évaluations avant déploiement.

Éditeur : REACTAPP.TOP24 min de lecture5607 mots

Sources: Architecting the Agent Improvement Loop: Production-Grade Eval Pipelines

Mettre un agent en production demande le moins d’efforts. Améliorer ensuite ce système est ce qui distingue une construction amateur d’un système fiable. De nombreuses équipes lancent une première version puis passent des mois à répondre une par une aux plaintes. Chaque correctif est une réaction à un cas particulier, chaque solution reste locale, et l’agent ne s’améliore jamais dans son ensemble. Les solutions de contournement s’accumulent ainsi.

La cause habituelle est de traiter un agent comme un logiciel conventionnel. Dans les programmes ordinaires, le code explique ce que fait le système, donc une erreur signifie de lire le code source jusqu’à ce qu’elle soit trouvée. Avec les agents, cette hypothèse ne tient pas. Le code ne décrit que ce que le système est autorisé à faire, tandis que les traces enregistrent ce qu’il a réellement fait. Cet écart constitue la base de tout pipeline d’évaluation sérieux, y compris ceux décrits par Anthropic dans ses écrits techniques et par LangChain dans la documentation LangSmith.

Ce qui suit décrit un cycle d’amélioration complet pour un agent LangGraph. Il commence par un petit agent ReAct, enregistre ses traces dans LangSmith, ajoute trois niveaux d’évaluation, identifie des schémas de défaillance récurrents, transforme ces défaillances en un ensemble de tests hors ligne permanent, et effectue une évaluation de régression avant que toute correction ne soit déployée. Chaque étape est accompagnée du code correspondant, d’une explication ainsi que des résultats attendus.

Le cycle fonctionne de la manière suivante :

  1. L’agent traite une requête réelle d’utilisateur et génère des traces couvrant chaque appel à un LLM, chaque appel à une outil ainsi que les résultats intermédiaires.
  2. Des vérifications basées sur du code évaluent chaque trace en ce qui concerne le format et la correction de l’utilisation des outils, un juge basé sur un LLM évalue l’utilité et la pertinence des réponses, tandis que des humains examinent les défaillances subtiles.
  3. Les traces ayant de faibles scores sont regroupées en schémas de défaillance tels que l’utilisation du mauvais outil, un écart dans le raisonnement ou une intention non reconnue.
  • Chaque échec récurrent devient un cas de test hors ligne avec un comportement attendu connu.
  • Au moment où une nouvelle version de l’agent est déployée, elle est testée contre l’ensemble croissant de cas de test.
  • Seuls les changements qui améliorent les performances de base atteignent la production, et chaque échec corrigé reste à jamais dans l’ensemble de test.
  • C’est ainsi qu’un agent s’améliore avec le temps plutôt que de se contenter d’accumuler des correctifs. Les six étapes sont les suivantes :

    • Étape 1, fondations : un agent LangGraph ReAct avec suivi par LangSmith.
    • Étape 2, collecte des traces : extraction des exécutions en production sous forme de données structurées.
    • Étape 3, évaluation : vérifications du code, jugement par un LLM et révision humaine.
    • Étape 4, découverte de motifs : transformation des traces évaluées en catégories d’échec.
    • Étape 5, ensembles de test hors ligne : conversion des échecs en un ensemble de données sécurisé contre les régressions.
  • Étape 6, boucler le cycle : évaluer avant l’envoi et rehausser la référence de base.
  • Étape 1 : l’agent de base

    La première étape crée un petit agent de support client capable de répondre aux questions concernant un compte. Il dispose de deux outils, l’un permettant de rechercher les détails du compte et l’autre de vérifier les commandes récentes, et il utilise l’agent ReAct préconstruit par LangGraph, qui constitue le modèle standard pour les agents appelant des outils. Le suivi via LangSmith est activé, de sorte que chaque exécution, chaque appel à un LLM et chaque appel à un outil est enregistré automatiquement.

    Trois éléments permettent cela :

    1. Le StateGraph de LangGraph constitue le graphique de base, assurant la connexion entre un nœud qui décide des actions à entreprendre et un nœud qui exécute un outil. L’aide-mémoire create_react_agent encapsule ce modèle.
  • Le suivi de LangSmith s’active lorsque la variable d’environnement LANGSMITH_TRACING=true est définie. Par la suite, chaque appel à LangChain ou LangGraph génère un suivi sans modification de code.
  • Les outils sont des fonctions Python décorées avec @tool de LangChain, ce qui permet à chaque outil d’être exposé à l’agent avec un nom et une description servant à guider le choix de l’outil.
  • Mise en place de l’environnement

    Il faut d’abord installer les bibliothèques :

    # Install the libraries we need
    pip install langgraph langchain langchain-openai langsmith
    

    Quatre paquets sont installés. langgraph fournit l’environnement d’exécution des graphes ainsi que l’agent ReAct prédéfini. langchain offre le décorateur @tool et les types de messages. langchain-openai encapsule le modèle de chat d’OpenAI dans l’interface de LangChain. langsmith est la bibliothèque client utilisée pour interagir avec le backend de LangSmith aux fins de suivi et d’évaluation.

    Viennent ensuite les variables d’environnement, qui activent le suivi sans modifier le code :

    export LANGSMITH_TRACING=true
    export LANGSMITH_API_KEY=your_langsmith_key
    export LANGSMITH_PROJECT=agent-improvement-loop
    export OPENAI_API_KEY=your_openai_key
    

    LANGSMITH_TRACING=true est l’option à activer. LANGSMITH_PROJECT désigne le projet qui regroupe les traces, ce qui permet leur filtrage ultérieur. Les deux autres variables représentent les identifiants nécessaires pour LangSmith et le fournisseur de modèles.

    Lorsque ces quatre variables sont définies, toute appel à LangChain ou LangGraph dans le processus envoie une trace à LangSmith. Aucune manipulation supplémentaire n’est requise, car le suivi s’effectue en arrière-plan.

    Création de l’agent

    D’abord, les importations :

    # Step 1: Import the pieces we need
    from langchain_openai import ChatOpenAI
    from langchain_core.tools import tool
    from langgraph.prebuilt import create_react_agent
    

    Ces importations fournissent trois éléments. ChatOpenAI encapsule l’API de complétion de chat d’OpenAI pour LangChain. Le décorateur tool transforme une fonction Python ordinaire en une fonction appelable que l’agent peut utiliser. create_react_agent est un outil prêt à l’emploi de LangGraph qui assemble un graphe de type ReAct pour appeler des outils.

    Puis il y a les deux outils :

    # Step 2: Define two tools the agent can call
    @tool
    def get_account_info(account_id: str) -> str:
        """Look up basic information for an account by account_id."""
        fake_accounts = {
            "A100": "Account A100: plan=pro, status=active, email=ada@example.com",
            "A200": "Account A200: plan=free, status=suspended, email=turing@example.com",
        }
        return fake_accounts.get(account_id, f"No account found with id {account_id}")
    
    @tool
    def get_recent_orders(account_id: str) -> str:
        """Return the three most recent orders for a given account_id."""
        fake_orders = {
            "A100": "Orders for A100: #9001 shipped, #9002 processing, #9003 returned",
            "A200": "Orders for A200: no orders in the last 90 days",
        }
        return fake_orders.get(account_id, f"No orders for {account_id}")
    

    Chaque outil accepte un account_id et renvoie une chaîne de caractères. La documentation associée à chaque fonction est importante, car le LLM lit ce texte pour décider quel outil appeler. Le décorateur récupère le nom de la fonction, ses paramètres ainsi que sa documentation afin de construire le schéma que le modèle perçoit. Dans un système réel, ces fonctions interrogeraient une base de données ou une API, mais la démonstration renvoie des chaînes de caractères codées à l’avance, ce qui permet au système de rester autonome et reproductible.

    Finalement, l’agent est assemblé :

    # Step 3: Wire the tools into a ReAct agent
    model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    tools = [get_account_info, get_recent_orders]
    agent = create_react_agent(model, tools)
    

    Le modèle de chat est créé avec une température nulle afin d’assurer la reproductibilité. Les outils sont ajoutés à une liste et transmis avec le modèle à create_react_agent, qui renvoie un StateGraph LangGraph compilé contenant déjà une étape de raisonnement, une étape d’exécution des outils ainsi que des liens conditionnels les reliant.

    Test de l’agent

    Une requête simple le met à l’épreuve :

    # Step 4: Invoke the agent with a simple query
    result = agent.invoke({
        "messages": [{"role": "user", "content": "What plan is account A100 on?"}]
    })
    
    for message in result["messages"]:
        print(message.type, ":", message.content)
    

    L’appel transmet un dictionnaire contenant une clé messages. Cela s’explique par le fait que create_react_agent s’appuie sur le schéma préconstruit MessagesState, dont l’état n’est rien de plus qu’une liste de messages de chat, étendue par l’agent au fur et à mesure qu’il réfléchit et utilise des outils. La boucle affiche ensuite chaque type de message avec son contenu, ce qui permet de voir l’ensemble du suivi.

    Résultat attendu :

    human : What plan is account A100 on?
    ai :
    tool : Account A100: plan=pro, status=active, email=ada@example.com
    ai : Account A100 is on the pro plan.
    

    L’agent a reçu le message de l’utilisateur, puis a émis un message d’IA qui a déclenché une appel à outil (son contenu est vide car il ne contient que l’appel à outil sans texte). L’outil a renvoyé la chaîne d’identifiant du compte, et l’agent a terminé par une réponse. En ouvrant maintenant LangSmith, on voit une nouvelle trace dans le projet agent-improvement-loop, avec chaque étape enregistrée.

    Rien dans ce code ne capturait cette trace. Dès que LANGSMITH_TRACING=true est défini dans l’environnement, chaque appel envoie les entrées, les sorties et les étapes intermédiaires à LangSmith. Cet état d’observabilité permanente est ce sur quoi repose le reste du cycle.

    La première étape laisse un agent effectuant des appels à outil fonctionner sous LangGraph, chaque exécution générant une trace. Cependant, une trace affichée dans un tableau de bord n’est pas encore un signal d’amélioration ; il faut la récupérer et y agir, tâche qui incombe à l’étape suivante.

    Étape 2 : collecte des traces

    Cette étape extrait les traces de LangSmith pour les intégrer dans le code d’analyse. Une trace correspond à une exécution complète d’un agent, incluant les entrées, les sorties, les appels aux outils et le temps de latence. Le client Python de LangSmith liste les exécutions du projet, les filtre selon une fenêtre temporelle et un statut, puis les transforme en objets que l’étape d’évaluation peut utiliser.

    Quelques termes facilitent la lecture du code :

    1. Une exécution est une unité de travail enregistrée, qu’il s’agisse d’un appel complet à un agent ou d’un appel à un LLM imbriqué à l’intérieur. Une invocation d’agent forme ainsi un arbre d’exécutions.
    2. Une exécution racine est l’exécution de niveau supérieur d’un appel à un agent, c’est celle qui est listée lorsque l’on souhaite un enregistrement par requête utilisateur.
    3. Un projet est l’espace de noms auquel appartient une exécution, défini par LANGSMITH_PROJECT. Le filtrage par projet permet de séparer les exécutions en environnement de production de celles effectuées en développement local.

    Récupération des exécutions récentes

    Le client est créé en premier :

    # Step 1: Create a LangSmith client
    from langsmith import Client
    from datetime import datetime, timedelta
    
    client = Client()
    

    Client est importé depuis le package langsmith et créé sans arguments. Il lit automatiquement la valeur de LANGSMITH_API_KEY dans l’environnement, de sorte qu’aucune identifiante n’est transmise.

    Ensuite, les dernières exécutions en tant que root sont listées :

    # Step 2: List recent root runs from our project
    recent_runs = list(client.list_runs(
        project_name="agent-improvement-loop",
        is_root=True,
        start_time=datetime.utcnow() - timedelta(days=1),
    ))
    
    print(f"Found {len(recent_runs)} root runs in the last 24 hours")
    

    client.list_runs est appelé avec trois filtres. project_name limite la recherche au projet concerné, is_root=True ne retient que les appels des agents de niveau supérieur et non tous les appels imbriqués aux LLM ou outils, tandis que start_time restreint les résultats aux 24 dernières heures. L’itérateur est différé, donc list() le force à devenir une véritable liste.

    Résultat attendu :

    Found 42 root runs in the last 24 hours
    

    Ne conserver que les champs importants

    Un exécution brute de LangSmith comprend de nombreux champs, mais l’évaluation ne nécessite que cinq éléments : l’entrée, la sortie, le temps de réponse, d’éventuels erreurs, ainsi que les exécutions secondaires indiquant quels outils ont été utilisés.

    # Step 3: Build a compact trace record from each run
    def compact_trace(run):
        return {
            "run_id": str(run.id),
            "inputs": run.inputs,
            "outputs": run.outputs,
            "error": run.error,
            "latency_ms": (run.end_time - run.start_time).total_seconds() * 1000
                if run.end_time else None,
            "tool_calls": [
                child.name for child in client.list_runs(
                    trace_id=run.trace_id, run_type="tool"
                )
            ],
        }
    
    traces = [compact_trace(run) for run in recent_runs]
    print(f"Collected {len(traces)} compact traces")
    print("Example tool calls:", traces[0]["tool_calls"])
    

    Cet outil d’aide accepte une exécution brute et la réduit à un petit dictionnaire. Les entrées et les sorties (run.inputs, run.outputs) contiennent la requête de l’utilisateur et la réponse finale, tandis que run.error vaut None en cas de succès et une chaîne de caractères en cas d’échec. La latence est calculée en soustrayant start_time de end_time. Les appels d’outils nécessitent une deuxième requête LangSmith utilisant run_type="tool" ainsi que le même trace_id, ce qui ne renvoie que les exécutions d’outils de cet arbre d’exécution.

    Résultat attendu :

    Collected 42 compact traces
    Example tool calls: ['get_account_info']
    

    L’idée n’est pas de copier les traces depuis LangSmith pour ensuite l’abandonner. La version compacte est simplement plus facile à évaluer, tandis que la trace complète reste dans LangSmith pour des débogages approfondis. Conserver une vue détaillée dans le tableau de bord et une version simplifiée pour l’analyse est une manière judicieuse de séparer l’observabilité de l’évaluation.

    Vérification du collecteur

    # Step 4: Inspect the first trace end to end
    import json
    print(json.dumps(traces[0], indent=2, default=str))
    

    La première trace est générée sous forme de JSON indenté à l’aide de json.dumps. L’argument default=str permet à json de transformer en chaîne tout ce qu’il ne peut pas sérialiser directement, ce qui inclut les types datetime et UUID retournés par LangSmith.

    Résultat attendu :

    {
      "run_id": "b1d2e3f4-...",
      "inputs": {"messages": [{"role": "user", "content": "What plan is account A100 on?"}]},
      "outputs": {"messages": [{"role": "ai", "content": "Account A100 is on the pro plan."}]},
      "error": null,
      "latency_ms": 1423.51,
      "tool_calls": ["get_account_info"]
    }
    

    Chaque série de production dispose désormais d’un enregistrement clair et structuré, que l’étape d’évaluation viendra enrichir. La collecte des traces coûte peu, et l’effort réside dans le fait de les rendre utiles. De nombreuses équipes s’arrêtent au tableau de bord et se demandent pourquoi l’observabilité ne se traduit jamais par des améliorations. Des traces sans évaluation ne sont que du bruit, et une trace sans évaluation n’est qu’une ligne de journal.

    Étape 3 : évaluation des traces

    Trois niveaux d’évaluation alimentent une seule trace enrichie. L’article technique d’Anthropic sur les évaluations décrit exactement la même organisation : des évaluateurs basés sur du code, rapides, objectifs et reproductibles ; des évaluateurs basés sur des modèles, flexibles et évolutifs ; et des évaluateurs humains, qui constituent la référence absolue mais sont coûteux. Un exemple de chacun est développé ici.

    1. Les vérifications basées sur du code sont des fonctions déterministes rapides qui vérifient des faits structurels, tels que le fait que l’agent ait utilisé l’outil approprié ou que la sortie soit au format attendu. Elles permettent de détecter les échecs évidents sans aucun coût.
    2. Un juge basé sur un LLM est un deuxième modèle qui évalue des qualités subjectives telles que l’utilité, la pertinence et le ton. Il peut être utilisé à grande échelle car il fonctionne sur chaque trace, mais il doit d’abord être calibré en fonction du jugement humain avant que quiconque ne puisse lui faire confiance.
    3. La révision par un expert humain place un spécialiste du domaine face aux traces que l’automatisation a manquées ou évaluées de manière incohérente. C’est la vérité de référence à partir de laquelle les deux autres niveaux sont calibrés.

    Niveau 1 : exactitude de l’outil

    # Step 1: Define a rule that says which tool SHOULD be called for a given query
    import re
    
    def expected_tool_for_query(query: str) -> str:
        q = query.lower()
        if re.search(r"\b(order|shipment|shipped|return)\b", q):
            return "get_recent_orders"
        if re.search(r"\b(plan|account|status|email)\b", q):
            return "get_account_info"
        return "any"
    
    def score_tool_correctness(trace):
        query = trace["inputs"]["messages"][0]["content"]
        expected = expected_tool_for_query(query)
        actual = trace["tool_calls"][0] if trace["tool_calls"] else None
        if expected == "any":
            return 1.0
        return 1.0 if actual == expected else 0.0
    

    Deux fonctions sont définies. expected_tool_for_query applique une expression régulière au texte de la requête afin de deviner quel outil devrait l’avoir traitée. Il s’agit d’une heuristique rudimentaire, adaptée pour détecter des erreurs de routage évidentes mais insuffisante en environnement de production. score_tool_correctness prend la requête de l’utilisateur issue du suivi, détermine l’outil attendu et le compare au premier outil réellement appelé par l’agent. Un manque de correspondance donne un score de 0,0 tandis qu’une correspondance donne un score de 1,0.

    Une version destinée à la production remplacerait l’expression régulière par un classificateur plus performant ou un moteur de règles basé sur les intentions. Le principe reste le même : une vérification déterministe est effectuée avant les évaluateurs coûteux basés sur des LLM.

    Niveau 2 : un juge basé sur un LLM pour l’utilité

    # Step 2: Score qualitative properties with an LLM judge
    from langchain_openai import ChatOpenAI
    from langchain_core.prompts import ChatPromptTemplate
    
    judge_model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    
    judge_prompt = ChatPromptTemplate.from_messages([
        ("system", "You are an expert reviewer of AI agent responses. "
                   "Rate the response on a scale of 1 to 5 for helpfulness. "
                   "Respond with only a single integer, nothing else."),
        ("user", "User question: {question}\n\nAgent response: {response}\n\nScore:"),
    ])
    
    def score_helpfulness(trace):
        question = trace["inputs"]["messages"][0]["content"]
        response = trace["outputs"]["messages"][-1]["content"]
        chain = judge_prompt | judge_model
        result = chain.invoke({"question": question, "response": response})
        try:
            return int(result.content.strip()) / 5.0
        except ValueError:
            return 0.0
    

    Une petite chaîne LangChain est créée en acheminant un modèle de prompt vers le modèle juge. Le message système oblige ce dernier à retourner un seul entier compris entre 1 et 5, ce qui permet une interprétation déterministe du résultat. À l’intérieur de score_helpfulness, la question de l’utilisateur et la réponse finale de l’agent sont extraites du suivi des opérations, la chaîne est exécutée, et l’entier est converti en un nombre flottant compris entre 0 et 1 afin de pouvoir être combiné facilement avec la note attribuée à l’outil. gpt-4o-mini sert de juge car il est peu coûteux et rapide.

    Les juges basés sur des LLM sont stochastiques, ce qui signifie que deux exécutions sur le même suivi peuvent donner des notes légèrement différentes. Les recommandations d’Anthropic conseillent de calibrer un juge à l’aide d’un ensemble étiqueté par des humains avant de s’y fier, et c’est précisément cet ensemble qui est créé par la troisième couche.

    Couche 3 : révision humaine en tant que file d’attente d’annotation

    LangSmith dispose d’une file d’annotation native permettant aux utilisateurs de marquer les traces dans le tableau de bord. Afin que l’exemple reste autonome, la même structure est modélisée dans le code.

    # Step 3: Model a human review signal
    def score_human(trace, human_labels: dict) -> float | None:
        run_id = trace["run_id"]
        return human_labels.get(run_id)
    
    human_labels = {
        "b1d2e3f4-...": 1.0,
        "c2e3f4g5-...": 0.0,
    }
    

    La fonction cherche un identifiant d’exécution dans un dictionnaire contenant des étiquettes fournies par des humains. Si une étiquette existe, elle est renvoyée ; sinon, la fonction retourne None, ce qui signifie que l’exécution n’a pas encore été examinée. En environnement de production, ce dictionnaire serait accessible via l’API de file d’attente d’annotation de LangSmith. Ce qui importe, c’est le comportement des scores : ils sont sporadiques car seuls certains enregistrements en disposent, ils sont autoritaires car ils prennent le pas sur les autres en cas de désaccord, et ils servent à calibrer le juge basé sur un LLM.

    Assemblage des trois couches

    # Step 4: Run all three scorers and attach scores to each trace
    def enrich(trace):
        trace["scores"] = {
            "tool_correctness": score_tool_correctness(trace),
            "helpfulness": score_helpfulness(trace),
            "human": score_human(trace, human_labels),
        }
        return trace
    
    enriched = [enrich(t) for t in traces]
    print(json.dumps(enriched[0]["scores"], indent=2))
    

    La fonction enrich ajoute les trois scores à une trace et la renvoie. Elle est appliquée à chaque trace collectée pendant la phase de collecte, ce qui produit une liste de traces enrichies contenant chacune un dictionnaire scores structuré. La regex basée sur des règles génère tool_correctness, le juge LLM produit helpfulness, et human est un nombre flottant ou None.

    Résultat attendu :

    {
      "tool_correctness": 1.0,
      "helpfulness": 0.8,
      "human": null
    }
    

    Les trois scores diffèrent délibérément. La correction de l’outil indique que le bon outil a été utilisé, tandis que le juge basé sur un LLM estime que la réponse mérite 4 sur 5. Un réviseur humain qui intervient plus tard pourrait affirmer que l’outil était approprié, mais que la formulation ne correspondait pas au ton de la marque. Chaque couche détecte ce que les autres manquent, ce qui permet d’obtenir une couverture plus complète et une vérité de référence à différents coûts.

    Chaque trace contient désormais trois scores : un basé sur des règles, un évalué par un LLM, et, le cas échéant, un score humain. Les scores sans structure sont presque aussi peu utiles que l’absence totale de scores, c’est pourquoi les modèles de reconnaissance suivent.

    Étape 4 : identification des modèles d’échec

    Dès que les traces contiennent des scores, la même erreur a tendance à réapparaître à plusieurs reprises. L’agent choisit l’outil inadapté pour une formulation particulière. Son raisonnement dérive face à des requêtes complexes en plusieurs étapes. Ses réponses sont techniquement correctes mais ne répondent pas aux attentes de l’utilisateur. Chaque schéma nécessite un type de correction différent, qu’il s’agisse d’une description réécrite de l’outil, d’une mise à jour du prompt ou d’une restructuration du flux de travail. Identifier le schéma est la partie difficile, tandis que sa correction est généralement simple par la suite.

    LangChain propose un Insights Agent conçu précisément pour gérer ce type de problèmes à grande échelle. Il analyse les traces, les regroupe en fonction des raisons de leurs échecs et aide les équipes à déterminer quelles problématiques méritent d’être traitées en premier. Une version simplifiée du même concept est présentée dans le code ci-dessous.

    Filtrage des échecs

    # Step 1: Keep only traces where at least one score is low
    def is_low_scoring(trace):
        scores = trace["scores"]
        if scores["human"] is not None and scores["human"] < 0.5:
            return True
        if scores["tool_correctness"] < 0.5:
            return True
        if scores["helpfulness"] < 0.6:
            return True
        return False
    
    failures = [t for t in enriched if is_low_scoring(t)]
    print(f"{len(failures)} of {len(enriched)} traces flagged as low scoring")
    

    is_low_scoring effectue trois vérifications. Un score humain inférieur à 0,5 qualifie la trace d’échec. Sinon, les scores automatisés décident : une exactitude de l’outil inférieure à 0,5 signifie que l’agent a pris la mauvaise direction, et un niveau d’aide inférieur à 0,6 indique que le juge LLM n’a pas apprécié la réponse. Ces seuils peuvent être ajustés, et il s’agit de valeurs par défaut raisonnables pour une première analyse. Le filtre traite les traces enrichies issues de l’étape précédente afin de déterminer l’ensemble des échecs.

    Résultat attendu :

    9 of 42 traces flagged as low scoring
    

    Regroupement des échecs en catégories

    Une petite heuristique étiquette les échecs ici. En production, des techniques d’incorporation associées au clustering permettraient d’accomplir cette tâche, ou bien l’Insights Agent de LangChain, qui fait précisément cela à partir des traces générées par LangSmith.

    # Step 2: Tag each failure with a category based on its scores
    def categorize(trace):
        scores = trace["scores"]
        if scores["tool_correctness"] < 0.5:
            return "wrong_tool"
        if scores["helpfulness"] < 0.6 and scores["tool_correctness"] >= 0.5:
            return "unhelpful_answer"
        if scores["human"] is not None and scores["human"] < 0.5:
            return "human_flagged"
        return "other"
    
    from collections import Counter
    categories = Counter(categorize(t) for t in failures)
    print(categories.most_common())
    

    La fonction categorize vérifie les éléments dans l’ordre de priorité. Un outil inadapté représente le problème le plus structurel, c’est pourquoi il est marqué en premier. Une réponse peu utile utilisant l’outil approprié indique un problème dans la formulation de la demande ou dans la génération de la réponse. Une erreur signalée par un humain que l’automatisation a laissée passer est classée dans sa propre catégorie. Le Counter standard de Python compte le nombre d’éléments dans chaque catégorie, et il est importé directement ici car c’est sa première utilisation.

    Résultat attendu :

    [('wrong_tool', 5), ('unhelpful_answer', 3), ('human_flagged', 1)]
    

    La catégorie indique la solution possible. Une série de cinq échecs dus à l’outil incorrect renvoie à la description de l’outil ou à l’instruction de routage. Trois réponses inutiles malgré l’outil correct indiquent que c’est l’instruction qui génère les réponses. Un échec détecté uniquement par un humain signifie que les évaluateurs automatisés ont manqué quelque chose, il est donc probable qu’un nouvel évaluateur soit nécessaire.

    Examen d’un seul schéma

    # Step 3: Print the inputs and outputs for every wrong_tool failure
    wrong_tool = [t for t in failures if categorize(t) == "wrong_tool"]
    for trace in wrong_tool[:3]:
        print("QUERY:", trace["inputs"]["messages"][0]["content"])
        print("TOOLS CALLED:", trace["tool_calls"])
        print("RESPONSE:", trace["outputs"]["messages"][-1]["content"])
        print("---")
    

    Le ensemble des échecs est réduit aux cas wrong_tool, et la boucle affiche la requête de l’utilisateur, les outils réellement appelés ainsi que la réponse finale pour les trois premiers. C’est là qu’une personne ou un LLM plus performant examine ces échecs et formule une hypothèse, par exemple que toute requête contenant « subscription » déclenche get_recent_orders au lieu de get_account_info. Cette hypothèse devient alors l’objectif à corriger.

    Résultat attendu :

    QUERY: Is my subscription active?
    TOOLS CALLED: ['get_recent_orders']
    RESPONSE: I could not find any recent orders to confirm your subscription status.
    ---
    QUERY: Tell me about my subscription to your product
    TOOLS CALLED: ['get_recent_orders']
    RESPONSE: There are no recent orders for this account.
    ---
    

    Trois exemples affichés rendent ce schéma évident. « Abonnement » est sémantiquement plus proche de « statut du compte » que de « commandes », pourtant l’agent utilise une correspondance superficielle des mots-clés. La solution probable consiste à modifier en une seule ligne la documentation de get_account_info en y ajoutant des termes relatifs aux abonnements, ou à intégrer un bref message système pour expliquer la différence.

    Cette étape se termine par des schémas d’échec présentant des statistiques, des catégories et des exemples. Un problème résolu aujourd’hui a peu de valeur si une modification effectuée par un autre ingénieur le brise à nouveau la semaine suivante ; il faut donc valider la correction par un test.

    Étape 5 : ensembles de tests hors ligne

    Ici, les échecs se transforment en tests statiques. Chaque échec réel en environnement de production est ajouté à un ensemble de données LangSmith en tant qu’exemple contenant l’entrée (la requête de l’utilisateur), le comportement attendu (l’outil approprié ou une réponse acceptable) ainsi que des métadonnées optionnelles comme la catégorie de l’échec. Une fois stocké, il ne quitte jamais cet ensemble, ce qui empêche un échec récurrent de se produire discrètement.

    L’évaluation hors ligne dans LangSmith comporte trois parties :

    1. Un ensemble de données est une collection nommée d’exemples, chacun comportant des entrées et, éventuellement, des sorties attendues.
    2. La fonction cible est le code à tester ; pour un agent, il s’agit généralement de sa méthode .invoke.
    3. Les évaluateurs sont des fonctions qui notent la sortie de la fonction cible par rapport à chaque exemple, suivant le même principe que les outils de notation utilisés lors de l’étape d’évaluation.

    Création de l’ensemble de données

    # Step 1: Create a LangSmith dataset for our agent
    dataset_name = "agent-production-failures"
    
    dataset = client.create_dataset(
        dataset_name=dataset_name,
        description="Production traces where the agent failed. Each example captures the user query and the expected tool call.",
    )
    print(f"Created dataset: {dataset.id}")
    

    Le même client utilisé lors de l’étape de collecte appelle create_dataset en fournissant un nom et une description, puis renvoie un objet dataset dont le .id pourra être utilisé ultérieurement. Une mise en production réelle inclurait une vérification pour créer le dataset uniquement s’il n’existe pas déjà, mais la démonstration suppose une première exécution.

    Résultat attendu :

    Created dataset: 3f2a1b0c-...
    

    Transformer les échecs en exemples

    # Step 2: Convert each failure trace into a dataset example
    examples = []
    for trace in failures:
        query = trace["inputs"]["messages"][0]["content"]
        expected_tool = expected_tool_for_query(query)
        examples.append({
            "inputs": {"question": query},
            "outputs": {"expected_tool": expected_tool},
            "metadata": {"category": categorize(trace), "source_run_id": trace["run_id"]},
        })
    
    client.create_examples(dataset_id=dataset.id, examples=examples)
    print(f"Added {len(examples)} examples to {dataset_name}")
    

    Le code parcourt chaque trace d’échec et crée un dictionnaire d’exemple contenant trois champs. inputs contient la question de l’utilisateur, outputs indique le comportement à tester, qui correspond ici à l’outil qui aurait dû être appelé, et metadata contient la catégorie ainsi que l’ID de l’exécution originale, afin que chaque exemple puisse être retracé jusqu’à sa source. Une seule appelation batchée de client.create_examples charge tout cela.

    Résultat attendu :

    Added 9 examples to agent-production-failures
    

    Les déploiements réels permettent généralement à un examinateur de déterminer le résultat attendu, car l’heuristique basée sur les expressions régulières n’est qu’un point de départ. Une personne qui approuve chaque exemple fournit une vérité factuelle plus fiable, c’est précisément à cela que sert la file d’attente d’annotation de LangSmith.

    L’évaluateur de régression

    # Step 3: Write an evaluator that checks the agent's tool call against the expected tool
    def tool_match_evaluator(inputs: dict, outputs: dict, reference_outputs: dict):
        actual_messages = outputs.get("messages", [])
        tool_called = None
        for m in actual_messages:
            if getattr(m, "tool_calls", None):
                tool_called = m.tool_calls[0]["name"]
                break
        expected = reference_outputs["expected_tool"]
        score = 1.0 if (expected == "any" or tool_called == expected) else 0.0
        return {"key": "tool_match", "score": score}
    

    tool_match_evaluator respecte la signature attendue par LangSmith : il reçoit les inputs de l’exemple, les outputs réels de l’agent ainsi que les reference_outputs de l’exemple. Il parcourt les messages d’output de l’agent, trouve le premier contenant une appel à outil et extrait son nom. Il compare ensuite ce nom à l’outil attendu et renvoie un dictionnaire contenant une key et une score. La key devient le nom de la métrique affichée dans LangSmith. Sa structure suit le guide d’initiation officiel à l’évaluation de LangSmith.

    La fonction cible

    # Step 4: Wrap our agent in a function that takes a LangSmith example and returns its output
    def run_agent(inputs: dict) -> dict:
        result = agent.invoke({
            "messages": [{"role": "user", "content": inputs["question"]}]
        })
        return result
    

    run_agent est délibérément conçu de manière minimale. À partir des inputs d’un exemple de dataset, il appelle l’agent et renvoie le dictionnaire brut qui lui est retourné, ce qui correspond à ce que la fonction evaluate de LangSmith attend. L’agent appelé a été créé durant l’étape de fondation.

    Le fait de garder ce wrapper léger est intentionnel : tout ce qui y est placé s’exécute à chaque évaluation, augmentant ainsi les coûts ; en revanche, un simple passage direct permet à l’évaluateur de tester l’agent et non le wrapper.

    Ainsi, une fois le dataset créé et l’évaluateur connecté, chaque échec de production devient un cas d’épreuve permanent. L’étape finale exécute cet évaluateur avant toute mise à jour.

    Étape 6 : boucler le cycle

    Ce est la porte de régression. L’agent reçoit une mise à jour, par exemple une description d’outil actualisée visant à corriger la faille de routage liée aux « abonnements » découverte précédemment. Avant le déploiement, l’ensemble des données relatives aux échecs passés est testé avec la nouvelle version. LangSmith enregistre ces deux tests en tant qu’expériences et montre, exemple par exemple, où la nouvelle version a amélioré les résultats et où elle a connu des dégradations. Le déploiement n’a lieu que lorsque la nouvelle version bat l’ancienne et qu’aucune correction antérieure ne provoque de régression.

    Exécution de l’évaluation de la version de référence

    # Step 1: Run the evaluator across the dataset with the current agent
    from langsmith.evaluation import evaluate
    
    experiment_results = evaluate(
        run_agent,
        data=dataset_name,
        evaluators=[tool_match_evaluator],
        experiment_prefix="agent-v1-baseline",
        max_concurrency=4,
    )
    

    evaluate est importé depuis langsmith.evaluation. On lui fournit l’objectif run_agent, le nom du jeu de données (que LangSmith associe au jeu de données de l’étape précédente), la liste des évaluateurs, un préfixe d’expérience servant à identifier l’exécution, ainsi qu’une concurrence de quatre afin que l’évaluation s’exécute en parallèle. Il renvoie un objet de résultats d’expérience indiquant l’exécution dans LangSmith.

    Résultat attendu :

    View the evaluation results for experiment: 'agent-v1-baseline-...' at:
    https://smith.langchain.com/o/.../datasets/.../compare?selectedSessions=...
    
    9/9 runs | avg tool_match: 0.33
    

    Sur neuf exemples échoués, l’agent actuel continue de diriger la plupart d’entre eux de manière incorrecte, et c’est là la situation de base.

    Déploiement de la correction et nouvelle mesure

    La correction suggérée par l’étape de détection de schémas consiste à réécrire la documentation de get_account_info en utilisant le langage propre aux abonnements.

    # Step 2: Update the tool docstring to teach the agent about subscriptions
    @tool
    def get_account_info_v2(account_id: str) -> str:
        """Look up basic information for an account, including plan, status,
        subscription tier, and contact email. Use this tool for any question
        about the account itself, including subscription status."""
        fake_accounts = {
            "A100": "Account A100: plan=pro, status=active, email=ada@example.com",
            "A200": "Account A200: plan=free, status=suspended, email=turing@example.com",
        }
        return fake_accounts.get(account_id, f"No account found with id {account_id}")
    
    tools_v2 = [get_account_info_v2, get_recent_orders]
    agent_v2 = create_react_agent(model, tools_v2)
    
    def run_agent_v2(inputs: dict) -> dict:
        result = agent_v2.invoke({
            "messages": [{"role": "user", "content": inputs["question"]}]
        })
        return result
    

    Outil d’information sur le compte : il a été redéfini avec une documentation plus détaillée mentionnant explicitement le statut de l’abonnement. Comme c’est la documentation que le LLM lit pour choisir un outil, son élargissement permet de résoudre directement le problème de routage. L’agent a été reconstruit avec l’outil mis à jour et intégré dans une nouvelle cible run_agent_v2. Tout le reste reste inchangé.

    # Step 3: Run the evaluator on the v2 agent
    experiment_results_v2 = evaluate(
        run_agent_v2,
        data=dataset_name,
        evaluators=[tool_match_evaluator],
        experiment_prefix="agent-v2-docstring-fix",
        max_concurrency=4,
    )
    

    L’évaluation identique est exécutée à nouveau sur le même ensemble de données avec le même évaluateur, seuls l’objectif changeant. Un nouveau experiment_prefix permet de distinguer cette exécution dans l’interface LangSmith afin de la comparer à la ligne de base. LangSmith fait passer chaque exemple par l’agent v2, enregistre chaque trace complète et évalue chaque résultat à l’aide de l’évaluateur.

    Résultat attendu :

    View the evaluation results for experiment: 'agent-v2-docstring-fix-...' at:
    https://smith.langchain.com/o/.../datasets/.../compare?selectedSessions=...
    
    9/9 runs | avg tool_match: 0.89
    

    Le score est passé de 0,33 à 0,89. Huit des neuf échecs ont été résolus, et le neuvième correspond à une requête véritablement ambiguë qui pourrait être traitée dans la file d’annotation humaine.

    Before (v1):       After (v2):
    wrong_tool: 5      wrong_tool: 1
    unhelpful: 3       unhelpful: 0
    human: 1           human: 0
    avg score: 0.33    avg score: 0.89
    

    Divers facteurs ont rendu cette comparaison fiable :

    1. L’évaluation de base a produit une note concrète avant toute modification du code.
    2. La correction consistait en une seule modification ciblée, un seul document d’explication.
    3. La réévaluation a mesuré l’effet exact de cette modification à l’aide d’un ensemble sélectionné d’échecs réels.
    4. Le jeu de données de test est resté inchangé, ce qui a permis une comparaison équivalente.
    5. Chaque trace d’échec initial est désormais associée à un test, de sorte qu’aucune modification ultérieure ne peut la compromettre discrètement.

    C’est ce qui rend le processus itératif efficace. Chaque correction ajoute des exemples au jeu de données, et chaque version future est évaluée par rapport à tous les échecs antérieurs. Le déploiement devient ainsi de plus en plus rapide avec le temps, et non plus lent, car la confiance augmente au même rythme que les données.

    Tout le cycle s’exécute désormais de bout en bout. Les traces sont collectées et enrichies de scores, des motifs apparaissent, les échecs deviennent des tests hors ligne, l’évaluation précède le déploiement, et un changement n’est proposé que lorsque la valeur de référence augmente. Chaque cycle débute à un niveau plus élevé.

    Une exécution complète par jour

    Un seul moteur peut exécuter l’ensemble du cycle une fois, avec des données de production fraîches.

    # Full loop driver
    def run_improvement_loop():
        # 1. Pull traces
        runs = list(client.list_runs(
            project_name="agent-improvement-loop",
            is_root=True,
            start_time=datetime.utcnow() - timedelta(days=1),
        ))
        traces = [compact_trace(r) for r in runs]
    
        # 2. Enrich with scores
        enriched = [enrich(t) for t in traces]
    
        # 3. Find failures and categorize them
        failures = [t for t in enriched if is_low_scoring(t)]
        categories = Counter(categorize(t) for t in failures)
    
        # 4. Add failures to the dataset
        new_examples = [{
            "inputs": {"question": t["inputs"]["messages"][0]["content"]},
            "outputs": {"expected_tool": expected_tool_for_query(
                t["inputs"]["messages"][0]["content"])},
            "metadata": {"category": categorize(t), "source_run_id": t["run_id"]},
        } for t in failures]
    
        if new_examples:
            client.create_examples(dataset_id=dataset.id, examples=new_examples)
    
        # 5. Re-evaluate the current agent against the full dataset
        result = evaluate(
            run_agent_v2,
            data=dataset_name,
            evaluators=[tool_match_evaluator],
            experiment_prefix="daily-regression",
        )
    
        return {
            "traces_collected": len(traces),
            "failures_found": len(failures),
            "by_category": dict(categories),
            "examples_added": len(new_examples),
        }
    
    print(run_improvement_loop())
    

    Cette fonction relie les six étapes entre elles. Elle récupère les traces d’hier, les évalue, catégorise les échecs, ajoute de nouveaux échecs en tant qu’exemples de dataset et effectue une évaluation par régression. Elle renvoie un petit dictionnaire de tableau de bord qui peut être enregistré ou posté sur Slack chaque jour.

    Résultat attendu :

    {
      "traces_collected": 186,
      "failures_found": 12,
      "by_category": {"wrong_tool": 4, "unhelpful_answer": 6, "human_flagged": 2},
      "examples_added": 12,
      "regression_score": 0.87
    }
    

    Ce que montre le résultat : la journée a produit 186 traces de production sans aucun changement dans le code de l’agent, et douze d’entre elles ont obtenu au moins une note faible. Il y a maintenant plus de réponses inutiles que d’appels à des outils incorrects, ce qui indique que la correction du routage a porté ses fruits et que la prochaine correction devrait porter sur la génération des réponses. Le score de régression est resté à 0,87, ce qui signifie que aucune des modifications de la semaine n’a réactivé une défaillance antérieure. Douze nouveaux exemples ont été ajoutés au dataset permanent, où ils serviront à éviter les répétitions.

    Cinq mises à niveau à effectuer prochainement

    Chacune d’elles correspond à une fonctionnalité de LangChain, LangGraph ou LangSmith qui peut être activée sans devoir reconstruire l’architecture existante.

    1. Remplacer le classifieur basé sur des expressions régulières par des embeddings. OpenAIEmbeddings permet d’incorporer chaque requête de manière à ce que les traces d’échec soient regroupées en fonction de la similarité sémantique et non selon des règles de mots-clés, ce qui met en évidence des problèmes plus subtils tels que des incohérences de ton.
    2. Remplacer le juge LLM intégré par la bibliothèque OpenEvals. Le guide de démarrage rapide de LangSmith utilise openevals avec des prompts prêts à l’emploi tels que CORRECTNESS_PROMPT, qui se révèle plus fiable qu’un juge conçu manuellement et propose des variantes sans référence pour un suivi en ligne.
    3. Ajouter des évaluations à plusieurs tours. Les évaluations multi-tours de LangSmith font fonctionner un juge LLM sur l’ensemble des conversations et non seulement sur les réponses individuelles, permettant ainsi de détecter les dérives dans le raisonnement que les évaluateurs à un seul tour manquent chez les agents basés sur des chats.
  • Intégrez l’Insights Agent de LangChain pour découvrir des motifs à grande échelle. Le catégoriseur regex gère des dizaines d’échecs, mais face à des milliers, l’Insights Agent oriente automatiquement les traces vers les groupes de projets et met en évidence les modes d’échec, en utilisant la même structure de dataset.
  • Procédez à une mise en œuvre via LangGraph avec un déploiement progressif. En conservant la version v2 dans un projet canari défini avec LANGSMITH_PROJECT=agent-canary pendant un jour, puis en comparant ses résultats en temps réel avec ceux du projet de contrôle, on s’assure qu’elle ne remplace la version en production que si elle est nettement supérieure.
  • Le cycle se répète ensuite à chaque fois à partir d’une base de référence plus élevée, avec des traces de meilleure qualité, des évaluations améliorées et un agent encore plus performant.