Главная / Статьи / Условное остановка инструментов: Artefacts превосходят return_direct в LangGraph

Условное остановка инструментов: Artefacts превосходят return_direct в LangGraph

Остановка/продолжение работы в ходе каждого звонка с использованием инструментальных компонентов — ручная разработка промежуточных модулей ReAct и create_agent — когда статическая функция return_direct не может принять решение.

2508 слов

Когда статический параметр return_direct является неподходящим решением

Каждый, кто выпускал агента для вызова инструментов LangGraph, сталкивался с параметром return_direct=True: в этом случае результат работы инструмента не отправляется обратно через модель, и цикл прерывается. Всё кажется идеальным, пока решение о прекращении работы не должно зависеть от результата текущего вызова, а не от того, какой инструмент был зарегистрирован.

В этом обзоре мы сталкиваемся с такой проблемой, вручную создаём минимальный цикл ReAct, а затем воссоздаём ту же логику с использованием middleware в функции create_agent. Краткий ответ: да, это работает — но первоначальный подход с middleware может дать сбой из-за тонких особенностей порядка передачи сообщений, а не потому, что фреймворк тайно игнорирует обновления.

Для ясности: «legacy» означает langgraph==0.6.6 (последняя версия перед заменой create_react_agent на create_agent); «current» — это langchain==1.4.2, использующий langgraph==1.2.11. Каждый агент ReAct представляет собой цикл «модель ↔ инструменты»; здесь речь идет о связи от инструментов обратно к модели, а также о том, когда эта связь должна исчезать при определенном вызове.

Требование, которое нарушило стандартный цикл

Интеграция поискового инструмента казалась обычной: модель вызывает search(query), читает результаты, отвечает или продолжает работу. Однако два фактора сделали стандартный цикл неэффективным.

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

При неудаче сбои делятся на противоположные категории: полная тупиковая ситуация (нет ничего, что можно было бы использовать — повторная попытка бесполезна) против временного исчерпания таймаута или кода 503 (в таком случае повторная попытка оправдана). Следовательно, правило зависит от каждого вызова: успех → прекратить, сбой, подлежащий повторной попытке → продолжить, фатальный сбой → прекратить — решение принимается на основе данных, передаваемых в запросе, а не на основе статического типа инструмента.

Почему return_direct не может этого указать

В файле langgraph.prebuilt.chat_agent_executor на закрепленной устаревшей версии механизм маршрутизации выглядит следующим образом:

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

should_return_direct рассчитывается один раз во время создания графа на основе атрибута .return_direct инструмента. Этот флаг означает, что «этот инструмент всегда завершает цикл». У него нет режима, действующего для каждого отдельного вызова. Это несоответствие категорий, а не дефект самого флага.

В темах форума звучит одна и та же проблема: из-за громоздких результатов работы инструментов возникают бесполезные дополнительные вызовы модели, а разработчики часто советуют вручную подключать tool_node → END. Отдельные обсуждения, касающиеся обновлений Command и параметра return_direct (включая langgraph#5496), служат дополнительным контекстом; основной аргумент заключается в том, что для этого не требуется исправления бага — статические флаги просто не могут передавать динамические результаты.

Форма контрольного потока

Проще говоря:

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

Два пункта назначения, которые выбираются заново при каждом вызове. В данном тексте эта структура реализуется дважды — один раз вручную, другой раз с помощью промежуточного программного обеспечения.

Разделенные каналы: content и artifact

В LangChain механизм @tool уже разделяет то, что видит модель, и то, что получает код приложения с помощью параметра response_format="content_and_artifact". Инструмент возвращает значение (content, artifact): ToolMessage.content передается модели, а artifact остаётся в сообщении для организации работы и никогда не попадает в обработку LLM.

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

Желаемый формат:

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

в отличие от ситуации, когда параметр return_direct приводит к одному статическому ответу:

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

Один фиксированный флаг, пытающийся ответить как на вопрос «что видит пользователь?», так и на вопрос «нужно ли завершать цикл?», или два отдельных канала, каждый из которых отвечает на один вопрос.

Устаревший ручной вариант ReAct

Вместо того чтобы создавать цикл, сократите create_react_agent до минимальной структуры: сохраните имена узлов и циклическую связь, удалите хуки для ввода данных, форматы структурированных ответов, динамическое определение модели, учет оставшихся шагов, функции сохранения состояния, прерывания и параллельную отправку сообщений с помощью Send.

Остаются три узла:

  • agent — вызов модели; если существуют tool_calls, продолжить, в противном случае завершить.
  • tools — обычный узел ToolNode; добавляются результаты в формате ToolMessage.
  • finalize — без вызова модели; выбранный текст инструмента помещается в формат AIMessage без изменений.

Два маршрутизатора:

  • should_continue после узла agent: если есть вызовы инструментов — перейти к узлу tools, в противном случае — к состоянию END.
  • route_after_tools после tools: анализируется результат работы инструмента, после чего либо возвращается к agent, либо переходит к этапу finalize (вместо первоначальной проверки на использование статического параметра return_direct).
  • finalize представляет собой осознанный компромисс: он исключает вызов большой языковой модели и отображает именно то, что выдал инструмент, однако инструмент должен генерировать читаемый текст, а модель не может объединять этот результат с другими данными. В случаях, когда «вывод инструмента уже является ответом», такой компромисс оказывается выгодным.

    Результаты поиска как метаданные, а не команды графа

    Инструмент поиска устанавливает значение artifact["stop"] на основе того, что произошло в ходе *этого* вызова. Параметр stop является метаданными приложения, а не полем, зарезервированным для LangChain. Важно понимать, что инструмент сообщает о результате, а система оркестрации его интерпретирует. Это позволяет сохранять модульность механизма маршрутизации при использовании правил, недоступных для инструмента.

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

    Три случая:

    • Успех с реальным содержимым → stop=True (ещё один запуск модели приведёт лишь к перефразированию).
    • Ошибка, подлежащая повторной попытке → stop=False (дать модели ещё один шанс).
    • Критическая ошибка → stop=True (повторные попытки тратят токены на один и тот же некорректный ответ).

    stop=False не означает «повторить попытку сейчас» — это лишь отсрочка окончательной остановки. Модель всё равно может снова вызвать поиск, попробовать что-то другое или дать ответ. Маршрутизатор сводится к проверке состояния в одной строке:

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

    Конструкция, обязывающая использовать значения "success" | "retryable" | "fatal", делает пути детерминированными для реальной модели Groq: случаи успеха и критической ошибки ведут к выполнению операций tools → finalize → END без дополнительных вызовов модели; случай ошибки, подлежащей повторной попытке, возвращает систему к состоянию agent.

    Границы применения: когда маршрутизация также зависит от оставшихся шагов, количества предыдущих попыток или флагов аутентификации, одного только артефакта недостаточно — маршрутизатор должен учитывать состояние всей структуры. Параметр content_and_artifact оказывается полезным, когда результат работы этого инструмента определяет следующий шаг.

    Что больше, чем простой поиск

    Подходит любой инструмент, результат которого более детализирован, чем просто «успех/неудача»: инструмент write_record может устанавливать значение already_applied; инструмент для сбора данных может задавать progress для интерфейса, о котором модель никогда не упоминает. Артефакт представляет собой обычные данные, которые можно использовать в условных переходах, в промежуточном программном обеспечении или в интерфейсе, который никогда не взаимодействует со структурой графа. Параметр return_direct является частью определения маршрутизации; у него нет режима «хранить информацию и принять решение позже».

    Та же идея в функции create_agent

    Требуемые версии: Python 3.12, langchain==1.4.2 / langgraph==1.2.11, langchain-groq==1.1.3. Функция create_agent заменяет ручное построение графа на декларативную структуру со встраиваемым промежуточным обработчиком.

    Первоначальная идея: использовать wrap_tool_call и возвращать объект Command(goto=END) при установке параметра stop.

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

    В протестированной версии этот путь срабатывает только тогда, когда до точки END уже можно добраться с помощью механизма return_direct. Промежуточный обработчик может определить, что stop=True, но цикл продолжает отправлять запросы модели, пока та в конечном итоге не ответит без использования инструментов. Ранее это происходило в соответствии с изменением #5496 в текущих версиях — пока две скриптовые версии не показали иное поведение:

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

    Версия A работает корректно. Версия B добавляет реле AIMessage без элементов tool_calls в том же обновлении. Проверка на выход просматривает сообщения в обратном порядке, начиная с последнего AIMessage, чтобы оценить параметр return_direct; она находит реле, не обнаруживает вызовов инструментов и продолжает цикл. Параметр Command был применён — порядок сообщений скрыл исходное сообщение с вызовом инструмента от проверки на выход. Это не пропущенное обновление, и это не #5496.

    Даже после устранения этой проблемы используемый подход предполагает применение параметра before_model: он вообще не требует наличия параметра return_direct.

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

    before_model выполняется непосредственно перед каждым вызовом модели — на более поздних итерациях это происходит сразу после инструментов. @hook_config(can_jump_to=["end"]) позволяет перейти к END независимо от любых флагов инструментов. Возврат значения {"jump_to": "end", ...} представляет собой обычную обновление состояния, которое считывается ребрами графа. Один хук одновременно обнаруживает артефакт и создаёт реле AIMessage — задача распределяется между операциями route_after_tools и finalize.

    Принудительные результаты соответствуют вручную созданному графу: успех или фатальная перебой с точным содержимым инструментов; возможность повторной попытки восстанавливает цикл работы модели.

    Вывод

    content_and_artifact не был создан как средство маршрутизации. Он разделяет аудитории — контент, видимый для модели, и метаданные, доступные только приложению — и именно это разделение позволяет чётко определить, «стоит ли остановиться?», без необходимости просить модель анализировать поток управления. return_direct смешивает вопросы отображения и завершения в один статический флаг, из-за чего возникают ошибки там, где ответы на эти вопросы должны различаться для каждого вызова.

    Если в том или ином случае использования требуется условное завершение, следует разделять результат работы инструмента и решение о маршрутизации: предоставляйте метаданные рядом с ответом, пусть оркестрация примет решение. content_and_artifact уже обеспечивает такой канал связи.

    Заметки по проектированию, о которых команды забывают после первого успешного теста

    Условная остановка считается решенной после того, как происходят три предустановленных результата. Процесс производства позволяет использовать параллелизм: два вызова инструментов за один цикл модели или группа поисков, при которой должен быть завершен только один. Необходимо определить, приводит ли любой фактор остановки к преждевременному завершению всего шага, должны ли все факторы совпадать, или применим порядок приоритетов. Эту политику следует закодировать в маршрутизаторе, а не полагаться на устную традицию.

    Возможность наблюдения должна позволять отображать результат работы рядом с сообщением ToolMessage без записи конфиденциальных данных из поля content. При остановке процесса необходимо фиксировать, какое правило сработало — успех, критическая ошибка или переопределение политики — чтобы служба поддержки могла объяснить, почему ассистент не «подумал дольше». Это следует сочетать с учетом токенов: вся суть механизма завершения при успехе заключается в уменьшении количества вызовов модели; панели управления должны демонстрировать полученную экономию.

    Будьте осторожны при переносе шаблонов между компонентами LangGraph. Названия хуков среды обработки, возможность вызова Command и проверки на немедленный выход из функции изменились при переходе с версии 0.6 на 1.x. Всегда используйте тесты, которые определяют, будет ли обновление успешным, требующим повторной попытки или приводящим к критической ошибке. Если хук вдруг начинает бесконечно циклировать, сначала проверьте структуру списка сообщений, прежде чем сообщать о багах фреймворка — передача сообщений часто становится причиной подобных проблем.

    Наконец, не стоит «в этот единственный раз» вставлять флаги управления в поле content. Как только модель обнаруживает в тексте значение stop=true, она может описывать логику управления или отображать внутренние коды для пользователей. Существование таких механизмов позволяет осуществлять точное управление процессами, сохраняя при этом чистоту интерфейса для пользователя.

    Сопоставление шаблона с смежными фреймворками

    Та же самая схема разделения на содержимое и управление встречается и за пределами LangGraph. Любая среда выполнения агентов, которая объединяет вывод инструментов в единственный канал сообщений, в конечном итоге создает специальные маркеры, JSON-оболочки или вспомогательную метаинформацию. Предпочитайте официальный вспомогательный канал, если платформа его предоставляет; создавайте документированную оболочку, если её нет; никогда не полагайтесь на то, что модель проигнорирует токены управления, скрытые в тексте.

    Если команде необходимо поддерживать как устаревшие графы create_react_agent, так и новые приложения create_agent, следует сохранять идентичный контракт артефакта инструмента и заменять только реализацию маршрутизатора. Это позволяет ограничить изменения версий только тестами оркестрации. По мере расширения функционала промежуточного слоя — проверки авторизации, лимиты расходов, маскировка персональных данных — необходимо запускать соответствующие хуки перед обработкой команды stop, чтобы отказ в выполнении политики не был ошибочно принят за успешное прерывание работы. Порядок запуска хуков является частью публичного поведения агента, даже если это кажется внутренними процедурами.

    Документ, объясняющий будущим читателям, зачем существует метод finalize (или переход before_model): это осознанный выбор продукта, позволяющий делать текст инструмента видимым для пользователя без дополнительной обработки. Если позже продукт захочет стиль устного резюме, достаточно снова добавить узел модели на пути остановки, вместо того чтобы перегружать инструмент необходимостью одновременной генерации двух стилей текста. Разделение процессов «расчёта результата» и «описания результата» обеспечивает возможность повторного использования инструментов в приложениях для голосового общения, чата и API-клиентах.

    Интуитивное понимание различий между остановкой и продолжением работы

    Представьте инструмент оформления заказа, который иногда возвращает готовый квитанционный документ, иногда сообщение об «ошибке временного ожидания платежной системы», а иногда — «отклонение карты навсегда». Эти три варианта соответствуют состояниям успешного завершения, возможности повторной попытки и фатальной ошибки. content квитанции может содержать HTML-код, готовый к отображению клиентом; объект хранит значение { "stop": true, "reason": "completed" }. При ошибке временного ожидания в content указывается краткое объяснение для модели, а в объекте — значение { "stop": false, "reason": "transient" }. Отклонение карты навсегда прерывает цикл с сообщением, безопасным для пользователя, и значением { "stop": true, "reason": "fatal" }, чтобы агент не продолжал попытки обработки запроса. Такая же схема может быть применена к поиску, созданию тикетов или экспорту документов без необходимости переписывать маршрутизатор — достаточно лишь изменить соответствия в самом инструменте.

    От ошибок сырого API до незначительных изменений в словаре значений. Сохранение минимального размера этого словаря (completed / transient / fatal / policy_block) предотвращает хаос, возникающий при использовании данной модели различными инструментами. Рецензенты должны отклонять уникальные имена типа boolean для каждого инструмента, если в пакете оркестрации уже существует общий enum.

    Привычки верификации

    Храните средство обеспечения принудительного результата в CI с фиктивной моделью чата, генерирующей заранее определённые вызовы инструментов. Реальные запуски Groq предназначены лишь для периодической проверки работоспособности в полном цикле, а не для каждой коммит-операции. Проверяйте точные последовательности путей: какие узлы были запущены, происходил ли второй вызов модели и равен ли финальный контент содержимому инструмента на путях остановки. Когда кто-то «упрощает» промежуточные компоненты и снова вводит return_direct, средство должно явно показать ошибку. Храните эталонные записи рядом с средством, чтобы ошибки можно было сравнивать. Условная остановка — это поведенческий контракт; тесты обеспечивают соблюдение этого контракта при рефакторингах в различных версиях LangGraph и среди инженеров, которые лишь бегло читают первоначальные записи проекта.

    Если позже продукту потребуется, чтобы модель смешивала результаты своих действий с предыдущими этапами обработки, даже в случае успеха, добавьте необязательный узел для доработки после этапа finalize вместо удаления механизма короткого замыкания. Использование флагов функций эффективнее переписывания кода: параметр stop_mode=hard|polish|never позволяет продолжать эксперименты без нарушения условий работы модели. Перед выбором стандартного режима измерьте расход токенов в каждом режиме на одном и том же наборе запросов.

    Условия использования этой схемы

    Скопируйте схему артефакта и тесты маршрутизатора перед копированием основного текста. Ценность эссе заключается в разделении функций, а не в примерах из инструментов поиска. Если в вашей среде используются другие метки ошибок, отнесите их к тем же трём категориям и сохраните простоту маршрутизатора. Воздерживайтесь от добавления четвёртой категории, пока это не потребуется реальным инцидентом. В случае сомнений лучше продолжить работу с моделью, чем останавливаться из-за неоднозначных ошибок — бесшумные короткие замыкания, скрывающие частичные сбои, хуже, чем дополнительный недорогой вызов модели, объясняющий пользователю неопределённость.

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