Accueil / Articles

Tri des tickets de support multi-agents avec LangGraph et Ollama

Créer un graphe de tri des tickets avec trois agents à l’aide de LangGraph : un classificateur, un répondant et un contrôleur humain, fonctionnant sur Ollama local ou via une API, avec une seule ligne modifiée.

Éditeur : REACTAPP.TOP16 min de lecture3572 mots

Sources: Building a Multi-Agent Support Ticket Triage With LangGraph

Tout ceux qui ont géré une boîte de réception pour le support connaissent la routine matinale : quelqu’un lit chaque nouveau ticket, classe les demandes de réinitialisation de mot de passe et les questions de facturation en piles distinctes, puis cherche les rares messages vraiment urgents. Il s’agit d’une tâche purement mécanique, exactement le type de travail que peut assumer une petite équipe d’agents collaborant.

LangGraph permet de constituer cette équipe. Un agent classe chaque ticket, un routeur décide de la suite à donner et un deuxième agent rédige une réponse. Les cas urgents sont dépassés par l’automatisation et transmis à un humain. Le graphe est écrit une seule fois et s’exécute soit localement sur Ollama, soit via une API cloud, sans modification des agents ni du système de routage.

Pourquoi plus d’un agent ?

Un agent isolé est comme un travailleur qui ne parle jamais à personne, et dans une vraie entreprise, cela entrave rapidement son développement. L’ajout de tâches supplémentaires rend la commande unique longue, fragile et difficile à déboguer, car un seul modèle est chargé de classer, de décider, d’écrire et d’évaluer les risques en même temps. En divisant le travail, chaque partie reste petite, testable et remplaçable. Un système multi-agents ressemble à une équipe de spécialistes, chacun ayant un rôle défini, coordonnant les prochaines actions.

Le tri des tickets correspond bien à cette idée, car le travail se divise naturellement, et un pipeline qui traite chaque message de la même manière serait inadapté pour au moins l’un des cas suivants :

  • Une question de facturation courante peut être répondue automatiquement.
  • Une demande de fonctionnalité vague reçoit une réponse préliminaire que quelqu’un va affiner par la suite.
  • Un message du type « ma application est down » ne devrait absolument pas être traité de manière automatisée et doit être transmis immédiatement à un humain.
  • LangGraph gère précisément ces cas où l’étape suivante dépend de l’état actuel. L’équipe se compose de trois membres :

    1. Agent de classification, qui lit un ticket et lui attribue une catégorie ainsi qu’une priorité.
    2. Agent de réponse, qui rédige des réponses pour les tickets pouvant être traités automatiquement.
    3. Contrôle humain, un nœud qui transmet les tickets urgents à une personne au lieu de répondre lui-même.

    Entre eux se trouve la logique de routage, qui agit en quelque sorte comme un superviseur. Elle examine la priorité et choisit le parcours à suivre.

    Comment LangGraph pense

    Imaginez un organigramme sur un tableau blanc. LangGraph se compose de quatre parties :

    • État est une mémoire de clippage transmise d’une station à l’autre. Elle contient tout ce qui est connu jusqu’à présent sur le ticket : le texte original, la catégorie, la priorité, la réponse ébauchée le cas échéant, ainsi que l’état actuel.
    • Nœud est une station, c’est-à-dire une fonction qui prend l’état, effectue une tâche et renvoie des mises à jour.
    • Arête est une flèche fixe allant d’une station à l’autre.
    • Arête conditionnelle est une flèche choisie en temps de exécution. Une petite fonction routeur lit l’état et décide vers où aller, au lieu de se diriger toujours vers le même endroit.

    Ainsi, l’état circule à travers les nœuds, les arêtes les relient, et les arêtes conditionnelles permettent au parcours de se diviser en fonction de ce que dit actuellement l’état. Si vous êtes nouveau dans ce domaine, l’état n’est rien d’autre qu’un dictionnaire Python ayant une structure définie. Un TypedDict indique quels clés le dictionnaire possède.

    Le stack technologique

    LangGraph constitue la couche d’orchestration. Il définit les nœuds, l’état ainsi que le routage entre les agents.

    Ollama avec Llama 3 exécute tout localement, gratuitement, sur votre propre machine. Les tickets de support contiennent souvent des données personnelles des clients ; par conséquent, dans un environnement professionnel, il est avantageux de conserver ces données sur son propre infrastructure plutôt que de les envoyer à une API externe.

    Un modèle API , ici gpt-4o-mini, constitue une alternative lorsque vous n’avez pas de matériel supplémentaire ou que vous avez besoin d’une performance supérieure à celle offerte par un petit modèle local. Comme le graphe est écrit une seule fois, vous pouvez commencer localement puis passer à une API ultérieurement sans devoir réécrire la logique.

    Le développement étape par étape

    1. Préparation

    Installez trois paquets : langgraph pour le graphe, langchain-ollama en tant que pont vers Ollama et langchain-openai en tant que pont vers le modèle API.

    # Install LangGraph and the two model integrations
    pip install langgraph langchain-ollama langchain-openai
    

    Un environnement virtuel propre maintient les paquets de ce projet à l’écart de vos autres projets. Sous Windows, créez-le avec python -m venv votrenom et activez-le via le dossier Scripts qu’il contient. Sous Mac ou Linux, utilisez python3 -m venv venv suivi de source venv/bin/activate. Une fois activé, la commande d’installation ci-dessus n’affecte que ce projet.

    Si Ollama n’est pas encore installé, téléchargez-le depuis ollama.com/download. Ensuite, téléchargez le modèle local depuis la ligne de commande :

    # Download the Llama 3 model so we can run it locally
    ollama pull llama3
    

    2. Les agents

    Désormais, tout est placé dans un seul fichier nommé triage.py, ajouté de haut en bas dans l’ordre indiqué ci-dessous.

    Une usine pour le modèle. Commencez par l’élément qui rend les deux backends possibles : une petite fonction d’usine, get_llm, qui retourne le modèle approprié en fonction d’une seule configuration. La seule tâche d’une usine est de restituer un objet prêt à l’emploi. Imaginez une machine à café avec un bouton marqué « local » et un autre marqué « API » : appuyez sur l’un d’eux pour obtenir la tasse correspondante, sans jamais avoir à comprendre ce qui se passe à l’intérieur.

    La fonction prend un paramètre backend dont la valeur par défaut est "local" ; donc, l’appel sans argument utilise le modèle local.

    # A factory that returns the model we want, so the rest of the code
    # never has to care which backend we are using.
    def get_llm(backend: str = "local"):
        if backend == "local":
            from langchain_ollama import ChatOllama
            # temperature=0 keeps classification stable and repeatable
            return ChatOllama(model="llama3", temperature=0)
        elif backend == "api":
            from langchain_openai import ChatOpenAI
            return ChatOpenAI(model="gpt-4o-mini", temperature=0)
        else:
            raise ValueError("backend must be 'local' or 'api'")
    
    
    # Choose your backend here: "local" or "api"
    BACKEND = "local"
    llm = get_llm(BACKEND)
    

    Une chaîne if/elif/else vérifie le paramètre et renvoie soit llama3, soit gpt-4o-mini, soit une erreur. Les importations sont délibérément placées à l’intérieur des branches. Quelqu’un qui travaille uniquement en local n’a jamais besoin de langchain-openai, et vice versa.

    temperature=0 contrôle le degré de aléatoire des réponses du modèle. Des valeurs plus élevées apportent plus de variété, tandis que 0 rend les réponses aussi stables et prévisibles que possible. C’est exactement ce qu’il faut pour la classification, car le même ticket doit systématiquement recevoir la même catégorie. BACKEND = "local" définit ce choix, et la ligne suivante appelle la fonction de création.

    Le classificateur. Cet agent lit un ticket et renvoie une catégorie ainsi qu’une priorité. Afin de traiter le résultat de manière programmée, on demande au modèle de répondre en JSON.

    L’aide parse_json transforme la réponse en un véritable dictionnaire Python. Elle utilise find pour localiser d’abord les crochets d’ouverture et de fermeture, car les modèles locaux ne renvoient pas toujours du JSON propre. Ils ont tendance à l’entourer de commentaires tels que « Bien sûr, voici le JSON : ... J’espère que cela vous aide. »

    La fonction classify_ticket représente l’agent lui-même. Elle reçoit l’état actuel et renvoie des mises à jour. Son prompt définit clairement le rôle du modèle en tant qu’assistant de tri des demandes de support, limite la réponse à quatre catégories et trois priorités, et exige du JSON pur sans aucun commentaire supplémentaire. Le prompt est délibérément strict afin que la réponse soit plus propre et que parse_json ait moins de travail. Appeler llm.invoke(prompt) envoie ce prompt au modèle.

    Remarquez ce qui est retourné : seuls les deux champs que cet agent remplit, la catégorie et la priorité. C’est une caractéristique fondamentale de LangGraph. Un nœud ne renvoie pas l’ensemble de l’état, mais uniquement les parties qu’il souhaite modifier, et LangGraph les fusionne dans l’état partagé. Cela permet de garder les nœuds de taille réduite, de les tester facilement indépendamment, et d’éviter qu’un agent ne supprime par erreur un champ appartenant à un autre.

    import json
    
    def parse_json(text: str) -> dict:
        # Local models sometimes wrap their JSON in extra text,
        # so we grab everything between the first { and the last }.
        start = text.find("{")
        end = text.rfind("}") + 1
        return json.loads(text[start:end])
    
    def classify_ticket(state: dict) -> dict:
        prompt = f"""You are a support triage assistant.
    Classify the following support ticket.
    Respond with ONLY a JSON object with two keys:
    "category": one of ["billing", "technical", "account", "general"]
    "priority": one of ["low", "medium", "high"]
    
    Ticket:
    {state['ticket']}"""
    
        response = llm.invoke(prompt)
        result = parse_json(response.content)
    
        # We return only the keys we want to update in the shared state
        return {"category": result["category"], "priority": result["priority"]}
    

    Le répondant. Le deuxième agent rédige des réponses pour les tickets qui peuvent être traités automatiquement. draft_reply reçoit l’état du ticket, crée une instruction visant à faire agir le modèle comme un agent de support sympathique et concis, et ajoute une restriction : aucune promesse qui ne puisse être tenue. Il renvoie deux champs, le texte rédigé dans draft_reply et le statut « auto-drafted », afin que vous puissiez voir plus tard que le ticket a été traité automatiquement sans être transmis à un niveau supérieur. L’instruction donnée au modèle est délibérément modeste. Une réponse de support écrite par un modèle doit rester courte et honnête, et ce sont des décisions relatives aux délais de remboursement ou aux dates de lancement de fonctionnalités que des humains doivent prendre.

    def draft_reply(state: dict) -> dict:
        prompt = f"""You are a friendly and concise support agent.
    Write a short, helpful reply to this {state['category']} ticket.
    Do not make promises you cannot keep.
    
    Ticket:
    {state['ticket']}"""
    
        response = llm.invoke(prompt)
    
        return {"draft_reply": response.content, "status": "auto-drafted"}
    

    Le guichet humain. Le troisième agent n’appelle jamais le modèle. Tout ce qu’il fait, c’est marquer la demande comme escaladée afin qu’une personne s’en occupe. Il renvoie un draft_reply vide, car aucune réponse automatique ne doit exister, ainsi que le statut « escaladé à l’examen humain ». Cela indique clairement dans l’état que c’est un être humain qui gère la demande. Un nœud n’a pas besoin d’utiliser un LLM pour être considéré comme un agent dans le graphe. Tout ce qui modifie l’état, effectue une tâche et renvoie des mises à jour peut exister sur le même schéma, que ce soit en appelant un modèle, une base de données ou rien du tout.

    def escalate_to_human(state: dict) -> dict:
        # No auto-reply for urgent tickets. We flag them for a person.
        return {
            "draft_reply": "",
            "status": "escalated to human review",
        }
    

    3. Connexion du graphe

    Chacun des trois agents a une tâche à accomplir. Il faut maintenant les relier, ce qui commence par la description de la structure de l’état partagé. La classe TicketState correspond au presse-papiers mentionné précédemment, en indiquant quels champs existent et quel est leur type.

    Ces cinq champs permettent également de suivre le parcours d’un ticket au sein du système. ticket contient le texte original. Le classificateur remplit les champs category et priority. Le responsable de la réponse remplit draft_reply, ou le laisse vide après une escalade. status enregistre l’endroit où le ticket s’est finalement retrouvé.

    from typing import TypedDict
    
    
    class TicketState(TypedDict):
        ticket: str        # the original ticket text
        category: str      # filled in by the classifier
        priority: str      # filled in by the classifier
        draft_reply: str   # filled in by the responder (or cleared on escalation)
        status: str        # where the ticket ended up
    

    Vient ensuite le routeur, l’administrateur. route_by_priority lit la priorité attribuée par le classificateur et renvoie une chaîne indiquant à LangGraph quel chemin emprunter. Si la priorité est « haute », le ticket est acheminé vers un intervenant humain. Sinon, la fonction renvoie « responder » et le ticket reçoit une réponse automatique.

    def route_by_priority(state: TicketState) -> str:
        # Urgent tickets skip the automatic reply and go to a human.
        if state["priority"] == "high":
            return "human_review"
        return "responder"
    

    Voici comment assembler le graphe :

    1. Créez un graphe vide avec StateGraph(TicketState), en passant la structure du état afin que le graphe sache quels champs se trouvent dans le presse-papiers.
    2. Enregistrez les trois agents à l’aide de add_node. Chaque appel associe un nom court à une fonction : le nom « classifier » correspond à classify_ticket, « responder » à draft_reply, et « human_review » à escalate_to_human. Chaque étape ultérieure fait référence aux nœuds par ces noms, il convient donc de les choisir avec soin et de les maintenir cohérents.
    3. Ajoutez add_edge(START, “classifier”) afin que chaque ticket commence par le classificateur. START est un point d’entrée intégré.
  • Ajoutez le bord conditionnel à l’aide de add_conditional_edges, fonction qui prend le nœud où a lieu la bifurcation (“classifier”), la fonction de routage (route_by_priority) ainsi qu’une table de recherche.
  • Envoyez les deux chemins vers END, car une exécution est considérée comme terminée que le ticket ait été traité ou escaladé.
  • Appelez graph.compile(), qui transforme la définition en quelque chose d’exécutable. Le résultat est stocké dans app, et c’est ce qui traite les tickets.
  • from langgraph.graph import StateGraph, START, END
    
    # Create the graph and tell it the shape of our state
    graph = StateGraph(TicketState)
    
    # Register each agent as a node
    graph.add_node("classifier", classify_ticket)
    graph.add_node("responder", draft_reply)
    graph.add_node("human_review", escalate_to_human)
    
    # Every ticket starts at the classifier
    graph.add_edge(START, "classifier")
    
    # After classifying, the router decides where to go next
    graph.add_conditional_edges(
        "classifier",
        route_by_priority,
        {
            "responder": "responder",       # normal tickets get an auto-draft
            "human_review": "human_review",  # urgent tickets get escalated
        },
    )
    
    # Both paths finish the run
    graph.add_edge("responder", END)
    graph.add_edge("human_review", END)
    
    # Compile turns our definition into something runnable
    app = graph.compile()
    

    Le dictionnaire passé à add_conditional_edges est la table de recherche. Ses clés sont les chaînes de caractères que le routeur peut retourner, et ses valeurs sont les nœuds vers lesquels ces chaînes pointent. Lorsque le routeur retourne "human_review", LangGraph se rend au nœud human_review. Cette correspondance constitue le principe fondamental selon lequel un graphe se ramifie. Pour ajouter plus tard une nouvelle catégorie de traitement, par exemple un spécialiste des facturations, cela se traduit par l’ajout d’un nouveau nœud, d’une nouvelle clé dans la table et d’une branche supplémentaire dans le routeur.

    4. Le lancement, en local ou via API

    Envoyez d’abord un ticket urgent par l’intermédiaire du graphe :

    # A clearly urgent billing issue
    urgent_ticket = (
        "I was charged twice for my subscription this month "
        "and I need a refund today. This is unacceptable."
    )
    
    result = app.invoke({"ticket": urgent_ticket})
    
    print("Category:", result["category"])
    print("Priority:", result["priority"])
    print("Status:  ", result["status"])
    print("Reply:   ", result["draft_reply"])
    

    Exécutez le script avec python triage.py. Les mots exacts de la réponse affichée peuvent varier légèrement d’une exécution à l’autre et d’un modèle à l’autre, mais la catégorie, la priorité et le statut doivent rester constants, c’est pourquoi on utilise temperature=0. Le ticket a une haute priorité, donc le routeur l’envoie à un agent humain. Le statut retourné est « escalated to human review » et aucune réponse automatique n’est générée.

    Essayez un deuxième ticket de routine :

    # A routine, low-stakes question
    routine_ticket = "How do I change the email address on my account?"
    
    result = app.invoke({"ticket": routine_ticket})
    
    print("Category:", result["category"])
    print("Priority:", result["priority"])
    print("Status:  ", result["status"])
    print("Reply:   ", result["draft_reply"])
    

    Cette méthode envoie le message au destinataire et renvoie une réponse préparée que quelqu’un peut examiner avant d’envoyer. Exécuter les deux processus en parallèle rend la différence de trajectoire concrète : même graphe, même code, deux chemins différents déterminés uniquement par la priorité attribuée par le classificateur.

    Passer à un modèle API

    Exécuter la même opération via une API plutôt que localement ne nécessite qu’une clé API et une seule ligne modifiée, grâce à la fonction factory.

    Connectez-vous à la plateforme API d’OpenAI, créez une nouvelle clé et copiez-la immédiatement, car elle n’est affichée qu’une seule fois. Par sécurité, ne collez pas la clé dans le script. Stockez-la en tant que variable d’environnement et laissez le code la lire à partir de là. Exécutez la commande adaptée à votre système dans un terminal, en remplaçant your-key-here par la clé correspondante :

    # On Windows (PowerShell)
    setx OPENAI_API_KEY "your-key-here"
    
    # On Mac/Linux
    export OPENAI_API_KEY="your-key-here"
    

    Exécutez-les dans le terminal, et non dans le script, de préférence dans le même terminal où l’environnement virtuel est actif. La variable est définie au niveau de la session du terminal, et non à l’intérieur de venv. Par conséquent, cette clé se trouve en dehors de votre code, ne apparaît jamais dans le script et ne peut pas se retrouver par erreur sur GitHub.

    Ensuite, modifiez une ligne dans le script et exécutez à nouveau python triage.py :

    # Switch from local to API without touching the graph or the agents
    BACKEND = "api"
    llm = get_llm(BACKEND)
    

    Ce que cela montre et où aller ensuite

    L’exemple reflète un flux de travail quotidien réel. L’objectif n’est pas de remplacer l’équipe d’assistance, mais de prendre en charge le tri mécanique tandis que les cas importants et urgents sont traités par un humain. Comme le graphe, les agents et le routage fonctionnent aussi bien avec Ollama qu’avec une API, vous pouvez créer un prototype avec Ollama puis passer à un modèle basé sur une API lorsque des performances plus élevées sont nécessaires.

    Il y a beaucoup de possibilités d’évolution. Les agents pourraient disposer de plus de mémoire, ce qui permettrait à un ticket de suivi provenant du même client de conserver son contexte. D’autres spécialistes pourraient rejoindre l’équipe, comme un agent de facturation ayant accès aux données réelles des comptes. Le graphe pourrait se connecter à un véritable système de gestion de tickets via son API, transformant ainsi le script en partie intégrante d’un processus réel. De plus, une étape appropriée impliquant un humain pourrait placer les tickets escaladés dans une file d’attente de révision avec la classification déjà associée.

    Pour en savoir plus, la documentation de LangGraph et LangChain disponible à https://docs.langchain.com/oss/python/langgraph/overview ainsi que le guide de démarrage rapide d’Ollama à https://docs.ollama.com/quickstart décrivent les outils utilisés ici.