Головна / Статті

Застосування правил агента в коді: PreToolUse, PostToolUse та функції Stop Hooks

Дізнайтеся, чому авторизація агентів LLM має знаходитися у детерміністичних гаках виклику інструментів, як безпечно відхиляти виклики та як обгорнути диспетчер без рекурсії.

Видавець: REACTAPP.TOP12 хв читання2721 слів

Джерела: LLM Agents, The Way Nobody Told You — Part 8: Hooks

Попередження системи, яке каже «лише преміум-користувачі можуть подати запит на повернення грошей», здається правилом, але для моделі це є сильною рекомендацією. Її будуть дотримуватися майже для кожного запиту, але ігнорувати у випадках, коли запит сформульований достатньо дивно. У цій статті показано, як перемістити справді важливі правила з попереджень у хуки — невеликі фрагменти детерміністичного коду, які виконуються після кожного виклику інструменту, мають доступ до надійного контексту з боку сервера та можуть зупинити дію до її виконання. До кінця ви дізнаєтесь, який тип хука використовувати для кожної задачі, як відхилити запит, не порушуючи розмову, та як безпечно приєднати хуки до існуючого диспетчера інструментів.

Як правило попередження зазнає невдачі на практиці

Уявіть собі співробітника служби підтримки клієнтів разом із фахівцем з повернення коштів. У інструкції зазначено, що лише клієнти преміум-тарифу можуть відкривати справи щодо повернення коштів, а клієнтам безкоштовного тарифу слід ввічливо пояснити, що повернення коштів не входить до їхнього плану. Тестування пройшло добре: користувачі безкоштовного тарифу отримують відмову, а користувачі преміум-тарифу — файл справи.

Потім користувач безкоштовного тарифу надсилає скаргу, сформульовану дивним чином. Вона схожа на запит про повернення коштів, хоча насправді йдеться про інший вид кредиту на рахунок. Модель, створена для надання допомоги, але зараз не впевнена щодо формулювання, вирішує, що ситуація достатньо схожа, і все одно створює файл справи. У правилах не було нічого двозначного з точки зору людини, але клієнт, якому ніколи не дозволяли відкривати справи, тепер має таку справу.

Це та категорія гаків для виправлень помилок, які існують для їх усунення. Модель не погано виконує інструкції; вона працює на основі ймовірностей. Правило, яке працює для 99% вхідних даних, може не спрацювати для певної частини решти 1%, і в умовах реальної роботи саме ця частина проявляється. Коли порушення правила має реальні наслідки, його необхідно забезпечувати кодом, який виконується щоразу та з яким модель не може посперечатися.

Три точки гаків у життєвому циклі виклику інструменту

Гаки підключаються до конкретних моментів у життєвому циклі виклику інструменту. Їх є три, і кожен з них виконує окрему функцію:

  • PreToolUse виконується перед тим, як інструмент запуститься. Він може відхилити виклик, переписати його вхідні дані або просто спостерігати за ним.
  • PostToolUse виконується після того, як інструмент виконав свої дії. Він може лише спостерігати: фіксувати дані, проводити аудит чи повідомляти. Він не може скасувати те, що вже сталось.
  • Stop запускається після завершення сеансу та відповідає за очищення, підсумки та збереження даних.
  • Компактний спосіб їх запам’ятати: PreToolUse запитує, чи має щось відбутися, PostToolUse фіксує, що це дійсно сталось, а Stop позначає кінець сеансу.

    Ці ролі не є взаємозамінними. Авторизація належить до PreToolUse, оскільки перевірка в PostToolUse виконується після того, як дія вже мала ефект. Логування аудиту належить до PostToolUse, тому що запис у PreToolUse фіксує лише намір; виклик все ще може бути відхилений або зазнати невдачі, тож у журналі будуть описані речі, які ніколи не відбулися. Підберіть кожну проблему до гака, час виконання якого підходить їй.

    Функціональні можливості, авторизація та політика — це різні перевірки

    Припустимо, ви хочете додати три нові правила до інструменту для обробки справ:

    • Правило А: справа має містити не порожній список доказів.
    • Правило Б: клієнтам з безкоштовного тарифу не дозволяється створювати справи.
    • Правило В: створення справ блокується з 2 години ранку до 4 години ранку під час щотижневого оновлення.

    Кожне з цих правил відхиляє певні запити інструменту, проте вони належать до трьох різних категорій. Корисно класифікувати кожне правило у одну з чотирьох категорій:

    • Функціональність – тобто чи є вхідні дані внутрішньо послідовними – знаходиться у реалізації інструменту. „Список доказів має бути не порожнім“ є перевіркою функціональності.
    • Дозвіл – тобто чи може цей користувач виконати цю дію зараз – знаходиться у функції PreToolUse. „Користувачі безкоштовного тарифу не можуть створювати справи“ є прикладом дозволу.
  • Політика, тобто правило, що стосується всієї організації, також знаходиться у гаку PreToolUse. „Заборона на записи під час вікна розгортання“ є прикладом політики.
  • Спостереження, тобто запис того, що сталось, знаходиться у гаку PostToolUse. „Записувати кожен запис у журнал аудиту“ є прикладом спостереження.
  • Корисний швидкий спосіб: інструмент запитує, чи може він виконати операцію з отриманими аргументами, тоді як гак запитує, чи дозволено виконавцю це зробити у цей момент.

    Чому такий розподіл випливає з того, що бачить кожен шар

    Це розрізнення не є стилістичним; воно походить від даних, які має кожен шар. Інструмент отримує лише аргументи, згенеровані моделлю. Він може порівнювати ці аргументи між собою, перевіряючи, чи список не порожній або чи сума є додатною, але у нього немає нічого іншого для порівняння.

    Хук отримує ті самі аргументи, а також надійний контекст, створений сервером: ідентифікатор автентифікованого користувача, справжній рівень користувача, поточний час та ідентифікатор сеансу. Цей контекст зберігається у об’єкті HookContext, створеному на початку сеансу, а потім замороженому.

    Маючи це під рукою, хук може міркувати так: модель хоче викликати lookup_user з параметром user_id='u_999', але сеанс належить u_004, тому виклик відхиляється. Інструмент ніколи не зможе прийняти таке рішення, оскільки він ніколи не бачить, кому належить сеанс. Авторизація завжди означає порівняння ненадійного результату роботи моделі з надійним станом сервера, і лише хуки мають обидва ці елементи.

    Розділення надійного контексту та ненадійних вхідних даних

    HookContext — це стан з боку сервера. Ваш власний код створює його під час початку сесії на основі джерел, якими ви керуєте, таких як шар автентифікації, менеджер сесій та база даних. Він залишається незмінним протягом усього життєвого циклу сесії, і ні користувач, ні модель не можуть на нього вплинути. Наведений нижче заморожений клас даних містить мінімум необхідної інформації:

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

    frozen=True виконує справжню роботу тут: після створення об’єкт не може бути змінений жодним шляхом коду, включаючи гак із помилкою.

    tool_input, навпаки, — це словник, який модель розміщує у своєму блоку tool_use. Ставтеся до нього точно так само, як до тіла вхідного HTTP-запиту. Користувач може змусити модель створити майже будь-що там, шляхом вставки команд або просто завдяки постійному зберіганню даних.

    Перевірка авторизації порівнює ці два значення. Чи дорівнює user_id, який надав модель, значенню HookContext.user_id? Чи виключає умова HookContext.tier == "free" використання цього інструменту для запису? Принцип, який забезпечує безпеку системи, є простим: рішення щодо безпеки має ґрунтуватися на HookContext, а не на чомусь, що надала модель.

    Відмова у виклику без порушення розмови

    Очевидним способом відмовити у виклику є пропуск його. Однак з API для виклику інструментів, які дотримуються формату повідомлень Anthropic, це не спрацює. Кожен блок tool_use, який відправляє асистент, має бути відповідьмо блоком tool_result, який містить ту саму цифру tool_use_id у наступному повідомленні користувача. Якщо один з них залишиться без відповіді, наступний запит API завершиться з помилкою 400.

    Отже, відмова у використанні PreToolUse має створювати синтетичний результат, який замінює вихід даного інструменту. У наведеному нижче прикладі йдеться про словник у Python (зверніть увагу на True), позначений як помилка, із повідомленням, що пояснює причину відмови та що робити замість цього:

    {
      "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."
    }
    

    Це відповідає умовам API, і модель сприймає це як звичайне спостереження. Вона бачить, що запит було відхилено з певної причини, вибачається та пропонує інший шлях, після чого розмова продовжується як зазвичай.

    Два фактори сприяють ефективній роботі цього підходу.

    Єдиний префікс перетворює відмови на сигнал

    Префікс BLOCKED: — це не вимога API, а лише угода. Єдиний маркер допомагає моделі розпізнати навмисний відмову замість тимчасової несправності, тож вона змінює підхід замість того, щоб повторювати ту саму спробу. Цей же маркер дозволяє інструментам оцінки пізніше розрізняти навмисні відмови від справжніх помилок інструменту. Угода у вигляді простого тексту перетворюється на сигнал, зрозумілий машинам.

    Повідомлення про відмову — це запит

    Модель будуватиме свою наступну відповідь на основі будь-якого тексту, який ви надасте, тому пишіть його з такою ж увагою, як і запит. Просте „Denied“ не дає їй нічого для роботи. Повідомлення, у якому вказано причину (наприклад, безкоштовний тариф не дозволяє використовувати цей інструмент) та запропоновано альтернативи (підвищення тарифу чи посилання на FAQ), повідомляє їй як причину невдачі, так і те, що можна запропонувати замість цього.

    Додавання хуків шляхом обгортки диспетчера

    Більшість агентів вже мають диспетчера з функцією call_tool, яка приймає блок tool_use та викликає відповідну функцію Python. Найменш інвазивний спосіб додати гачки — це обгорнути цю функцію, замість того щоб переписувати диспетчер. Першим кроком є збереження посилання на оригінал перед будь-якими змінами:

    original_call_tool = dispatcher.call_tool   # save the reference FIRST
    

    Потім визначається обгортка, яка виконує ланцюжок гачків навколо збереженого оригіналу, та встановлюється замість старої функції. У опублікованому уривку останній оператор return result та переатрибуція dispatcher.call_tool = hooked_call_tool були об’єднані в один рядок; насправді це два окремі оператори, причому переатрибуція знаходиться поза тілом функції.

    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
    

    Обгортка виконує п’ять кроків:

    1. Перетворює виклик інструменту на подію гачка.
    2. Виконує кожен гачок типу PreToolUse.
  • Якщо хтось із них відмовляється, поверніть синтетичний результат помилки; реальний інструмент ніколи не викликається.
  • Інакше викличте збережений оригінал та зафіксуйте його результат.
  • Виконайте кожен гак PostToolUse для аудиту, сповіщень та логування.
  • Збережіть перед заміною

    Шаблон, який слід дотримуватися, — це збереження оригіналу перед його заміною, і це стосується однаково декораторів, мідлвейру та інтерцепторів. Розгляньте, що станеться, якщо ви пропустите збережений посилання та напишете dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw). Функція lambda шукає dispatcher.call_tool лише під час виконання, а до того часу цей атрибут вказує на саму функцію lambda. Обгортка викликає себе, що призводить до подальших викликів себе, поки перший виклик інструменту не завершиться через необмежену рекурсію та переповнення стеку.

    Спочатку присвоєння оригіналу змінній означає, що клоузур зберігає посилання на справжній об’єкт функції, а не на ім’я, яке буде пізніше переадресовано. Тому веб-обгортка завжди отримує доступ до справжньої реалізації.

    Чому хуки кращі за правила запитів та статичні списки інструментів

    Існує три поширені способи зупинити агента від виконання певної дії, і кожен з них працює по-різному:

    • Інструкцію запиту, таку як «не робіть X», можна просто проігнорувати, коли модель не впевнена.
    • Фільтрація інструментів, тобто виключення інструмента зі списку повністю, є статичною та не може реагувати на стан під час виконання.
    • Хуки є детермінованими та усвідомлюють стан під час виконання.

    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.

    Щоб дізнатися про аналогічний підхід у межах фреймворку, подивіться, як LangChain guardrails та middleware вирішують таку саму проблему.

    Одна поверхня інструментів означає один шар контролю

    Hooks надають максимальну ефективність, коли кожна дія агента моделюється як виклик інструменту: делегування завдань підагентам, запити до зовнішніх API, запити до баз даних та пошук у базі знань. Обгортка hooked_call_tool потім бачить усі ці дії, включаючи інструменти, додані пізніше, а авторизація, політики та аудит застосовуються однаково до всієї поверхні дій.

    Якби делегування підагенту здійснювалося шляхом прямого виклику функції, шар хуків ніколи б цього не побачив. Для маршрутизації знадобився б окремий механізм авторизації, що означає необхідність підтримки двох систем та наявність двох місць, де можуть ховатися помилки. Якщо з самого початку проектувати єдиний інтерфейс інструментів, хуки стануть незначним доповненням; якщо ж впроваджувати їх пізніше, це перетвориться на масштабну реконструкцію.

    Як поводиться ланцюг хуків PreToolUse

    У реальних системах накопичується кілька хуків PreToolUse — наприклад, перевірка рівня доступу, перевірка часового вікна розгортання, збіг ідентифікатора користувача та обмеження витрат. Вони виконуються по черзі згідно з наступною семантикою:

    • Перше відхилення має перевагу. Воно перериває ланцюг, тож подальші хуки та сам інструмент не виконуються.
    • Дозвіл не призводить до переривання. Кожен хук має пройти перевірку, щоб виклик міг продовжитися.
  • Модифікації складаються одна з одною. Hook B отримує вхідні дані у тому вигляді, у якому їх переписав hook A.
  • Впорядкуйте ланцюг за вартістю та критичністю, розміщуючи спочатку дешеві та вирішальні перевірки. Перевірка того, чи є користувач у безкоштовній версії, вимагає ознайомлення з одним полем; перевірка тижневого ліміту витрат організації може потребувати запиту до бази даних. Виконання дешевої перевірки спочатку уникає зайвих операцій щодо запитів, які все одно будуть відхилені, подібно до того, як у клозиці SQL WHERE спочатку ставляться найбільш селективні умови.

    Що має відбуватися, якщо сам hook зламується

    Кожен тип hook потребує різної політики поводження з несправностями, і її неправильне застосування призводить до справжніх багів, а не лише до проблем з форматуванням:

    • Помилки PreToolUse мають передаватися далі. Зламаний hook політики є дефектом. Вдача, ніби процедура пройшла, коли насправді цього не сталося, гірша за зупинку, тому потрібно зупинити агента до виправлення політики.
    • Помилки PostToolUse потрібно ігнорувати. Несправний журнал аудиту не повинен блокувати запит користувача, адже це має більше значення у даному моменті, ніж запис у журналі. Запишіть проблему в окремий канал та продовжуйте роботу.
    • Помилки Stop також потрібно ігнорувати. Сесія все одно закінчується, тому найкращим варіантом є запис про проблему під час очищення та вихід з програми.

    Хук PreToolUse, який тихо перехоплює власні винятки, є вразливістю з точки зору безпеки: перевірка не відбулася, виклик пройшов, а журнал аудиту не містить пояснень. Хук PostToolUse, який дозволяє виняткам вийти назовні, перетворює кожну проблему з журналуванням на збій роботи агента.

    Де знаходиться журнал аудиту

    У простій конфігурації створюються два файли з можливістю лише додавання даних:

    • data/audit_log.jsonl — файл, створюваний PostToolUse після кожного виклику інструменту, який містить назву інструменту, вхідні дані, результат, позначку про помилку та час створення.
    • data/session_summaries.jsonl — файл, який створюється один раз на сесію за допомогою хука Stop; він містить один рядок з агрегованими статистичними даними за кожну розмову.

    Журнал аудиту швидко стає найціннішим файлом у проекті. Створений з самого початку, він забезпечує дотримання правил, може використовуватися для оцінки, аналізу витрат, виправлення помилок та створення панелей керування, надаючи єдине джерело інформації. У продакшені зазвичай ці ж записи передаються у надійний сховище, але формат даних залишається незмінним.

    Основні висновки

    • Інструкції до запиту є рекомендаційними; будь-які правила, порушення яких має реальні наслідки, повинні бути реалізовані у коді.
  • Вбудуйте перевірки послідовності вхідних даних у інструмент, авторизацію та політики — у PreToolUse, а аудит — у PostToolUse.
  • Усі рішення щодо безпеки мають ґрунтуватися на зафіксованому контексті з боку сервера, а вхідні дані інструменту моделі слід розглядати як ненадійні дані запиту.
  • Відхиляйте виклики, повертаючи синтетичний результат із чітким префіксом tool_result, щоб розмова залишалася дійсною та модель могла відновитися.
  • Оточіть диспетчер, спочатку зберігаючи оригінальну функцію, та направляйте кожну дію агента через цей єдиний інтерфейс інструменту.
  • Нехай помилки PreToolUse зупиняють агента, а помилки PostToolUse та Stop записуються та ігноруються.