Strona główna / Artykuły / Zatrzymywanie narzędzi warunkowo: Artefakty przewyższają return_direct w LangGraph

Zatrzymywanie narzędzi warunkowo: Artefakty przewyższają return_direct w LangGraph

Zatrzymanie/kontynuacja na poziomie każdej wywołania za pomocą narzędzi typu artifact — ręcznie stworzone komponenty ReAct oraz middleware create_agent — gdy statyczna funkcja return_direct nie może podjąć decyzji.

2508 słów

Kiedy statyczna wartość return_direct jest niewłaściwym rozwiązaniem

Każdy, kto wdrożył agenta wywołującego narzędzia LangGraph, spotkał się z ustawieniem return_direct=True: wtedy pomija się wysyłanie wyniku narzędzia z powrotem przez model i kończy pętlę. Wydaje się to doskonałe, dopóki decyzja o zatrzymaniu nie musi opierać się na wyniku tego konkretnego wywołania, a nie na tym, które narzędzie zostało zarejestrowane.

W tym przewodniku napotykamy tę barierę, ręcznie budujemy minimalną pętlę ReAct, a następnie odtwarzamy to samo zachowanie przy użyciu create_agent wraz z middleware. Krótka odpowiedź: tak, to działa — ale pierwszy instynkt związany z middleware może zawieść z powodu subtelnych różnic w kolejności przesyłanych wiadomości, a nie dlatego, że framework cicho odrzuca aktualizacje.

Aby uniknąć nieporozumień, wskazujemy wersje: „legacy” oznacza langgraph==0.6.6 (ostatni wersja przed tym, jak create_react_agent został zastąpiony przez create_agent); „current” oznacza langchain==1.4.2, który korzysta z langgraph==1.2.11. Każdy agent ReAct to pętla model ↔ narzędzia; tutaj kluczowy jest odcinek łączący narzędzia z modelem oraz moment, w którym ten odcinek powinien zniknąć przy danej próbie wywołania.

Wymóg, który zniszczył domyślną pętlę

Integracja narzędzia do wyszukiwania wydawała się prosta: model wywołuje search(query), odczytuje wyniki, udziela odpowiedzi lub kontynuuje działanie. Dwie cechy sprawiły, że ta standardowa pętla nie nadawała się do tego zastosowania.

Gdy wyszukiwanie odnosiło sukces, narzędzie zwracało dużą stronę w formacie JSON. Wgranie tego obiektu z powrotem do kontekstu przed kolejną iteracją modelu jest kosztowne i zazwyczaj bezcelowe – jeśli wyszukiwanie już udzieliło odpowiedzi, drugie wywołanie przeważnie jedynie formułuje pytanie na nowo, ponosząc pełne koszty.

W przypadku niepowodzenia błędy dzielą się na przeciwieństwa: prawdziwy ślepy zaułek (brak czegoś do dopasowania – ponawianie prób jest stratą czasu) w porównaniu z tymczasowym timeoutem lub błędem 503 (ponawianie prób ma sens). Zatem zasada obowiązuje dla każdej wywołania: sukces → przerwać, błąd podlegający ponownej próbie → kontynuować, błąd fatalny → przerwać – decyzja opiera się na treści przesyłanej, a nie na statycznym typie narzędzia.

Dlaczego return_direct nie może tego określić

W pliku langgraph.prebuilt.chat_agent_executor na zainstalowanej wersji starszej routowanie wygląda następująco:

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 jest obliczany raz na czas budowy grafu na podstawie atrybutu .return_direct narzędzia. Ten flag oznacza „to narzędzie zawsze kończy pętlę”. Nie posiada trybu specyficznego dla poszczególnych wywołań. To niezgodność kategorii, a nie wada samego flaga.

Tematy na forum odzwierciedlają ten sam problem: duże rozmiary wyników narzędzia zmuszają do niepotrzebnych dodatkowych wywołań modelu, a administratorzy często sugerują ręczne połączenie tool_node → END. Odrębna dyskusja dotycząca aktualizacji Command oraz return_direct (włączając langgraph#5496) dostarcza kontekstu; główny argument nie wymaga błędu — statyczne flagi po prostu nie mogą przenosić dynamicznych wyników.

Kształt przepływu sterowania

Mówiąc prościej:

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

Dwa destynacje, wybierane na nowo przy każdym wywołaniu. Reszta tego tekstu implementuje ten kształt dwa razy — raz ręcznie, a raz za pomocą middleware.

Rozdzielone kanały: content i artifact

@tool z LangChain już dzieli to, co widzi model, od tego, co otrzymuje kod aplikacji za pomocą response_format="content_and_artifact". Narzędzie zwraca (content, artifact). ToolMessage.content trafia do modelu; artifact pozostaje w wiadomości służącej do orkiestracji i nigdy nie trafia do ścieżki 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"}

Zamierzony wzorzec:

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

w przeciwieństwie do tego, co return_direct sprowadza do jednej statycznej odpowiedzi:

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

Jeden stały flag próbujący odpowiedzieć zarówno na pytanie „co widzi użytkownik?” jak i „czy pętla powinna się zakończyć?” — albo dwa kanały, z których każdy odpowiada na jedno pytanie.

Stary, ręcznie budowany ReAct

Zamiast wymyślać pętlę, sprowadź create_react_agent do najistotniejszych elementów: zachowaj nazwy węzłów i cykl, usuń elementy typu prompt hooks, ustrukturyzowane formaty odpowiedzi, dynamiczną rozwiązywanie problemów modelu, rejestrację pozostałych kroków, funkcje checkpointing, przerwy oraz równoległe wysyłanie komend Send.

Pozostają trzy węzły:

  • agent — wywołaj model; jeśli istnieją tool_calls, kontynuuj, w przeciwnym razie zakończ.
  • tools — zwykły ToolNode; dodaj wyniki ToolMessage.
  • finalize — bez wywołania modelu; umieść wybrany tekst końcowy narzędzia jako AIMessage w niezmienionej formie.

Dwa routery:

  • should_continue po agent: wywołania narzędzi → tools, w przeciwnym razie END.
  • route_after_tools po tools: sprawdza artefakt i albo wraca do agent, albo przechodzi do finalize (zastępując pierwotną statyczną kontrolę ustawienia return_direct).
  • finalize to świadoma kompromisowa decyzja: pomija wywołanie LLM i pokazuje dokładnie to, co wygenerowało narzędzie, ale narzędzie musi wytwarzać tekst nadający się do prezentacji, a model nie może połączyć tego wyniku z innymi dowodami. W przypadku, gdy „wynik narzędzia jest już odpowiedzią”, taka kompromisowa opcja okazuje się korzystna.

    Wyniki wyszukiwania jako metadane, a nie polecenia grafu

    Narzędzie wyszukiwania ustawia artifact["stop"] na podstawie tego, co wydarzyło się podczas tego wywołania. stop to metadane aplikacji, a nie pole zarezerwowane przez LangChain. Kluczowe jest to, że narzędzie zgłasza wynik, a system orkiestracji go interpretuje. Dzięki temu routowanie pozostaje zintegrowane z politykami, których narzędzie w ogóle nie widzi.

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

    Trzy przypadki:

    • Sukces z rzeczywistą treścią → stop=True (kolejna próba modelu polegałaby jedynie na przefrazowaniu).
    • Błąd, który można ponownie spróbować → stop=False (dać modelowi kolejną szansę).
    • Krytyczny błąd → stop=True (błędne pętlenie marnuje tokeny na tym samym nieodpowiednim wyniku).

    stop=False nie oznacza „spróbuj teraz ponownie” – jedynie zapobiega natychmiastowemu zatrzymaniu. Model nadal może zdecydować się na ponowne wyszukiwanie, próbę czegoś innego lub udzielenie odpowiedzi. Router sprowadza się do jednolinowej weryfikacji artefaktów:

    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"
    

    Konstrukcja, która wymusza wartości "success" | "retryable" | "fatal", sprawia, że ścieżki są deterministyczne w przypadku rzeczywistego modelu Groq: sukcesy i błędy krytyczne przechodzą przez tools → finalize → END bez dodatkowej próby modelu; błędy, które można ponownie spróbować, wracają do agent.

    Granica: gdy planowanie trasy zależy również od pozostałych kroków, liczby wcześniejszych prób lub flag autoryzacji, sam artefakt jest niewystarczający — router musi odczytać stan szerszego grafu. content_and_artifact sprawdza się wtedy, gdy wynik tego narzędzia decyduje o następnym kroku.

    Poza wyszukiwaniem

    Odpowiednie są wszystkie narzędzia, których wynik jest bardziej złożony niż tylko „ok”/„nieudane”: narzędzie write_record może ustawić wartość already_applied; narzędzie typu poller może ustawić progress dla interfejsu użytkownika, o którym model nigdy nie wspomina. Artefakt to zwykłe dane — można ich używać w warunkowych łączach, w middleware lub w interfejsie użytkownika, który nie ma dostępu do grafu. return_direct jest decyzją dotyczącą planowania trasy wbudowaną w definicję; nie posiada trybu „przechowywanie informacji, decyzja później”.

    Ta sama koncepcja w create_agent

    Pins: Python 3.12, langchain==1.4.2 / langgraph==1.2.11, langchain-groq==1.1.3. Funkcja create_agent zastępuje ręczny graf połączeń deklaratywnymi łączami wraz z middleware’em.

    Pierwszy instynkt: użyć wrap_tool_call i zwrócić Command(goto=END), gdy ustawiono 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
    

    W przetestowanej wersji ten ścieżka powoduje tylko skrócenie obwodu, gdy END jest już dostępny w sposób określony przez return_direct. Middleware może obliczyć wartość stop=True, podczas gdy pętla nadal zwraca się do modelu, aż ten w końcu udzieli odpowiedzi bez użycia narzędzi. Wyglądało to tak, jakby #5496 nadal funkcjonowało w obecnych konfiguracjach — dopóki dwa warianty oparte na skryptach nie pokazały czegoś innego:

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

    Wersja A działa poprawnie. Wersja B dodaje w tej samej aktualizacji przekaźnik AIMessage bez elementów tool_calls. Sprawdzenie wyjścia przeszukuje elementy wstecz do ostatniego AIMessage, aby ocenić wartość return_direct; znajduje przekaźnik, nie widzi żadnych wywołań narzędzi i nadal kontynuuje pętlę. Został zastosowany Command – kolejność przesyłanych wiadomości ukryła oryginalną wiadomość z wywołanymi narzędziami przed sprawdzeniem wyjścia. To nie jest awaryjna aktualizacja, ani #5496.

    Nawet po naprawieniu tego problemu używana metoda opiera się na before_model: w ogóle nie wymaga ona użycia 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 jest wykonywany tuż przed każdym wezwaniem modelu — w późniejszych iteracjach zaraz po narzędziach. @hook_config(can_jump_to=["end"]) umożliwia przejście do END niezależnie od żadnego flagi narzędzia. Zwrócenie {"jump_to": "end", ...} stanowi zwykłą aktualizację stanu, którą odczytuje krawędź grafu. Jeden hook zarówno wykrywa ten artefakt, jak i tworzy przekaźnik AIMessage — zadanie jest dzielone pomiędzy route_after_tools a finalize.

    Wymuszone wyniki odpowiadają ręcznie zbudowanemu grafowi: sukces lub fatalne przerwanie działania z dokładnie takim samym treścią narzędzia; możliwość ponownej próby oznacza ponowne otwarcie etapu modelu.

    Wniosek

    content_and_artifact nie został zaprojektowany jako element służący do routingu. Rozdziela on odbiorców – treści widoczne dla modelu w porównaniu z metadanymi dostępnymi wyłącznie w aplikacji – a to samo rozdzielenie umożliwia klarowne określenie pytania „czy powinniśmy przestać?”, bez konieczności proszenia modelu o analizę przepływu sterowania. return_direct łączy aspekty prezentacji i zakończenia w jedną statyczną flagę, co powoduje błędy dokładnie wtedy, gdy odpowiedzi na te pytania muszą być różne przy każdej wywołaniu.

    Jeśli jakiś przypadek użycia wymaga warunkowego zatrzymania, należy trzymać oddzielnie wynik narzędzia od decyzji o routingu: udostępnij metadane obok odpowiedzi i pozwól systemowi orkiestracji podjąć decyzję. content_and_artifact już zapewnia taki kanał komunikacji.

    Uwagi projektowe, o których zespoły zapominają po pierwszym udanym teście

    Zatrzymanie warunkowe wydaje się rozwiązane, gdy miną trzy przewidziane wyniki. Proces produkcji wprowadza równoczesność: dwa wywołania narzędzia w jednej turze modelu lub seria zapytań, z których tylko jedno powinno zostać zakończone. Należy określić, czy jakiś element powodujący zatrzymanie może przerwać cały proces, czy wszystkie muszą się zgadzać, lub jakie zasady priorytetu obowiązują. Tę politykę należy zapisać w routerze, a nie w lokalnej wiedzy zespołu.

    Możliwość obserwacji powinna pokazywać ten element obok ToolMessage bez zapisywania poufnych danych z pola content. Gdy następuje zatrzymanie, należy odnotować, która reguła zadziałała – sukces, błąd krytyczny czy przełamanie polityki – aby zespół obsługi mógł wyjaśnić, dlaczego asystent nie „myślał dłużej”. Należy to połączyć z rozliczaniem tokenów: cały sens mechanizmu finalizacji przy sukcesie polega na zmniejszeniu liczby wywołań modelu; panele kontrolne powinny potwierdzać te oszczędności.

    Należy uważać podczas przenoszenia wzorców między różnymi wersjami LangGraph. Nazwy hooki middleware, dostępność elementów typu Command oraz sprawdzania warunków wyjścia po zwróceniu wartości uległy zmianie w przejściu od wersji 0.6 do 1.x. Należy utrzymywać test charakterystyczny, który gwarantuje sukces, możliwość ponownej próby lub reakcję awaryjną przy każdej aktualizacji. Jeśli hook nagle zacznie się w nieskończoność pętlić, należy najpierw sprawdzić strukturę listy wiadomości, zanim zgłosi się błąd frameworka – przekazywanie wiadomości jest częstym powodem takich problemów.

    Wreszcie, unikaj umieszczania flag kontrolnych w polu content „tylko tym razem”. Gdy model zobaczy w tekście wartość stop=true, może opisywać tok sterowania lub pokazywać użytkownikom wewnętrzne kody. Taka struktura istnieje po to, aby procesy mogły być precyzyjnie kontrolowane, przy jednoczesnym zachowaniu czystości kanału dostępnego dla użytkowników.

    Dopasowywanie wzorca do sąsiednich frameworków

    To samo rozdzielenie na treść i kontrolę występuje poza LangGraph. Każdy mechanizm działania agenta, który łączy wyniki wyjściowe narzędzi z jedynym kanałem komunikacji, ostatecznie tworzy specjalne znaczniki, otoczenia JSON lub metadane dodatkowe. W przypadku gdy platforma oferuje oficjalny kanał pomocniczy, należy go używać; jeśli takiego nie ma, trzeba stworzyć udokumentowane otoczenie; nigdy nie należy polegać na tym, że model zignoruje tokeny kontrolne ukryte w tekście.

    Jeśli zespół musi obsługiwać zarówno starsze grafy create_react_agent, jak i nowe aplikacje create_agent, należy utrzymać identyczny kontrakt artefaktu narzędzia i zmienić jedynie implementację routera. Dzięki temu zmiany wersji są ograniczone do testów orkiestracji. Gdy middleware się rozszerza – o weryfikację autoryzacji, limity wydatków, redakcję danych PII – należy uruchomić te hooki przed interpretacją polecenia stop, aby odrzucenie polityki nie zostało pomyłkowo uznane za udany przeciążeniowy zakończenie. Kolejność hooki stanowi część publicznego zachowania agenta, nawet jeśli wydaje się to sprawą techniczną.

    Dokument wyjaśniający przyszłym czytelnikom, dlaczego istnieje funkcja finalize (lub skok before_model): jest to świadoma decyzja produktowa, pozwalająca na pokazywanie tekstu narzędzia użytkownikowi bez dodatkowej obróbki językowej. Jeśli w przyszłości produkt będzie wymagał stylu podsumowania mówionego, należy ponownie dodać węzeł modelu na ścieżce zatrzymania, zamiast obciążać narzędzie pisaniem dwóch wersji jednocześnie. Rozdzielenie „obliczania wyniku” od „opisywania wyniku” umożliwia ponowne wykorzystanie narzędzi w aplikacjach głosowych, czatowych i klientach API.

    Intuicja dotycząca decyzji o zatrzymaniu lub kontynuowaniu

    Wyobraź sobie narzędzie do realizacji zamówień, które czasami zwraca ukończony paragon, czasami komunikat „timeout procesora płatności”, a czasami informację „karta odrzucona trwale”. Te trzy przypadki odpowiadają odpowiednio stanowi sukcesu, możliwości ponownej próby oraz krytycznemu błędowi. content paragonu może być gotowym do wyświetlenia klientowi HTML; plik zawiera wartość { "stop": true, "reason": "completed" }. W przypadku timeoutu w content umieszcza się krótkie wyjaśnienie dla modelu, a w pliku wartość { "stop": false, "reason": "transient" }. Odrzucenie karty trwale zatrzymuje pętlę, wyświetlając bezpieczną dla użytkownika wiadomość oraz wartość { "stop": true, "reason": "fatal" }, aby agent nie nadmiernie obciążał procesora. To samo schemat może być zastosowane do wyszukiwania, tworzenia ticketów czy eksportu dokumentów bez konieczności przepisywania routera – tylko mappingu narzędzia.

    Od błędów surowego API po niewielką zmianę słownictwa opisującego przyczyny. Utrzymywanie tego słownictwa w małych rozmiarach (completed / transient / fatal / policy_block) zapobiega chaosowi spowodowanemu różnymi rozwiązaniami, gdy coraz więcej narzędzi przyjmuje ten wzorzec. Recenzenci powinni odrzucać jednorazowe nazwy typu boolean dla poszczególnych narzędzi, gdy w pakiecie orkiestracji istnieje już wspólne enum.

    Zwyczaje weryfikacji towarzyszące

    Zachowaj narzędzie do sterowania wynikiem w CI przy użyciu fałszywego modelu czatu, który emituje z góry określone wywołania narzędzi. Prawdziwe uruchomienia Groq służą jedynie do okazjonalnej weryfikacji całego procesu, a nie przy każdym commitie. Upewnij się co do dokładnych sekwencji ścieżek: które węzły zostały uruchomione, czy nastąpiło drugie wywołanie modelu oraz czy treść końcowa jest identyczna z treścią generowaną przez narzędzie na ścieżkach zakończenia. Gdy ktoś „uproszcza” middleware i ponownie wprowadza return_direct, narzędzie powinno głośno zwrócić błąd. Przechowuj idealne transkrypcje obok narzędzia, aby błędy można było porównać. Warunkowe zatrzymywanie jest umową behawioralną; testy zapewniają, że ta umowa pozostaje nienaruszona podczas refaktoryzacji w różnych wersjach LangGraph oraz między inżynierami, którzy tylko przeglądają oryginalne notatki projektowe.

    Jeśli w przyszłości produkt będzie wymagał, aby model łączył wyniki z poprzednich prób nawet w przypadku sukcesu, dodaj opcjonalny węzeł dopracowywania po kroku finalize zamiast usuwać mechanizm skrótu. Flagi funkcjonalne są lepsze od przepisywań: stop_mode=hard|polish|never umożliwia kontynuowanie eksperymentów bez utraty zgodności z kontraktem artefaktu. Zmierz zużycie tokenów w każdym trybie na tym samym zestawie zapytań przed wyborem domyślnego ustawienia.

    Kontrakt czytelnika dotyczący przyjęcia tego wzorca

    Zrób kopię schematu artefaktu oraz testów routera przed skopiowaniem tekstu. Wartość eseju leży w rozdzieleniu różnych aspektów, a nie w anegdotach o narzędziach wyszukiwania. Jeśli twoja domena używa innych oznaczeń błędów, przyporządkuj je do tych samych trzech kategorii i zachowaj prostotę routera. Unikaj dodawania czwartej kategorii, dopóki rzeczywy incydent tego nie wymusi. W razie wątpliwości lepiej kontynuuj pracę nad modelem niż zatrzymywać się na niejasnych błędach – ciche zwarcia ukrywające częściowe awarie są gorsze od dodatkowego, niewielkiego wywołania modelu, które wyjaśnia użytkownikowi niepewność.

    Wysyłaj zestaw wraz z artykułem, aby czytelnicy mogli sami sprawdzić przypadki krawędziowe na swoim środowisku, zanim zaufają temu wzorcowi w rzeczywistym ruchu sieciowym.

    Literatura pokrewna

  • ReAct Agents w LangGraph: Myślenie, Działanie, Obserwacja krok po kroku — Implementuj pętlę ReAct jako wyraźne węzły grafu z typowanym stanem, wywołaniami narzędzi oraz warunkami zatrzymania, które można przetestować.