Перейти к содержимому
AI-агенты

Как тестировать ИИ-агента, не платя за каждый прогон

Как тестировать ИИ-агента, не платя за каждый прогон

Сборка в CI прогоняет двести тестов. Если каждый дёргает живую модель, вы платите за двести запросов на каждый пуш, ждёте минут восемь вместо двадцати секунд и получаете красный билд из-за таймаута на стороне провайдера — при полностью рабочем коде. Тесты ИИ-агента без API-ключей решают все три проблемы разом, и делается это не моками поверх HTTP, а контрактом на уровне типов.

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

Почему тесты на живой модели ничего не проверяют

Живая LLM недетерминирована. Один и тот же промпт при temperature выше нуля даёт разные формулировки, и тест, который сверяет ответ со строкой, будет мигать. Разработчик научится игнорировать красное — а это худшее, что может случиться с набором тестов.

Выкрутить температуру в ноль недостаточно: провайдер меняет версию модели под тем же именем, и поведение уезжает без вашего участия. Anthropic в разборе про построение агентов советует именно это — прогонять примеры и итерировать по описаниям инструментов, наблюдая за ошибками модели. Но это исследование, а не регрессионный тест. Задачи разные.

Регрессионный тест должен отвечать на другой вопрос: не «хорошо ли модель формулирует», а «правильно ли моя обвязка ведёт себя, когда модель вернула вот это». Второе детерминировано полностью, и живая модель для него не нужна.

Protocol вместо наследования

Точка разделения — контракт клиента модели. В Python это typing.Protocol: структурная типизация, при которой класс подходит по факту наличия методов, а не по родителю.

from typing import Protocol

class LLMClient(Protocol):
    async def chat(self, messages: list[dict], max_tokens: int = 1024) -> str: ...

Почему не абстрактный базовый класс. С ABC вам придётся наследовать чужой SDK-клиент или писать обёртку с обязательным super(). С Protocol ничего наследовать не нужно: и адаптер вокруг реального API, и заглушка независимо удовлетворяют контракту, а mypy проверит это статически. Агент принимает LLMClient и не знает, что ему подсунули.

Тот же приём распространяется на остальные слои: MemoryStore с реализациями в памяти и в шифрованном хранилище, PermissionChecker, за которым может стоять RBAC, ABAC или простой allowlist. Про то, как эти слои устроены целиком, есть разбор шести слоёв продакшен-агента.

Что должен уметь MockLLM

Заглушка, которая всегда возвращает "ok", бесполезна. Полезная заглушка проигрывает сценарий.

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

class MockLLM:
    def __init__(self, responses: list[str]):
        self._responses = list(responses)
        self.calls: list[list[dict]] = []

    async def chat(self, messages: list[dict], max_tokens: int = 1024) -> str:
        self.calls.append(messages)
        if not self._responses:
            raise AssertionError("MockLLM: запросов больше, чем заготовленных ответов")
        return self._responses.pop(0)

Обратите внимание на две детали. Список calls хранит всё, что агент отправил модели, — по нему проверяется, что в промпт попала память, что системная инструкция на месте, что PII не утекли в запрос. А исключение при опустевшем списке ловит самую частую поломку: цикл, который сделал больше итераций, чем вы ожидали. Молчаливое возвращение пустой строки этот баг спрячет.

Security-пробы: тесты на отказ, а не на ответ

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

Заставьте MockLLM вернуть вызов файлового инструмента с путём, который ведёт за пределы рабочей директории. Тест должен утверждать, что инструмент не выполнился, что в аудит-лог легла запись об отказе и что агент вернул пользователю внятное сообщение, а не трассировку. Отдельным тестом — путь с символической ссылкой, отдельным — путь вида /tmp/workspace-evil, который проходит наивную проверку по префиксу.

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

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

Как понять, что тесты врут

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

Лечится узкой прослойкой контрактных тестов, которые ходят к настоящему API. Их должно быть мало: пять-семь штук, проверяющих только форму ответа, а не логику. Запускать не на каждый пуш, а по расписанию, раз в сутки, отдельной джобой с отдельным ключом. Если ночная джоба покраснела, а основной набор зелёный — контракт уехал.

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

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

Источники