Главная / Статьи / Создание исследовательского агента ReAct в LangGraph: мозг, руки, маршрутизатор

Создание исследовательского агента ReAct в LangGraph: мозг, руки, маршрутизатор

Узнайте, как реализовать цикл ReAct «рассуждение-действие-наблюдение» в качестве подграфа LangGraph с принудительным отражением, лимитами итераций и параллельными методами сбора и анализа данных.

4678 слов

Одного вызова большой языковой модели недостаточно для исследования вопроса, о котором она ничего не знает. Полезный агент для исследований должен выполнять поиск, читать полученные результаты, определять, что ещё необходимо, и продолжать поиск до тех пор, пока у него не будет достаточно информации для ответа. Паттерн ReAct придаёт такому поведению чёткую структуру, а LangGraph позволяет представить его в виде небольшой явной графической структуры вместо запутанной сети циклов while. К концу этого обзора вы поймёте функции каждого узла рабочего подграфа для исследований на основе ReAct, узнаете, где он может дать сбой, и получите список улучшений, обеспечивающих его безопасную работу в производственных условиях.

Дизайн основан на компоненте исследователя из открытого проекта под названием deep-research-agent. На высоком уровне один цикл работы выглядит следующим образом:

  1. От пользователя поступает вопрос для исследования.
  2. Большая языковая модель, действуя в роли «мозга», анализирует вопрос и выбирает инструмент для его решения.
  • Исполнитель инструментов, «руки», запускает данный инструмент и возвращает результат наблюдения.
  • Маршрутизатор анализирует последнее сообщение от «мозга», чтобы определить, были ли запрошены дополнительные вызовы инструментов.
  • Если да, управление возвращается к шагу 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, что позволяет использовать его как составную часть: более крупная система может вызывать его как единый элемент. Если вы хотите повторить основные концепции перед тем, как приступать к коду, ознакомьтесь с нашим обзором том, как искусственные интеллект-агенты сочетают рассуждения с действиями в реальном мире.

    Три компонента и состояние, которым они обладают

    Цикл исследователя состоит из трех небольших функций, каждая из которых выполняет определенную задачу. «Мозг» (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, которая принимает строку с именем модели, начинающуюся с указателя поставщика.

    # 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

    Эти инструменты принимают вызовы из последнего сообщения «мозга» и выполняют их. Этот фрагмент кода также написан на Python, несмотря на обозначение «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}
    

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

    Маппинг tools_by_name используется, но никогда не определяется в блокноте проекта. Его нужно создать самостоятельно, например в виде {"tavily_search": tavily_search, "think_tool": think_tool}, или с помощью конструкции dictionary comprehension над списком инструментов, чтобы имена всегда синхронизировались с тем, что было задано.

    Если «мозг» просит функцию tavily_search найти информацию о «самых свежих исследованиях в области ИИ», «руки» выполняют запрос и возвращают сообщение примерно такого формата:

    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.

    Сборка и компиляция графа

    Этот фрагмент также написан на Python, несмотря на обозначение 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()
    

    Чтение сверху вниз:

    1. StateGraph(ResearcherState, output_schema=ResearcherOutputState) создаёт граф с полным внутренним состоянием и ограниченной схемой вывода, которую будут видеть родительские графы.
    2. Регистрируются три узла: llm_call, tool_node и compress_research.
  • add_edge(START, „llm_call“) делает «мозг» точкой входа.
  • add_conditional_edges подключает маршрутизатор к выходу «мозга», используя словарь, в котором каждое возможное значение возврата соответствует узлу.
  • Фиксированная ребро от 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 принимает строку с информацией о рефлексии и возвращает её с префиксом, указывающим на подтверждение. Длинная документация не является декоративной элементом: при настройке 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, поэтому легко установить жесткое ограничение, которое будет соблюдаться роутером. Надежная конфигурация использует оба подхода: промпты формируют нормальное поведение модели, а код гарантирует наличие верхнего предела. В нашей статье «Ограниченные агентные циклы для использования инструментов LLM» рассматривается та же идея на примере TypeScript.

    Метод разброса-сбора с надзирателем

    В третьем дополнении весь цикл 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, который удаляет необработанные заметки, когда объем контекста становится слишком большим.

    Использование команд вместо статических связей обеспечивает руководителю гибкость во время выполнения. После каждого шага он может решить запустить дополнительных исследователей, отправить проект на проверку, уменьшить объем контекста или завершить работу в зависимости от текущего состояния. Недостатком является то, что логика маршрутизации переходит в код узлов, поэтому структура графа становится менее очевидной на основе только объявлений связей; в таких случаях более важной становится качественная логгинг-система и отслеживание процессов.

    Отслеживание полного запуска

    Чтобы увидеть, как все элементы работают вместе, задайте агенту довольно сложный вопрос. Источник обозначает этот вызов как обычный текст; на самом деле это код на 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, а также разделяйте внутреннее состояние от того, что отображается субграфом.
    • Инструмент рефлексии без действий — это недорогой и видимый способ обеспечения синтеза между поисками.
    • Бюджеты подсказок влияют на поведение, но только ограничение на уровне кода гарантирует завершение работы.
  • Как только цикл превращается в скомпилированный подграф, системный администратор может распараллелить его обработку и рассматривать каждого исследователя как «черный ящик».
  • Связанные материалы