Startseite / Artikel / Erstellung eines ReAct-Forschungs-Agenten in LangGraph: Gehirn, Hände, Router

Erstellung eines ReAct-Forschungs-Agenten in LangGraph: Gehirn, Hände, Router

Erfahren Sie, wie Sie den ReAct-Reason-Act-Observe-Zyklus als LangGraph-Subgraph umsetzen können, inklusive erzwungener Reflexion, Iterationsbudgets sowie paralleler Scatter-Gather-Forschung.

4678 Wörter

Ein einziger Aufruf einer LLM kann keine Frage erforschen, von der sie nichts weiß. Ein nützlicher Forschungsagent muss suchen, das Ergebnis lesen, entscheiden, was noch fehlt, und erneut suchen, bis er genug Informationen hat, um zu antworten. Das ReAct-Muster gibt diesem Verhalten eine präzise Struktur, und LangGraph ermöglicht es, dieses Verhalten als kleinen, expliziten Graphen statt als ein Gewirr aus while-Schleifen darzustellen. Am Ende dieses Übungsbeispiels werden Sie jeden Knoten eines funktionsfähigen ReAct-Forschungs-Subgraphs verstehen, wissen, wo er versagen kann, und über eine Liste von Verbesserungen verfügen, die einen sicheren Betrieb in der Produktion gewährleisten.

Die Gestaltung folgt dem Forschungsmodul eines Open-Source-Projekts namens deep-research-agent. Auf höherer Ebene sieht ein einzelner Ablauf so aus:

  1. Eine Forschungsfrage kommt vom Benutzer.
  2. Die LLM, die als „Gehirn“ fungiert, überlegt sich die Frage und wählt ein Tool aus, das aufgerufen werden soll.
  • Ein Tool-Executor, die „Hände“, führt das Tool aus und gibt eine Beobachtung zurück.
  • Ein Router prüft die neueste Nachricht des Gehirns, um festzustellen, ob weitere Tool-Aufrufe angefordert wurden.
  • Falls ja, geht die Steuerung zurück zu Schritt 2.
  • Falls nein, wird die angesammelte Forschung in eine endgültige Antwort komprimiert.
  • Was das ReAct-Muster tatsächlich ist

    ReAct steht für „Reason and Act“. Es stammt aus der Forschungsarbeit „ReAct: Synergizing Reasoning and Acting in Language Models“ (erstmals veröffentlicht im Jahr 2022 und auf dem ICLR 2023 vorgestellt). Die zentrale Erkenntnis ist, dass Sprachmodelle besser abschneiden, wenn sie zwei Arten von Schritten abwechseln: darüber nachzudenken, was als Nächstes getan werden soll, und ein Tool zu nutzen, um echte Informationen zu erhalten. Jeder dieser Schritte allein führt zum Scheitern. Ein Modell, das nur rationalisiert, wird fälschlicherweise Fakten erfinden, da es keine Grundlage dafür hat. Ein Modell, das nur handelt, wird Tools mechanisch aufrufen, ohne die zurückgegebenen Ergebnisse zu interpretieren.

    Die Architekturrichtlinien von Google Cloud beschreiben dieses Muster als einen Zyklus aus natursprachlichen Schritten, der so lange weiterläuft, bis eine Abbruchbedingung erfüllt ist. In der Praxis lässt es sich in drei wiederkehrende Phasen unterteilen:

    1. Gedanke. Das Modell betrachtet alles, was bisher gesammelt wurde, und entscheidet, ob die Anfrage bereits beantwortet wurde oder was als Nächstes zu tun ist.
    2. Aktion. Auf der Grundlage dieser Überlegung ruft es entweder ein Tool auf, um weitere Daten zu sammeln, oder schreibt eine endgültige Antwort, wodurch der Kreislauf beendet wird.
    3. Beobachtung. Die Ausgabe des Tools kommt zurück und wird im Gespräch gespeichert. Da frühere Beobachtungen weiterhin sichtbar sind, kann das Modell darauf aufbauen, anstatt Suchvorgänge zu wiederholen oder den Kontext zu vergessen.

    Dies entspricht in etwa der Vorgehensweise eines erfahrenen Ingenieurs bei der Untersuchung eines unbekannten Problems: Er sucht nach Informationen, denkt darüber nach, recherchiert weiter und schreibt erst danach das Fazit. Die Dokumentation zur Tool-Nutzung bei Anthropic beschreibt denselben Mechanismus aus Sicht der API: Das Modell sendet einen Antrag zur Tool-Nutzung, Ihre Anwendung führt diesen aus und sendet das Ergebnis zurück, wobei dieser Austausch sich wiederholt. Der Kreislauf ist unabhängig davon identisch, ob das zugrunde liegende Modell Claude, GPT oder Gemini ist.

    Das ist der wichtige Punkt, den man im Gedächtnis behalten sollte: ReAct ist ein Muster und keine Funktion einer Bibliothek. LangGraph bietet zufällig eine übersichtliche Möglichkeit, es mit einem StateGraph darzustellen, doch denselben Mechanismus kann man mit jedem Modell und jedem Orchestrierungskodex umsetzen. Das Projekt deep-research-agent packt es als LangGraph-Subgraph zusammen, wodurch es komponierbar wird – ein größeres System kann es als Einheit aufrufen. Wenn Sie vor dem Eintauchen in den Code eine konzeptionelle Erinnerung wünschen, lesen Sie unseren Überblick zu wie KI-Agenten Denken mit Handlungen in der realen Welt verbinden.

    Die drei Komponenten und der gemeinsame Zustand

    Der Forscher-Loop besteht aus drei kleinen Funktionen, von jeder mit einer bestimmten Aufgabe. Der „Verstand“ (llm_call) liest das aktuelle Gespräch und antwortet entweder mit reinem Text oder mit Anfragen zur Nutzung von Tools. Die „Hände“ (tool_node) führen alle angeforderten Tool-Aufrufe aus und geben ihre Ergebnisse zurück. Der „Router“ (should_continue) entscheidet, ob eine weitere Runde erforderlich ist. Durch die Trennung dieser Aufgaben kann man jede einzelne funktionsbasiert testen, das Modell oder die Tools unabhängig voneinander austauschen und den Loop durch das Lesen von drei kurzen Funktionen verstehen.

    Definierung des Forscher-Zustands

    Alles, was durch ein LangGraph-Graph fließt, befindet sich in einem State-Objekt, das als TypedDict deklariert ist. Der Researcher-State hat fünf Felder. researcher_messages enthält das laufende Gespräch; es ist in Annotated mit dem Reducer add_messages eingebettet, der LangGraph anweist, neue Nachrichten in die vorhandene Liste einzufügen anstatt sie zu überschreiben. tool_call_iterations zählt die Schleifenzyklen, research_topic dokumentiert, was der Agent untersucht, compressed_research erhält die endgültige Zusammenfassung, und raw_notes sammelt Notizen mithilfe des Reducers operator.add, sodass Listen, die von verschiedenen Knoten zurückgegeben werden, miteinander verbunden werden.

    # Define the state that flows through the entire ReAct loop
    from typing import Annotated, Sequence, List, TypedDict
    from langgraph.graph.message import add_messages
    from langchain_core.messages import BaseMessage
    import operator
    
    class ResearcherState(TypedDict):
        # The message history accumulates as the loop runs
        researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
        # Tracks how many tool call iterations have occurred
        tool_call_iterations: int
        # The topic this researcher is investigating
        research_topic: str
        # The final compressed output after the loop ends
        compressed_research: str
        # Raw notes collected during research
        raw_notes: Annotated[List[str], operator.add]
    

    Die Reduzierer sorgen dafür, dass der Loop funktioniert. Jedes Mal, wenn das „Gehirn“ spricht oder die Hände Ergebnisse zurückgeben, gibt ein Knoten nur die neuen Nachrichten aus, und der Reduzierer fügt sie hinzu. Ohne add_messages würde jeder Knoten die Historie ersetzen, und das Modell würde alles verlieren, was es in früheren Iterationen gelernt hat.

    Das Projekt deklariert außerdem ein engeres Ausgabeschema. Es bestimmt, welche Felder den Untergraphen verlassen, wenn ein Elterngraph ihn aufruft.

    # Output schema controls what the parent graph sees
    class ResearcherOutputState(TypedDict):
        compressed_research: str
        raw_notes: Annotated[List[str], operator.add]
        researcher_messages: Annotated[Sequence[BaseMessage], add_messages]
    

    Die Trennung des internen Zustands vom Ausgabezustand ist eine gute Praxis in LangGraph. Aufzeichnungen wie tool_call_iterations sind nur innerhalb des Loops relevant, sodass der Elterngraph sie niemals sieht. Der Elterngraph erhält die komprimierten Forschungsergebnisse, die Rohnotizen sowie die Nachrichten – dadurch bleibt die Schnittstelle zwischen den Graphen übersichtlich und gezielt gestaltet.

    Warum ein TypedDict statt eines gewöhnlichen Wörterbuchs? Es dokumentiert genau, welche Daten durch das Graphen fließen, und ermöglicht es Typprüfern, falsch geschriebene Schlüssel zu erkennen. Der Reducer add_messages fügt zusätzliches Verhalten hinzu: Er fügt neue Nachrichten hinzu, und wenn eine eingehende Nachricht die ID einer bereits in der Liste vorhandenen Nachricht enthält, ersetzt er diese anstelle davon zu duplizieren, sodass Reihenfolge und Identität konstant bleiben.

    Das Gehirn: llm_call

    Das Gehirn ist der Ort, an dem das Denken stattfindet. Es erstellt einen Prompt aus einer Systemnachricht zusammen mit der gesamten Nachrichtengeschichte und fragt das Modell, was als Nächstes zu tun ist. Beachten Sie, dass die Quelle diesen Codeausschnitt als JavaScript kennzeichnet; es handelt sich dabei um Python.

    # The "brain" of the researcher: analyzes current state and decides next action
    from langchain_core.messages import SystemMessage
    
    def llm_call(state: ResearcherState):
        # Invoke the LLM with the system prompt and full conversation history
        return {
            "researcher_messages": [
                model_with_tools.invoke(
                    [SystemMessage(content=research_agent_prompt.format(date=get_today_str()))]
                    + state["researcher_messages"]
                )
            ]
        }
    

    Drei Dinge geschehen hier. Erstens wird eine SystemMessage aus research_agent_prompt erstellt, wobei das heutige Datum über get_today_str() eingefügt wird, damit das Modell beurteilen kann, wie aktuell seine Informationen sind. Zweitens wird diese Systemnachricht vor alles in state["researcher_messages"] eingefügt, sodass das Modell stets den vollständigen Kontext sieht. Drittens sendet model_with_tools.invoke() alles an ein Modell, dem Tools zugeordnet sind. Die Antwort ist entweder reiner Text, was darauf hindeutet, dass die Forschung abgeschlossen ist, oder eine oder mehrere Anfragen zur Toolnutzung, was darauf hindeutet, dass weitere Informationen benötigt werden. Die Funktion gibt die Antwort in Form einer Liste unter researcher_messages zurück, und der Reducer fügt sie hinzu.

    Der Systemprompt wird bei jedem Aufruf neu erstellt und nicht im Zustand gespeichert. Dadurch bleibt die Historie sauber und es ist gewährleistet, dass die Anweisungen stets Vorrang haben, selbst nach vielen Iterationen.

    model_with_tools wird während der Einrichtung erstellt. Anstelle einer Klasse des Anbieters wie ChatOpenAI zu importieren, verwendet das Projekt LangChains init_chat_model(), welcher einen mit dem Provider präfixierten Modellstring entgegennimmt.

    # Initialize the model using LangChain's provider-agnostic helper
    from langchain.chat_models import init_chat_model
    
    # The project uses different models for different tasks
    model = init_chat_model(model="openai:gpt-4o")
    
    # Bind the research tools so the model knows what actions are available
    model_with_tools = model.bind_tools([tavily_search, think_tool])
    

    Der Vorteil des Provider-Prefixes besteht darin, dass der Wechsel von "openai:gpt-4o" auf ein Anthropic-Modell (eine Zeichenkette im Format "anthropic:claude-sonnet-4-20250514") eine Konfigurationsänderung und keine Importänderung darstellt. Überprüfen Sie die aktuelle Modellliste Ihres Providers, da sich Identifikatoren im Laufe der Zeit ändern können. .bind_tools() macht die verfügbaren Aktionen anschließend dem Modell bekannt. Zwei Tools sind gebunden: tavily_search, das Suchanfragen über die Tavily-API durchführt, sowie think_tool, ein Reflexionswerkzeug, das weiter unten erläutert wird.

    Die „Hände“: tool_node

    Die „Hände“ übernehmen die Tool-Aufrufe aus der letzten Nachricht des „Gehirns“ und führen sie aus. Dieser Codeausschnitt ist trotz seiner JavaScript-Bezeichnung ebenfalls in Python geschrieben.

    # The "hands" of the researcher: executes all tool calls from the brain
    from langchain_core.messages import ToolMessage
    
    def tool_node(state: ResearcherState):
        # Get the tool calls from the last message (the brain's output)
        tool_calls = state["researcher_messages"][-1].tool_calls
        observations = []
    
        # Execute each tool call and collect raw results
        for tool_call in tool_calls:
            tool = tools_by_name[tool_call["name"]]
            observations.append(tool.invoke(tool_call["args"]))
    
        # Convert raw results into properly formatted ToolMessage objects
        tool_outputs = [
            ToolMessage(
                content=str(observation),
                name=tool_call["name"],
                tool_call_id=tool_call["id"]
            )
            for observation, tool_call in zip(observations, tool_calls)
        ]
    
        return {"researcher_messages": tool_outputs}
    

    Die Funktion liest die tool_calls aus der neuesten Nachricht, sucht nach jedem angeforderten Tool anhand des Namens in dem Dictionary tools_by_name und ruft es mit den vom Modell bereitgestellten Argumenten auf. Der entscheidende Teil für die Korrektheit ist der zweite Schritt: Jedes Rohergebnis wird in eine ToolMessage mit drei Feldern eingebettet. content enthält das als String umgewandelte Ergebnis, name gibt an, welches Tool es erzeugt hat, und tool_call_id verknüpft das Ergebnis mit der genaueren Anfrage, die es ausgelöst hat. Modelle-APIs erfordern diese ID; ein Tool-Ergebnis, das nicht mit einer Anfrage abgeglichen werden kann, wird abgelehnt, und eine Anfrage ohne passendes Ergebnis lässt das Gespräch in einem ungültigen Zustand zurück.

    Die Zuordnung tools_by_name wird verwendet, ist aber im Projekt-Notebook nie definiert. Man müsste sie selbst erstellen, beispielsweise als {"tavily_search": tavily_search, "think_tool": think_tool}, oder mithilfe einer Dictionary-Comprehension über die Liste der Tools, damit die Namen stets mit dem Gebundenen übereinstimmen.

    Falls das „Gehirn“ tavily_search bittet, nach „neuesten KI-Forschungen“ zu suchen, führen die „Hände“ die Abfrage aus und geben eine Nachricht in dieser Form zurück:

    ToolMessage(content="Search results for 'latest AI research': ...", name="tavily_search", tool_call_id="call_abc123")
    

    Weil die Aufrufe der Tools in einer einfachen for-Schleife sequenziell ausgeführt werden, dauert ein Vorgang mit mehreren Suchanfragen genauso lange wie alle zusammen. Das ist für eine Demo in Ordnung; später werden wir uns ansehen, wie man diesen Knoten robuster gestalten kann.

    Der Router: should_continue

    Der Router ist die kleinste Funktion und diejenige, die den gesamten Kreislauf steuert. Er betrachtet die letzte Nachricht und wählt den nächsten Knoten aus.

    # The "router": determines whether to loop again or finish
    from typing import Literal
    
    def should_continue(state: ResearcherState) -> Literal["tool_node", "compress_research"]:
        # Check the last message in the conversation
        messages = state["researcher_messages"]
        last_message = messages[-1]
    
        # If the brain requested tool calls, continue the loop
        if last_message.tool_calls:
            return "tool_node"
    
        # If no tool calls, the brain is done researching
        return "compress_research"
    

    Falls die neueste Nachricht des „Gehirns“ tool_calls enthält, gibt der Router "tool_node" zurück und der Kreislauf setzt sich fort. Wenn das „Gehirn“ nur Text erzeugt hat, gibt er "compress_research" zurück, wodurch der Kreislauf beendet wird und man zu einem Zusammenfassungsschritt übergeht. Die Entscheidung basiert ausschließlich auf der Ausgabe des Modells; der Router selbst hat keine Meinung darüber, ob die Forschung gut genug ist.

    Die Rückgabeanmerkung Literal["tool_node", "compress_research"] teilt LangGraph mit, welche Ziele möglich sind. LangGraph verwendet diese Informationen, um die Zweige des Routers zu kennen (zum Beispiel beim Zeichnen des Graphen oder wenn keine explizite Zuordnung bereitgestellt wird); daher geht es dabei um mehr als nur Dokumentation. Es hindert die Funktion jedoch nicht daran, zur Laufzeit einen anderen String zurückzugeben; dies würde als Fehler sichtbar werden, wenn dieser Zweig gewählt wird.

    Warum auf einen Komprimierungs-Schritt weitergeleitet werden anstatt direkt zu Ende zu gehen? Weil dieser Untergraph dazu konzipiert ist, von einem Überwachungsagenten aufgerufen zu werden. Der Überwacher benötigt eine prägnante, strukturierte Antwort – nicht einen langen Auszug aus Suchvorgängen, Überlegungen und Tool-Daten. Die Komprimierung innerhalb des Untergraphs hält den eigenen Kontext des Überwachers klein.

    Verbindung der Schleife mit einem StateGraph

    Sobald die drei Funktionen vorhanden sind, besteht der nächste Schritt darin, sie miteinander zu verbinden. Anstatt den Ablauf manuell zu programmieren, deklariert man Knoten und Kanten, wobei LangGraph den Graphen ausführt. Die Ausführung beginnt im „Gehirn“, geht über den Router und gelangt entweder zu den Händen (woraufhin sie immer wieder zum Gehirn zurückkehrt) oder verlässt den Graphen über compress_research.

    Zusammenstellen und Kompilieren des Graphen

    Auch dieser Codeausschnitt ist Python, obwohl er als JavaScript gekennzeichnet ist.

    # Build the ReAct loop as a LangGraph StateGraph
    from langgraph.graph import StateGraph, START, END
    
    # Initialize the graph with both input state and output schema
    agent_builder = StateGraph(ResearcherState, output_schema=ResearcherOutputState)
    
    # Add the three nodes to the graph
    agent_builder.add_node("llm_call", llm_call)                       # The brain
    agent_builder.add_node("tool_node", tool_node)                     # The hands
    agent_builder.add_node("compress_research", compress_research)     # The exit point
    
    # Wire the entry point: execution starts at the brain
    agent_builder.add_edge(START, "llm_call")
    
    # Wire the router: after the brain thinks, decide what to do next
    agent_builder.add_conditional_edges(
        "llm_call",
        should_continue,
        {
            "tool_node": "tool_node",
            "compress_research": "compress_research",
        },
    )
    
    # Wire the loop: after the hands act, always go back to the brain
    agent_builder.add_edge("tool_node", "llm_call")
    
    # Wire the exit: after compression, end the graph
    agent_builder.add_edge("compress_research", END)
    
    # Compile the graph into a runnable agent
    researcher_agent = agent_builder.compile()
    

    Wenn man ihn von oben nach unten liest:

    1. StateGraph(ResearcherState, output_schema=ResearcherOutputState) erstellt den Graphen mit dem vollständigen internen Zustand sowie dem eingeschränkten Ausgabeschema, das von den übergeordneten Graphen verwendet wird.
    2. Drei Knoten werden registriert: llm_call, tool_node und compress_research.
  • add_edge(START, „llm_call“) macht das Gehirn zum Eingangspunkt.
  • add_conditional_edges verbindet den Router mit der Ausgabe des Gehirns, wobei ein Dictionary jedes mögliche Rückgabewert mit einem Knoten verknüpft.
  • Eine feste Verbindung von tool_node zurück zu llm_call schließt den Kreislauf.
  • add_edge(„compress_research“, END) beendet das Graphen, sobald die Zusammenfassung geschrieben wurde.
  • .compile() wandelt die Deklaration in ein ausführbares Objekt um. Es wird researcher_agent genannt und nicht einfach agent, weil es im vollständigen Projekt ein Untergraph ist, der vom Supervisor aufgerufen wird.

    Die dem add_conditional_edges übergebene Zuordnung verdient Aufmerksamkeit. Ihre Schlüssel "tool_node" und "compress_research" müssen genau dem entsprechen, was should_continue zurückgibt, und ihre Werte müssen echte Knotennamen sein. Die Kompilierung überprüft, ob die zugeordneten Ziele existieren, sodass ein Tippfehler im Knotennamen bereits frühzeitig zum Fehler führt, anstatt erst mitten in der Ausführung. Ein Router hingegen gibt nur dann einen Fehler aus, wenn dieser bestimmte Pfad ausgeführt wird; daher sollte der Router auf beiden Pässen getestet werden. Dennoch ist diese Methode weitaus einfacher zu überprüfen als ein äquivalenter, manuell implementierter while-Schleifen-Code, bei dem ein falscher Pfad einfach falsch funktioniert.

    Visualisierung des Zyklus

    Das kompilierte Graphenmodell erzeugt folgenden Ablauf:

    START
      │
      ▼
    llm_call (Brain reasons about the query)
      │
      ├── has tool_calls? ──► tool_node (Hands execute tools)
      │                            │
      │                            └──► llm_call (Back to brain)
      │
      └── no tool_calls? ──► compress_research (Summarize and exit)
    

    Das ist der klassische ReAct-Zyklus. Gehirn und Hände können so oft abwechselnd arbeiten, wie das Modell Werkzeuge anfordert. Jeder Durchlauf fügt Beobachtungen zum Zustand hinzu, sodass jede neue Entscheidung mit mehr Informationen getroffen wird als die vorherige.

    Hinzufügen von Checkpointing

    Das bisherige Beispiel ist ein eigenständiger, im Speicher laufender Agent. Für langlaufende Prozesse fügt man zur Kompilierzeit einen Checkpointer hinzu, damit der Zustand des Graphen nach jedem Schritt gespeichert wird.

    # Production: add checkpointing for fault tolerance
    from langgraph.checkpoint.memory import MemorySaver
    
    checkpointer = MemorySaver()
    agent = agent_builder.compile(checkpointer=checkpointer)
    

    MemorySaver speichert Checkpoints im Prozessgedächtnis, was sich ideal für Entwicklung und Tests eignet, aber beim Neustart verschwindet. Für die Produktion bietet LangGraph persistente Checkpointer wie PostgresSaver und SqliteSaver, die es ermöglichen, eine unterbrochene Ausführung von dem letzten gespeicherten Schritt aus fortzusetzen und einen Verlauf jeder Zustandsänderung zu hinterlassen. Ein praktischer Aspekt: Sobald ein Graph einen Checkpointer hat, benötigt jeder invoke-Aufruf in seiner Konfiguration einen Thread-Identifikator (zum Beispiel {"configurable": {"thread_id": "..."}}), damit LangGraph weiß, welche gespeicherte Konversation geladen und aktualisiert werden soll.

    Verstärkung des Loops: Reflexion, Budgets und Parallelität

    Ein reiner ReAct-Loop weist zwei Fehlermöglichkeiten auf, die schnell auftreten. Er kann endlos weiterlaufen, ohne zu einem Ergebnis zu gelangen, wodurch Tokens und API-Kredite bei Suche nach Suche verbraucht werden. Oder er kann die Suchvorgänge in Windeseile durchführen, ohne sie wirklich zu verarbeiten, wodurch oberflächliche Ergebnisse entstehen. Das Projekt behebt beide Probleme und erweitert den Loop anschließend um drei Ergänzungen.

    Erzwungene Reflexion mit think_tool

    Die interessanteste Ergänzung ist ein Tool, das keinerlei externe Aktionen ausführt. Sein einziger Zweck besteht darin, das Modell dazu zu bringen, anzuhalten und schriftlich nachzudenken.

    # A tool that forces the agent to pause and reflect
    from langchain_core.tools import tool
    
    @tool(parse_docstring=True)
    def think_tool(reflection: str) -> str:
        """Tool for strategic reflection on research progress and decision-making.
    
        Use this tool after each search to analyze results and plan next steps
        systematically. This creates a deliberate pause in the research workflow
        for quality decision-making.
    
        Args:
            reflection: Your detailed reflection on research progress, findings,
                        gaps, and next steps.
    
        Returns:
            Confirmation that reflection was recorded for decision-making.
        """
        return f"Reflection recorded: {reflection}"
    

    think_tool erhält einen reflection-String und gibt ihn mit einer Bestätigungsangabe voran zurück. Die lange Dokumentation dient nicht als Dekoration: Mit parse_docstring=True extrahiert LangChain die Beschreibung der Funktion sowie die Beschreibungen der Argumente aus der Dokumentation, und genau diesen Text liest das Modell, wenn es zwischen den Funktionen wählt. Die Wirkung entsteht durch den Systemprompt, der dem Agenten mitteilt, think_tool nach jeder Suche aufrufen zu sollen. Dadurch wird das Modell gezwungen, mitzuteilen, was es gerade gelernt hat, was noch fehlt und was es als Nächstes vorhat.

    Ohne diesen Schritt neigen die Agenten dazu, Suchvorgänge nacheinander durchzuführen, ohne etwas dazwischen zu synthetisieren. Das Ingenieurteam von Anthropic hat eine entsprechende Beobachtung gemacht: Agenten, die ihre eigenen Ausgaben überprüfen und korrigieren können, sind zuverlässiger, denn sie erkennen Fehler, bevor diese sich verschlimmern, und können den Kurs korrigieren, wenn sie abweichen. think_tool integriert in jede Iteration eine kleine Selbstüberprüfung. Dies ist kostengünstig, da es nur die Tokens der Reflexion selbst sowie eine zusätzliche Kommunikationsrunde erfordert, und es hält das Modell an sein Ziel gebunden.

    Nehmen wir an, nach einer Suche reflektiert der Agent, dass er drei RAG-Indizierungsansätze gefunden hat, aber immer noch keine Vergleichsbenchmarks dafür besitzt. Das Tool gibt einfach diese Reflexion mit seinem Bestätigungspräfix zurück:

    Reflection recorded: The search results show three approaches to RAG indexing. I still need to find benchmarks comparing them.
    

    Diese Zeichenkette wird als ToolMessage gespeichert, sodass beim nächsten Durchlauf das System seinen eigenen Plan wieder abruft. Warum ein Tool anstelle davon, das Modell einfach dazu aufzufordern, „Schritt für Schritt zu denken“? Ein Toolaufruf ist ein diskretes, sichtbares Ereignis im Protokoll; er wird in der Nachrichtengeschichte in einem vorhersehbaren Format gespeichert, und die Anweisung kann verlangen, dass er an einem bestimmten Punkt im Loop ausgeführt wird.

    Budgetkontrollen im Systemprompt

    Die zweite Ergänzung begrenzt, wie viele Suchvorgänge der Agent durchführen darf:

    Budget rules embedded in the system prompt:
    
    - Simple queries: 2 to 3 search calls maximum
    - Complex queries: up to 5 search calls maximum
    - Always stop after 5 calls if sources are not found
    

    Diese Grenzen befinden sich im Systemprompt und nicht im Code. Dem Modell wird mitgeteilt, wie viele Suchvorgänge für eine einfache oder komplexe Anfrage angemessen sind und wann es aufgeben soll. Es handelt sich dabei um eine pragmatische Entscheidung: Keine Zähler oder zusätzliche Graphenlogik, nur Anweisungen, deren Befolgung dem Modell anvertraut wird.

    Der Kompromiss ist real. Die Richtlinien von Google Cloud weisen darauf hin, dass der iterative Ansatz im Vergleich zu einer einzigen Abfrage zu Verzögerungen führt und dass die Ergebnisse stark von der Qualität des Modells abhängen. Ein Suchbudget begrenzt diese Verzögerungen direkt, indem es die Anzahl der möglichen Durchläufe einschränkt.

    Die auf Prompts basierenden Beschränkungen sind jedoch flexibel. Ein Modell kann die Komplexität falsch einschätzen oder die Anweisung einfach ignorieren. Der State verfügt bereits über ein Feld tool_call_iterations, wodurch es einfach ist, eine feste Obergrenze hinzuzufügen, die vom Router durchgesetzt wird. Eine zuverlässige Konfiguration nutzt beides: Prompts formen das normale Verhalten, während Code eine obere Grenze garantiert. Unser Artikel zu begrenzten agierenden Schleifen für die Nutzung von LLM-Tools behandelt denselben Ansatz in TypeScript.

    Scatter-Gather mit Supervisor

    Die dritte Erweiterung behandelt den gesamten ReAct-Loop als wiederverwendbaren Arbeiter. Ein Überwachungsagent teilt eine umfassende Frage in Unterfragen auf und führt für jede davon parallel einen separaten Forschungs-Subgraphen aus.

    Supervisor receives: "Compare the economic impact of AI on healthcare vs. education"
    
    Supervisor creates two parallel research tasks:
    ├── ReAct Agent 1: Research AI impact on healthcare
    └── ReAct Agent 2: Research AI impact on education
    
    Both agents run their ReAct loops independently.
    Results are gathered and synthesized by the supervisor.
    

    Es handelt sich um das Scatter-Gather-Muster: Die Arbeit wird an unabhängige Arbeiter verteilt, anschließend wird ihre Ergebnisse gesammelt und zusammengeführt. Jeder Forscher verfügt über seinen eigenen Zustand, seine Tools sowie sein Budget, sodass die lange Suchhistorie einer Unterfrage niemals den Kontext einer anderen beeinträchtigt. Der Überwachungsagent sieht nur die komprimierten Ausgaben.

    Der Überwachungsagent selbst ist ein kleiner Graph. Anstelle einer festen for-Schleife über die Unterfragen setzt er auf den Command-Rückgabetyp von LangGraph, der es einem Knoten ermöglicht, den Zustand zu aktualisieren und im selben Schritt den nächsten Knoten zu benennen:

    Supervisor sub-graph nodes:
    ├── supervisor          (LLM decides what to do next)
    ├── supervisor_tools    (executes supervisor-level tools like ConductResearch)
    ├── red_team            (attacks draft logic to find flaws)
    └── context_pruner      (clears raw notes to manage context size)
    
    The supervisor_tools node uses Command to route dynamically:
      - If research is needed → spawns researcher sub-graphs via ConductResearch tool
      - If critique is needed → routes to red_team node
      - If context is bloated → routes to context_pruner node
      - If research is complete → routes to END
    

    Für den Supervisor ist der Forscher eine Black Box. Er ruft ein Tool namens ConductResearch auf, und dieses Tool setzt den kompilierten Forscher-Subgraphen in Gang, der den gesamten ReAct-Zyklus unabhängig ausführt. Um diesen herum befinden sich weitere spezialisierte Knoten: ein red_team-Knoten, der die Argumentation des Entwurfs angreift, um Schwachstellen zu finden, sowie ein context_pruner, der Rohnotizen löscht, wenn der Kontext zu groß wird.

    Durch die Verwendung von Command anstelle statischer Kanten erhält der Supervisor Flexibilität während des Laufs. Nach jedem Schritt kann er je nach aktuellem Zustand entscheiden, weitere Forscher zu starten, einen Entwurf zur Kritik einzusenden, den Kontext zu reduzieren oder die Arbeit abzuschließen. Der Nachteil ist, dass die Routenlogik in den Knotencode übergeht, wodurch die Struktur des Graphen allein aus den Kantenangaben weniger erkennbar ist; gute Protokollierung und Rückverfolgung werden dadurch wichtiger.

    Rückverfolgung eines vollständigen Laufs

    Um zu sehen, wie die Komponenten zusammenarbeiten, geben Sie dem Agenten eine mäßig komplexe Frage. Die Quelle bezeichnet diesen Aufruf als reinen Text; es handelt sich dabei um Python.

    # Run the agent with a research question
    result = agent.invoke({
        "researcher_messages": [
            HumanMessage(content="What are the main approaches to reducing hallucination in RAG systems?")
        ]
    })
    

    Für den eigenen Ausführungstest sind zwei kleine Korrekturen notwendig. Das zuvor kompilierte Diagramm wird researcher_agent genannt, daher sollten Sie diesen Namen verwenden (oder den in einem Checkpoint gespeicherten agent aus dem vorherigen Abschnitt). Wenn Sie die in einem Checkpoint gespeicherte Version verwenden, geben Sie wie oben beschrieben einen thread_id in der Konfiguration mit an.

    Ein veranschaulichendes Ausführungsprotokoll sieht so aus. Es handelt sich dabei um ein Log-File und nicht um Python-Code.

    --- Iteration 1 ---
    [Brain] Reasoning: I need to search for approaches to reducing RAG hallucination.
    [Brain] Tool call: search_tool(query="reducing hallucination in RAG systems approaches")
    [Hands] Executing search_tool...
    [Hands] Results: Found 5 relevant articles about RAG hallucination reduction.
    [Router] Last message has tool_calls? No (think_tool was called)
    
    --- Iteration 2 ---
    [Brain] Tool call: think_tool(reflection="The search results mention three main
    approaches: better chunking strategies, re-ranking retrieved documents, and
    adding citation verification. I should search for specific implementations.")
    [Hands] Executing think_tool...
    [Router] tool_calls present -> route to tool_node
    
    --- Iteration 3 ---
    [Brain] Tool call: search_tool(query="citation verification RAG pipeline implementation")
    [Hands] Executing search_tool...
    [Router] tool_calls present -> route to tool_node
    
    --- Iteration 4 ---
    [Brain] Tool call: think_tool(reflection="I now have solid coverage of the three
    main approaches with implementation details. I have enough information to
    provide a comprehensive answer.")
    [Hands] Executing think_tool...
    [Router] tool_calls present -> route to tool_node
    
    --- Iteration 5 ---
    [Brain] No tool calls. Generating final response.
    [Router] No tool_calls -> route to compress_research
    [Compress] Summarizing all findings into structured output.
    

    Was das Protokoll zeigt:

    1. Der Agent führte zwei Suchvorgänge sowie zwei Reflexionsaufrufe durch, was vollständig innerhalb des vom Prompt vorgegebenen Budgets liegt.
    2. Jede Reflexion fasste das Gelernte zusammen und bereitete den nächsten Suchvorgang vor – genau das soll die Regel forced-reflection bewirken.
  • Der Agent hielt von selbst an, sobald er feststellte, dass er genügend Informationen hatte: vier Aufrufe von Tools, gefolgt von einer endgültigen Textantwort.
  • Der Router schickte die Ausführung an tool_node, solange Toolaufrufe vorlagen, und an compress_research, sobald keine mehr vorhanden waren.
  • Die endgültige Ausgabe war eine komprimierte Zusammenfassung statt des unverarbeiteten Gesprächstextes.
  • Betrachten Sie die Protokollausgabe als Skizze und nicht als wörtliche Programmausgabe. Darin wird das Suchtool search_tool genannt, obwohl das tatsächlich verwendete Tool tavily_search ist. Zudem heißt es in der Zeile des Routers in der ersten Iteration, es seien keine Toolaufrufe erfolgt, obwohl eine Suche angefordert wurde – was eigentlich einen Aufruf an tool_node auslösen sollte. Das Gesamtbild, bei dem Suche und Reflexion abwechseln, bis das Modell schließlich in Klartext antwortet, ist das Wichtigste, was man sich merken sollte.

    Die Schleife in die Produktion überführen

    Das Forscher-Subgraph ist eine solide Grundlage, doch fünf Verbesserungen machen es in echten Implementierungen weitaus zuverlässiger:

    1. Setzen Sie das Budget im Code durch. Erhöhen Sie tool_call_iterations bei jedem Durchlauf und stellen Sie sicher, dass should_continue im Falle des Erreichens einer Obergrenze – unabhängig von den Anfragen des Modells – auf compress_research verweist. Dies dient als Sicherheitsnetz unter den weichen Grenzen der Anweisung.
    2. Übermitteln Sie den Fortschritt an die Benutzer. LangGraphs .astream_events() gibt Ereignisse für Knotenübergänge und Toolaufrufe aus, sodass eine Benutzeroberfläche während des Laufs der Schleife Nachrichten wie „Suche läuft...“ oder „Ergebnisse analysiert...“ anzeigt, anstelle eines Ladezeichens.
  • Aus Fehlern der Tools wiederherstellen. Heutzutage würde ein Netzwerkzeitüberschreitung oder ein Fehler bezüglich der Aufrufrate innerhalb eines Tools den Ablauf zum Abbruch bringen. Umhüllen Sie jeden Aufruf mit try/except und geben Sie eine ToolMessage zurück, die den Fehler beschreibt. Dadurch erkennt das System den Fehler und kann mit anderen Parametern erneut versuchen oder einen anderen Ansatz wählen. Die Dokumentation von Anthropic empfiehlt, Tool-Fehler auf diese Weise an das Modell zurückzumelden.
  • Lange Forschungsabläufe aufrechterhalten. Verwenden Sie PostgresSaver für Aufgaben, die mehrere Iterationen umfassen, damit ein unterbrochener Ablauf an der Stelle fortgesetzt werden kann, an der er abgebrochen wurde, und dabei jeder Schlussfolgerungs Schritt sowie jede Tool-Aufruf nachvollziehbar bleiben.
  • Fügen Sie Breakpoints ein, bei denen ein Mensch eingreifen kann. LangGraph kann an diesen Punkten pausieren und auf Zustimmung warten. Bei hochriskanten Forschungsarbeiten sollte alle N Iterationen pausiert werden, um einer Person zu zeigen, was gefunden wurde, damit sie fortfahren, den Agenten umleiten oder stoppen kann.
  • Kernpunkte

    • ReAct ist ein Kreislauf aus Denken, Handeln und Beobachten; er ist framework-unabhängig, und LangGraph macht diesen Kreislauf explizit und überprüfbar.
    • Drei speziell konzipierte Knoten (Gehirn, Hände, Router) zusammen mit einem durch einen Reducer gestützten Zustand reichen aus, um einen funktionsfähigen Forschungsagenten zu erstellen.
    • Verknüpfen Sie immer das Ergebnis eines Tools mit seinem tool_call_id, und trennen Sie den internen Zustand von dem, was der Untergraph darstellt.
    • Ein sinnloser Reflexions-Tool ist eine kostengünstige, sichtbare Möglichkeit, Synthesen zwischen Suchvorgängen zu erzwingen.
    • Prompt-Budgets prägen das Verhalten, doch nur eine Obergrenze auf Codeebene garantiert die Beendigung.
  • Sobald der Loop zu einem kompilierten Untergraphen geworden ist, kann ein Supervisor ihn parallel ausbreiten und jeden Forscher als Black Box behandeln.
  • Verwandte Artikel