Startseite / Artikel / Bedingter Stopp von Tools: Artefakte überwiegen return_direct in LangGraph.

Bedingter Stopp von Tools: Artefakte überwiegen return_direct in LangGraph.

Stopp/Weiterführung pro Aufruf auf Basis von Tool-Artefakten – handgebautes ReAct sowie create_agent-Middleware – wenn die statische Funktion return_direct keine Entscheidung treffen kann.

2508 Wörter

Wann return_direct das falsche Werkzeug ist

Jeder, der bereits einen LangGraph-Tool-Aufruf-Agent veröffentlicht hat, ist auf return_direct=True gestoßen: Dabei wird das Ergebnis des Tools übersprungen und der Loop beendet, anstatt es über das Modell zurückzusenden. Das scheint perfekt zu sein – bis die Entscheidung zum Stoppen von dem Ergebnis dieser Konkretisierung abhängen muss und nicht davon, welches Tool registriert ist.

In dieser Anleitung wird diese Grenze erreicht, ein minimaler ReAct-Loop wird von Hand erstellt und anschließend dasselbe Verhalten mithilfe von Middleware in create_agent wieder aufgebaut. Die kurze Antwort: Ja, es funktioniert – doch das erste Instinktverhalten bezüglich der Middleware kann aufgrund feiner Unterschiede in der Nachrichtenreihenfolge fehlschlagen, und zwar nicht, weil das Framework die Updates stillschweigend ignoriert.

Zur Klarheit: „legacy“ bezeichnet langgraph==0.6.6 (die vorletzte Zeile, bevor create_react_agent durch create_agent ersetzt wurde); „current“ bedeutet langchain==1.4.2, das langgraph==1.2.11 verwendet. Jeder ReAct-Agent ist ein Kreislauf aus Modell und Tools; der Fokus liegt hier auf der Verbindung von den Tools zurück zum Modell – sowie darauf, wann diese Verbindung bei einem bestimmten Aufruf verschwinden sollte.

Die Anforderung, die den Standardkreislauf brach

Die Integration einer Suchmaschine schien unkompliziert: Das Modell ruft search(query) auf, liest die Ergebnisse, gibt eine Antwort oder setzt fort. Zwei Eigenschaften machten den Standardkreislauf jedoch ungeeignet.

Fand die Suche ein Ergebnis, lieferte das Tool eine große JSON-Datei zurück. Diese Datei erneut in den Kontext einzubringen, um sie von einem weiteren Modul durchgehen zu lassen, ist aufwändig und meist sinnlos – wenn die Suche bereits die Frage beantwortet hat, bedeutet ein zweiter Aufruf in der Regel nur eine teure Neuformulierung.

Bei einem Fehlschlag teilen sich die Fehler in Gegensätze auf: ein echter Sackgassenzustand (es gibt nichts, womit man übereinstimmen kann – ein erneuter Versuch ist sinnlos) im Gegensatz zu einem vorübergehenden Timeout oder 503-Fehler (ein erneuter Versuch ist sinnvoll). Die Regel gilt also pro Aufruf: Erfolg → Beenden, wiederholbarer Fehler → Fortsetzen, fataler Fehler → Beenden – dies wird anhand des Payloads entschieden, nicht aufgrund des statischen Typs der Tool.

Warum return_direct das nicht angeben kann

In langgraph.prebuilt.chat_agent_executor der festgelegten Legacy-Version sieht die Routing-Logik wie folgt aus:

should_return_direct = {t.name for t in tool_classes if t.return_direct}
...
def route_tool_responses(state):
    for m in reversed(_get_state_value(state, "messages")):
        if not isinstance(m, ToolMessage):
            break
        if m.name in should_return_direct:
            return END
    ...
    return entrypoint

should_return_direct wird einmalig zur Zeit des Graphenbaus aus dem .return_direct-Attribut der Tool berechnet. Die Flag bedeutet „diese Tool beendet immer den Loop“. Sie verfügt nicht über einen Modus pro Aufruf. Das ist ein Mismatch der Kategorien, kein Defekt in der Flag.

Die Forenbeiträge spiegeln denselben Schmerz wider: aufwendige Tool-Ergebnisse erzwingen nutzlose nachfolgende Modellaufrufe, und die Wartungsteams schlagen oft vor, tool_node → END manuell zu verbinden. Eine separate Diskussion zu Command-Aktualisierungen sowie return_direct (einschließlich langgraph#5496) liefert zusätzlichen Kontext; der Kernargument ist nicht auf einen Fehler beschränkt – statische Flags können einfach keine dynamischen Ergebnisse übertragen.

Die Struktur des Kontrollflusses

Einfach ausgedrückt:

Tool call
 ├── success or unfixable failure  →  stop, use the tool's result
 └── fixable failure               →  let the model decide

Zwei Zielorte, die bei jedem Aufruf neu ausgewählt werden. Der Rest dieses Textes implementiert diese Struktur zweimal – einmal manuell, einmal mit Middleware.

Gesonderte Kanäle: content und artifact

LangChain’s @tool trennt bereits das, was das Modell sieht, von dem, was der Anwendungscode über response_format="content_and_artifact" erhält. Das Tool gibt (content, artifact) zurück. ToolMessage.content gelangt zum Modell; artifact bleibt im Nachrichtenobjekt zur Orchestrierung und gelangt niemals in den Pfad des LLMs.

@tool(response_format="content_and_artifact")
def search(query: str):
    return "the content the LLM sees", {"stop": True, "debug": "extra stuff"}
node = ToolNode([search])
result = node.invoke(state)
msg = result["messages"][0]
# msg.content  -> "the content the LLM sees"
# msg.artifact -> {"stop": True, "debug": "extra stuff"}

Das gewünschte Muster:

tool result
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
       content               artifact
          │                     │
          ↓                     ↓
        model                router
                                │
                        continue / stop

im Gegensatz zu dem, was return_direct in eine einzige statische Antwort zusammenfasst:

return_direct           content_and_artifact
     │                        │
     └── tool           content  → model
         definition     artifact → routing metadata
         → routing

Ein fester Flag, der versucht, sowohl „Was sieht der Benutzer?“ als auch „Soll die Schleife enden?“ zu beantworten – oder zwei Kanäle, von denen jeder eine Frage beantwortet.

Herkömmliches, manuell erstelltes ReAct

Anstatt einen Schleifenmechanismus zu erfinden, sollte create_react_agent auf das Wesentliche reduziert werden: Nur die Knotennamen und der Zyklus bleiben, während Prompt-Hooks, strukturierte Antwortformate, dynamische Modellauflösung, die Erfassung verbleibender Schritte, Checkpointing, Unterbrechungen sowie parallele Send-Aufrufe weggelassen werden.

Drei Knoten bleiben übrig:

  • agent – ruft das Modell auf; falls tool_calls vorhanden sind, wird weitergemacht, andernfalls wird beendet.
  • tools – einfacher ToolNode; die Ergebnisse von ToolMessage werden hinzugefügt.
  • finalize – es erfolgt keine Modellaufruf; der vom Tool ausgewählte Endtext wird wortwörtlich als AIMessage eingebettet.

Zwei Router:

  • should_continue nach agent: Toolaufrufe → tools, andernfalls END.
  • route_after_tools nach tools: Es wird das Artefakt überprüft, und es wird entweder zum agent zurückgekehrt oder der Schritt finalize aufgerufen (wodurch die ursprüngliche statische Überprüfung von return_direct ersetzt wird).
  • finalize stellt einen bewussten Kompromiss dar: Es wird auf einen Aufruf des LLM verzichtet und zeigt genau das an, was das Tool erzeugt hat, doch das Tool muss textbasierte Ausgaben liefern, und das Modell kann dieses Ergebnis nicht mit anderen Hinweisen verknüpfen. Bei Fällen, in denen „die Ausgabe des Tools bereits die Antwort ist“, ist dieser Kompromiss vorteilhaft.

    Suchergebnisse als Metadaten, nicht als Graph-Befehle

    Das Suchtool setzt artifact["stop"] basierend auf dem Verlauf dieser spezifischen Aufruf. stop gehört zu den Anwendungsmetadaten und ist kein vom LangChain reserviertes Feld. Entscheidend ist, dass das Tool ein Ergebnis meldet und die Orchestrierungstiefe dieses Ergebnis interpretiert. Dadurch bleibt die Routenplanung mit Richtlinien kompatibel, die das Tool nicht sehen kann.

    @tool(response_format="content_and_artifact")
    def search(query: str) -> tuple[str, dict]:
        """Search a knowledge base for information about the query."""
        outcome = force_outcome or rng.choices(
            list(resolved_weights), weights=list(resolved_weights.values())
        )[0]
    
        if outcome == "retryable":
            return rng.choice(_RETRYABLE_MESSAGES), {"stop": False}    if outcome == "fatal":
            return rng.choice(_FATAL_MESSAGES), {"stop": True}    query_lower = query.lower()
        for topic, page in _INDEX.items():
            if topic in query_lower or query_lower in topic:
                return page, {"stop": True}
        return "Nothing in the index overlaps with this query.", {"stop": True}
    

    Drei Fälle:

    • Erfolg mit echtem Inhalt → stop=True (ein weiterer Modellaufruf würde lediglich umformulieren).
    • Ausfall, der erneut versucht werden kann → stop=False (das Modell erhält eine weitere Chance).
    • Fataler Fehler → stop=True (das Endloslaufen verschwendet Token bei derselben nicht-antwortenden Ausgabe).

    stop=False bedeutet nicht „versuche es jetzt erneut“ – es verhindert lediglich ein sofortiges Beenden. Das Modell kann weiterhin entscheiden, die Suche erneut aufzurufen, etwas anderes auszuprobieren oder zu antworten. Der Router reduziert sich auf eine einzeilige Überprüfung des Artefakts:

    def route_after_tools(self, state: AgentState) -> str:
        last_message = state["messages"][-1]
        if (
            isinstance(last_message, ToolMessage)
            and isinstance(last_message.artifact, dict)
            and last_message.artifact.get("stop")
        ):
            return "finalize"
        return "agent"
    

    Ein Framework, das "success" | "retryable" | "fatal" vorschreibt, macht die Abläufe bei einem echten Groq-Modell deterministisch: Erfolg und fataler Fehler führen zu tools → finalize → END ohne weitere Modellaufrufe; ausfälle, die erneut versucht werden können, kehren zum agent zurück.

    Grenze: Wenn die Routenfindung auch von verbleibenden Schritten, vorherigen Versuchszählungen oder Authentifizierungsflaggen abhängt, reicht das Artefakt allein nicht aus – der Router muss den Zustand des umfangreicheren Graphen einsehen. content_and_artifact ist dann nützlich, wenn das Ergebnis dieses Tools den nächsten Schritt bestimmt.

    Jenseits der Suche

    Jedes Tool, dessen Ergebnis mehr als nur „ok/fail“ liefert, eignet sich: Ein write_record-Tool kann already_applied setzen; ein Poller kann progress für eine Benutzeroberfläche festlegen, über die das Modell nie berichtet. Ein Artefakt ist reine Daten – es kann von einer bedingten Kante, Middleware oder einer Benutzeroberfläche verwendet werden, die den Graphen nie berührt. return_direct ist eine in der Definition festgelegte Routenfindungsentscheidung; es gibt keinen Modus „Informationen übertragen, später entscheiden“.

    Dieselbe Idee bei create_agent

    Pins: Python 3.12, langchain==1.4.2 / langgraph==1.2.11, langchain-groq==1.1.3. create_agent ersetzt das Hand-Graph durch deklarative Verbindungen zusammen mit Middleware.

    Erster Impuls: wrap_tool_call verwenden und bei gesetztem stop Command(goto=END) zurückgeben.

    class StopOnArtifact(AgentMiddleware):
        def wrap_tool_call(self, request, handler):
            result = handler(request)
            if isinstance(result, ToolMessage):
                stop = isinstance(result.artifact, dict) and result.artifact.get("stop")
                if stop:
                    relay = AIMessage(content=str(result.content))
                    return Command(goto=END, update={"messages": [result, relay]})
                return Command(goto="model", update={"messages": [result]})
            return result
    

    In der getesteten Version führt dieser Weg nur dann zu einem Kurzschluss, wenn END bereits auf die Weise erreichbar ist, wie es return_direct vorsieht. Die Middleware kann stop=True berechnen, während der Loop weiterhin zum Modell zurückkehrt, bis dieses schließlich ohne Tools antwortet. Das sah auf aktuellen Stack-Versionen wie #5496 aus – bis zwei skriptbasierte Varianten etwas anderes zeigten:

    A: update={"messages": [result]}            -> stops correctly
    B: update={"messages": [result, relay]}     -> loops back to the model
    

    Version A funktioniert. Version B fügt im selben Update einen Relay AIMessage ohne tool_calls hinzu. Die Abbruchprüfung durchläuft rückwärts den letzten AIMessage, um return_direct zu prüfen; sie findet den Relay, erkennt keine Tool-Aufrufe und läuft weiter im Kreis. Der Command wurde angewendet – die Reihenfolge der Nachrichten verbarg die ursprüngliche Nachricht mit den Tool-Aufrufen vor der Abbruchprüfung. Es handelt sich weder um einen fehlgeschlagenen Update noch um #5496.

    Auch nach Behebung dieses Problems verwendet der veröffentlichte Ansatz stattdessen before_model: Dabei ist return_direct überhaupt nicht erforderlich.

    class StopOnArtifact(AgentMiddleware):
        @hook_config(can_jump_to=["end"])
        def before_model(self, state, runtime):
            last = state["messages"][-1]
            if isinstance(last, ToolMessage) and isinstance(last.artifact, dict) and last.artifact.get("stop"):
                relay = AIMessage(content=str(last.content))
                return {"jump_to": "end", "messages": [relay]}
            return None
    

    before_model wird unmittelbar vor jedem Aufruf des Modells ausgeführt – in späteren Iterationen also direkt nach den Tools. @hook_config(can_jump_to=["end"]) ermöglicht es, unabhängig von irgendwelchen Tool-Flags auf END zu springen. Die Rückgabe von {"jump_to": "end", ...} stellt eine einfache Zustandsaktualisierung dar, die von den Kanten des Graphen gelesen wird. Ein einziger Hook erkennt sowohl das Artefakt als auch den Relay AIMessage – die Aufgabe ist dabei in route_after_tools und finalize aufgeteilt.

    Erzwungene Ergebnisse entsprechen dem manuell erstellten Graphen: Erfolg sowie ein fataler Kurzschluss mit exakt dem Inhalt der Tools; versuchbare Fälle öffnen erneut die Modellphase.

    Fazit

    content_and_artifact wurde nicht als Routing-Primitiv konzipiert. Es trennt die Zielgruppen – modellseitig sichtbarer Inhalt versus ausschließlich für die App bestimmte Metadaten – und ermöglicht durch diese Trennung eine klare Entscheidung bezüglich „Sollten wir aufhören?“, ohne vom Modell eine Abwägung des Kontrollflusses verlangen zu müssen. return_direct vermengt Präsentation und Beendigung in einem einzigen statischen Flag und versagt genau dann, wenn diese Antworten pro Aufruf unterschiedlich sein müssen.

    Falls ein Anwendungsfall eine bedingte Beendigung erfordert, sollten das Tool-Ergebnis und die Routing-Entscheidung getrennt bleiben: Lassen Sie die Metadaten neben der Antwort bereitstehen und überlassen Sie die Entscheidung der Orchestrierung. content_and_artifact bietet bereits diesen Kanal.

    Entwurfsnotizen, die Teams nach dem ersten erfolgreichen Test vergessen

    Die bedingte Beendigung scheint gelöst zu sein, sobald die drei vorgegebenen Ergebnisse erreicht sind. Die Produktion erlaubt mehr Parallelität: Zwei Tool-Aufrufe in einer Modellumgebung oder eine Reihe von Suchvorgängen, bei denen eigentlich nur einer abgeschlossen werden sollte. Es muss entschieden werden, ob jedes Beendigungsereignis den gesamten Prozess abbricht, ob alle Ereignisse übereinstimmen müssen oder ob eine Prioritätsreihenfolge gilt. Diese Richtlinie sollte im Router kodiert werden und nicht in mündlich überliefertem Wissen.

    Die Beobachtbarkeit sollte das Ergebnis neben der ToolMessage anzeigen, ohne Geheimnisse aus dem content zu protokollieren. Wenn eine Beendigung ausgelöst wird, muss aufgezeichnet werden, welche Regel zutraf – Erfolg, Fehler oder Richtlinienüberschreibung – damit das Support-Team erklären kann, warum der Assistent nicht länger „nachgedacht“ hat. Dies sollte mit einer Token-Verwaltung kombiniert werden: Der ganze Sinn von „finalisieren bei Erfolg“ besteht darin, weniger Modellaufrufe durchzuführen; Dashboards sollten die Einsparungen nachweisen.

    Achten Sie darauf, Muster nicht einfach auf verschiedene LangGraph-Minoren zu übertragen. Die Namen der Middleware-Hooks, die Erreichbarkeit von Command sowie die Überprüfungen für direkten Rückgabeausgang haben sich bei der Version 0.6 → 1.x geändert. Führen Sie stets einen Charakterisierungs-Test durch, der bei jedem Upgrade ein Erfolgsergebnis, eine Wiederholungsmöglichkeit oder einen fatalen Fehler erzwingt. Wenn ein Hook plötzlich endlos in einer Schleife bleibt, prüfen Sie zunächst die Struktur der Nachrichtenliste, bevor Sie Framework-Bugs melden – Relay-Nachrichten sind ein häufiges Problem.

    Zusätzlich sollten Sie davor zurückhaltend sein, Kontrollflaggen „nur dieses eine Mal“ in den content-Teil einzufügen. Sobald das Modell stop=true in Textform erkennt, könnte es den Kontrollfluss beschreiben oder interne Codes an Benutzer weitergeben. Artefakte dienen dazu, dass die Orchestrierung entscheidend wirken kann, während der für Benutzer sichtbare Kanal sauber bleibt.

    Kartierung des Musters auf benachbarte Frameworks

    Dieselbe Trennung zwischen Inhalt und Steuerung tritt auch außerhalb von LangGraph auf. Jeder Agenten-Runtime, der den Standardausgabeinhalt von Tools in den einzigen Nachrichtenkanal integriert, entwickelt letztendlich ad-hoc-Marker, JSON-Umschläge oder zusätzliche Metadaten. Wählt einen offiziellen Nebenkanal, wenn die Plattform diesen anbietet; erstellt einen dokumentierten Umschlag, falls nicht – verlasst euch niemals darauf, dass das Modell Steuerungstoken, die im Text versteckt sind, ignoriert.

    Falls ein Team sowohl herkömmliche create_react_agent-Graphen als auch neue create_agent-Anwendungen unterstützen muss, sollte der Artefaktvertrag des Tools unverändert bleiben und nur die Router-Implementierung ausgetauscht werden. Dadurch wird der Versionswechsel auf die Orchestrierungstests beschränkt. Wenn das Middleware-System erweitert wird – durch Authentifizierungsprüfungen, Ausgaboberge, Redaktion personenbezogener Daten – sollten diese Hooks vor der Auswertung von stop ausgeführt werden, damit eine Ablehnung einer Richtlinie nicht fälschlicherweise als erfolgreicher Kurzschluss angesehen wird. Die Reihenfolge der Hooks ist Teil des öffentlichen Verhaltens des Agents, auch wenn sie wie interne Mechanismen erscheint.

    Dokumentieren Sie für zukünftige Leser, warum finalize (oder der before_model-Sprung) existiert: Es handelt sich um eine bewusste Produktentscheidung, dass Tool-Texte ohne Nachbearbeitung für den Benutzer sichtbar sein dürfen. Soll das Produkt später einen gesprochenen Zusammenfassungsstil verwenden, sollte stattdessen ein Modulknoten auf dem Stoppfad wieder eingeführt werden, anstatt das Tool mit der Aufgabe zu überlasten, gleichzeitig zwei Tonarten zu erzeugen. Die Trennung von „Ergebnis berechnen“ und „Ergebnis erzählen“ sorgt dafür, dass Tools in Sprach-, Chat- und API-Klienten wiederverwendet werden können.

    Intuitive Herangehensweise bei Stoppen versus Fortsetzen

    Stellen Sie sich ein Bezahlinstument vor, das manchmal eine fertige Quittung zurückgibt, manchmal einen „Payment-Processor-Timerout“ und manchmal die Meldung „Karte wurde dauerhaft abgelehnt“. Diese drei Fälle entsprechen exakt den Zuständen „Erfolg – Fortsetzen“, „Wiederholbar – Fortfahren“ und „Kritischer Fehler – Beenden“. Der content-Teil der Quittung kann HTML sein, das für den Kunden bereit ist; das Ergebnisdokument enthält { "stop": true, "reason": "completed" }. Bei einem Timeout wird im content-Teil eine kurze Erklärung für das Modell hinterlassen und im Dokument { "stop": false, "reason": "transient" }. Eine dauerhafte Ablehnung beendet den循环 mit einer für den Benutzer sicheren Nachricht sowie { "stop": true, "reason": "fatal" }, damit der Agent den Zahlungsdienstleister nicht ständig belastet. Dasselbe Schema lässt sich anschließend auf Suchfunktionen, Ticketerstellung oder Dokumentexport anwenden, ohne den Router umschreiben zu müssen – lediglich die Zuordnung des Tools muss angepasst werden.

    Von fehlerhaften Roh-API-Daten bis zu geringfügigen Änderungen im Wortschatz der Gründe. Die Beibehaltung dieses kleinen Wortschatzes (completed / transient / fatal / policy_block) verhindert Chaos durch unübersichtliche Strukturen, wenn immer mehr Tools dieses Muster übernehmen. Prüfer sollten einzelne boolesche Namen pro Tool ablehnen, wenn bereits eine gemeinsame Enum im Orchestrierungspaket vorhanden ist.

    Gewohnheiten bei der Begleitüberprüfung

    Halten Sie den Harness mit dem erzwungenen Ergebnis in CI bei einem falschen Chat-Modell, das vorgegebene Tool-Aufrufe auslöst. Echte Groq-Ausführungen dienen gelegentlichen End-to-End-Überprüfungen, nicht bei jedem Commit. Überprüfen Sie genaue Pfadsequenzen: Welche Knoten wurden ausgeführt, ob ein zweiter Modellaufruf stattfand und ob der finale Inhalt auf den Stop-Pfaden dem Tool-Inhalt entspricht. Wenn jemand Middleware „vereinfacht“ und return_direct erneut einführt, sollte der Harness deutlich fehlschlagen. Speichern Sie goldene Transkripte neben dem Harness, damit Fehler verglichen werden können. Bedingte Beendigung ist ein Verhaltensvertrag – Tests sorgen dafür, dass dieser Vertrag auch bei Refaktorings in verschiedenen LangGraph-Versionen sowie bei Ingenieuren, die nur die ursprünglichen Designnotizen überfliegen, erhalten bleibt.

    Falls das Produkt später den Modell benötigt, um die Ausgabe des Tools mit früheren Schritten zu verbinden – auch bei Erfolg – fügen Sie stattdessen einen optionalen Polierungs-Node nach „finalize“ hinzu, anstatt den Kurzschluss zu löschen. Feature-Flags sind besser als Überarbeitungen: stop_mode=hard|polish|never ermöglicht es, Experimente fortzusetzen, ohne den Vertrag bezüglich der Erzeugnisse zu verletzen. Messen Sie den Tokenverbrauch unter jedem Modus mit derselben Abfragesammlung, bevor Sie einen Standardwert wählen.

    Vertrag für Nutzer, die dieses Muster übernehmen

    Kopieren Sie zunächst das Artefakt-Schema sowie die Router-Tests, bevor Sie den Text kopieren. Der Wert des Artikels liegt in der Trennung der Aufgabenbereiche, nicht in der Anekdote über das Suchwerkzeug. Falls Ihr Domänenumfeld unterschiedliche Fehlerbezeichnungen verwendet, ordnen Sie diese dennoch den gleichen drei Kategorien zu und halten Sie den Router einfach. Vermeiden Sie es, eine vierte Kategorie hinzuzufügen, es sei denn, ein echtes Problem erfordert dies. Im Zweifel ist es besser, mit dem Modell weiterzumachen, anstatt bei unklaren Fehlern abrupt abzubrechen – stille Kurzschlüsse, die teilweise Fehler verbergen, sind schlimmer als ein zusätzlicher, günstiger Modellaufruf, der dem Benutzer die Unsicherheit erklärt.

    Schicken Sie das Hilfsprogramm zusammen mit dem Artikel, damit Leser die Randfälle auf ihrer eigenen Infrastruktur selbst überprüfen können, bevor sie das Muster im Produktivbetrieb einsetzen.