Галоўная / Артыкулы / Умовна зупінка інструментаў: адмініструемыя ресурсы перамагаюць метод return_direct у LangGraph.

Умовна зупінка інструментаў: адмініструемыя ресурсы перамагаюць метод return_direct у LangGraph.

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

2508 слоў

Калі статычны параметр 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 версый. Зберагуйце тэсты, якія змусваюць систему працаваць у режыме успех/перапрыбутку/фатальнай памылкі пасля кожнага апдэйта. Якщо хук раптам пачне бесканечна ціклізаваць, спачатку пераканайцеся ў формacie ліста паведамленняяў, перш чым адзначаць багі фрэймворку — перадача паведамленняяў часта становіць прычыну такіх проблем.

    На заканчанне, утримайцеся ад дадавання флагаў кантролю ў 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) запобегае хаосу, калі все большыя інструменты адмаўляюць гэты патэрн. Рэвізоры должны адхіліць імены булевых значэнняяў, якія выкарыстоўваюцца толькі адзін раз у кожным інструменте, калі ў пакете арканізацыі вялікасабраны enum уже існуе.

    Звычкі пераканання супутніх элементаў

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

    Якщо пазлу пасуюць модэль, яка трэба з’еднае выходы інструменту з попярэднімі раундамі нават у разе успеху, дадзіце неабяжлівы вузол для дапраўкі пасля фіналізацыі, а не выдалейце схему з короткам дзвяненням. Флагі функцый працуюць краща за перапісвы: stop_mode=hard|polish|never дазволяе працаваць над эксперыментамі без адхыленняя ад умов контракту. Перад выборам стандартнага режыму пераканайцеся, сколькі токенав витрачаецца кожны режым на аднай і той жа сэтце запитоў.

    Контракт чытача для адпрацоўкі гэтага патэрану

    Перад тым, як копіюваць сам текст, скопіюйте схему артэфакта і тесты рутера. Ценнае ў эсэі — раздзеленыя функцыі, а не прыгадкі про інструменты пошуку. Якщо ваш домэн викорыстоўвае іншыя пазначкі для абэранцэй, перакладзіце іх у тыя ж тры категорыі і залейкайце рутер простым. Утримваюцца ад дадзення чэтыртай категорыі, пакуль гэта не будзе неабходна ў зв’язку з рэальным інцыдентам. Калі є сумневы, краща продаважваць працэю моделі, чым зупіняцца через нечысткія бягі — тыхі короткі замыканні, якія маскуюць частковыя абэранцыі, ўжо горшыя, чым дадзенне додатковага, недорогага вызову моделі, які пояснюе корыстніку нэвядомасць.

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