Strona główna / Artykuły

Wdrażanie zasad agenta w kodzie: PreToolUse, PostToolUse oraz hooki Stop

Dowiedz się, dlaczego autoryzacja agentów LLM powinna znajdować się w deterministycznych hookach do wywoływania narzędzi, jak bezpiecznie odrzucać żądania oraz jak otoczyć dysponenta bez użycia rekurencji.

Wydawca: REACTAPP.TOP12 min czytania2721 słów

Źródła: LLM Agents, The Way Nobody Told You — Part 8: Hooks

Zaproszenie systemowe mówiące „tylko klienci premium mogą prosić o zwrot pieniędzy” wydaje się regułą, ale dla modelu jest to silna sugestia. Będzie ona przestrzegana w przypadku niemal każdego wprowadzonego tekstu, natomiast zignorowana przy sporadycznych prośbach sformułowanych w nieco dziwny sposób. Ten artykuł pokazuje, jak przenieść istotne reguły z zaproszenia do hooków – małych fragmentów kodu deterministycznego, które uruchamiają się przy każdym wywołaniu narzędzia, mają dostęp do zaufanego kontekstu po stronie serwera i mogą zatrzymać dane działanie przed jego wykonaniem. Pod koniec dowiesz się, jaki typ hooka użyć do danego zadań, jak odrzucić prośbę bez zakłócenia rozmowy oraz jak bezpiecznie przymocować hooki do istniejącego dysponenta narzędzi.

Jak reguła w zaproszeniu zawodzi w praktyce

Załóżmy pracownika obsługi klienta razem ze specjalistą od zwrotów pieniędzy. W instrukcjach zaznaczono, że tylko klienci premium mogą otwierać sprawy dotyczące zwrotów, a klientom z planem darmowym należy uprzejmie poinformować, że zwroty nie są częścią ich pakietu. Testy wyglądają dobrze: użytkownicy darmowi są odrzucani, natomiast użytkownicy premium otrzymują plik z danymi sprawy.

Następnie użytkownik z planem darmowym wysyła skargę sformułowaną w nietypowy sposób. Brzmi ona jak prośba o zwrot pieniędzy, chociaż w rzeczywistości dotyczy innego rodzaju kredytu na koncie. Model, zaprojektowany tak, by być pomocny, ale teraz niepewny co do sformułowania, uznaje sytuację za wystarczająco podobną i mimo to tworzy plik z danymi sprawy. Nic w tych zasadach nie było dwuznaczne dla człowieka, ale klient, któremu nigdy nie wolno było otwierać takich spraw, teraz ma jedną.

To jest kategoria haczyków błędów, które istnieją po to, by je usunąć. Model nie radzi sobie źle z wykonywaniem instrukcji; jest probabilistyczny. Zasada, która działa w 99% przypadków, zawiedzie w części pozostałych 1%, a w środowisku produkcyjnym ta część faktycznie się pojawia. Gdy złamanie zasady ma rzeczywisty koszt, musi być egzekwowana przez kod, który uruchamia się za każdym razem i z którym model nie może się kłócić.

Trzy punkty haczyków w cyklu życia wywołania narzędzia

Haczyki są przypinane do konkretnych momentów w trakcie wywołania narzędzia. Istnieją trzy takie punkty, a każdy z nich pełni inną funkcję:

  • PreToolUse uruchamia się przed wykonaniem narzędzia. Może odrzucić wywołanie, przepisać jego dane wejściowe lub po prostu je obserwować.
  • PostToolUse uruchamia się po wykonaniu narzędzia. Może jedynie obserwować: rejestrować, audytować lub powiadamiać. Nie może odwrócić tego, co już się wydarzyło.
  • Stop uruchamia się po zakończeniu sesji i zajmuje się czyszczeniem, podsumowaniem oraz przechowywaniem danych.
  • Kompaktowy sposób na zapamiętanie ich funkcji to ten, że PreToolUse pyta, czy coś powinno się wydarzyć, PostToolUse rejestruje, że faktycznie się to wydarzyło, a Stop oznacza koniec sesji.

    Te role nie są wzajemnie zamienialne. Uprawnienia należą do PreToolUse, ponieważ sprawdzenie w PostToolUse odbywa się po tym, jak działanie już zadziałało. Rejestracja działań audytowych należy do PostToolUse, ponieważ wpis zapisany w PreToolUse odnotowuje jedynie intencję; wezwanie może nadal zostać odrzucone lub zawieść, więc log opisywałby rzeczy, które nigdy się nie wydarzyły. Dopasuj każdą kwestię do hooka, którego moment wykonywania jest do niej odpowiedni.

    Zdolności, uprawnienia i zasady to różne formy weryfikacji

    Załóżmy, że chcesz dodać trzy nowe reguły do narzędzia do zarządzania plikami spraw:

    • Zasada A: plik sprawy musi zawierać niepusty wykaz dowodów.
    • Zasada B: klienci z planu darmowego nie mogą tworzyć plików spraw.
    • Zasada C: tworzenie spraw jest blokowane od 2 do 4 rano podczas wykonywania cotygodniowej aktualizacji.

    Każda z tych zasad odrzuca niektóre żądania narzędzia, a mimo to znajduje się w trzech różnych miejscach. Pomaga uporządkowanie każdej zasady w jednej z czterech kategorii:

    • Zdolność, czyli sprawdzanie, czy dane wejściowe są spójne wewnętrznie, znajduje się w implementacji narzędzia. „Wykaz dowodów musi być niepusty” to sprawdzenie zdolności.
    • Upoważnienie, czyli określenie, czy dany użytkownik może to teraz zrobić, znajduje się w hooku PreToolUse. „Użytkownicy darmowi nie mogą tworzyć plików spraw” to przykład upoważnienia.
  • Zasada, czyli reguła obowiązująca w całej organizacji, znajduje się również w hooku PreToolUse. „Brak zapisów podczas okna implementacji” to przykład zasady.
  • Obserwacja, czyli zapis tego, co się wydarzyło, znajduje się w hooku PostToolUse. „Zapisywanie każdego zapisu do dziennika audytowego” to przykład obserwacji.
  • Korzystne uproszczenie: narzędzie pyta, czy może wykonać operację przy użyciu otrzymanych argumentów, natomiast hook pyta, czy osoba wywołująca ma na to w tym momencie uprawnienia.

    Dlaczego takie rozdzielenie wynika z tego, co może widzieć każda warstwa

    Różnica ta nie jest kwestią stylu; wynika z danych, jakie ma każda warstwa. Narzędzie otrzymuje tylko argumenty wygenerowane przez model. Może porównywać te argumenty między sobą, aby sprawdzić na przykład, czy lista nie jest pusta lub czy wartość jest dodatnia, ale nie ma nic innego, z czym mogłoby je porównać.

    Hook otrzymuje te same argumenty plus zaufany kontekst przygotowany przez serwer: zidentyfikowaną identyfikację użytkownika, rzeczywisty poziom użytkownika, aktualną godzinę oraz identyfikator sesji. Ten kontekst znajduje się w obiekcie HookContext, utworzonym na początku sesji, a następnie zamrożonym.

    Mając oba te elementy, hook może postępować w ten sposób: model chce wywołać funkcję lookup_user z parametrem user_id='u_999', ale sesja należy do użytkownika u_004, więc wywołanie jest odrzucane. Narzędzie nie mogłoby podjąć takiej decyzji, ponieważ nigdy nie wie, do kogo należy sesja. Autoryzacja polega zawsze na porównywaniu niezaufanego wyniku modelu z zaufanym stanem serwera, a tylko hooki posiadają oba te elementy.

    Rozdzielenie zaufanego kontekstu od niezaufanych danych wejściowych

    HookContext to stan po stronie serwera. Twój własny kod tworzy go na początku sesji, na podstawie źródeł, którymi dysponujesz, takich jak warstwa autoryzacji, menedżer sesji i baza danych. Jest niezmienialny przez cały czas trwania sesji, a ani użytkownik, ani model nie mogą na niego wpłynąć. Poniższa klasa danych typu frozen zawiera to, co jest absolutnie konieczne:

    @dataclass(frozen=True)
    class HookContext:
        user_id: str         # set from your auth layer
        tier: Literal["free", "premium"]
        session_id: str
    

    frozen=True pełni tu kluczową rolę: po utworzeniu obiektu nie można go modyfikować żadną ścieżką kodową, włączając w to haki zawierające błędy.

    tool_input to natomiast słownik, który model umieszcza w swoim bloku tool_use. Traktuj go dokładnie tak samo jak treść przychodzącej żądania HTTP. Użytkownik może skłonić model do wygenerowania praktycznie czegokolwiek, poprzez iniekcję poleceń lub po prostu dzięki utrzymywaniu stanu.

    Weryfikacja uprawnień porównuje te dwa wartości. Czy user_id dostarczony przez model jest równy HookContext.user_id? Czy warunek HookContext.tier == „free” wyklucza tę funkcję zapisu? Zasada zapewniająca bezpieczeństwo systemu jest prosta: decyzje dotyczące bezpieczeństwa muszą opierać się na HookContext, a nie na czymkolwiek, co dostarczył model.

    Odrzucenie połączenia bez przerwania rozmowy

    Oczywistym sposobem na odrzucenie połączenia jest jego pominięcie. W przypadku interfejsów API do wywoływania narzędzi, które stosują format wiadomości Anthropic, to nie działa. Każdy blok tool_use wysłany przez asystenta musi zostać odpowiedziany blokiem tool_result zawierającym ten sam tool_use_id w następnej wiadomości użytkownika. Jeśli jeden z nich pozostanie bez odpowiedzi, kolejna prośba API zakończy się błędem 400.

    Zatem odrzucenie żądania PreToolUse musi generować syntetyczny wynik, który zastępuje wyjście narzędzia. Poniższy przykład to słownik w Pythonie (zwróć uwagę na True) oznaczony jako błąd, wraz z komunikatem wyjaśniającym, dlaczego żądanie zostało odrzucone i co należy zrobić zamiast tego:

    {
      "type": "tool_result",
      "tool_use_id": "toolu_01ABC",
      "is_error": True,
      "content": "BLOCKED: free-tier users cannot call prepare_case_file. Suggest upgrade or route to general FAQ."
    }
    

    To spełnia warunki umowy API, a model traktuje to jak każdą inną obserwację. Widzi, że żądanie zostało odrzucone z powodu określonego powodu, przeprasza i proponuje inną drogę postępowania, a rozmowa toczy się dalej normalnie.

    Dwa szczegóły sprawiają, że to rozwiązanie dobrze funkcjonuje.

    Jednolity prefiks przekształca odrzucenia w sygnał

    Przedrostek BLOCKED: nie jest wymagany przez API – to jedynie konwencja. Spójny znacznik pomaga modelowi rozróżnić celowe odrzucenie od tymczasowego błędu, dzięki czemu zmienia strategię zamiast ponawiać tę samą próbę. Ten sam znacznik umożliwia późniejsze oddzielenie celowych odmów od rzeczywistych błędów narzędzia podczas oceny. Konwencja w formie tekstu prostego staje się sygnałem czytelnym dla maszyny.

    Wiadomość o odmowie jest instrukcją

    Model będzie budował swoją następną odpowiedź na podstawie tekstu, który mu przekażesz, dlatego pisz go z taką samą starannością jak instrukcję. Proste „Denied” nie daje mu żadnych podstaw do pracy. Wiadomość, która wskazuje powód (np. poziom darmowy nie umożliwia użycia tego narzędzia) oraz proponuje alternatywy (np. aktualizację lub odwołanie się do ogólnych pytań często zadawanych), informuje model zarówno o przyczynie niepowodzenia, jak i o tym, co można zaoferować zamiast tego.

    Dodawanie elementów za pomocą otaczania dysponenta

    Większość agentów ma już dysponenta z funkcją call_tool, która przyjmuje blok tool_use i wywołuje odpowiadającą mu funkcję w Pythonie. Najmniej inwazyjnym sposobem na dodanie hooków jest otoczenie tej funkcji warstwą wrappera, zamiast przepisywać cały dysponent. Pierwszym krokiem jest zachowanie odniesienia do oryginalnej funkcji przed jakimikolwiek zmianami:

    original_call_tool = dispatcher.call_tool   # save the reference FIRST
    

    Następnie definiuje się wrapper, który uruchamia łańcuch hooków wokół zapisanej oryginalnej funkcji i instaluje go w miejsce starej funkcji. W opublikowanym fragmencie instrukcje return result oraz przypisanie dispatcher.call_tool = hooked_call_tool zostały połączone na jednej linii; w rzeczywistości są to dwie oddzielne instrukcje, przy czym przypisanie znajduje się poza ciałem funkcji.

    def hooked_call_tool(name, args):
        event = build_event(name, args)
        if denial := registry.run_pre(event):
            return synthetic_error_result(denial)   # PreToolUse denied
        result = original_call_tool(name, args)     # call the saved original
        registry.run_post(event, result)
        return resultdispatcher.call_tool = hooked_call_tool   # NOW replace it
    

    Wrapper wykonywa pięć kroków:

    1. Zmiana wywołania narzędzia na zdarzenie hooka.
    2. Uruchomienie wszystkich hooków typu PreToolUse.
  • Jeśli któryś z nich odmówi, zwróć syntetyczny wynik błędu; rzeczywisty narzędzie nigdy nie zostanie wywołane.
  • w przeciwnym razie wezwij zapisany oryginał i przechwytaj jego wynik.
  • Zastosuj każdy hook PostToolUse w celu audytu, powiadamiania i rejestracji działań.
  • Zapisz przed zastąpieniem

    Wzorcem do przyjęcia jest zapisywanie oryginału przed jego zastąpieniem, a dotyczy to zarówno dekoratorów, middleware, jak i interceptorów. Rozważ, co się stanie, jeśli pominiesz zapisaną referencję i napiszesz dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw). Lambda szuka wartości dispatcher.call_tool tylko w momencie wykonywania, a do tego czasu ten atrybut wskazuje już na samą lambdę. Wrapper wywołuje siebie, a ten z kolei ponownie siebie, aż pierwsze wywołanie narzędzia skończy się nieskończoną rekurencją i przepełnieniem stosu.

    Przypisanie oryginału do zmiennej najpierw oznacza, że zamykanie przechowuje odwołanie do rzeczywistego obiektu funkcji, a nie do nazwy, która zostanie później ponownie przypisana. Dlatego wrapper zawsze dostępuje do prawdziwej implementacji.

    Dlaczego haki są lepsze od zasad promptów i statycznych list narzędzi

    Istnieją trzy powszechne sposoby na powstrzymanie agenta przed wykonywaniem jakiejś czynności, a każdy z nich zawodzi w inny sposób:

    • Instrukcja promptu taka jak „nie rób X” może zostać po prostu zignorowana, gdy model jest niepewny.
    • Filtrowanie narzędzi, czyli całkowite wykluczenie danego narzędzia z listy, jest statyczne i nie może reagować na stan w czasie wykonywania.
    • Haki są deterministyczne i biorą pod uwagę stan w czasie wykonywania.

    Tool filtering is still valuable. If the free-tier specialist never needs create_jira_ticket, do not give it that tool. A model cannot emit a tool_use block for a tool it has never been told about, and an ability the model lacks is more reliable than an instruction it might disregard.

    Filtering falls short for rules that depend on the session. Whether the user is free-tier depends on who is logged in: the agent code is identical, but different sessions are allowed different actions. A static tool list cannot express that; a hook can.

    The robust arrangement layers all three:

    1. Tool filtering at the specialist level, for tools a specialist never needs.
    2. Prompt guidance for soft preferences and general nudges.
    3. Hooks for runtime rules that must hold without exception.

    Aby zobaczyć porównywalne podejście w ramach określonego frameworku, sprawdź, jak LangChain guardrails i middleware radzą sobie z tym samym problemem.

    Jedna powierzchnia narzędzi oznacza jedną warstwę egzekwowania

    Hooks zapewniają największe możliwości, gdy każda akcja, którą może wykonać agent, jest modelowana jako wywołanie narzędzia: delegowanie zadań podagentom, żądania do zewnętrznych API, zapytania do bazy danych oraz wyszukiwania w bazie wiedzy. Wrapper hooked_call_tool obejmuje je wszystkie, włączając narzędzia dodane później, a autoryzacja, polityki i audyt są stosowane równomiernie we wszystkich akcjach.

    Gdyby delegacja do podagenta była realizowana poprzez bezpośredni wywołanie funkcji, warstwa hooków w ogóle by tego nie zauważyła. Potrzebny byłby drugi mechanizm autoryzacji wyłącznie do kierowania ruchem, co oznacza konieczność utrzymywania dwóch systemów oraz dwa miejsca, gdzie mogą się ukrywać błędy. Projektowanie od samego początku z myślą o jednolitej interfejsie narzędzia sprawia, że hooki stanowią niewielkie uzupełnienie; ich dodawanie później przekształca je w dużą operację refaktoryzacji.

    Jak zachowuje się łańcuch hooków PreToolUse

    Rzeczywiste systemy gromadzą kilka hooków PreToolUse, na przykład sprawdzenie poziomu uprawnień, kontrolę okna wdrożenia, dopasowanie identyfikatora użytkownika oraz limit wydatków. Wykonują się one sekwencyjnie zgodnie z następującą semantyką:

    • Pierwsze odrzucenie ma pierwszeństwo. Przerwać może cały łańcuch, dzięki czemu późniejsze hooki oraz samo narzędzie nie są uruchamiane.
    • Zatwierdzenie nie przerywa łańcucha. Każdy hook musi zostać przezwyciężony, aby wywołanie mogło kontynuować się dalej.
  • Modyfikacje są komponowane. Hook B otrzymuje dane wejściowe w takiej formie, jaką zapisał hook A.
  • Należy uporządkować łańcuch według kosztu i stopnia krytyczności, umieszczając najtańsze, decydujące sprawdzenia na początku. Sprawdzanie, czy użytkownik należy do planu darmowego, wymaga odczytu jednego pola; sprawdzanie tygodniowego limitu wydatków organizacji może wymagać zapytania do bazy danych. Wykonanie najtańszego sprawdzenia na początku pozwala uniknąć niepotrzebnej pracy przy żądaniach, które i tak zostaną odrzucone, podobnie jak umieszczanie najbardziej selektywnych warunków na początku klauzuli WHERE w SQL.

    Co powinno się stać, gdy sam hook ulegnie awarii

    Każdy typ hooka wymaga innej polityki radzenia sobie z awariami, a jej błędne wdrożenie powoduje prawdziwe błędy, a nie problemy stylistyczne:

    • Błędy PreToolUse powinny być przenoszone dalej. Awaryjny hook polityki to wada. Udawanie, że proces się odbył, podczas gdy tak nie było, jest gorsze niż jego zatrzymanie, dlatego należy wstrzymać agenta, dopóki polityka nie zostanie poprawiona.
    • Błędy PostToolUse muszą być ignorowane. Awaria rejestratora audytu nie powinna blokować żądania użytkownika, co ma większe znaczenie w danym momencie niż wpis do logu. Zapisz błąd na oddzielnym kanale i kontynuuj.
    • Błędy Stop muszą być ignorowane. Sesja i tak się kończy, więc najlepszym rozwiązaniem jest zapisanie błędu przy sprzątaniu danych i wyjście.

    Hook PreToolUse, który w tajemnicy łapie własne wyjątki, stanowi lukę bezpieczeństwa: sprawdzenie nie zostało przeprowadzone, wywołanie przebiegło pomyślnie, a ślad audytu nie dostarcza żadnego wyjaśnienia. Hook PostToolUse, który pozwala wyjątkom uciec, zamienia każdą awarię logowania w awarię agenta.

    Gdzie znajduje się ślad audytu

    Prosta konfiguracja polega na tworzeniu dwóch plików do dodawania tylko danych:

    • data/audit_log.jsonl, tworzony przez PostToolUse po każdej wywołanej narzędziu, zawierający nazwę narzędzia, dane wejściowe, wynik, flagę błędu oraz datę i godzinę.
    • data/session_summaries.jsonl, generowany raz na sesję przez hook Stop, zawierający jedną wiersz z statystykami ogólnymi na każdą rozmowę.

    Zapisy z logu audytowego szybko stają się najcenniejszym plikiem w projekcie. Budowany od samego początku, dostarcza dane niezbędne do spełniania wymogów regulacyjnych, oceny efektywności, analizy kosztów, debugowania oraz tworzenia paneli kontrolnych, wywodząc się z jednego źródła prawdy. W środowisku produkcyjnym zazwyczaj te same dane są przechowywane w trwałym magazynie, ale ich struktura pozostaje niezmienna.

    Główne wnioski

    • Instrukcje dotyczące promptów mają charakter doradczy; wszelkie zasady, których naruszenie wiąże się z rzeczywistymi konsekwencjami, muszą być realizowane w kodzie.
  • Umieść sprawdzania spójności danych wejściowych w narzędziu, autoryzację i zasady w PreToolUse, a audyt w PostToolUse.
  • Każdą decyzję bezpieczeństwa opieraj na zamrożonym kontekście po stronie serwera i traktuj dane wejściowe modelu jako niezaufane informacje z żądania.
  • Odrzuć wywołania, zwracając syntetyczny, wyraźnie sformatowany tool_result, aby rozmowa pozostała ważna, a model mógł się odzyskać.
  • Otocz mechanizm dyspozytora, najpierw zapisując oryginalną funkcję, i kieruj każdą akcję agenta przez tę jedyną interfejs narzędzia.
  • Pozwól, aby błędy w PreToolUse zatrzymywały agenta, natomiast błędy w PostToolUse i Stop miały być rejestrowane, ale ignorowane.
  • Literatura pokrewna

  • Działania agentów w zależności od skutków, a nie od czasowników: lekcje z grupy pięciu agentów — Jak mała struktura wielu agentów radziła sobie z bramką zatwierdzeń opartą na słowach kluczowych, co agenci sami stworzyli oraz dlaczego uprawnienia muszą opisywać skutki, a nie słowa.