Умовне зупинення інструментів: Artifacts перевершує return_direct у LangGraph
Зупинка/продовження кожного виклику за допомогою інструментальних елементів — власне створене ReAct та проміжне програмне забезпечення create_agent — коли статична функція return_direct не може прийняти рішення.
Коли статичний return_direct є неправильним інструментом
Кожен, хто випускав агента для виклику інструментів LangGraph, стикався з параметром return_direct=True: це дозволяє пропустити надсилання результату інструменту через модель та завершити цикл. Усе здається ідеальним, поки рішення про зупинку не має залежати від результату цього виклику, а не від того, який інструмент був зареєстрований.
У цьому посібнику ми натрапляємо на цю проблему, вручну створюємо мінімальний цикл ReAct, а потім відтворюємо ту саму поведінку за допомогою create_agent та проміжкових компонентів. Коротка відповідь: так, це працює — але первинний підхід з використанням проміжкових компонентів може зазнати невдачі через тонкі особливості порядку обробки повідомлень, а не тому, що фреймворк потайки відхиляє оновлення.
Версії для ясності: „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 — це свідомий компроміс: він пропускає виклик LLM та показує саме те, що створило інструмент, але інструмент має генерувати читабельний текст, і модель не може поєднати цей результат з іншими даними. У випадку, коли «вихід інструменту вже є відповіддю», цей компроміс є вигідним.
Результати пошуку як метадані, а не команди графа
Інструмент пошуку встановлює значення 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" }, щоб агент не постійно надсилав запити до обробника. Ця сама схема може бути застосована до пошуку, створення тикетів чи експорту документів без необхідності переписування маршрутизатора — лише змінюється спосіб прив’язки інструменту до цих функцій.
Звички перевірки
Зберігайте засіб для примусового отримання результату в CI разом із фейковою моделлю чату, яка генерує заздалегідь визначені виклики інструментів. Справжні запуски Groq використовуються лише для періодичної перевірки функціональності від початку до кінця, а не для кожної зміни коду. Перевіряйте точні послідовності шляхів: які вузли були запущені, чи відбувся другий виклик моделі, та чи зміст, який генерується на кроках завершення, збігається із змістом, отриманим за допомогою інструментів. Коли хтось „спрощує“ середовище обробки та знову вводить return_direct, засіб має гучно виявити помилку. Зберігайте ідеальні записи роботи поруч із засобом, щоб можна було порівнювати результати. Умовне завершення є частиною контракту поведінки; тести забезпечують дотримання цього контракту під час рефакторингу у різних версіях LangGraph та серед інженерів, які лише швидко переглядають початкові записи проекту.
Якщо згодом продукту знадобиться, щоб модель поєднувала результати своїх операцій із попередніми, навіть у разі успіху, додайте необов’язковий етап відшліфування після кроку finalize замість видалення механізму короткого замикання. Флаги функцій кращі за переписування коду: stop_mode=hard|polish|never дозволяє проводити експерименти без порушення умов контракту продукту. Перед вибором стандартного режиму виміряйте кількість використаних токенів у кожному режимі для одного й того самого набору запитів.
Контракт користувача щодо використання цього паттерну
Перед копіюванням основного тексту скопіюйте схему артефакта та тести роутера. Цінність есею полягає у розділенні функцій, а не у історіях про інструменти пошуку. Якщо у вашій системі використовуються інші позначення помилок, віднесіть їх до трьох однакових категорій та залиште роутер простим у використанні. Утримуйтеся від додавання четвертої категорії, поки це не стане необхідним через справжню проблему. У разі сумнівів краще продовжувати роботу з моделлю, ніж раптово зупинятися через неоднозначні помилки — тихі короткі замикання, які приховують часткові невдачі, гірші за додатковий недорогий виклик моделі, який пояснює користувачеві невизначеність.
Надсилайте комплект разом із статтею, щоб читачі могли перевірити крайні випадки на власних середовищах перед тим, як почати використовувати цю схему у реальному трафіку.