Tool calling без сюрпризов: как построить контрактные тесты для LLM-агента

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

Контрактные тесты tool calling на Python с JSON Schema, метриками и результатами GitHub Actions
Контрактные тесты tool calling на Python с JSON Schema, метриками и результатами GitHub Actions
Visit of Ursula von der Leyen, President of the European Commission, to India (P-065755-00-36).jpg | by Europäische Kommission — Audiovisueller Dienst, CE — Service audiovisuel, EC — Audiovisual Service, Dati Bendo | wikimedia_commons | CC BY 4.0

Вызов инструмента для LLM-агента — это не просто строка JSON в ответе модели. На этом стыке встречаются сразу несколько независимых рисков: модель выбирает неправильную функцию, передаёт аргумент в неверном типе, пропускает обязательное поле или создаёт вызов, которого не было в пользовательском запросе.

Проверять только HTTP-статус ответа недостаточно. Запрос к провайдеру может завершиться успешно, а полученный вызов окажется непригодным для вашего API. Надёжнее рассматривать tool calling как контракт между моделью и приложением. У контракта должны быть:

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

В этой статье — узкий практический подход: как собрать такой контракт для LLM-агента на Python и не допустить незаметной регрессии после изменения модели, схемы или системных инструкций.

Сначала опишите границу между моделью и кодом

До запуска тестов отделите два слоя:

модель предлагает вызов;

приложение проверяет его и решает, разрешать ли выполнение.

Модель не должна напрямую определять, какие данные получит внешний сервис. Даже корректный на вид JSON нужно проверить на стороне приложения. Минимальная последовательность выглядит так:

получить ответ LLM;

проверить наличие вызова;
3. найти имя функции в белом списке;
4. разобрать аргументы;
5. провалидировать их по JSON Schema;
6. только после этого передать данные исполнителю.

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

Для каждого инструмента полезно заранее зафиксировать идентификатор, назначение, обязательные поля, допустимые значения и запреты. Название `lookup_invoice` информативнее, чем `tool_1`, а поле `invoice_id` безопаснее, чем универсальное `value`.

Зафиксируйте воспроизводимое окружение

Результат теста зависит от большего числа параметров, чем кажется. В репозитории храните следующие значения:

  • точный идентификатор модели;
  • версию SDK;
  • температуру и остальные параметры генерации;
  • системные инструкции;
  • список инструментов и порядок их передачи;
  • версию JSON Schema;
  • набор тестовых сценариев;
  • дату последнего обновления baseline.

Параметр `temperature=0` снижает случайность, но не превращает генерацию в полностью детерминированную процедуру. На результат могут влиять обновление модели на стороне провайдера, формат запроса и изменение описания функции. Поэтому один удачный прогон не заменяет серию повторов.

Пример конфигурации для тестового набора:

python
TEST_CONFIG = {
«provider»: «openai»,
«model»: «gpt-4o-2024-08-06»,
«temperature»: 0,
«system_prompt»: (
«Выбирай инструмент только при наличии достаточных данных. «
«Не выдумывай обязательные идентификаторы.»
),
«tools_version»: «2025-01-15»,
}

Значение модели в примере нужно заменить на реально используемое в проекте. Нельзя смешивать результаты разных моделей в одну строку baseline: иначе падение показателя будет невозможно корректно объяснить.

Составьте тесты вокруг решений агента

Набор сценариев должен проверять не только «правильный» путь. Для одного инструмента достаточно начать с 12–20 случаев, распределив их по категориям.

Категория Что проверяет Пример ожидаемого результата
Прямой запрос Выбор нужной функции `lookup_invoice` с корректным `invoice_id`
Неполные данные Отказ от выдумывания параметров Уточняющий вопрос или отсутствие вызова
Неподходящий запрос Лишний запуск инструмента Текстовый ответ без вызова
Граничное значение Тип, длину и допустимый диапазон Ошибка валидации или нормализованный аргумент
Конфликт функций Различение похожих описаний Выбрана только одна подходящая функция

Полезно хранить сценарии в JSON или YAML, а не зашивать их в тестовый код. Так набор проще просматривать на ревью и расширять после инцидента.

python
TEST_CASES = [
{
«id»: «invoice_by_id»,
«input»: «Покажи статус счёта INV-1042»,
«expected_tool»: «lookup_invoice»,
«expected_args»: {«invoice_id»: «INV-1042»},
},
{
«id»: «missing_invoice_id»,
«input»: «Проверь мой последний счёт»,
«expected_tool»: None,
«expected_args»: None,
},
]

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

Проверяйте схему до вызова сервиса

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

python
INVOICE_SCHEMA = {
«type»: «object»,
«properties»: {
«invoice_id»: {
«type»: «string»,
«pattern»: «^INV-[0-9]{4,}$»
}
},
«required»: [«invoice_id»],
«additionalProperties»: False,
}

Проверка должна ловить как минимум четыре класса ошибок:

  • отсутствующее обязательное поле;
  • неизвестное дополнительное поле;
  • неверный тип значения;
  • значение, не соответствующее формату или перечислению.

Параметр `additionalProperties: false` особенно полезен для критичных функций: он не позволит незаметно принять опечатку вроде `invoce_id`. Для свободных текстовых полей, напротив, следует отдельно определить допустимую длину и правила очистки.

Пример вспомогательной проверки с библиотекой `jsonschema`:

python
from jsonschema import Draft202012Validator

def validate_arguments(schema: dict, arguments: dict) -> list[str]:
validator = Draft202012Validator(schema)
return [
error.message
for error in validator.iter_errors(arguments)
]

Ошибку валидации нужно считать отдельным исходом теста. Не смешивайте её с сетевым исключением или отказом провайдера: у этих проблем разные способы исправления и разные владельцы.

Разделите метрики по типам ошибок

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

Метрика Формула Зачем нужна
Tool selection accuracy правильный инструмент / все сценарии Показывает, умеет ли агент выбрать функцию
Argument exact match все аргументы совпали / сценарии с вызовом Выявляет ошибки в полях и значениях
Schema pass rate вызовы без ошибок схемы / все вызовы Оценивает пригодность JSON для приложения
No-call precision корректные отсутствия вызова / все сценарии без вызова Помогает отслеживать лишние действия
Valid response rate ответы, обработанные без технической ошибки / все запросы Отделяет ошибки формата от ошибок выбора

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

Минимальная функция сравнения:

python
def score_case(actual: dict | None, expected: dict | None) -> dict:
if expected is None:
return {
«tool_correct»: actual is None,
«args_correct»: actual is None,
}

if actual is None:
return {
«tool_correct»: False,
«args_correct»: False,
}

tool_correct = actual.get(«name») == expected[«name»]
args_correct = (
tool_correct
and actual.get(«arguments») == expected[«arguments»]
)

return {
«tool_correct»: tool_correct,
«args_correct»: args_correct,
}

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

Используйте повторы для вероятностного поведения

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

Например:

  • 5 повторов для быстрого pull request;
  • 20–30 повторов для ночной проверки;
  • отдельный расширенный прогон перед сменой модели.

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

Порог тоже выбирайте по назначению:

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

Не объявляйте модель «стабильной» только на основании одного запуска. Сначала проверьте повторяемость на фиксированном окружении, затем сравнивайте версии.

Поставьте регрессию в CI

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

Быстрый контур запускается на pull request и проверяет:

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

Расширенный контур запускается по расписанию или перед релизом. Он вызывает модель, выполняет повторы и сравнивает результаты с baseline. Если внешний API платный, лимитируйте этот запуск и сохраняйте результаты в артефакт CI.

Пример workflow для GitHub Actions:

yaml
name: LLM tool contract tests

on:
pull_request:
workflow_dispatch:

jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
— uses: actions/checkout@v4

— uses: actions/setup-python@v5
with:
python-version: «3.12»

— run: pip install -r requirements-test.txt
— run: python -m pytest tests/contracts -q
— run: python scripts/compare_metrics.py \
—baseline tests/baseline.json \
—current reports/current.json

Порог падения должен учитывать размер выборки. Изменение с 92% до 91% на десяти сценариях нельзя интерпретировать так же, как изменение с 99% до 98% на тысяче повторов. В отчёте показывайте абсолютное число успешных и неуспешных случаев рядом с процентом.

Разбирайте сбой по причине

После падения теста не меняйте сразу промпт. Сначала определите класс ошибки:

Неправильное имя функции — проверьте пересекающиеся названия и описания.

Пропущенный аргумент — проверьте обязательность поля и формулировку инструкции.
3. Неверный тип — добавьте явное описание формата и схему.
4. Лишний вызов — проверьте условие запуска и сценарии без инструмента.
5. Валидный JSON с неверным смыслом — добавьте проверку бизнес-ограничений.
6. Технический сбой — отделите таймаут, лимит и ошибку SDK от результата модели.

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

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

Ограничения метода

Контрактные тесты не доказывают, что агент безопасен во всех ситуациях. Они проверяют заранее описанные входы и не заменяют:

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

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

Практический план внедрения

Начните с одного инструмента, который влияет на данные или запускает внешнее действие. Для него:

опишите JSON Schema и запретите неизвестные поля;

добавьте сценарии успешного вызова, неполных данных и отсутствия вызова;
3. сохраните точную конфигурацию модели;
4. посчитайте выбор функции, аргументы и прохождение схемы отдельно;
5. запустите быстрый набор в CI;
6. добавьте каждый найденный инцидент как новый регрессионный тест.

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

Источники:

  • OpenAI, Function calling: https://platform.openai.com/docs/guides/function-calling
  • Mistral AI, Function calling: https://docs.mistral.ai/capabilities/function_calling/
  • Anthropic Cookbook, рекомендации по использованию инструментов: https://github.com/anthropics/anthropic-cookbook/blob/main/tool_use/tool_use_best_practices.md
  • Shreya R, Guardrails: https://github.com/ShreyaR/guardrails
  • Исследование на arXiv о возможностях LLM в задачах tool calling: https://arxiv.org/abs/2407.04769