Startseite / Artikel

Mehr-Agenten-Unterstützung bei der Priorisierung von Supportanfragen mit LangGraph und Ollama

Erstellen Sie ein Graphenmodell für die Priorisierung von Supportanfragen mit drei Agenten mithilfe von LangGraph: ein Klassifikator, ein Antwortender und eine menschliche Überprüfungsebene, das auf lokalem Ollama oder über eine API läuft und dabei nur eine Zeile geändert wird.

Verlag: REACTAPP.TOP16 Min. Lesezeit3572 Wörter

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

Jeder, der bereits eine Support-E-Mail-Box betreut hat, kennt die morgendliche Routine. Jemand liest jede neue Anfrage durch, sortiert die Anfragen zur Passwortrücksetzung sowie zu Rechnungsfragen in verschiedene Gruppen und sucht nach den wenigen wirklich dringenden Nachrichten. Es handelt sich dabei um rein mechanische Aufgaben, genau die Art von Arbeit, die ein kleines Team kooperierender Agenten übernehmen kann.

Das Folgende zeigt, wie mit LangGraph ein solches Team aufgebaut wird. Ein Agent klassifiziert jede Anfrage, ein Router entscheidet, was als Nächstes geschieht, und ein zweiter Agent entwirft eine Antwort. Dringende Fälle werden der Automatisierung vorgezogen und an einen Menschen weitergeleitet. Der Graph wird einmalig erstellt und entweder lokal auf Ollama oder gegen eine Cloud-API ausgeführt, ohne dass sich die Agenten oder die Routen ändern.

Warum mehr als ein Agent?

Ein einzelner Agent ist wie ein Mitarbeiter, der nie mit jemandem spricht – in einem echten Unternehmen führt das schnell dazu, dass das Wachstum zum Stillstand kommt. Wenn weitere Aufgaben hinzukommen, wird die Anweisung für den Agenten lang, anfällig und schwer zu debuggen, denn ein einziges Modell soll gleichzeitig klassifizieren, entscheiden, schreiben und Risiken bewerten. Durch Aufteilung der Aufgaben bleibt jede Teilaufgabe klein, testbar und austauschbar. Ein Mehr-Agenten-System ähnelt einem Team von Spezialisten, bei denen jeder eine definierte Rolle hat und die nächsten Schritte koordiniert.

Ticket-Triage passt zu diesem Konzept, da die Arbeit dabei von Natur aus in mehrere Richtungen aufgeteilt wird. Ein Pipeline-System, das jede Nachricht identisch verarbeitet, wäre zumindest in einem der folgenden Fälle falsch:

  • Eine routinemäßige Rechnungsfrage kann automatisch beantwortet werden.
  • Eine vage Funktionsanfrage erhält zunächst einen Entwurf einer Antwort, den ein Mensch später überarbeitet.
  • Eine Nachricht wie „Meine Anwendung ist down“ sollte überhaupt nicht automatisiert werden und muss sofort einen Menschen erreichen.
  • LangGraph kümmert sich genau um diese Situationen, in denen der nächste Schritt vom aktuellen Zustand abhängt. Das Team besteht aus drei Mitgliedern:

    1. Klassifizierungsagent, der ein Ticket liest und eine Kategorie sowie Priorität zuweist.
    2. Antwortagent, der Entwürfe für Antworten auf Tickets erstellt, die automatisch bearbeitet werden können.
    3. Menschlicher Kontrollpunkt, ein Knoten, der dringende Tickets an einen Menschen weiterleitet anstatt selbst zu antworten.

    Zwischen ihnen befindet sich die Routierungslogik, im Grunde ein Aufsichtssystem. Sie prüft die Priorität und wählt den entsprechenden Weg aus.

    Wie LangGraph denkt

    Stellen Sie sich ein Flussdiagramm an einer Tafel vor. LangGraph hat vier Teile:

    • State ist ein Clipboard, das von einer Station zur nächsten weitergegeben wird. Es enthält alles, was bislang über das Ticket bekannt ist: den ursprünglichen Text, die Kategorie, die Priorität, ggf. eine entworfene Antwort sowie den Status.
    • Node ist eine Station, also eine Funktion, die den State entgegennimmt, eine Aufgabe ausführt und Aktualisierungen zurückgibt.
    • Edge ist ein fester Pfeil von einer Station zur nächsten.
    • Conditional edge ist ein Pfeil, der zur Laufzeit ausgewählt wird. Eine kleine Router-Funktion liest den State und entscheidet, wohin weitergegangen werden soll, anstatt stets zum selben Ort zu gelangen.

    Der State fließt somit durch die Nodes, die Edges verbinden sie miteinander, und die conditional edges ermöglichen es, den Pfad je nach dem aktuellen Zustand abzweigen zu lassen. Für Anfänger: Der State ist einfach ein Python-Dictionary mit definiertem Aufbau. Ein TypedDict beschreibt, welche Schlüssel das Dictionary enthält.

    Die Technologie-Stack

    LangGraph ist die Orchestrierungsschicht. Sie definiert die Knoten, den Zustand sowie die Verbindungen zwischen den Agenten.

    Ollama mit Llama 3 läuft alles lokal und kostenlos auf Ihrem eigenen Rechner. Support-Anfragen enthalten oft persönliche Kundendaten, weshalb es in einem Unternehmensumfeld einen echten Vorteil darstellt, diese Daten auf der eigenen Infrastruktur zu speichern anstatt sie an eine externe API zu senden.

    Ein API-Modell, hier gpt-4o-mini, ist die Alternative, wenn Sie keine zusätzliche Hardware haben oder mehr Leistung benötigen als ein kleines lokales Modell bietet. Da der Graph nur einmal erstellt wird, können Sie zunächst lokal arbeiten und später auf eine API wechseln, ohne die Logik neu schreiben zu müssen.

    Schritt für Schritt aufbauen

    1. Einrichtung

    Installieren Sie drei Pakete: langgraph für das Graphen, langchain-ollama als Brücke zu Ollama und langchain-openai als Brücke zum API-Modell.

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

    Eine frische virtuelle Umgebung hält die Pakete dieses Projekts von Ihren anderen getrennt. Unter Windows erstellen Sie sie mit python -m venv yourname und aktivieren Sie sie über den darin befindlichen Scripts-Ordner. Unter Mac oder Linux verwenden Sie python3 -m venv venv gefolgt von source venv/bin/activate. Sobald sie aktiviert ist, betrifft der obige Installationsbefehl nur dieses Projekt.

    Falls Ollama noch nicht installiert ist, holen Sie es sich von ollama.com/download. Anschließend ziehen Sie das lokale Modell über die Terminal an:

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

    2. Die Agenten

    Von nun an wird alles in eine einzige Datei mit dem Namen triage.py eingefügt, von oben nach unten in der folgenden Reihenfolge.

    Eine Fabrik für das Modell. Beginnen Sie mit dem Element, das beide Backend-Optionen ermöglicht – einer kleinen Fabrikfunktion get_llm, die je nach Einstellung das richtige Modell zurückgibt. Die einzige Aufgabe einer Fabrik besteht darin, ein fertiges Objekt zurückzugeben. Stellen Sie sich eine Kaffeemaschine vor, die einen Knopf mit der Aufschrift „local“ und einen weiteren mit der Aufschrift „API“ hat: Drücken Sie einen Knopf, erhalten Sie die entsprechende Tasse – ohne jemals erfahren zu müssen, was im Inneren vor sich geht.

    Die Funktion nimmt einen Parameter backend entgegen, dessen Standardwert "local" ist; daher verwendet der Aufruf ohne Argument das lokale Modell.

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

    Eine if/elif/else-Kette überprüft den Parameter und gibt entweder llama3, gpt-4o-mini oder einen Fehler zurück. Die Importe befinden sich absichtlich innerhalb der Zweige. Jemand, der nur lokal arbeitet, benötigt niemals langchain-openai, und umgekehrt.

    temperature=0 steuert, wie zufällig die Antworten des Modells sind. Höhere Werte erzeugen mehr Vielfalt, während 0 die Antworten so stabil und vorhersehbar wie möglich macht. Für die Klassifizierung ist das genau richtig, da derselbe Ticket zuverlässig in dieselbe Kategorie fallen sollte. BACKEND = „local“ legt die Wahl fest, und die nächste Zeile ruft die Factory auf.

    Der Klassifikator. Dieser Agent liest ein Ticket und gibt eine Kategorie sowie eine Priorität zurück. Um das Ergebnis programmatisch verarbeiten zu können, wird vom Modell verlangt, in JSON zu antworten.

    Der Hilfsfunktion parse_json wandelt die Antwort in ein echtes Python-Dictionary um. Sie verwendet find, um zunächst die öffnenden und schließenden Klammern zu lokalisieren, denn lokale Modelle liefern nicht immer sauberes JSON. Sie neigen dazu, es in Kommentare wie „Klar, hier ist das JSON: ... Ich hoffe, das hilft.“ einzubetten.

    Die Funktion classify_ticket ist das Agent selbst. Sie erhält den aktuellen Zustand und gibt Aktualisierungen zurück. Die dazugehörige Anweisung weist dem Modell klar die Rolle eines Support-Triage-Assistenten zu, beschränkt die Antwort auf vier Kategorien und drei Prioritäten sowie verlangt reines JSON ohne zusätzliche Kommentare. Die Anweisung ist absichtlich streng gestaltet, damit die Antwort sauberer zurückkommt und parse_json weniger zu tun hat. Durch Aufruf von llm.invoke(prompt) wird sie an das Modell gesendet.

    Achten Sie darauf, was zurückgegeben wird: nur die beiden Felder, die dieser Agent ausfüllt – Kategorie und Priorität. Das ist ein zentrales Merkmal von LangGraph. Ein Knoten gibt nicht den gesamten Zustand zurück, sondern nur die Teile, die er ändern möchte, und LangGraph fügt sie zum gemeinsamen Zustand zusammen. Dadurch bleiben die Knoten klein, es ist einfach, sie einzeln zu testen, und ein Agent kann versehentlich kein Feld überschreiben, das einem anderen gehört.

    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"]}
    

    Der Antwortgeber. Der zweite Agent erstellt Antworten für Tickets, die sich zur Automatisierung eignen. draft_reply erhält den Status des Tickets, erstellt eine Anweisung, in der das Modell als freundlicher, prägnanter Support-Mitarbeiter dargestellt wird, und fügt eine Sicherheitsmaßnahme hinzu: Es werden keine Versprechen gemacht, die nicht eingehalten werden können. Es werden zwei Felder zurückgegeben – der entworfene Text in draft_reply sowie der Status „auto-drafted“ – damit man später erkennen kann, dass das Ticket automatisch bearbeitet und nicht weitergeleitet wurde. Die Anweisung ist absichtlich bescheiden formuliert. Eine von einem Modell verfasste Support-Antwort sollte kurz und ehrlich bleiben, und Entscheidungen bezüglich der Zeit des Rückerstattungseinsatzes oder des Erscheinungsdatums einer Funktion sollten von Menschen getroffen werden.

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

    Das menschliche Gate. Der dritte Agent ruft das Modell niemals auf. Er markiert lediglich den Ticket als eskaliert, damit eine Person sich darum kümmert. Er gibt einen leeren draft_reply zurück, da es keine automatische Antwort geben sollte, und setzt den Status auf „auf menschliche Überprüfung eskaliert“. Dadurch wird eindeutig klar, dass der Ticket von einem Menschen bearbeitet wird. Ein Knoten muss kein LLM verwenden, um als Agent im Graphen zu gelten. Alles, was den Status übernimmt, eine Aufgabe ausführt und Aktualisierungen zurückgibt, kann auf derselben Ebene vorhanden sein – egal ob es ein Modell, eine Datenbank oder gar nichts aufruft.

    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. Verkabelung des Graphen

    Jeder der drei Agenten hat eine Aufgabe. Nun müssen sie miteinander verbunden werden, und das beginnt mit der Beschreibung der Form des gemeinsamen Zustands. Die TicketState-Klasse ist die zuvor erwähnte Zwischenablage, die angibt, welche Felder vorhanden sind und welche Typen sie haben.

    Diese fünf Felder verfolgen außerdem den Weg eines Tickets durch das System. ticket enthält den ursprünglichen Text. Der Klassifizierer füllt category und priority aus. Der Antwortgeber füllt draft_reply aus oder lässt es nach einer Eskalation leer. status dokumentiert, wo das Ticket schließlich landete.

    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
    

    Danach kommt der Router, der Überwacher. route_by_priority liest die vom Klassifikator zugewiesene Priorität und gibt einen String zurück, der LangGraph mitteilt, welchen Weg es einschlagen soll. Wenn die Priorität „hoch“ ist, gelangt das Ticket an den menschlichen Ansprechpartner. Andernfalls gibt die Funktion „responder“ zurück und das Ticket erhält eine automatische Antwort.

    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"
    

    Nun wird das Diagramm zusammengesetzt:

    1. Erstellen Sie ein leeres Diagramm mit StateGraph(TicketState) und übergeben Sie die Zustandsstruktur, damit das Diagramm weiß, welche Felder auf dem Zwischenablagebereich vorhanden sind.
    2. Registrieren Sie die drei Agenten mit add_node. Jeder Aufruf verknüpft einen kurzen Namen mit einer Funktion. Der Name „classifier“ gehört zu classify_ticket, „responder“ gehört zu draft_reply und „human_review“ gehört zu escalate_to_human. Jeder nachfolgende Schritt bezieht sich auf die Knoten anhand dieser Namen, daher sollten Sie diese sorgfältig wählen und konsequent verwenden.
    3. Fügen Sie add_edge(START, „classifier“) hinzu, damit jedes Ticket bei dem Classifier beginnt. START ist ein eingebautes Eingangspunkt.
  • Fügen Sie die bedingte Kante mit add_conditional_edges hinzu, wobei dieser Parameter den Knoten angibt, an dem die Abzweigung stattfindet ("classifier"), die Router-Funktion (route_by_priority) sowie eine Abfrage-Tabelle.
  • Senden Sie beide Pfade an END, da eine Ausführung abgeschlossen ist, egal ob das Ticket beantwortet oder weitergeleitet wurde.
  • Rufen Sie graph.compile() auf, wodurch die Definition in etwas Ausführbares umgewandelt wird. Das Ergebnis wird in app gespeichert, und das ist es, was die Tickets verarbeitet.
  • 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()
    

    Das Dictionary, das an add_conditional_edges übergeben wird, ist die Abfrageschlüsseltabelle. Ihre Schlüssel sind die Zeichenketten, die der Router zurückgeben kann, und ihre Werte sind die Knoten, auf die diese Zeichenketten verweisen. Wenn der Router „human_review“ zurückgibt, wechselt LangGraph zum human_review-Knoten. Diese Zuordnung bildet den Kern dafür, wie sich ein Graph verzweigt. Fügen Sie später eine neue Kategorie zur Verarbeitung hinzu, beispielsweise einen Rechnungsprüfer, so bedeutet die Änderung einen neuen Knoten, einen neuen Schlüssel in der Tabelle sowie eine Verzweigung im Router.

    4. Ausführen – lokal oder über API

    Senden Sie zunächst ein dringendes Ticket über den Graphen:

    # 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"])
    

    Führen Sie das Skript mit python triage.py aus. Die genaue Formulierung der ausgegebenen Antwort kann bei verschiedenen Ausführungen sowie zwischen verschiedenen Modellen leicht variieren, doch Kategorie, Priorität und Status sollten konstant bleiben – genau deshalb wird temperature=0 verwendet. Das Ticket hat eine hohe Priorität, weshalb der Router es an den menschlichen Bearbeiter weiterleitet. Der Status lautet „escalated to human review“ und es wird keine automatische Antwort erzeugt.

    Probieren Sie ein zweites, gewöhnliches Ticket aus:

    # 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"])
    

    Dieser Weg führt zum Antwortenden und liefert einen entworfenen Entwurf, den ein Mensch vor dem Versand überprüfen kann. Das Parallellaufen beider Vorgänge macht die Abzweigungen greifbar: identisches Diagramm, identischer Code, zwei verschiedene Wege, die ausschließlich durch die von dem Klassifikator zugewiesene Priorität bestimmt werden.

    Wechsel zum API-Modell

    Durch Ausführung desselben Vorgangs über eine API anstelle lokal reicht dank der Factory-Funktion ein einziger API-Schlüssel sowie eine einzige geänderte Zeile aus.

    Melden Sie sich auf der OpenAI API-Plattform an, erstellen Sie einen neuen Schlüssel und kopieren Sie ihn sofort, da er nur einmal angezeigt wird. Aus Sicherheitsgründen sollten Sie den Schlüssel nicht in das Skript einfügen. Speichern Sie ihn als Umgebungsvariable und lassen Sie den Code ihn von dort auslesen. Führen Sie im Terminal den Befehl für Ihr System aus und ersetzen Sie your-key-here durch den Schlüssel:

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

    Führen Sie diese Befehle im Terminal aus, nicht in der Skriptdatei, und idealerweise im selben Terminal, in dem die virtuelle Umgebung aktiv ist. Die Variable wird auf der Ebene der Terminal-Sitzung gesetzt, nicht innerhalb der venv. Danach befindet sich der Schlüssel an einem Ort außerhalb Ihres Codes, erscheint niemals im Skript und kann versehentlich nicht auf GitHub landen.

    Ändern Sie anschließend eine Zeile im Skript und führen Sie python triage.py erneut aus:

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

    Was angezeigt wird und wohin es als Nächstes geht

    Das Beispiel spiegelt einen echten täglichen Arbeitsablauf wider. Ziel ist es nicht, das Support-Team zu ersetzen, sondern die mechanische Sortierung zu übernehmen, während wichtige und dringende Fälle an einen Menschen weitergeleitet werden. Da das Diagramm, die Agenten und die Routierung sowohl mit Ollama als auch mit einer API funktionieren, können Sie zunächst mit Ollama prototypisieren und auf ein API-Modell wechseln, wenn mehr Leistung erforderlich ist.

    Es gibt viel Raum für weitere Erweiterungen. Die Agenten könnten über mehr Speicher verfügen, sodass ein nachfolgendes Ticket vom selben Kunden den Kontext beibehält. Weitere Spezialisten könnten hinzukommen, wie zum Beispiel ein Rechnungsagent mit Zugriff auf echte Kontodaten. Das Diagramm könnte über seine API mit einem tatsächlichen Ticketing-System verbunden werden, wodurch der Script zu Teil eines echten Workflows wird. Zudem könnte ein eigentlicher Schritt mit menschlichem Eingriff die eskalierten Tickets in eine Überprüfungsqueue einordnen, bei der bereits die Klassifizierung vorliegt.

    Zur weiteren Lektüre behandeln die LangGraph- und LangChain-Dokumentation unter https://docs.langchain.com/oss/python/langgraph/overview sowie der Ollama-Quickstart unter https://docs.ollama.com/quickstart die hier verwendeten Tools.