AI / LLM
Вызовы инструментов: цикл round-trip, валидация схемы и защита от зацикливания агента
Выкатили саппорт-агента. Клиент пишет «отмени мой последний заказ». Модель уверенно выдаёт вызов инструмента: POST /orders/{id}/cancel с id: "ord_9f3c" — id, который она нигде не видела, выдуман так, чтобы выглядеть правдоподобно. Хендлер сработал в стиле eval: доверился аргументам и отправил запрос. Отменён заказ не того клиента. Хуже того, через неделю другой баг заставил модель перевызывать падающий инструмент lookup_order 40 раз за один ход, пока кто-то не убил процесс — $9 токенов на вопрос, у которого не было ответа. У обоих багов один корень: цикл доверился модели.
Цикл round-trip — это контракт, а не функция
Вызов инструментов делает модель похожей на функцию, которую ты зовёшь, но проводка инвертирована: вызывающий — модель, а твой код — вызываемый. Ты объявляешь инструменты; модель решает, когда вызвать, и выдаёт структурированный запрос; выполняет его твой код. Сама модель никогда ничего не исполняет.
Каноничная форма — цикл while по полю stop_reason из ответа:
- Отправь запрос с массивом
toolsи сообщением пользователя. - Модель отвечает
stop_reason: "tool_use"и одним или несколькими блокамиtool_use(у каждого имя инструмента и JSON-объект аргументов). - Выполни каждый инструмент. Оформи результаты как блоки
tool_result. - Отправь новый запрос со всей историей плюс эти блоки
tool_result. - Повторяй, пока
stop_reasonвсё ещё"tool_use". Выход — на"end_turn"(финальный ответ),"max_tokens","stop_sequence"или"refusal".
Несущая деталь, которую усваивает сеньор: шаг 4 — это совершенно новый вызов модели. Задача с тремя инструментами — это четыре обращения к модели, каждое перешлёт весь растущий транскрипт. Поэтому latency инструментов доминирует — и поэтому защита цикла ниже не опциональна.
Инструменты — это объявления через JSON-схему
Каждый инструмент — это имя, описание и input_schema — объект JSON Schema, описывающий аргументы. Модель читает схему так же, как разработчик читает сигнатуру функции. Схема делает реальную работу: она и говорит модели, как звать инструмент, и даёт тебе контракт, против которого валидировать перед выполнением.
Схемы не бесплатны. Типичное определение инструмента стоит примерно 500 токенов; десять инструментов — это ~5 000 токенов накладных на каждый запрос в цикле, ведь массив tools пересылается целиком каждый раунд. Агент на 10 инструментов в задаче из 6 шагов платит этот налог в 5 000 токенов шесть раз. Здесь впервые окупается prompt caching — кэширование статичного блока инструментов режет стоимость входа на 40–80% и улучшает time-to-first-token.
tool_choice | Поведение | Когда выбирает сеньор |
|---|---|---|
auto | Модель решает: вызвать инструмент или ответить текстом | Дефолт для агентов; модель сама судит, нужен ли инструмент |
any | Обязан вызвать какой-то инструмент, модель выбирает какой | Когда текст никогда не валидный ответ (роутер, обязанный диспетчить) |
tool (форсированный) | Обязан вызвать именно этот названный инструмент | Структурное извлечение: форсируй одну схему ради JSON гарантированной формы |
none | Запретить все инструменты на этот ход | Заставить дать текстовое резюме, когда результаты уже собраны |
Параллельные вызовы режут latency — но не всякая цепочка их допускает
Современные модели (класса Claude 4), когда нужно несколько независимых инструментов, выдают несколько блоков tool_use в одном ответе. Ты запускаешь их параллельно и возвращаешь все блоки tool_result вместе. Это схлопывает три последовательных round trip в один — реальный выигрыш по latency, ведь каждый round trip — свежий вызов модели от сотен мс до секунд.
Загвоздка — зависимость. Параллелизм помогает, только когда вызовы независимы (get_weather(NYC) и get_weather(SF)). Цепочка, где второй вызов нуждается в выводе первого (find_user, затем cancel_user_order), по природе последовательна и не параллелится — модель должна увидеть первый результат, прежде чем заполнить аргументы второго инструмента. Можно выставить disable_parallel_tool_use: true, чтобы форсировать один инструмент за ход, когда твой слой исполнения не может безопасно работать конкурентно.
Почему это работает
Серверные инструменты (web search, code execution) крутят свой цикл внутри провайдера и имеют встроенный лимит итераций. Когда они упираются в него посреди задачи, ответ приходит с stop_reason: "pause_turn", а не "end_turn" — ты пересылаешь разговор, чтобы продолжить. У клиентских инструментов такого встроенного лимита нет; защиту пишешь ты сам.
Валидируй аргументы — никогда им не доверяй
Модель выдаёт правдоподобный JSON, а не корректный. Она может выдумать id, изобрести значение enum, которого схема никогда не перечисляла, опустить обязательное поле или передать строку там, где нужно число. Стартовая катастрофа — выдуманный ord_9f3c, скормленный прямо в эндпоинт отмены, — каноничный провал: хендлер обошёлся с выводом модели как с доверенным вводом.
Дисциплина сеньора — жёсткий шлагбаум перед выполнением:
- Валидируй аргументы по схеме против
input_schemaинструмента (валидатор вроде Pydantic илиjsonschema). Отвергай некорректные формы сразу; никогда не делайevalи не разбирай их вслепую. - Авторизуй и проверяй существование упомянутых сущностей. Корректный по форме
idвсё ещё недоверенный — подтверди, что заказ существует и принадлежит этому вызывающему, прежде чем действовать. - При отказе верни
tool_resultс ошибкой, а не исключение, ломающее цикл. Модель читает ошибку и может исправиться на следующем ходу — этот канал обратной связи и есть весь смысл возврата структурированных ошибок инструмента.
Относись к аргументам инструмента ровно как к любому другому недоверенному вводу пользователя, пересекающему границу доверия, — потому что это именно он и есть.
Защита по max-iteration от зацикленных циклов
Без лимита ходов сбитая с толку модель крутится вечно: зовёт lookup_order, получает ошибку, зовёт снова с теми же аргументами, получает ту же ошибку, и так по кругу. Каждая итерация — полный вызов модели, тарифицирующий весь накапливающийся транскрипт — стоимость и токены растут с каждым шагом. Это реальный инцидент и реальный счёт (один застрявший ход тихо сжёг $9 токенов, пока не вмешался человек).
Две защиты, обе обязательны в проде:
- Жёсткий лимит итераций —
for step in range(MAX_STEPS)(часто 8–15). Упёрся в лимит — останови цикл, верни пользователю аккуратный отказ. Никогда неwhile True. - Детекция повторов — если модель зовёт тот же инструмент с теми же аргументами дважды подряд, это сигнал застревания. Прерви или вброс сообщения, что вызов уже упал, чтобы она перестала повторяться.
Цикл агента зовёт реальные мутирующие эндпоинты (отмена заказа, возврат). Как обращаться с аргументами, которые выдаёт модель?
Задача агента на 5 шагов использует инструменты на каждом шаге. Сколько это примерно вызовов модели и почему это важно?
Модель возвращает tool_use для cancel_order с id 'ord_9f3c', которого в разговоре не было. Какой ход сеньора?
Расставь одну безопасную итерацию клиентского цикла tool-use:
- 1 Отправь запрос с массивом tools; прочитай stop_reason из ответа
- 2 Если stop_reason это tool_use, извлеки имя и аргументы из каждого блока tool_use
- 3 Валидируй аргументы по схеме, потом авторизуй/проверь существование упомянутых сущностей
- 4 Выполни валидные вызовы (параллельно, если независимы); оформи результаты как блоки tool_result
- 5 Отправь новый запрос со всей историей + блоками tool_result — под лимитом max-iteration
- 01Пройди по тому, почему цикл tool-use без защиты — это и риск корректности, и риск стоимости, и какие две защиты ты добавляешь.
- 02Почему обязательно валидировать аргументы инструмента и что значит «валидировать» для мутирующего эндпоинта вроде cancel_order?
Вызов инструментов инвертирует обычный контракт: вызывающий — модель, а твой код — вызываемый. Модель выдаёт структурированный запрос tool_use с именем инструмента и JSON-аргументами; твой код выполняет его и возвращает tool_result, и цикл повторяется, пока stop_reason остаётся "tool_use". Три вещи определяют tool use уровня сеньора. Первое — latency и стоимость: каждый round trip инструмента это совершенно новый вызов модели, пересылающий весь растущий транскрипт плюс массив схем по ~500 токенов на инструмент, так что задача на 5 шагов — это ~6 вызовов модели; prompt caching статичного блока инструментов — стандартное смягчение. Второе — валидация: аргументы модели — недоверенный ввод, который бывает галлюцинацией, поэтому валидируешь по схеме, затем авторизуешь и проверяешь существование сущностей, затем выполняешь, возвращая ошибки как tool_result, чтобы модель исправилась — никогда не eval выдуманный id в мутирующий эндпоинт. Третье — защита: жёсткий лимит итераций плюс детекция повторов, ведь цикл без защиты будет перевызывать падающий инструмент вечно и жечь реальные деньги. Используй tool_choice (auto/any/tool/none), чтобы управлять тем, сработает ли инструмент и какой, и параллельные вызовы, чтобы схлопнуть независимые round trip — но только когда вызовы не зависят друг от друга.