Tool calling в LLM без иллюзий: как построить воспроизводимый тестовый контур

Практическая методика проверки tool calling в LLM: как разделить ошибки выбора инструмента и аргументов, собрать эталонный набор, измерить задержку и стоимость, а затем поставить защитные пороги в CI.

Что именно нужно проверять

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

Удобнее рассматривать один ответ модели как набор независимых проверок:

был ли вызов нужен;

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

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

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

Метрики для рабочего контура

Одной общей «точности агента» недостаточно. Минимальный набор метрик выглядит так:

Метрика Что измеряет Как считать Зачем нужна
Точность выбора Долю правильных инструментов среди вызовов Правильные вызовы / все вызовы Показывает ошибки маршрутизации
Полнота обязательных вызовов Долю сценариев, где нужный инструмент действительно вызван Обязательные вызовы / все сценарии с обязательным вызовом Выявляет пропуски
Точность аргументов Долю вызовов без ошибок в параметрах Валидные вызовы / все вызовы Оценивает качество структурированного ответа
Доля лишних вызовов Вызовы там, где требовался обычный текстовый ответ Лишние вызовы / сценарии без вызова Контролирует ненужные расходы и побочные эффекты
P95 задержки Время до получения пригодного вызова 95-й перцентиль длительности Показывает редкие медленные случаи

Для задач выбора инструмента полезно отдельно хранить false positive и false negative:

  • false positive — модель вызвала функцию, хотя запрос этого не требовал;
  • false negative — модель не вызвала функцию, хотя без неё корректный ответ невозможен.

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

Аргументы стоит проверять на двух уровнях. Сначала применяется JSON Schema: типы, обязательные поля, перечисления и формат. Затем запускаются прикладные проверки: существует ли объект, принадлежит ли он текущему пользователю, разрешено ли действие и не истёк ли срок операции.

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

Эталонный набор сценариев

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

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

Начните с коротких детерминированных сценариев.

Один инструмент

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

Выбор из нескольких вариантов

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

Ответ без вызова

Пользователь просит объяснение, преобразование текста или другую операцию, для которой внешний инструмент не нужен. Тест защищает от лишних вызовов и позволяет измерять false positive.

Последовательность действий

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

Неполные данные

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

Конфликтующие ограничения

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

Повтор запроса

Один и тот же ответ модели или сетевой повтор может привести к повторному действию. Для операций с побочным эффектом тестируйте ключ идемпотентности и поведение при уже выполненном вызове.

Как сравнивать аргументы

Сравнение JSON-объектов «строка в строку» даёт много ложных срабатываний. Сначала нормализуйте структуру, затем применяйте правила для конкретного поля.

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

Простейший валидатор на Python:

python
from jsonschema import ValidationError, validate

def validate_arguments(arguments: dict, schema: dict) -> tuple[bool, str | None]:
try:
validate(instance=arguments, schema=schema)
return True, None
except ValidationError as error:
return False, error.message

Схема должна быть строгой настолько, насколько это безопасно для приложения. Если неизвестные поля не используются, их лучше запрещать через `additionalProperties: false`. Это не заменяет прикладную проверку: JSON Schema не знает, имеет ли пользователь право выполнить действие.

Для каждого теста сохраняйте фактический вызов целиком:

json
{
«scenario_id»: «account_lookup_missing_id»,
«tool_name»: null,
«arguments»: null,
«expected»: {
«action»: «ask_clarifying_question»
},
«latency_ms»: 842,
«input_tokens»: 912,
«output_tokens»: 74
}

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

Пороговые проверки в CI

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

Пример конфигурации порогов:

yaml
metrics:
tool_selection_accuracy:
minimum: 0.97
required_call_recall:
minimum: 0.98
argument_validity:
minimum: 0.99
unwanted_call_rate:
maximum: 0.02
latency_p95_ms:
maximum: 2500

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

Пример шага GitHub Actions:

yaml
name: Tool calling evaluation

on:
pull_request:
push:
branches: [main]

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

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

— run: pip install -r requirements.txt
— run: python evaluate_tool_calling.py —dataset tests/tools.jsonl —output metrics.json
— run: python check_thresholds.py metrics.json thresholds.yaml

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

Где брать тестовые данные

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

Хорошая практика — разделить набор на три части:

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

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

Для проверки инструментов и схем полезны следующие материалы:

Документация поставщиков меняется, поэтому перед настройкой порогов проверяйте актуальные форматы ответов, ограничения API и правила тарификации по официальным страницам.

План внедрения на неделю

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

Соберите 20–30 запросов: обязательный вызов, отсутствие вызова, неправильные и неполные аргументы.
2. Зафиксируйте ожидаемый инструмент и допустимые значения параметров.
3. Добавьте JSON Schema и отдельную прикладную валидацию.
4. Сохраните имя модели, версию набора и параметры запуска вместе с результатом.
5. Посчитайте выбор инструмента, полноту, валидность аргументов, лишние вызовы и P95 задержки.
6. Подключите smoke-набор к pull request.
7. Переносите каждый найденный сбой в регрессионный набор.
8. Для опасных операций добавьте идемпотентность, авторизацию и ручное подтверждение.

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