中文翻译暂不可用,显示俄语原文。

Как тестировать инструменты агента (tool testing изолированно)?

Краткий тезис

Изолированное тестирование инструментов агента (tool testing) гарантирует, что каждый инструмент работает корректно независимо от LLM и других компонентов. Основные аспекты: unit-тесты валидации аргументов по JSON Schema, интеграционные тесты с мок-серверами, обработка ошибок (таймауты, 500, невалидный ввод), проверка механизмов rate limiting и идентичности вызовов (idempotency). Такой подход позволяет выявить дефекты на ранней стадии, снизить зависимость от внешних API и повысить надёжность агента.


1. Зачем нужно изолированное тестирование инструментов?

Инструменты в AI-агенте — это функции, которые агент вызывает для получения внешних данных или выполнения действий (поиск в БД, вызов API, работа с файлами). Без изоляции:

  • Сбой внешнего API может маскировать ошибку в самом инструменте
  • LLM может «галлюцинировать» параметры, но тесты инструмента должны это выявлять
  • Инструменты имеют собственную логику: преобразование аргументов, обработка ответов, пагинация, кэширование

Изолированное тестирование означает, что мы проверяем инструмент без запуска всей цепочки агента, подменяя внешние зависимости моками (mocks) или стабами (stubs).


2. Unit-тестирование: валидация аргументов (JSON Schema)

2.1. Определение схемы инструмента

Каждый инструмент агента должен иметь декларативную спецификацию входных параметров — обычно JSON Schema (OpenAPI или инструменты фреймворков вроде LangChain, AutoGPT, Semantic Kernel).

Пример схемы для инструмента «Поиск авиабилетов»:

{
  "name": "search_flights",
  "description": "Ищет доступные рейсы между городами",
  "parameters": {
    "type": "object",
    "properties": {
      "origin": {"type": "string", "description": "IATA код отправления"},
      "destination": {"type": "string", "description": "IATA код прибытия"},
      "date": {"type": "string", "format": "date"},
      "passengers": {"type": "integer", "minimum": 1, "maximum": 9}
    },
    "required": ["origin", "destination", "date"]
  }
}

2.2. Что тестируем в unit-тестах

  • Корректность парсинга схемы – проверяем, что инструмент принимает только разрешённые параметры.
  • Пограничные значения (boundary tests): для passengers – 1, 9, 0, -1, 10.
  • Типы данных: передача строки вместо числа, передача несуществующего ключа.
  • Обязательные поля: вызов без origin или date должен вернуть ошибку валидации.
  • Форматы: дата в неверном формате (например, "01-01-2025" вместо "2025-01-01").

2.3. Реализация на pytest

import pytest
from agent.tools.flight_search import FlightSearchTool

def test_valid_arguments():
    tool = FlightSearchTool()
    result = tool.run(origin="LHR", destination="CDG", date="2025-06-15", passengers=2)
    assert result.success

def test_missing_required_field():
    tool = FlightSearchTool()
    with pytest.raises(ValidationError):
        tool.run(origin="LHR", date="2025-06-15")  # нет destination

def test_invalid_passengers_count():
    tool = FlightSearchTool()
    with pytest.raises(ValidationError):
        tool.run(origin="LHR", destination="CDG", date="2025-06-15", passengers=0)

def test_invalid_date_format():
    tool = FlightSearchTool()
    with pytest.raises(ValidationError):
        tool.run(origin="LHR", destination="CDG", date="15/06/2025", passengers=1)

Термины:

  • ValidationError – исключение, выбрасываемое при несоответствии схеме.
  • Boundary test – тест граничных значений.

3. Интеграционное тестирование: реальный вызов API с мок-сервером

3.1. Зачем мокировать внешний API?

Реальные API могут быть медленными, платными, недоступными или возвращать разные данные. Для изолированного тестирования мы подменяем HTTP-запросы с помощью библиотек типа responses (Python) или wiremock.

3.2. Пример теста с моками

import responses
import pytest
from agent.tools.weather_tool import WeatherTool

@responses.activate
def test_weather_success():
    # Настраиваем mock ответа
    responses.add(
        responses.GET,
        "https://api.weather.com/v1/city=London",
        json={"temp": 20, "humidity": 60},
        status=200
    )
    tool = WeatherTool()
    result = tool.run(city="London")
    assert result.temp == 20
    assert result.humidity == 60

3.3. Что проверяем в интеграционных тестах

  • Формирование URL и query-параметров – правильный эндпоинт, передача токена, заголовков.
  • Обработка ответов – парсинг JSON, XML, или бинарных данных.
  • Пагинация – несколько страниц результата.
  • Таймауты – как инструмент ведёт себя при задержке ответа.

Термин: Mock-сервер – программа или библиотека, имитирующая поведение реального API на основе заданных правил.


4. Тестирование обработки ошибок (error handling)

4.1. Типичные сценарии ошибок

  • HTTP-ошибки: 4xx (клиентские), 5xx (серверные).
  • Сетевые ошибки: таймаут соединения, DNS-ошибка, отказ в соединении.
  • Невалидный ответ: JSON с неожиданной структурой, пустой ответ.

4.2. Пример теста

@responses.activate
def test_weather_http_500():
    responses.add(
        responses.GET,
        "https://api.weather.com/v1/city=London",
        status=500,
        body="Internal Server Error"
    )
    tool = WeatherTool()
    with pytest.raises(ToolExecutionError, match="API вернул ошибку 500"):
        tool.run(city="London")

4.3. Ожидаемое поведение инструмента

  • Возвращать специализированное исключение (например, ToolExecutionError), а не общее Exception.
  • Содержать в сообщении код ошибки, URL, тело запроса (без чувствительных данных).
  • Логировать ошибку для последующего анализа.

Термин: ToolExecutionError – кастомное исключение, инкапсулирующее информацию об ошибке вызова инструмента.


5. Тестирование rate limiting и очередей

5.1. Зачем тестировать rate limiting?

Инструменты могут вызываться агентом часто, и внешние API накладывают ограничения (например, 100 запросов в минуту). Агент должен корректно обрабатывать такие ситуации: ждать, повторять, переключаться на резервный источник.

5.2. Аспекты тестирования

  • Превышение лимита: мок возвращает 429 Too Many Requests с заголовком Retry-After.
  • Логика повторных попыток: проверяем, что инструмент делает указанное количество retry с экспоненциальной задержкой.
  • Очередь запросов: если инструмент имеет встроенную очередь, тестируем, что она не переполняется и не блокирует агента.

5.3. Пример

@responses.activate
def test_rate_limit_retry():
    # Первый вызов – 429, второй – 200
    responses.add(
        responses.GET,
        "https://api.weather.com/v1/city=London",
        status=429,
        headers={"Retry-After": "2"}
    )
    responses.add(
        responses.GET,
        "https://api.weather.com/v1/city=London",
        json={"temp": 18},
        status=200
    )
    tool = WeatherTool(max_retries=2, retry_delay=0.1)  # ускорено для теста
    result = tool.run(city="London")
    assert result.temp == 18
    # Проверяем, что было ровно 2 вызова
    assert len(responses.calls) == 2

Термин: Retry-after – заголовок HTTP, указывающий клиенту, через сколько секунд повторить запрос.


6. Тестирование идемпотентности (idempotency)

6.1. Определение

Идемпотентный инструмент – его повторный вызов с одинаковыми аргументами не приводит к дублированию побочных эффектов (например, создание дубликата заказа, повторная отправка email).

6.2. Как тестировать

  • Генерируем уникальный ключ идемпотентности (idempotency key) для каждого вызова (часто используется идемпотентный хеш от аргументов).
  • Симулируем повторный вызов с тем же ключом и проверяем, что:
    • Возвращается тот же результат (если инструмент кэширует ответ).
    • Не происходит повторного side effect (проверяем через мок, что количество запросов к целевому API = 1).
  • Для неидемпотентных инструментов (например, «отправить сообщение») документируем это и проверяем, что каждый вызов обрабатывается отдельно.

6.3. Пример

@responses.activate
def test_idempotency():
    responses.add(
        responses.POST,
        "https://api.example.com/create_order",
        json={"order_id": "123", "status": "created"},
        status=201
    )
    tool = CreateOrderTool()
    # Первый вызов
    first = tool.run(items=["book"], idempotency_key="abc123")
    # Второй вызов с тем же ключом
    second = tool.run(items=["book"], idempotency_key="abc123")
    assert first == second  # результат одинаков
    # Проверяем, что реальный POST был вызван только один раз
    assert len(responses.calls) == 1

Термин: Idempotency key – уникальный идентификатор, прикрепляемый к запросу, чтобы сервер мог распознать дубликат.


7. Автоматизация тестов (pytest, unittest)

7.1. Структура тестового набора

tests/
├── tools/
│   ├── test_flight_search.py
│   ├── test_weather.py
│   └── test_create_order.py
├── conftest.py   (фикстуры, общие моки)
└── pytest.ini

7.2. Использование фикстур

# conftest.py
import pytest
import responses

@pytest.fixture
def mocked_responses():
    with responses.RequestsMock() as rsps:
        yield rsps

7.3. Запуск с покрытием

pytest tests/ --cov=agent.tools --cov-report=term-missing

Цель – не менее 90% покрытия для критических инструментов.


8. Мокирование внешних зависимостей (mock library)

8.1. Инструменты

8.2. Пример подмены сложной зависимости

Инструмент может использовать не HTTP, а вызов Redis, RabbitMQ, базы данных. Используем unittest.mock.patch:

from unittest.mock import patch

def test_redis_cache():
    with patch('agent.tools.cache.get_redis_client') as mock_redis:
        mock_redis.return_value.get.return_value = None  # кэш пуст
        tool = CachedWeatherTool()
        result = tool.run(city="Paris")
        assert result.source == "api"
        # Проверяем, что был вызов set для сохранения в кэш
        mock_redis.return_value.set.assert_called_once()

9. Инструменты тестирования: продвинутые техники

  • Тестирование с real API против mock: для критических инструментов полезно иметь дополнительный набор интеграционных тестов с реальным API (но с низкой частотой запуска, помеченных @pytest.mark.slow).
  • Параметризация тестов: проверка множества наборов входных данных.
@pytest.mark.parametrize("origin, destination, date, expected_code", [
    ("LHR", "CDG", "2025-06-15", 200),
    ("LHR", "CDG", "invalid-date", 422),
    ("", "CDG", "2025-06-15", 400),
])
@responses.activate
def test_flight_search_parametrized(origin, destination, date, expected_code):
    responses.add(responses.GET, "https://api.flights.com/search",
                  status=expected_code)
    tool = FlightSearchTool()
    if expected_code // 100 != 2:
        with pytest.raises(ToolExecutionError):
            tool.run(origin=origin, destination=destination, date=date)
    else:
        result = tool.run(origin=origin, destination=destination, date=date)
        assert result.success

10. Пример комплексного теста инструмента

Ниже – тест, который объединяет проверку валидации, мок API, обработку ошибки и rate limiting.

import pytest
import responses
from agent.tools.currency_tool import CurrencyTool, ToolExecutionError

@pytest.fixture
def tool():
    return CurrencyTool(api_key="test-key", max_retries=3, retry_delay=0.01)

class TestCurrencyTool:
    def test_conversion_success(self, tool, mocked_responses):
        mocked_responses.get(
            "https://api.exchangerate.com/latest?base=USD&target=EUR&amount=100",
            json={"rate": 0.92, "result": 92.0},
            status=200
        )
        result = tool.convert(base="USD", target="EUR", amount=100)
        assert result == 92.0

    def test_missing_currency(self, tool):
        with pytest.raises(ValueError, match="неизвестная валюта"):
            tool.convert(base="XYZ", target="EUR", amount=100)

    def test_rate_limit_and_retry(self, tool, mocked_responses):
        # Первые два вызова – 429, третий – успех
        mocked_responses.get(
            "https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
            status=429,
            headers={"Retry-After": "1"}
        )
        mocked_responses.get(
            "https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
            status=429,
            headers={"Retry-After": "1"}
        )
        mocked_responses.get(
            "https://api.exchangerate.com/latest?base=USD&target=EUR&amount=10",
            json={"rate": 0.92, "result": 9.2},
            status=200
        )
        result = tool.convert(base="USD", target="EUR", amount=10)
        assert result == 9.2
        assert len(mocked_responses.calls) == 3

    def test_connection_timeout(self, tool, mocked_responses):
        import requests.exceptions
        mocked_responses.get(
            "https://api.exchangerate.com/latest?base=USD&target=EUR&amount=100",
            body=requests.exceptions.ConnectTimeout()
        )
        with pytest.raises(ToolExecutionError, match="таймаут"):
            tool.convert(base="USD", target="EUR", amount=100)

11. CI/CD и регрессионное тестирование

  • Добавьте тесты в конвейер CI (GitHub Actions, GitLab CI).
  • Запускайте unit-тесты при каждом коммите.
  • Интеграционные тесты с реальными API – по расписанию (например, раз в день) или с меткой @pytest.mark.integration.
  • При изменении схемы инструмента (добавление нового параметра) тесты должны немедленно сигнализировать о нарушениях.

12. Лучшие практики

  • Тестируйте через публичный интерфейс инструмента (run, call), а не внутренние методы.
  • Используйте property-based testing (Hypothesis) для генерации крайних значений JSON Schema.
  • Документируйте тестовые сценарии в репозитории – что покрыто, а что нет.
  • Изолируйте тесты от внешнего состояния (БД, файловая система) – используйте tmp_path для фалов.
  • Не смешивайте тесты разных инструментов в одном тесте.

Пет-проект для закрепления

Задача Разработать инструмент WeatherTool для агента, который обращается к OpenWeatherMap API, и написать для него полный набор изолированных тестов.

Инструменты Python 3.11, pytest, responses, unittest.mock.

Шаги:

  1. Реализуйте класс WeatherTool:
    • Валидация входных параметров: city (строка), units (metric/imperial, опционально).
    • Вызов API через requests.get.
    • Обработка ошибок: 404 (город не найден), 500, таймаут.
    • Добавьте механизм повторных попыток при 429 с заголовком Retry-After.
    • Реализуйте идемпотентность: если запрос уже выполнялся с таким городом и ключом, верните кэшированный ответ.
  2. Напишите unit-тесты для валидации (10+ тестов).
  3. Напишите интеграционные тесты с моками (5+ сценариев).
  4. Проверьте, что тесты запускаются изолированно (без интернета).
  5. Добавьте измерение покрытия (coverage >=95%).

Ожидаемый результат Папка с кодом инструмента и тестами, отчёт о покрытии, README с описанием, как запускать.


Связь с другими вопросами

ВопросТема
790Проектирование архитектуры AI-агента
791Интеграция инструментов в агента
792Безопасность вызовов инструментов
794Логирование и трассировка вызовов
795Мониторинг и алертинг агента

Навигация