Применение правил агента в коде: хуки PreToolUse, PostToolUse и Stop
Узнайте, почему авторизация агентов LLM должна находиться в хуках типа deterministic tool-call, как безопасно отклонять запросы и как обернуть диспетчер без рекурсии.
Источники: LLM Agents, The Way Nobody Told You — Part 8: Hooks
Предупреждение в системном запросе, гласящее «только премиум-пользователи могут запросить возврат средств», кажется правилом, но для модели это скорее настоятельная рекомендация. Она будет ею следовать практически при любом вводе, но проигнорирует её в редких случаях, когда запрос сформулирован достаточно необычно. В этой статье объясняется, как вынести действительно важные правила из запроса в хуки — небольшие фрагменты детерминистического кода, которые выполняются при каждом вызове инструмента, имеют доступ к надежному контексту с серверной стороны и могут остановить действие до его выполнения. К концу вы узнаете, какой тип хука использовать для каждой задачи, как отклонить запрос, не нарушив ход разговора, и как безопасно привязать хуки к существующему диспетчеру инструментов.
Как правило запроса терпит неудачу на практике
Представьте себе агента службы поддержки клиентов вместе со специалистом по возвратам средств. В инструкции указано, что только клиенты премиум-уровня могут открывать заявки на возврат, а клиентам бесплатного тарифа следует вежливо сообщить, что возвраты средств не входят в их пакет услуг. Тестирование прошло успешно: пользователи бесплатного тарифа отклоняются, а пользователи премиум-уровня получают досье на заявку.
Затем пользователь бесплатного тарифа отправляет жалобу, сформулированную необычным образом. Она похожа на запрос о возврате средств, хотя на самом деле касается другого вида кредита на счет. Модель, предназначенная для оказания помощи и теперь не уверенная в формулировке запроса, считает ситуацию достаточно похожей и всё равно создаёт досье на заявку. Ничто в правилах не было двусмысленным с точки зрения человека, но у клиента, которому раньше никогда не разрешали открывать заявки, теперь появилась такая заявка.
Это та категория хуков для исправления ошибок, которые существуют для их устранения. Модель неплохо справляется с выполнением инструкций, но она работает на основе вероятностей. Правило, справедливое для 99% входных данных, может не сработать для определённой части оставшихся 1%, и в реальных условиях эта часть проявляется. Когда нарушение правила имеет реальные последствия, его необходимо обеспечивать кодом, который выполняется каждый раз и с которым модель не может поспорить.
Три точки применения хуков в жизненном цикле вызова инструмента
Хуки привязываются к определённым моментам в жизненном цикле вызова инструмента. Их три, и у каждого своя функция:
- PreToolUse выполняется до запуска инструмента. Он может отклонить вызов, переписать его входные данные или просто наблюдать за процессом.
- PostToolUse выполняется после завершения работы инструмента. Он может только наблюдать: вести логи, проводить аудит или уведомлять. Он не может изменить то, что уже произошло.
Компактный способ запомнить их: PreToolUse проверяет, должно ли что-то произойти, PostToolUse фиксирует, что это действительно произошло, а Stop обозначает конец сессии.
Эти роли не могут быть заменены друг на друга. Авторизация относится к PreToolUse, поскольку проверка в PostToolUse выполняется после того, как действие уже совершилось. Логирование аудита относится к PostToolUse, потому что запись в PreToolUse фиксирует лишь намерение; запрос всё ещё может быть отклонён или провалиться, поэтому в логе будут описаны события, которые так и не произошли. Сопоставьте каждую задачу с хуком, временная точка которого ей соответствует.
Возможности, авторизация и политики — это разные виды проверок
Предположим, вы хотите добавить три новых правила в инструмент для работы с делами:
- Правило А: в деле должен быть непустой список доказательств.
- Правило Б: клиентам бесплатного тарифа не разрешается создавать дела.
- Правило В: создание дел блокируется с 2 часов ночи до 4 часов утра во время еженедельной развертки.
Каждое из этих правил отклоняет определённые вызовы инструментов, но они относятся к трём разным категориям. Полезно разделить каждое правило на одну из четырёх категорий:
- Возможности — это проверка внутренней согласованности входных данных, которая реализуется в самом инструменте. «Список доказательств должен быть непустым» является проверкой возможностей.
- Разрешения — это проверка того, может ли текущий пользователь выполнять данную операцию прямо сейчас, и она реализуется в хуке PreToolUse. «Пользователи бесплатного тарифа не могут создавать дела» относится к разрешениям.
Полезный трюк: инструмент проверяет, может ли он выполнить операцию с полученными аргументами, в то время как хук проверяет, разрешено ли вызывающему коду выполнять её в данный момент.
Почему такое разделение обусловлено возможностями каждого уровня
Это различие не стилистическое; оно обусловлено данными, доступными каждому уровню. Инструмент получает только аргументы, сгенерированные моделью. Он может сравнивать эти аргументы между собой, проверяя, пуст ли список или положительно ли значение, но у него нет ничего другого для сравнения.
Хук получает те же аргументы, плюс доверенный контекст, собранный сервером: идентификатор авторизованного пользователя, реальный уровень пользователя, текущее время и идентификатор сессии. Этот контекст хранится в объекте HookContext, созданном при запуске сессии, после чего он становится неизменным.
Имея оба этих элемента, хук может принимать решения следующим образом: модель хочет вызвать функцию lookup_user с параметром user_id='u_999', но сессия принадлежит пользователю u_004, поэтому вызов отклоняется. Инструмент никогда не сможет принять такое решение, поскольку он не видит, кому принадлежит сессия. Авторизация всегда подразумевает сравнение недоверенного вывода модели с доверенным состоянием сервера, и только хуки обладают обоими этими данными.
Разделение доверенного контекста и недоверенных данных
HookContext представляет собой состояние на стороне сервера. Ваш собственный код создает его в момент начала сессии на основе источников, которыми вы управляете, таких как слой аутентификации, менеджер сессий и база данных. Оно остается неизменным на протяжении всей жизни сессии, и ни пользователь, ни модель не могут на него повлиять. Приведённый ниже класс с параметром frozen=True отражает минимальный набор данных, необходимый для работы:
@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 — это лишь конвенция. Единообразный маркер помогает модели распознать умышленное отказание вместо временной неисправности, поэтому она меняет стратегию вместо повторной попытки выполнения того же запроса. Тот же маркер позволяет системам оценки позже отделять умышленные отказы от настоящих ошибок инструментов. Конвенция в виде обычного текста превращается в сигнал, понятный машине.
Сообщение об отказе — это подсказка
Модель будет формировать свой следующий ответ на основе любого текста, который вы вернёте, поэтому пишите его с такой же тщательностью, как и подсказку. Простое сообщение «Отклонено» не даёт ей ничего для работы. Сообщение, в котором указана причина (например, на бесплатном тарифе нельзя использовать этот инструмент) и предложены альтернативы (обновление тарифа или обращение к разделу часто задаваемых вопросов), сообщает ей как причину неудачи, так и что предложить вместо этого.
Подключение хуков путём оборачивания диспетчера
У большинства агентов уже есть диспетчер с функцией 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
Обертка выполняет пять шагов:
- Преобразование вызова инструмента в событие хука.
- Выполнение всех хуков типа PreToolUse.
Сохранение перед заменой
Принцип, который следует соблюдать, — это сохранение оригинала перед его заменой, и он одинаково применим к декораторам, мидлвэру и интерцепторам. Подумайте, что произойдёт, если пропустить сохранение ссылки и написать dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw). Лямбда будет искать переменную dispatcher.call_tool только в момент выполнения, а к тому времени эта переменная будет указывать на саму лямбду. Обёртка вызывает себя, что приводит к повторному самовызову до тех пор, пока первый вызов инструмента не приведёт к бесконечной рекурсии и переполнению стека.
Сначала присвоение исходного кода переменной означает, что клоузур хранит ссылку на реальный объект функции, а не на имя, которое будет позже переопределено. Поэтому обёртка всегда обращается к настоящей реализации.
Почему хуки превосходят правила ввода и статические списки инструментов
Существует три распространённых способа помешать агенту что-то сделать, и каждый из них терпит неудачу по-разному:
- Инструкция ввода, такая как «не делай 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:
- Tool filtering at the specialist level, for tools a specialist never needs.
- Prompt guidance for soft preferences and general nudges.
- Hooks for runtime rules that must hold without exception.
Чтобы узнать о схожем подходе в рамках фреймворка, посмотрите, как LangChain guardrails и middleware решают аналогичные проблемы.
Одна поверхность инструментов означает один уровень контроля
Хуки дают наилучшие результаты, когда каждое действие агента моделируется как вызов инструмента: делегирование задач субагентам, запросы к внешним API, запросы к базе данных и поиск в базе знаний. Обертка hooked_call_tool обрабатывает все эти действия, включая те, что добавляются позже, при этом авторизация, политики и аудит применяются единообразно ко всей поверхности действий.
Если бы делегирование подагенту осуществлялось путем прямого вызова функции, слой хуков никогда бы этого не увидел. Для маршрутизации потребовался бы отдельный механизм авторизации, что означает необходимость обслуживания двух систем и наличие двух мест, где могут скрываться ошибки. Если с самого начала проектировать единый интерфейс инструментов, хуки станут незначительным дополнением; внедрение их позже превратит процесс в масштабную рефакторку.
Как ведет себя цепочка хуков PreToolUse
В реальных системах собирается несколько хуков PreToolUse — например, проверка уровня доступа, проверка окна развертывания, сопоставление ID пользователя и ограничение расходов. Они выполняются последовательно согласно следующим правилам:
- Первое отклонение имеет приоритет. Оно прерывает работу цепочки, поэтому последующие хуки и сам инструмент не запускаются.
- Разрешение не прерывает работу цепочки. Для продолжения выполнения вызова все хуки должны быть пройдены.
Необходимо отсортировать цепочку по стоимости и критичности, размещая в первую очередь дешевые и решающие проверки. Проверка того, находится ли пользователь в бесплатном тарифе, требует изучения одного поля; проверка еженедельного лимита расходов организации может потребовать запроса к базе данных. Выполнение дешевой проверки в первую очередь позволяет избежать ненужной работы с запросами, которые всё равно будут отклонены, подобно тому как в SQL-выражении WHERE сначала ставятся наиболее строгие условия.
Что должно происходить, если сам хук выходит из строя
Каждому типу хука требуется своя политика обработки сбоев, и неправильный выбор приводит к реальным багам, а не к проблемам стиля:
- Ошибки PreToolUse должны передаваться дальше. Хук с неисправной политикой является дефектом. Представление, что проверка прошла, когда на самом деле этого не произошло, хуже, чем простое остановление работы, поэтому необходимо приостановить агента до устранения ошибки в политике.
- Ошибки PostToolUse необходимо игнорировать. Сбой в журнале аудита не должен мешать выполнению запроса пользователя, поскольку в данном случае это важнее, чем запись в журнал. Запишите сбой в отдельный канал и продолжайте работу.
- Ошибки остановки необходимо игнорировать. Сессия всё равно заканчивается, поэтому лучший вариант — зарегистрировать сбой при очистке данных и завершить работу.
Хук PreToolUse, который тихо перехватывает собственные исключения, представляет собой уязвимость: проверка не выполняется, вызов проходит без проблем, а журнал аудита не содержит никаких объяснений. Хук PostToolUse, позволяющий исключениям выйти наружу, превращает каждую проблему с логированием в сбой всего агента.
Где хранится журнал аудита
В простой конфигурации создаются два файла с возможностью только добавления данных:
data/audit_log.jsonl— файл, создаваемый PostToolUse после каждого вызова инструмента; содержит название инструмента, входные данные, результат, флаг ошибки и временную метку.data/session_summaries.jsonl— файл, создаваемый один раз в сессию с помощью хука Stop; содержит одну строку статистики высокого уровня для каждого разговора.
Журнал аудита быстро становится самым ценным файлом в проекте. Будучи реализованным с самого начала, он обеспечивает соблюдение стандартов, инструменты оценки, анализ затрат, отладку и создание дашбордов на основе единого источника правды. В производственной среде обычно эти же записи сохраняются в надежном хранилище, но структура данных остается прежней.
Основные выводы
- Инструкции к промптам носят рекомендательный характер; любые правила, нарушение которых влечет реальные последствия, должны быть реализованы в коде.
tool_result, чтобы диалог оставался действительным и модель могла восстановиться.