JSON Schema как предохранитель для tool calling: проверяем LLM-вызовы в CI

Контрактная проверка отделяет ошибки формата от смысловых сбоев AI-агента. Разбираем, как валидировать имя инструмента и его аргументы, организовать тестовые сценарии и настроить понятные условия блокировки релиза.

Схема проверки OpenAI function calling и Anthropic Claude tool use по JSON Schema в CI
Схема проверки OpenAI function calling и Anthropic Claude tool use по JSON Schema в CI
Ankara Esenboğa Airport main gate checkpoint (1).jpg | by Vasyatka1 | wikimedia_commons | CC BY-SA 4.0

Ошибка tool calling редко ограничивается неаккуратным JSON. Агент может выбрать отсутствующий инструмент, пропустить обязательный аргумент, передать число строкой или добавить поле, которое сервер не умеет обрабатывать. Если такой ответ без проверки отправляется во внешний API, дефект превращается в сбой интеграции.

Контрактная проверка ставит между LLM и исполняемым инструментом формальный барьер. Она не оценивает, насколько разумно действует агент, но подтверждает несколько проверяемых условий: инструмент существует, аргументы разбираются как JSON и соответствуют объявленной схеме.

Такой тест особенно полезен перед сменой версии LLM, провайдера, системной инструкции или описания инструментов. Вместо сравнения полного ответа проверяются только свойства, от которых зависит совместимость приложения.

Что считать контрактом tool calling

Контракт состоит не только из списка аргументов. На практике нужно зафиксировать четыре уровня:

Имя инструмента. Агент может вызывать только функцию, зарегистрированную приложением.

Формат ответа провайдера. Код должен корректно извлекать имя функции, идентификатор вызова и аргументы.
3. Схема аргументов. Обязательные поля, типы, допустимые значения и ограничения задаются через JSON Schema.
4. Правила исполнения. До обращения к API приложение повторно проверяет аргументы и применяет ограничения доступа.

Важно разделять схему, отправляемую провайдеру, и схему, которой доверяет сервер. Даже если API поддерживает строгую генерацию структурированного ответа, проверка на стороне приложения остаётся необходимой границей безопасности.

Контракт удобно хранить рядом с реализацией инструмента и версионировать вместе с кодом. Тогда изменение типа поля или его удаление становится видимым в обычном code review.

Почему сравнение с эталонной строкой не работает

Полный ответ LLM не является стабильным интерфейсом. Могут меняться служебные идентификаторы, порядок необязательных полей, текстовое сопровождение и представление данных внутри оболочки конкретного API. Тест, ожидающий точного совпадения строки, будет падать даже при корректном вызове.

Вместо этого следует проверять инварианты:

  • вызван разрешённый инструмент;
  • количество вызовов соответствует сценарию;
  • аргументы являются JSON-объектом;
  • обязательные поля присутствуют;
  • значения имеют ожидаемые типы;
  • перечисления и числовые ограничения соблюдены;
  • неизвестные поля запрещены, если это требует сервер;
  • опасный вызов не исполняется без дополнительного подтверждения.

Контрактный тест отвечает на вопрос «может ли приложение безопасно принять этот вызов». Вопрос «правильно ли агент понял пользователя» относится уже к семантическим или сквозным тестам.

Схема инструмента без лишней свободы

Рассмотрим функцию поиска товаров. Для неё достаточно трёх аргументов: поисковой строки, категории и максимальной цены.

json
{
«$schema»: «https://json-schema.org/draft/2020-12/schema»,
«type»: «object»,
«properties»: {
«query»: {
«type»: «string»,
«minLength»: 1
},
«category»: {
«type»: «string»,
«enum»: [«electronics», «clothing», «books»]
},
«max_price»: {
«type»: «number»,
«minimum»: 0
}
},
«required»: [«query»],
«additionalProperties»: false
}

Здесь есть несколько решений, влияющих на надёжность:

  • `minLength` не позволяет передать пустой поисковый запрос;
  • `enum` ограничивает категорию значениями, которые понимает сервер;
  • `minimum` отсекает отрицательную цену;
  • `additionalProperties: false` запрещает самопроизвольные аргументы;
  • в `required` включено только поле, без которого операция не имеет смысла.

Описание инструмента для API и схема в серверном валидаторе должны строиться из одного источника. Две копии быстро расходятся: разработчик обновляет объявление для LLM, но забывает изменить проверку перед исполнением.

Перед выбором версии JSON Schema необходимо проверить возможности провайдера и используемой библиотеки. Полная спецификация JSON Schema шире, чем подмножества схем, поддерживаемые отдельными API.

Какие сценарии включить в минимальный набор

Один удачный запрос подтверждает только один маршрут. Рабочий набор должен проверять выбор инструмента, структуру аргументов и отказ от вызова там, где он не нужен.

Сценарий Ожидаемый результат Что обнаруживает Решение в CI
Явный запрос на поиск товара Один вызов `search_products`, схема пройдена Ошибку имени, отсутствие вызова, неверные типы Блокировать релиз
Запрос с ценовым пределом `max_price` передан числом и не меньше нуля Строку вместо числа, потерю ограничения Блокировать релиз
Обычный вопрос без необходимости обращаться к каталогу Вызова инструмента нет Ложное срабатывание и лишние расходы Блокировать после подтверждённого повторения
Запрос с категорией вне списка Агент уточняет категорию или не передаёт недопустимое значение Выход за `enum` Блокировать исполнение
Попытка передать неизвестный аргумент Валидация отклоняет вызов Несогласованное изменение контракта Блокировать исполнение

Для каждого инструмента полезно иметь как минимум позитивный, граничный и негативный сценарии. Для операций записи — создания заказа, изменения прав или отправки сообщения — нужны отдельные проверки подтверждения и идемпотентности.

Тестовые данные не должны содержать реальные токены, персональные данные и рабочие идентификаторы. Внешние операции следует заменять заглушками или изолированной тестовой средой.

Как валидировать вызов в Python

Ниже приведён упрощённый пример с библиотекой `jsonschema`. Функция извлечения зависит от SDK провайдера, поэтому её лучше тестировать отдельно от проверки аргументов.

python
from jsonschema import Draft202012Validator

SEARCH_ARGUMENTS_SCHEMA = {
«$schema»: «https://json-schema.org/draft/2020-12/schema»,
«type»: «object»,
«properties»: {
«query»: {«type»: «string», «minLength»: 1},
«category»: {
«type»: «string»,
«enum»: [«electronics», «clothing», «books»]
},
«max_price»: {«type»: «number», «minimum»: 0}
},
«required»: [«query»],
«additionalProperties»: False
}

def validate_tool_call(tool_call):
if tool_call[«name»] != «search_products»:
raise AssertionError(
f»Unexpected tool: {tool_call[‘name’]}»
)

validator = Draft202012Validator(SEARCH_ARGUMENTS_SCHEMA)
errors = sorted(
validator.iter_errors(tool_call[«arguments»]),
key=lambda error: list(error.path)
)

assert not errors, [
{
«path»: list(error.path),
«message»: error.message
}
for error in errors
]

Сам тест проверяет не текст ответа, а нормализованное представление вызова:

python
def test_search_products_with_price(llm_client):
result = llm_client.run(
user_input=(
«Найди ноутбук в категории electronics «
«не дороже 500 долларов»
)
)

assert len(result.tool_calls) == 1
validate_tool_call(result.tool_calls[0])

Нормализация нужна потому, что OpenAI function calling и Anthropic Claude tool use используют разные структуры ответа. Внутри приложения их можно привести к единому объекту:

python
{
«id»: «call_id»,
«name»: «search_products»,
«arguments»: {
«query»: «ноутбук»,
«category»: «electronics»,
«max_price»: 500
}
}

После этого общий набор контрактных тестов не зависит от оболочки конкретного SDK. Адаптер провайдера проверяется отдельно на заранее сохранённых обезличенных примерах ответов.

Два режима тестирования вместо одного

Проверки с реальным обращением к LLM полезны, но они медленнее обычных модульных тестов и могут давать вариативные результаты. Поэтому набор лучше разделить на два контура.

Первый контур запускается на каждом коммите и не обращается к внешнему API. Он проверяет:

  • корректность самих JSON Schema;
  • работу адаптеров на сохранённых ответах;
  • отклонение неверных типов и неизвестных полей;
  • обработку пустого списка вызовов;
  • запрет неизвестных инструментов;
  • поведение исполнительного слоя при ошибке валидации.

Второй контур обращается к реальной LLM. Его можно запускать перед слиянием изменений, по расписанию и при обновлении версии провайдера. Здесь проверяются выбор инструмента и фактическая структура сгенерированных аргументов.

Такое разделение уменьшает число случайных падений и позволяет быстро понять источник дефекта. Если локальный валидатор проходит, а внешний прогон нет, проблема находится между инструкцией, описанием инструмента и поведением LLM. Если падают оба контура, сначала нужно проверить схему или код адаптера.

Как работать с вариативностью результатов

Повторный запуск теста не должен автоматически превращать ошибку в успех. Иначе реальная регрессия будет скрыта механизмом повторов.

Разумно разделять жёсткие и статистические условия:

  • синтаксически неверный JSON — жёсткий отказ;
  • неизвестное имя инструмента — жёсткий отказ;
  • нарушение серверной схемы — жёсткий отказ;
  • отсутствие ожидаемого вызова — повторная проверка с сохранением обоих результатов;
  • лишний вызов в неоднозначном сценарии — сигнал для ручного разбора;
  • снижение доли успешных сценариев относительно зафиксированного порога — остановка обновления LLM.

Для каждого запуска стоит сохранять версию LLM, параметры генерации, версию схемы, идентификатор сценария и нормализованный вызов. Содержимое пользовательских данных необходимо удалять или маскировать до записи в журнал.

Порог успешности нельзя выбирать произвольно. Сначала нужен базовый прогон текущей рабочей версии, после которого команда фиксирует допустимый уровень для каждого класса инструментов. Операции чтения и операции, меняющие данные, не должны иметь одинаковые требования.

Что проверять при смене провайдера или версии LLM

Миграция затрагивает больше, чем названия полей в SDK. Перед переключением нужно проверить:

Как объявляются инструменты и какие части JSON Schema поддерживаются.

В каком виде возвращаются аргументы: объектом или сериализованной строкой.
3. Может ли ответ содержать несколько вызовов.
4. Как связаны вызов инструмента и его результат.
5. Что происходит при отказе LLM использовать предложенную функцию.
6. Как API сообщает об ошибке структурированного вывода.
7. Сохраняется ли порядок нескольких зависимых вызовов.

OpenAI документирует function calling и режим Structured Outputs для соответствия аргументов заданной схеме. Anthropic описывает инструменты через `input_schema` и возвращает блоки использования инструмента. Эти механизмы похожи по назначению, но их транспортные форматы нельзя считать взаимозаменяемыми.

До миграции полезно запустить один и тот же набор сценариев на старой и новой конфигурации, а затем сравнить не полный текст, а:

  • долю корректных вызовов;
  • выбор имени инструмента;
  • число вызовов на сценарий;
  • ошибки по каждому полю схемы;
  • долю ненужных обращений к инструментам;
  • задержку и стоимость тестового набора.

Если новая версия чаще проходит схему, но хуже выбирает нужную функцию, обновление нельзя считать успешным.

Где заканчивается польза контрактного теста

Валидный вызов может быть бессмысленным. Например, поле `max_price` соответствует числовому типу, но значение не извлечено из запроса пользователя. Аналогично агент может выбрать существующий инструмент, который не нужен в данном контексте.

Поэтому контрактный слой не заменяет:

  • семантические проверки значений;
  • тесты маршрутизации между несколькими инструментами;
  • сквозные сценарии с реальным результатом операции;
  • контроль разрешений пользователя;
  • подтверждение необратимых действий;
  • защиту от повторного исполнения;
  • ограничения частоты и стоимости запросов.

Исполнительный сервис не должен доверять аргументам только потому, что их сформировала LLM. До операции записи необходимо проверить авторизацию, бизнес-правила и актуальное состояние объекта.

План внедрения в существующий проект

Начать можно с одного критичного инструмента, а не со всей системы сразу.

Выберите функцию, ошибка которой приводит к сбою или изменению данных.

Зафиксируйте её аргументы в JSON Schema.
3. Добавьте `additionalProperties: false`, если сервер не принимает расширения.
4. Реализуйте единый объект нормализованного вызова.
5. Напишите позитивный, граничный и негативный сценарии.
6. Подключите локальные проверки схемы к каждому коммиту.
7. Добавьте внешний прогон перед обновлением LLM или провайдера.
8. Сохраняйте отчёт по имени инструмента и пути поля, на котором произошла ошибка.
9. Запретите исполнительному слою обращаться к API после неуспешной валидации.
10. Версионируйте контракт при несовместимом изменении аргументов.

Первый полезный результат — не большое число тестов, а единая точка допуска к исполнению. Если каждый вызов проходит через реестр разрешённых функций и серверный валидатор, некорректный JSON не попадёт во внешний сервис даже при сбое внешнего тестового контура.

Источники и спецификации

  • OpenAI, Function calling: https://platform.openai.com/docs/guides/function-calling
  • Anthropic, Tool use with Claude: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
  • JSON Schema, руководство по спецификации: https://json-schema.org/understanding-json-schema
  • JSON Schema Draft 2020-12: https://json-schema.org/draft/2020-12
  • Python jsonschema: https://python-jsonschema.readthedocs.io/
  • Ajv JSON Schema Validator: https://ajv.js.org/