Галоўная / Артыкулы / Стварэнне даследжаўчага агента ReAct у LangGraph: Мозг, Рукі, Маршрутазыўчык

Стварэнне даследжаўчага агента ReAct у LangGraph: Мозг, Рукі, Маршрутазыўчык

Дазвольце даклэ научыцца, як рэалізаваць цыкл ReAct (разумаванне-дзеянне-спазірванне) як падграф LangGraph, з прымусовым адбіраннем рашэння, лімітамі ітерацый і паралельным методам даклэджвання і агрыявання дадзейнаў.

4678 слоў

Адзін вызов LLM не можа даследаваць пытанне, пра якое ён нічога не ведае. Корыстны агент для даследавання должен шукаць інфармацыю, чытаць тое, што ўзялося, выбіраць, што яшчэ не ўжо є, і зноў шукаць, пакуль у яго не будзе достатнько інфармацыі, каб адпаведзець. Шаблон ReAct дае такаму падходу чыстае форма, а LangGraph дазволяе выражыць яго як маленькі, чыста адзначаны граф у замене на заплутаныя ціклы while. До канца гэтага карусельнага нараджэння вы зразумеете кожны вузел працуючага падграфа даследавання ReAct, знаеце, дзе ён можа злучыцца, і будете мяць спіс пасляўпрабавак, якія робяць яго безбедным для выкарыстоўвання ў працэсе.

Дызайн следуе за компанентам даследніка з адкрытага кода проекту пад назвай deep-research-agent. На высокам рэвэле, адзін запуск выглядае так:

  1. Даследавальнае пытанне прыходзіць ад пользователя.
  2. LLM, які выступае ў ролі „мозгу“, разважае над пытаннем і выбірае інструмент для вызову.
  • Выкананнік інструмента, тое самае „раны“, запускае гэты інструмент і вяртае адпаведную інформацыю.
  • Рутэр аналізуе пасляпэўнейшыя супаказы мозга, ўбачаючы, чы не было запрошана яшчэ калька інструментам.
  • Якщо так, кантроль вяртаецца да 2-го крока.
  • Якщо ні, саставленыя рэзультаты аналізу складаюцца ў фінальны адказ.
  • Што на самай працоўцы ёсць патэрн ReAct

    ReAct — гэта скрацанне з "Reason and Act". Ён выйшаў у нараднай праце "ReAct: Synergizing Reasoning and Acting in Language Models" (першы раз опублікованай у 2022 годзе і представленай на ICLR 2023). Галоўныя вывары паказваюць, што мовныя моделі працуюць краща, калі яны выконваюць два типу дзеяння: размышляюць пра тое, што трэба зробіць далей, і выкарыстоўваюць інструменты для атрымання рэальных дадзеных. Кожны з эўх аспектаў сам по сабе не ўсёма дае рэзультат. Модель, якая толькі размышляе, будзе з упэўненасцю выдумваць факты, таму што нічога не стабілізуе ўсё гэта. Модель, якая толькі дзейнае, будзе механічна выкарыстоўваць інструменты, не адначытаючы таго, што яны вярнуць.

    Кераванне архітектурай у Google Cloud выкладае гэты патэрн як цыкл з дзеяннямі на мове, які продаваецца до таго часу, пакуль не будзе выпанаюцца умова завершэння. На практыцы ён складаецца з трох перадзяймаючыхся фаз:

    1. Мысль. Модель аналізуе ўсё, што было засобрана до гэтага, і выявляе, чый-жо ўжо дана адпаведь на запит, чым далей дзеяць.
    2. Дзеянне. На аднойчынку з гэтымі меркаваннямя ёна альбо запускае інструмент для збору дадзейнаў, альбо пішае заканчальную адпаведь, якая завершвае цыкл.
    3. Абазаванне. Рэзультаты работы інструмента вяртаюцца і захоўваныя ў размове. Паколькі паказваныя раней абазаванні застаюцца виднымі, модель можа будаваць своия выводы на іх аднойчынку, замест таго каб павтараць пошукі чы прачынаць забывчасць контэксту.

    Гэта супакоўна падобна да таго, як апытны інжынер даследжвае незнаёмую проблему: шукае інфармацыю, размышляе над ёю, шукае далейшую інфармацыю, а толькі пасля чаго пішае вывад. Документацыя Anthropic пра викорыстоўванне інструментаў описвае той самы механізм з боку API: модель адпавядае запитам на викорыстоўвання інструмента, ваша прыкладка яго выконвае і адправляе рэзультат, і гэты процес павтараецца. Цікл застаёцца тым самым, незалежна ад таго, чыі модель знаходзіцца пад спадкам — Claude, GPT чы Gemini.

    Гэта важная пункт, якій трэба прыдзеяць: ReAct — это шаблон, а не функцыя бібліятэкі. LangGraph проста дае вам прыемны спосаб выражэння яго за дапамою StateGraph, але вы можетэе рэалізаваць той жа цыкл з будзь-яму модэлю і будзь-яму кодам для оркестрацыі. Праект deep-research-agent упаковвае яго як падграф LangGraph, што робіць яго складаемым: большая система можа вызваць яго як адзін цэлы. Якщо вы хочаце павторытаць концэпцію перш чым працаваць з кодам, адзірніце нашы аптакст про тое, як AI-агенты спаўнаюць разумаванне з дзеяннямі у рэальным свете.

    Тры складнікі і стан, які ўсе яны дзеляць

    Цыкл доследніка складаецца з трохох маленькіх функцыяй, кожная з яых виконвае адзін завадак. «Мозг» (llm_call) чытае текущую розмову і адпавядае або простым тэкстам, або запросам да викорыстання інструментаў. «Рукі» (tool_node) выкананяюць усе запрошаныя виклікі інструментаў і вяртаюць ўсе яныя рэзультаты. «Маршрутызатор» (should_continue) выявляе, чы рэкламуецца наступны раунд. Раздзелэнне гэтых абавясцей значыць, што можна адзельна працаваць над тэстамі кожной з іх, незалежна меняць модель чы інструменты, а таксама разумець цыкл, чытая тры короткія функцыяй.

    Апішанне стану доследніка

    Усе, што працюе ў графе LangGraph, знаходзіцца ў об’екте стану, які выказваецца як TypedDict. Об’ект стану даследніка мае пяць характерыстыкаў. researcher_messages зберагае ход яго розмовы; ён абгортаваны ў Annotated з редюсерам add_messages, які паведамляе LangGraph з’яўляць новыя прывітанні да існуючага списку, а не заменяць яго. tool_call_iterations лічыць циклі петлі, research_topic фіксуе тое, чым займаецца агент, compressed_research прымае фінальны падсумак, а raw_notes скарабатвае прыміткі, выкорыстоўваючы як редюсер operator.add, тады списакі, якія вяртаюць разныя вузлы, з’едынаюцца.

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

    Редюсеры ўтвараюць механізм працы ціклу. Кожны раз, калі „галавной модуль“ выдае команды або рукі вяртаюць рэзультаты, кожны вузол вяртае толькі новыя паведамленні, а редюсер яны дадае. Без функцыі add_messages кожны вузел заменяў бы історыю дадзенаў, і модель втрачаў бы все, што навучылася ў пакульшых ітераціях.

    Проект таксама задае болей шчырокую структуру выходных дадзенаў. Яна кантролюе, якія поля выходзяць з падграфа, калі граф-родзіць яго вызывае.

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

    Раздзелэнне внутршняго стану ад стану выходных дадзенаў — гарная практыка ў LangGraph. Такія записы, як tool_call_iterations, маюць значэнне толькі ў цікле, таму граф-родзіць іх ніколи не бачыць. Родзіць отрымае скомпрэсаваныя рэзультаты доследжэння, неапранутыя нотаткі і паведамленні, што дапамагае залічыць інтэрфейс межу графамі простым і зрозумелым.

    Чаму TypedDict, а не звычны слоўнік? Ён точна фіксуе, якія даныя пераходзяць через граф, і дазволяе прамахоўчыкам типаў выявіць неправільна напісаныя ключы. Функцыя add_messages дадае дапаможныя можлівасці: яна даганяе новыя прыемныя паведамленні, а калі прыбуваюча паведамленне мае ID адной з уже ў списку, яна заменяе тое паведамленне заместа дублювання яго, тады порядак і ідэнтычнасць застаюцца стабільнымі.

    «Мозг»: llm_call

    «Мозг» — гэта там, дзе ведуцца мысленні. Ён стварае запит на адпаведны выхід на базе системнага паведамлення і всей історыі паведамленняў, а потым запитвае модель, што робіць далей. Заўважыце, што у выхідным кодзе гэты фрагмент пазначаны як JavaScript; на самай працэ ён напісаны на 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"]
                )
            ]
        }
    

    Ёсць тры крокі, які выканаюцца тут. Першы: з research_agent_prompt ствараецца SystemMessage, у якій через get_today_str() дадаецца сьегоднішня дата, ўсёлякім чынам даючы можлівасць модэлю ацэніць, насколькі актуальная ў яе інфармацыя. Другі: гэты системны паведамленне дадаецца на пачатак усіх элементаў у state["researcher_messages"], тады модэль завжды бачыць полны контекст. Трэці: model_with_tools.invoke() адправляе ўсё гэта модэлю, якая мае доступ да інструментаў. Адпаведзь можа быць або звычным текстам, які сигналізуе пра завершэнне даследжэння, або ж однам або калькама запытанняў пра викорыстанне інструментаў, якія сигналізуюць пра патрэбу ў дадатковай інфармацыі. Функцыя вяртае адпаведзь, якая знаходзіцца ў лісте пад ключам researcher_messages, а редукер яе дадае.

    Спрампт системы перзбудовваецца з кожнага вызову, а не запам’ятовываецца ў стане. эта дапамага падтрымваць чыстасцю історыі та гарантуе, што інструкцыі завжды будуць першымі, нават пасля багаторох ітерацый.

    model_with_tools ствараецца пад час наладкі. У змену імпорту класа від постачальніка, такога як ChatOpenAI, проект выкарыстоўвае init_chat_model() з LangChain, який прыме роштку модэлю з прыфіксам «provider».

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

    Прыямая перавага — це прыдзвоннік прадставальніка: пераход з "openai:gpt-4o" на модель Anthropic (строка у формате "anthropic:claude-sonnet-4-20250514") ўсьматрэцца як змена настройкаў, а не як змена імпорту. Пераканайцеся, што вы ведаеце актуальны список модэляў вашага прадставальніка, адтаку ўскладнення ідэнтыфікатораў змінююцца з часам. Функцыя .bind_tools() пазначае модэлю доступныя дзеяння. Прыўязаны два інструменты: tavily_search, який выканаўляе пошук у інтэрнете через API Tavily, і think_tool, інструмент для рафлексіі, пра які пісана нижэй.

    Інструменты: tool_node

    Інструменты прымоўляюць вызовы інструментаў з последняея паведамлення «галавы» і выканаюць іх. Хоць гэты фрагмент атрыбутаваны як JavaScript, на самай працэ ён таксама напісаны на Python.

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

    Функцыя чытае tool_calls з найсвежэйшага паведамлення, шукае кожны запрошаны інструмент па імени ў словніку tools_by_name і запускае яго з аргументамі, заданымі модэлем. Самае важлівая частка для правильнасці — гэта тое, што кожны неапрануты рэзультат ўкладаецца ў об’ект ToolMessage, які мае тры поля: content храніць рэзультат у формате строкі, name фіксуе, який інструмент яго створыў, а tool_call_id спаявае рэзультат з конкретным запитам, який яго выклікаў. API модэляў выкарыстоваюць гэты ID; рэзультат інструмента, які не можа быць паспраўнаны з запитам, адхіляецца, а запит без паспраўнага рэзультата заставляе размову ў невалідным стане.

    Мапаванне tools_by_name выкарыстоўваецца, але ніколі не ўзначанае ў зошыцы проекту. Яго трэба стварыць самостоятельна, напрыклад у вигляде {"tavily_search": tavily_search, "think_tool": think_tool}, або за дапамогай розумеўніка слоўніку над спісам абразоў, каб назвы завжды былі супараднаваны з тым, што ўзначана.

    Якщо „галава“ просіць tavily_search знайсці „паўнейшыя даследжэння AI“, „рукі“ выканаюць запит і вяртаюць паведамленне у такім формате:

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

    Пакалі вызовы абразоў выканаюцца па спрабах у звычнай цікле for, адзін цикл з калькама запытанняў займае столькі часу, сколькі і ўсія запытання разам. Гэта прыемна для дэманстраціі; пазней мы розглядзем, як зробіць гэты вузол болей надзеяным.

    Рутэр: should_continue

    Рутэр — это самая простая складовая частка, якая керуе цым усім циклам. Ён аналізуе пасляпэўныя запаведзі і выбирае наступную вузел.

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

    Якщо пасляпэўныя запаведзі «галавы» маюць элемент tool_calls, рутэр вяртае "tool_node" і цикл продаўжваецца. Якщо «галава» стварыла толькі текст, ён вяртае "compress_research", чым завершаецца цикл і пераходзіць да крока падсумавання. Рашэнне прыменяецца выключна на адпаведнасць з выходамі модэлю; сам рутэр не мае дзеяння ў тым, чы рэсурсы якіясь дастаткова хорашыя.

    Literal["tool_node", "compress_research"] вяртае анатамазію, яка паведамляе LangGraph, якія магчымыя направленні існуюць. LangGraph выкарыстоўвае яе, каб знайсці галузі маршрутазаводчыка (напрыклад, пад час прадраўання графа або калі не падае явныя даныя пра супарабатка), таму гэта больш, чым проста дакументацыя. Аднак гэта не заважае функцыі вяртаваць іншую строчку пад час выканання; гэта будзе адбівацца як адказка, калі будзе выбрана тая галузь.

    Чаму маршрутувацыя да крока стиснення, а не негайны заканчэнне? Таму што гэты падграф спроектаваны так, каб яго вызываў агент-надзірнік. Надзірніку патрабуецца стислы, структураваны адказ, а не дзяўолгі тэкст з пошукамі, размышленнямі і данымі з інструментаў. Стисненне ў самым падграфе дапамагае залічыць контэкст самога надзірніка мінімальным.

    Паў’язка цыклу за дапамою StateGraph

    Калі трыя функцыі ўжо створены, наступны крок — з’яўляецца неабходнасць ўз’язначыць іх між сабою. Уместа таго, каб рукамі пісаць алгорытм керавання, вы прызначаеце вузлы і рэшткі, а LangGraph запускае граф. Выконанне пачынаецца з «мозга», праходзіць через рутэра і або пераходзіць да «ракоў» (пасля чаго завжды вяртаецца да «мозга»), або выходзіць через compress_research.

    Складанне і компіляцыя графа

    Хоць ў цім фрагменте є атрыбут JavaScript, на самай працэ выкарыстоўваецца Python.

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

    Чытанне зверху вярху:

    1. StateGraph(ResearcherState, output_schema=ResearcherOutputState) стварае граф з усім внутршнім станом і обмежаным шаблонам выходных дадзеных, якія будуць видны для родніх графаў.
    2. Зарэгістрованы тры вузлы: llm_call, tool_node і compress_research.
  • add_edge(START, „llm_call“) прымеўшы ў вачох „brain“ як пункт выходу.
  • add_conditional_edges прыўязвае рутэр да выходу „brain“, аднараваючы кожную можлівую значэнне зворотнага рыхтуўкі да вузла.
  • Фіксаваны рыхтуўкі з tool_node назад да llm_call замыкае цікл.
  • add_edge(„compress_research“, END) завершае граф, калі ўжо напісана стыкатура.
  • .compile() ператварае адзначэнне на об’ект, які можна запусціць. Ён называецца researcher_agent, а не проста agent, таму што ў цэлым проекте яны ёсць падграфам, які запускаеся керуючым працэсам.

    Мапаванне, якое пасуецца функцыі add_conditional_edges, заслуговае на увагу. Яе ключы, "tool_node" і "compress_research", павінны абсалютна точна падабляцца таму, што вяртае should_continue, а яе значэння — цэлая назва вузла. Працэўванне пераконваецца, чыясць мапаваныя пункты выжывання існуюць, таму апіскаўкі ў назве вузла спрычыняюць абранне раней, а не на середзіне выконання. Наадварот, маршрутызатор, які вяртае значэнне, якога няма ў мапе, спрачыніць абранне толькі тады, калі будзе выканана вялікая гілка, таму трэба перапрацаваць маршрутызатор на обох гілках. Усё-такі, гэта заўсёды легча пераканаць, чым адпоўнага ручна напісанага цыклу while, дзе неправильная гілка проста будзе працаваць некоректна.

    Візуалізацыя цыклу

    Компільаваны граф стварае такі прайску:

    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)
    

    Это класычны цыкл ReAct. Мозг і руки можаюць змінювацца адно за адным столькі разоў, сколькі модэль продавае запиты на інструменты. Кожны пасэж дадае спазыраванні да стану, таму кожна новая рашэнне прымеўваецца на адной большай колькасці інфармацыі, чым пярэдняя.

    Дадаванне контрольных точак

    Прыклад, які мы ўжо бачылі, — это самастоятельны агент у памяці. Для будзь-чаго, што працуе довга, вы прыўязываеце контрольную точку ў час компілявання, каб стан графа быў збераглі пасля кожнага крока.

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

    MemorySaver зберагае пункты перапрацоўкі ў памяці процэса, што ідеальна для разработкі та тэставання, але зникае пасля перзапуску. Для прыменення ў рэальных умовах LangGraph адказвае стойкімі зберагувачамі пунктав перапрацоўкі, такімі як PostgresSaver та SqliteSaver, які дазволяюць практыку, якая была перарвана, продаваць з апошнега захаванага крока і залічваць кожную змяну стану. Адна практычная деталі: калі граф мае зберагувач пунктав перапрацоўкі, кожны вызов invoke патрэбуе ідэнтыфікатора нитки ў сваёй настройцы (напрыклад {"configurable": {"thread_id": "..."}}), каб LangGraph знаў, які захаваны дыялог трэба завантажыць та апдэйтуваць.

    Зміцненне цыклу: рэфлексія, бюджеты та паралелізм

    Без дадзеных элементаў цыкл ReAct мае два спосабы неяксамоў, якія выражаюцца быстра. Ён можа цыклізавацься без досягнення стабільнасці, выкорыстоўваючы токены і кредыты API для пошуку за пошукам. Альбо ён можа швытацься праз пошукі, не аналізуючы іх насуперак, чым дае поверхневыя рэзультаты. Проект рашае якія-то з гэтых проблем, а пасля расширвае цыкл за дапамою трохоў дадатковых элементаў.

    Прыводжанне да размышлення за дапамою think_tool

    Найцікавейшым дадатковым элементам ёсьць інструмент, які зовсім не выкарыстоўвае жадных зовнішнях дзеянь. Яго ўжоўна мета — змусіць модель зупыніцца і размышляць у писамай форме.

    # 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 прымае строку з данымі reflection і вяртае яе з прадактам паўтарэння. Дзейна докстрайнг не ёсць проста дэкорацыяй: калі задаць parse_docstring=True, LangChain выкарыстоўвае докстрайнг, каб выявіць апісанне інструмента та апісанне аргументаў, і самэ гэта тэкст чытае модель пад час выбору інструмента. Этот эфект ствараецца за дапамогою системнага запросу, які прыказвае агенту вызываць think_tool пасля кожнага пошуку. Гэта змушвае модель паведаміць, што яна ўжо вывучыла, што яшчэ не ёсць і што яна плануе зрабіць далей.

    Без гэтага крока агенты часта практыкуюць серію пошукоў, не синтэзуючы нічога межы ёмі. Інжынерныя каманды Anthropic зробілі адпаведную заазначэнне: агенты, якія можуць пераглядаць і правіць своі выходы, ўзятыя на аблік, являюцца болей надзеянымі, таму што яны выловляюць пакрыткі раней, чым яны стануць серйознымі, і могуць скоррекаваць свою дзеяльнасць, калі яны зблукаюць. think_tool стварае невялікі механізм самаперагляду ў кожной ітерацыі. Гэта недорага, таму што гэта коштуе толькі кантэйнероў самага размышлення плюс ўжо адна дапаможная пераходка, і гэта дапамагае модэлю застацца пры сваймі цялях.

    Падазроўваючы, што пасля пошуку агент размышляе, што знайшоў тры падходы да індэксавання RAG, але ўсё ж не мае критэрыяў для іх парабярання. Інструмент проста відтворывае гэта размышленне з прымескам паўтарэння:

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

    Этот стрынг зберагаецца як ToolMessage, таму ў наступнай ітерацыі система чытае свой сабе створаны план. Чаму викорыстоўваецца інструмент замест таго, каб проста папросіць модель «думаць пасля крока»? Выклік інструменту ўтварае окрасны, видны элемент у лог-даных, ён залягае ў історыі паведамленняў у прагнозаванай формате, а запит можа выкарыстоўваць яго у конкрэтны момент ціклу.

    Контроль бюджэту ў системным запыте

    Другі элемент лімітуе колькасць пошукоў, якія ведае агент:

    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
    

    Этыя ліміты знаходзяцца ў системным запыте, а не ў коде. Модельу паведамляецца, сколькі пошукоў падходзяць для простага чы сложнага запыту і калі трэба зупініцца. Це прагматычны выбор: без лічыльнікаў чы дадатковай логіки графа, толькі інструкцыі, да якіх можна пакластыся, што модель іх будзе выконваць.

    Этот компрэс існуе рэальна. У кераванні Google Cloud зазначаецца, што ітератыўны падход спрычыняе затрэмцанне у пораўнянні з адной запыткай, і што рэзультаты сильна залежаць ад якосці модэлю. Бюджет пошуку безпасебна обмежвае гэта затрэмцанне, адграничываючы колькість можлівых раундав.

    Аднак ліміты, заснованыя на запытках, ўмерзкія. Модэль можа неправым чынам ацэніць складнасць або проста ігнораваць інструкцыю. У стане вялікі ўжо існуе поле tool_call_iterations, таму проста дадаць жорсткі ліміт, які будзе выкарыстоўвацца рутэрам. Надзейны распарад выкарыстоўвае і тое, і другое: запыткі формуюць нормальную працę, а код гарантуе верхнія межа. Наша статыя на bounded agentic loops для выкарыстоўвання інструментаў LLM дакладна розглядае гэю ж ідею на TypeScript.

    Scatter-gather з надзірнікам

    Трэція частка розглядае весь цыкл ReAct як аб’ект, які можна перадаць у паводле запатрэбнасцей. Агент-кантролер дзеліць большую задачу на падзячыя задачы і паралельна запускае окольны граф доследніка для кожной з іх.

    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.
    

    Это шаблон рассеивания-сбора: задачы распределяюцца між незалежными працоўнікамі, а пасля сабираюцца і з’едынаюцца ўсе рэзультаты. Кожны доследнік мае свой стан, інструменты та бюджет, таму довгая історія пошуку аднай падзячай задачы ніколі не паражуе контэкст іншай. Кантролер бачыць толькі скомпрэсаваныя выходны данні.

    Сам кантролер ёсць маленькі граф. У замест на фіксаваны цыкл for па падзячыях задачах, ён выкарыстоўвае тип вярнення Command з LangGraph, які дазволяе вузлу адразу апдэйтаваць стан і назначыць наступны вузел:

    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
    

    Для кінавалідата доследнік ёсць чорныя скрынка. Ён вызывае інструмент ConductResearch, а гэты інструмент запускае скомпіляваны падграф доследніка, які незалежна працуе за цыклам ReAct. Акола з яго знаходзяцца іншыя спецыялізаваныя вузлы: вузел red_team, які атакуе логіку проекта, каб знайсці слабасці, і вузел context_pruner, які адчыняе неапранутыя запісы, калі контэкст становіцца занадта вялікім.

    Іспытанне Command замест статычных канектаў дае кінавалідату гнучкасць пад час выканання. Пасля кожнага крока ён можа выбраць запуск большых доследнікаў, апрацаваць проект на крытыку, адчыніць контэкст або завершыць работу, залежна ад текущаг стану. Недзея гэтага ёсць тое, што логіка маршрутацыі пераходзіць у код вузла, таму форма графа становіцца менш зрозумелай толькі з адзначэнняў канектаў; тады становіцца важлівейшым правільны логгінг і трэйсінг.

    Трэйсінг цэлаг выканання

    Ёнкшчыць, як элементы працуюць разам, дайте агенту сапраўдзіва складны запыт. Ў выхадных данных гэты кал вызначаецца як звычны тэкст; на самай працэ выкарыстаны 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?")
        ]
    })
    

    Якщо вы самі запускаеце гэты код, трэба зробіць два невялікія коректы. Граф, які быў скомпіляваны раней, называецца researcher_agent, таму трэба вжываць гэтае імя (або agent з паказчыка з пярэдньага разделу). А якщо вы вжываеце версію з паказчыка, то, як описана вышэй, трэба перадаць thread_id у настройках.

    Прыкладны траец экзекуцыі выглядае так. Цэў лог, а не код на Python.

    --- 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.
    

    Што паказвае траец:

    1. Агент адрабатаваў два пошуку і два вызовы рэфлексіі, што цалкам падпадае пад ліміт, заданы прымаптом.
    2. Кожная рэфлексія падсумаввала тое, што было выведзена, і падготавіла наступны пошук, што самэўсёды є метай правіла forced-reflection.
  • Агент сам стаў застацца, калі вырашыў, што у яго є достатнькі вясковы: чатыры раунды вызываў інструментаў, пасля чаго — фінальная тэкстовая адпаведзь.
  • Рутер надаў задачу на выкананне tool_node, калі былі вызывы інструментаў, а compress_research — калі іх не было.
  • Фінальны выход быў стиснутым падсумкам, а не первасным спілкуваннем.
  • Чытайце лог-данні як схематычны апіс, а не як буквальны выход програмы. У ём называецца інструмент пошуку search_tool, хоця на самае працоўны інструмент — tavily_search; калі ж у першай ітерацыі напісана, што вызываў інструментаў не было, хоця была запрасіта пошук, то на самае практыцы задача мала бы буць перадана tool_node. Галоўная сутнась — алтэранатыўны процес пошуку і аналізу, пакуль модель не дае адпаведзь у звычайным тэксте.

    Выведзенне ціклу ў працэс вырабоцтва

    Падрэбнік даследніка ёсць моцны фундамент, але пяць удосконаленняячых заходаў робяць яго ўсё надзейнейшым у рэальных застосоўваннях:

    1. Забезпечыце санкцыйваванне бюджэту ў кодзе. Збільшайце значэнне tool_call_iterations пасля кожнага праходу, і нехай should_continue направляе выконанне на compress_research, калі будзе досягнута максымальная колькасць, незалежна ад таго, чыго просіт модель. Гэта являе сабою захоўну сетку пад мяккімі лімітамі запиту.
    2. Прадаўайце прыбутак роботы корыстнікам. Функцыя .astream_events() у LangGraph выдае запісы пра пераходы межу вузламі та вызовы інструментаў, так што інтэрфейс можа паказваць такія запаведзі, як "Шукае..." або "Аналізуе рэзультаты...", паколькі працюе цикл, у замяну на іконку загрузкі.
  • Вярнуцеся пасля адміністрацыйных памялаў інструменту. Сёння тайм-аут у сеті чыста адміністрацыйны памял інструменту могу спакаваць выконанне. Абгорніце кожны вызов у try/except і верніце ToolMessage, які описвае памял. Тады «мозг» бачыць аббіекцыю і можа праказаць знову з іншымі параметрамі чыста зменіць падход. Дакументацыя Anthropic рэкамендуе адміністрацыяваць памялы інструменту да модэлю такім спосабам.
  • Зберагаеце длігія выконанні доследжэнняў. Для задач, якія включаюць многа ітэрацый, выкорыстоўваеце PostgresSaver, каб перарванае выконанне продавяжылася з таго месца, дзе яно завершылася, і кожны крок разумавання чыста вызов інструменту заставаліся працэсаванымі.
  • Дадзіце пункты зупнення, калі ў процэсе участвуе чалавек. LangGraph можа зупніцца на пунктах перерыву і чакаць на затверджэння. Для доследжэнняў з высокай значамасцю трэба зупніцца кожныя N ітерацый, паказаць чалавеку тое, што было знайдзена, і дазволіць яму продаваць працю, перенаправіць агента чы ўстаўіць його.
  • Ключовыя выводы

    • ReAct — это цікл раздумаў, дзеянняў і спазіравання; ён не залежыць ад конкрэтнага фреймворку, а LangGraph проста робіць гэты цікл явным і можна выявіць.
    • Тры вузлы з адным заведамым назначэнням (мозг, рукі, маршрутазатор) плюс стан, падтрымваны редукцэрам, дастатковы для функціональнага доследжэнчага агента.
    • Заўжды супакоюйце кожны рэзультат інструмента з яго tool_call_id, а таксама раздзеляйце внутршні станы ад таго, што выклячвае пад-граф.
    • Інструмент для рэфлексіі без дзеянняў — это недорогі, відразлівы спосаб прымусіць синтэз между пошукамі.
    • Бюджэты прамптаваў вплываюць на поведзенне, але толькі ліміт на рэвэрсны ўровень гарантуе завершэнне.
  • Калі цыкл стане скомпіляваным падграфам, адказначы можа распрацаваць яго паралельна і спрыяць кожнаму дзеершучыму як чорныя скрынькі.
  • Спадневае чытанне