Сборка в 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. Их должно быть мало: пять-семь штук, проверяющих только форму ответа, а не логику. Запускать не на каждый пуш, а по расписанию, раз в сутки, отдельной джобой с отдельным ключом. Если ночная джоба покраснела, а основной набор зелёный — контракт уехал.
Второй признак вранья — тесты, которые проверяют, что модель сказала. Такие надо переписывать: утверждение должно касаться поведения обвязки, а не текста. «Агент остановился на пятой итерации» проверяемо, «агент дал полезный ответ» — нет.
И держите правило: заготовленные ответы заглушки живут рядом с тестом, а не в общей фикстуре на весь проект. Общая фикстура через полгода превращается в файл, который никто не решается тронуть.
Источники
- Anthropic, Building effective agents — о тестировании в песочнице, итерациях по описаниям инструментов и условиях остановки цикла
- Документация Python, typing.Protocol
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — что имеет смысл покрывать security-пробами
- Репозиторий production-ai-agents — реализация MockLLM и тестового харнесса, запускается без ключей

