Tworzenie agenta badawczego ReAct w LangGraph: Mózg, ręce, router
Dowiedz się, jak zaimplementować pętlę ReAct (rozumowanie-działanie-obserwacja) jako podgraf LangGraph, z przymusowym refleksją, ograniczeniami iteracji oraz równoległymi metodami badawczymi typu scatter-gather.
Jedna wywołanie LLM nie może zbadać pytania, o którym nic nie wie. Praktyczny agent badawczy musi przeszukiwać informacje, czytać to, co otrzymał, określić, czego mu brakuje, i szukać ponownie, aż będzie miał wystarczająco dużo danych do udzielenia odpowiedzi. Wzorzec ReAct nadaje temu zachowaniu precyzyjną strukturę, a LangGraph umożliwia jego przedstawienie w postaci małego, wyraźnego grafu zamiast plątaniny pętli while. Po zakończeniu tego przewodnika będziesz rozumiał każdy węzeł działającego podgrafu badawczego ReAct, wiedział, gdzie może dojść do błędu, oraz miał listę ulepszeń zapewniających bezpieczne działanie w środowisku produkcyjnym.
Jego projekt opiera się na komponencie badacza z projektu otwartego o nazwie deep-research-agent. Na wyższym poziomie jeden cykl działania wygląda następująco:
- Od użytkownika przychodzi pytanie badawcze.
- LLM, pełniące rolę „mózgu”, analizuje pytanie i wybiera narzędzie do użycia.
Czym właściwie jest wzorzec ReAct
ReAct to skrót od „Reason and Act”. Pochodzi z artykułu badawczego „ReAct: Synergizing Reasoning and Acting in Language Models” (pierwotnie opublikowanego w 2022 roku i przedstawionego na ICLR 2023). Głównym spostrzeżeniem jest to, że modele językowe radzą sobie lepiej, gdy łączą dwa rodzaje działań: rozważanie tego, co należy zrobić dalej, oraz wykorzystywanie narzędzi do uzyskania rzeczywistych informacji. Każda z tych części sama w sobie nie jest skuteczna – model, który tylko rozumuje, będzie z pewnością wymyślał fakty, ponieważ nic go nie podtrzymuje, natomiast model, który tylko działa, będzie mechanicznie korzystać z narzędzi, nie interpretując tego, co one zwracają.
Wskazówki architektoniczne Google Cloud przedstawiają ten wzorzec jako pętlę obejmującą kroki w języku naturalnym, która trwa do momentu spełnienia warunku zakończenia. W praktyce dzieli się ona na trzy powtarzające się fazy:
- Rozważanie. Model analizuje wszystko, co zostało dotychczas zebrane, i decyduje, czy prośba została już odpowiedziana, czy też co robić dalej.
- Działanie. Na podstawie tych rozważań albo wywołuje narzędzie w celu zebrania dodatkowych danych, albo pisze ostateczną odpowiedź, co zamyka pętlę.
- Obserwacja. Wynik działania narzędzia wraca i jest przechowywany w rozmowie. Ponieważ wcześniejsze obserwacje pozostają widoczne, model może na nich budować dalej, zamiast powtarzać poszukiwania lub zapominać o kontekście.
To przypomina sposób, w jaki doświadczony inżynier bada nieznany problem: szuka informacji, zastanawia się nad nim, szuka dalszych danych, a dopiero potem formułuje wnioski. Dokumentacja dotycząca używania narzędzi w Anthropic opisuje tę samą mechanikę z perspektywy API: model odpowiada żądaniem użycia narzędzia, twoja aplikacja je wykonuje i wysyła wynik, a proces ten powtarza się. Pętla jest identyczna niezależnie od tego, czy modelem jest Claude, GPT czy Gemini.
To jest kluczowy punkt, który należy pamiętać: ReAct to wzorzec, a nie funkcja biblioteki. LangGraph dostarcza wygodny sposób na jego przedstawienie za pomocą StateGraph, ale tę samą strukturę można zaimplementować przy użyciu dowolnego modelu i kodu orkiestracji. Projekt deep-research-agent pakuje go jako podgraf LangGraph, co umożliwia jego łączenie z innymi elementami – większy system może wywołać go jako jedną całość. Jeśli chcesz odświeżyć wiedzę przed przystąpieniem do kodu, zapoznaj się z naszym przeglądem sposobu, w jaki agenci AI łączą rozumowanie z działaniami w rzeczywistym świecie.
Trzy komponenty i stan, którym się dzielą
Pętla badacza składa się z trzech małych funkcji, z których każda pełni jedną rolę. Mózg (llm_call) czyta bieżącą rozmowę i odpowiada albo tekstem prostym, albo żądaniami wywołania narzędzi. Ręce (tool_node) wykonują wszystkie zapytane wywołania narzędzi i zwracają ich wyniki. Router (should_continue) decyduje, czy potrzebna jest kolejna runda. Dzielenie tych obowiązków oznacza, że można przeprowadzać testy jednostkowe dla każdej z nich, niezależnie zmieniać model lub narzędzia oraz łatwiej zrozumieć funkcjonowanie pętli, analizując trzy krótkie funkcje.
Definicja stanu badacza
Wszystko, co przepływa przez graf LangGraph, znajduje się w obiekcie stanu, zdefiniowanym jako TypedDict. Stan badacza składa się z pięciu pól. researcher_messages przechowuje bieżącą rozmowę; jest otoczony przez Annotated wraz z reduktorem add_messages, który instruuje LangGraph do łączenia nowych wiadomości z istniejącą listą zamiast ją nadpisywania. tool_call_iterations liczy cykle pętli, research_topic rejestruje temat badania agenta, compressed_research otrzymuje końcowy streszczenie, a raw_notes zbiera notatki przy użyciu reduktora operator.add, dzięki czemu listy zwracane przez różne węzły są łączone.
# 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]
To właśnie reduktory sprawiają, że pętla funkcjonuje. Za każdym razem, gdy „mózg” wysyła komunikaty lub ręce zwracają wyniki, węzeł zwraca jedynie nowe wiadomości, a reduktor je dodaje. Bez funkcji add_messages każdy węzeł zastąpiłby historię, a model straciłby wszystko, czego nauczył się we wcześniejszych iteracjach.
Projekt deklaruje również węższy schemat wyjściowy. Określa on, które pola opuszczają podgraf, gdy graf nadrzędny do niego wzywa.
# 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]
Rozdzielenie stanu wewnętrznego od stanu wyjściowego to dobra praktyka w LangGraph. Rejestrowanie informacji takich jak tool_call_iterations ma znaczenie tylko wewnątrz pętli, więc graf nadrzędny nigdy ich nie widzi. Graf nadrzędny otrzymuje skompresowane wyniki badań, surowe notatki oraz komunikaty, co sprawia, że interfejs między grafami jest prosty i celowy.
Dlaczego TypedDict zamiast zwykłego słownika? Dokumentuje dokładnie, jakie dane przepływają przez graf, i pozwala narzędziom sprawdzania typów wykrywać błędnie napisane klucze. Reduktor add_messages dodaje dodatkową funkcjonalność: dołącza nowe wiadomości, a gdy przychodząca wiadomość zawiera identyfikator już istniejącej w liście, zastępuje tę wiadomość zamiast ją duplikować, dzięki czemu kolejność i tożsamość pozostają spójne.
Mózg: llm_call
Mózg to miejsce, gdzie odbywa się rozumowanie. Tworzy prompt na podstawie wiadomości systemowej oraz całej historii wiadomości i pyta model, co ma robić dalej. Należy zauważyć, że źródło oznacza ten fragment jako JavaScript; w rzeczywistości jest to 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"]
)
]
}
Tutaj dzieje się trzy rzeczy. Po pierwsze, z research_agent_prompt tworzony jest obiekt SystemMessage, do którego za pomocą get_today_str() dodawana jest data dzisiejsza, aby model mógł ocenić, na jak aktualne są jego informacje. Po drugie, ten obiekt systemowy jest dodawany na początek wszystkich elementów w state["researcher_messages"], dzięki czemu model zawsze ma dostęp do pełnego kontekstu. Po trzecie, model_with_tools.invoke() wysyła to wszystko do modelu wyposażonego w narzędzia. Odpowiedź może być albo zwykłym tekstem, co oznacza zakończenie badań, albo jedną lub więcej prośbami o użycie narzędzi, co wskazuje na potrzebę dodatkowych informacji. Funkcja zwraca odpowiedź umieszczoną w liście pod kluczem researcher_messages, a funkcja reducer dodaje ją do listy.
Wskazówka systemowa jest tworzona na nowo przy każdym wezwaniu, zamiast być przechowywana w stanie. Dzięki temu historia pozostaje czysta, a instrukcje zawsze są na pierwszym miejscu, nawet po wielu iteracjach.
model_with_tools jest tworzony podczas konfiguracji. Zamiast importować klasę dostawcy taką jak ChatOpenAI, projekt wykorzystuje funkcję init_chat_model() z LangChain, która przyjmuje ciąg znaków reprezentujący model z prefiksem dostawcy.
# 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])
Przedrostek dostawcy stanowi zaletę: przejście z "openai:gpt-4o" na model Anthropic (ciąg znaków w formie "anthropic:claude-sonnet-4-20250514") to zmiana konfiguracji, a nie zmiana importu. Sprawdź aktualną listę modeli swojego dostawcy, ponieważ identyfikatory zmieniają się z czasem. Funkcja .bind_tools() następnie informuje model o dostępnych działaniach. Związane są dwa narzędzia: tavily_search, które przeprowadza wyszukiwania w internecie za pośrednictwem API Tavily, oraz think_tool, narzędzie do refleksji opisane poniżej.
Narzędzia: tool_node
Narzędzia przetwarzają żądania pochodzące z ostatniej wiadomości od „mózgu” i je wykonywają. Ten fragment kodu jest również napisany w Pythonie, mimo etykiety JavaScript.
# 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}
Funkcja odczytuje tool_calls z najnowszej wiadomości, wyszukuje każde żądane narzędzie pod względem nazwy w słowniku tools_by_name i wywołuje je za pomocą argumentów dostarczonych przez model. To druga część ma kluczowe znaczenie dla poprawności: każdy surowy wynik jest umieszczany w obiektu ToolMessage zawierającym trzy pola. content przechowuje wynik w formie ciągu znaków, name odnotowuje, które narzędzie go wygenerowało, a tool_call_id łączy wynik z dokładnym żądaniem, które go spowodowało. API modeli wymagają tego ID; wynik narzędzia, który nie może zostać powiązany z żadnym żądaniem, jest odrzucany, a żądanie bez odpowiadającego mu wyniku pozostawia rozmowę w niewalidowanym stanie.
Mapowanie tools_by_name jest używane, ale nigdy nie zostało zdefiniowane w notatniku projektu. Musiałbyś je stworzyć sam, na przykład jako {"tavily_search": tavily_search, "think_tool": think_tool}, lub za pomocą wyrażenia słownikowego przetwarzającego listę narzędzi, aby nazwy zawsze były zgodne z tym, co zostało zdefiniowane.
Gdy „mózg” prosi funkcję tavily_search o wyszukanie „najnowszych badań w dziedzinie AI”, „ręce” wykonują zapytanie i zwracają komunikat w takim kształcie:
ToolMessage(content="Search results for 'latest AI research': ...", name="tavily_search", tool_call_id="call_abc123")
Ponieważ wywołania narzędzi są wykonywane sekwencyjnie w zwykłym pętli for, proces obejmujący kilka wyszukań trwa tyle samo, co suma czasu wszystkich z nich razem. Jest to akceptowalne w demonstracji; później przyjrzymy się sposobom na uczynienie tego węzła bardziej odpornym.
Router: should_continue
Router pełni najprostszą funkcję i zarządza całym pętlem. Analizuje ostatnią wiadomość i wybiera następny węzeł.
# 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"
Jeśli najnowsza wiadomość od mózgu zawiera tool_calls, router zwraca "tool_node" i pętla kontynuuje się. Jeśli mózg wygenerował jedynie tekst, router zwraca "compress_research", co powoduje opuszczenie pętli i przejście do kroku podsumowywania. Decyzja opiera się wyłącznie na wyniku modelu; sam router nie ma opinii co do tego, czy badanie jest wystarczająco dobre.
Literal["tool_node", "compress_research"] – adnotacja zwracana przez funkcję informuje LangGraph o możliwych destynacjach. LangGraph wykorzystuje ją do poznania gałęzi routera (na przykład podczas rysowania grafu lub gdy nie zostanie podana wyraźna mapa), więc stanowi coś więcej niż zwykłą dokumentację. Nie powstrzymuje jednak funkcji przed zwróceniem innej ciągu znaków w czasie wykonywania – takie zachowanie objawiłoby się jako błąd przy wyborze danej gałęzi.
Dlaczego kierować się na etap kompresji zamiast kończyć proces od razu? Ponieważ ten podgraf został zaprojektowany tak, aby być wywoływany przez agenta nadzorczego. Nadzorca potrzebuje zwięzłej, uporządkowanej odpowiedzi, a nie długiego opisu poszukiwań, refleksji oraz danych przekazywanych przez narzędzia. Kompresja wewnątrz podgrafu pomaga utrzymać mały rozmiar kontekstu samego nadzorcy.
Łączenie pętli za pomocą StateGraph
Gdy trzy funkcje są już dostępne, następnym krokiem jest ich połączenie. Zamiast ręcznie opisywać przepływ sterowania, deklaruje się węzły i krawędzie, a LangGraph uruchamia graf. Wykonanie zaczyna się w „mózgu”, przechodzi przez router i albo trafia do rąk (po czym zawsze wraca do „mózgu”), albo wychodzi przez compress_research.
Kompletowanie i kompilowanie grafu
To fragment również jest napisany w Pythonie, mimo etykiety JavaScript.
# 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()
Czytając od góry do dołu:
StateGraph(ResearcherState, output_schema=ResearcherOutputState)tworzy graf z pełnym stanem wewnętrznym oraz ograniczonym schematem wyjściowym, który będą widzieć grafy nadrzędne.- Zarejestrowano trzy węzły:
llm_call,tool_nodeicompress_research.
add_edge(START, „llm_call”) sprawia, że mózg staje się punktem wejścia.add_conditional_edges łączy router z wyjściem mózgu, przy użyciu słownika mapującego każdą możliwą wartość zwracaną na odpowiedni węzeł.tool_node z powrotem do llm_call zamknie pętlę.add_edge(„compress_research”, END) zakończa graf, gdy tylko zostanie napisany streszczenie..compile() przekształca deklarację w obiekt nadający się do uruchomienia. Nazywa się on researcher_agent, a nie po prostu agent, ponieważ w pełnym projekcie jest to podgraf wywoływany przez nadzorcę.
Należy zwrócić uwagę na mapowanie przekazywane do add_conditional_edges. Jego klucze, "tool_node" i "compress_research", muszą dokładnie odpowiadać wartościom zwracanym przez should_continue, a ich wartości muszą być rzeczywistymi nazwami węzłów. Proces kompilacji sprawdza, czy zmapowane destynacje istnieją, dzięki czemu błąd w nazwie węzła zostaje wykryty wcześnie, a nie w połowie wykonywania programu. Z kolei router, który zwraca wartość nieobecną w mapie, powoduje błąd dopiero w momencie wykonywania danej gałęzi, dlatego należy przetestować router w obu gałęziach. Mimo to jest to o wiele łatwiejsze do weryfikacji niż odpowiednia ręcznie napisana pętla while, w której błędna gałąź po prostu zachowuje się niewłaściwie.
Wizualizacja cyklu
Kompileowany graf tworzy następujący przepływ:
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)
To jest klasyczny cykl ReAct. Mózg i ręce mogą się naprzemiennie używać tyle razy, ile model będzie żądał narzędzi. Każdy etap dodaje obserwacje do stanu, dzięki czemu każda nowa decyzja jest podejmowana na podstawie większej ilości informacji niż poprzednia.
Dodawanie mechanizmu checkpointingu
Przykład dotychczasowy to samodzielny agent działający w pamięci. W przypadku zadań trwających dłużej dodaje się mechanizm checkpointingu w czasie kompilacji, aby stan grafu był zapisywany po każdym kroku.
# Production: add checkpointing for fault tolerance
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
agent = agent_builder.compile(checkpointer=checkpointer)
MemorySaver przechowuje punkty kontrolne w pamięci procesu, co jest idealne do celów rozwojowych i testowania, ale znika po ponownym uruchomieniu. Do zastosowań produkcyjnych LangGraph oferuje trwałe narzędzia do przechowywania punktów kontrolnych, takie jak PostgresSaver i SqliteSaver, które umożliwiają wznowienie przerwanego procesu od ostatniego zapisanego kroku oraz zachowywanie informacji o każdej zmianie stanu. Praktyczny szczegół: gdy graf posiada narzędzie do przechowywania punktów kontrolnych, każda funkcja invoke wymaga w swojej konfiguracji identyfikatora wątku (na przykład {"configurable": {"thread_id": "..."}}), aby LangGraph wiedział, który zapisany stan należy załadować i zaktualizować.
Zmocnienie pętli: refleksja, budżety i paralelizm
Prosty cykl ReAct ma dwa szybko występujące modele awarii. Może się pętlić bez osiągania konwergencji, marnując tokeny i kredyty API na kolejne wyszukiwania. Albo może szybko przeprowadzać wyszukiwania, nie analizując ich naprawdę, co skutkuje powierzchownymi wynikami. Projekt rozwiązuje oba te problemy, a następnie rozbudowuje cykl o trzy dodatkowe elementy.
Zmuszona refleksja za pomocą think_tool
Najciekawszym dodatkiem jest narzędzie, które w ogóle nie wykonuje żadnych działań zewnętrznych. Jego jedynym celem jest sprawienie, by model zatrzymał się i pomyślał na piśmie.
# 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 otrzymuje ciąg znaków reflection i zwraca go z prefiksem potwierdzenia. Długi docstring nie pełni funkcji dekoracyjnej: przy użyciu parametru parse_docstring=True LangChain wyodrębnia z docstringu opis narzędzia oraz opisy argumentów, a to właśnie ten tekst jest czytany przez model podczas wyboru narzędzia. Efekt ten wynika z promptu systemowego, który każe agencie wywoływać think_tool po każdym wyszukiwaniu. To zmusza model do określenia tego, czego właśnie się nauczył, czego wciąż brakuje oraz co planuje zrobić dalej.
Bez tego kroku agenci mają tendencję do prowadzenia serii poszukiwań bez żadnej syntezy pomiędzy nimi. Zespół inżynieryjny Anthropic dokonał powiązanego spostrzeżenia: agenci, którzy potrafią przejrzeć i poprawić własne wyniki, są bardziej niezawodni, ponieważ wykrywają błędy zanim się one nasilą i mogą skorygować kurs, gdy zbaczają z drogi. think_tool włącza małe narzędzie samoprzeglądu do każdej iteracji. Jest tanie, ponieważ kosztuje jedynie tokeny samego refleksji plus jeden dodatkowy obieg, a także pomaga utrzymać model przy jego celu.
Załóżmy, że po poszukiwaniach agent stwierdza, iż znalazł trzy podejścia do indeksowania typu RAG, ale nadal brakuje mu benchmarków porównujących je ze sobą. Narzędzie po prostu odbija to stwierdzenie z prefiksem potwierdzenia:
Reflection recorded: The search results show three approaches to RAG indexing. I still need to find benchmarks comparing them.
Ta strona jest przechowywana jako ToolMessage, więc przy następnej iteracji system odczytuje swój własny plan. Dlaczego używać narzędzia zamiast po prostu poprosić model o „myślenie krok po kroku”? Wywołanie narzędzia to odrębne, widoczne zdarzenie w śledzeniu działania systemu; trafia ono do historii wiadomości w przewidywalnym formacie, a prompt może wymagać jego użycia w określonym momencie pętli.
Kontrola budżetu w promptzie systemowym
Druga modyfikacja określa maksymalną liczbę wyszukiwań, jakie może przeprowadzić agent:
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
Te ograniczenia znajdują się w promptzie systemowym, a nie w kodzie. Model otrzymuje informacje o tym, ile wyszukiwań jest wystarczających dla prostej lub złożonej zapytania oraz kiedy należy przestać. To rozwiązanie praktyczne: brak liczników ani dodatkowej logiki graficznej, tylko instrukcje, których realizacji model ma zaufanie.
Kompromis jest rzeczywisty. Wytyczne Google Cloud wskazują, że styl iteracyjny powoduje opóźnienia w porównaniu z pojedynczym zapytaniem, a wyniki w dużej mierze zależą od jakości modelu. Budżet wyszukiwania bezpośrednio ogranicza to opóźnienie, limitując liczbę możliwych rund.
Jednak ograniczenia oparte na promptach są elastyczne. Model może błędnie ocenić złożoność lub po prostu zignorować instrukcję. Stan już zawiera pole tool_call_iterations, więc łatwo jest dodać sztywne ograniczenie, które egzekwuje router. Solidna konfiguracja wykorzystuje oba podejścia: prompt kształtuje normalne zachowanie, a kod gwarantuje górny limit. Nasz artykuł na temat ograniczonych pętli agentowych do wykorzystania narzędzi LLM omawia tę samą koncepcję w TypeScript.
Scatter-gather z nadzorcą
Trzecia część traktuje cały pętlę ReAct jako wielokrotnie używalnego pracownika. Agent nadzorczy dzieli szerokie pytanie na podpytania i uruchamia osobny podgraf badacza dla każdego z nich równolegle.
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.
To jest wzorzec rozproszenia i zbierania: rozkłada się pracę między niezależnych pracowników, a następnie zbiera i łączy ich wyniki. Każdy badacz ma swój własny stan, narzędzia i budżet, więc długa historia poszukiwań jednego podpytania nigdy nie zakłóca kontekstu innego. Nadzorca widzi jedynie skompresowane wyniki.
Nadzorca sam w sobie jest małym grafem. Zamiast stałej pętli for nad podpytaniami, opiera się na typie zwracanym przez LangGraph – Command, który umożliwia węźlowi aktualizację stanu i określenie nazwy następnego węzła w jednym kroku:
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
Dla nadzorcy badacz jest czarną skrzynką. Wywołuje on narzędzie ConductResearch, a to narzędzie uruchamia skompilowany podgraf badacza, który samodzielnie wykonuje pełny cykl ReAct. Wokół niego znajdują się inne specjalistyczne węzły: węzeł red_team, który atakuje rozumowanie projektu w celu znalezienia słabości, oraz context_pruner, który usuwa surowe notatki, gdy kontekst staje się zbyt obszerny.
Użycie Command zamiast statycznych krawędzi zapewnia nadzorcy elastyczność w trakcie działania. Po każdym kroku może on zdecydować się na uruchomienie dodatkowych badaczy, wysłanie projektu do oceny, usunięcie niepotrzebnego kontekstu lub zakończenie pracy, w zależności od aktualnego stanu. Wadą jest to, że logika routingu przenosi się do kodu węzłów, więc kształt grafu nie jest tak oczywisty na podstawie samych deklaracji krawędzi; dlatego ważniejsze stają się dokładne logowanie i śledzenie procesu.
Śledzenie całego procesu
Aby zobaczyć, jak elementy współpracują ze sobą, zadaj agencie dość złożone pytanie. Źródło oznacza ten kod jako zwykły tekst; jest to 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?")
]
})
Jeśli chcesz to uruchomić samodzielnie, konieczne są dwie małe korekty. Graf skompilowany wcześniej nazywa się researcher_agent, więc użyj tego nazwy (lub zapisanej w checkpointie wersji agent z poprzedniego rozdziału). A jeśli używasz wersji z checkpointem, przekaż thread_id w konfiguracji, jak opisano powyżej.
Illustracyjny zapis wykonywania wygląda w ten sposób. Jest to log, a nie kod Pythona.
--- 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.
To, co pokazuje zapis:
- Agenca przeprowadziła dwa wyszukiwania oraz dwa wywołania typu reflection, co mieści się w limicie określonym w instrukcji.
- Każde wywołanie reflection podsumowywało to, co zostało dowiedziane, i przygotowywało kolejne wyszukiwanie – dokładnie to ma zapewnić zasada forced-reflection.
tool_node, gdy występowały wywołania narzędzi, a do compress_research, gdy ich nie było.Czytaj ten zapis jak szkic, a nie dosłowny wynik działania programu. W nim narzędzie wyszukiwania nazywane jest search_tool, chociaż w rzeczywistości jest to tavily_search, a linia dotycząca routera w pierwszej iteracji podaje, że nie doszło do żadnych wywołań narzędzi, mimo że złożono prośbę o wyszukiwanie, co faktycznie powinno skierować działanie do tool_node. Kluczowym elementem jest ogólny schemat – naprzemienna faza wyszukiwania i refleksji, aż model udzieli odpowiedzi w formie zwykłego tekstu.
Wdrożenie pętli w produkcję
Podgraf badacza stanowi solidne podstawy, ale pięć ulepszeń sprawia, że staje się znacznie bardziej niezawodny w rzeczywistych zastosowaniach:
- Wymuszanie przestrzegania budżetu w kodzie. Zwiększaj wartość
tool_call_iterationsprzy każdym przetworzeniu i spraw, abyshould_continuekierował wywołanie docompress_research, gdy osiągnięty zostanie maksymalny limit, niezależnie od żądań modelu. To właśnie stanowi zabezpieczenie w ramach miękkich ograniczeń zadania. - Przekazywanie informacji o postępach użytkownikom. Funkcja
.astream_events()w LangGraph emituje wydarzenia dotyczące przejść między węzłami oraz wywołań narzędzi, dzięki czemu interfejs użytkownika może pokazywać komunikaty takie jak „Szukam...” lub „Analizuję wyniki...”, podczas gdy bieżący jest pętla, zamiast ikony spinera.
try/except i zwróć obiekt ToolMessage opisujący błąd. W ten sposób system może zauważyć niepowodzenie i spróbować ponownie z innymi argumentami lub zmienić podejście. Dokumentacja Anthropic zaleca w ten sposób zgłaszanie błędów narzędzi modelowi.PostgresSaver do zadań wymagających wielu iteracji, aby przerwany proces mógł zostać wznowiony od miejsca przerwy, a każdy krok rozumowania oraz wywołanie narzędzia pozostały poddawalne audytowi.Główne wnioski
- ReAct to pętla myślenia, działania i obserwacji; jest niezależny od konkretnego frameworku, a LangGraph po prostu czyni tę pętlę jawną i poddającą się inspekcji.
- Trzy węzły o określonym przeznaczeniu (mózg, ręce, router) w połączeniu ze stanem zarządzanym przez reduktora wystarczają do stworzenia funkcjonalnego agenta badawczego.
- Zawsze łącz wynik każdego narzędzia z jego
tool_call_id, a także oddziel stan wewnętrzny od tego, co udostępnia podgraf. - Narzędzie refleksji typu no-op to tani i widoczny sposób na wymuszenie syntezy pomiędzy poszczególnymi wyszukiwaniami.
- Budżety promptów kształtują zachowanie, ale tylko ograniczenie na poziomie kodu gwarantuje zakończenie procesu.
Literatura pokrewna
- Tworzenie agenta AI od zera: wzorce, ReAct i LangGraph — Poznaj podstawowe koncepcje agentów AI – planowanie, używanie narzędzi, refleksja oraz wzorzec ReAct – oraz to, jak LangChain i LangGraph wpisują się w ręczne tworzenie takich agentów.
- Wybór frameworku dla agenta AI w Pythonie w 2026 roku: praktyczne porównanie — Porównuje pięć frameworków dla agentów AI w Pythonie pod kątem sposobu radzenia sobie z błędami i złożonością, pomagając czytelnikom dobrać odpowiednie narzędzie do swojego procesu pracy.
- Routing, Fan-Out, ReAct, krytyka i zatwierdzenie: pięć wzorów LangGraph — Poznaj pięć wzorów pracy agentów w LangGraph, od routerów i pętli ReAct po bramy ewaluacyjne i ludzkie zatwierdzenie, wraz z zasadami bezpieczeństwa niezbędnymi przy ich stosowaniu w produkcji.
- Wnętrze InMemorySaver w LangGraph: jak funkcjonują checkpointy, zapisy i bloki danych — Przejrzyj strukturę słowników przechowywania, zapisów i bloków danych w InMemorySaver w LangGraph oraz prześledź, jak jeden mały proces przetwarzania grafu zamienia się w trzy powiązane checkpointy.
- Specjalistyczni agenci, szybki marszrutyzator i interupcja: Coach LangGrapha — Zaprojektuj asystenta LangGrapha z dwoma specjalistami, wykorzystującego deterministyczny marszrutyzator, wspólny stan przetrzymywany podczas przenoszenia obowiązków oraz punkt kontrolny stworzony na podstawie interupcji i poleceń.
- Od botu jednog węzła do agenta w LangGraphie z podporą MCP — Buduj aplikację LangGraph warstwa po warstwie: stan i reduktory, krawędzie, pętle narzędziowe, wątki z punktami kontrolnymi, trzy tryby strumieniowania oraz narzędzia dostarczane przez MCP.