Зачем юнит-тесты, если есть оценка качества
Прошлый раздел дал инструменты для недетерминированной части — качества ответов LLM. Но большая доля багов агента живёт не в модели, а в обычном коде вокруг неё, и эти баги — детерминированные, воспроизводимые, чинятся классическими тестами. Смешивать эти два мира — ошибка: judge'ем проверять парсер JSON дорого и глупо, а unit-тестом «правильность ответа по смыслу» — невозможно.
| Unit-тесты (этот урок) | Оценка качества (пред. раздел) | |
|---|---|---|
| Что проверяют | Детерминированный код: инструменты, парсеры, логику | Смысл ответа LLM |
| Результат | Строго pass/fail (==, исключения) | Score, судья, метрики |
| Скорость / цена | Миллисекунды, бесплатно | Секунды, токены |
| Детерминизм | Полный — тот же вход → тот же результат | Есть разброс |
Мысленно раздели агента на две части. Недетерминированное ядро — вызовы LLM (оцениваются метриками/judge). Детерминированная обвязка — инструменты, парсинг, роутинг, форматирование, управление состоянием: это обычный Python, который обязан работать предсказуемо. Unit-тесты покрывают вторую часть строго и дёшево. Чем чётче граница между ними, тем проще тестировать и то, и другое.
Что юнит-тестировать у агента
Пройдёмся по компонентам агента, которые обязаны тестироваться как обычный код — без всякой LLM.
Особое внимание — инструментам: это чаще всего чистые функции (вход → выход), и именно из-за них агент падает в проде. Для каждого инструмента тестируй: нормальный случай, границы (пустой/огромный ввод), ошибки (API недоступен, таймаут, некорректные аргументы). И — парсеры выхода LLM: модель иногда возвращает почти-JSON, лишний текст, обрезанную структуру — парсер обязан это переживать, и это идеально проверяется юнит-тестами на «плохих» примерах.
Пример: тест инструмента
Инструмент — чистая функция, тестируется тривиально и обязательно. Покрываем успех, границы и ошибки:
import pytest
def calculate(expression: str) -> float:
"""Инструмент агента: безопасно вычислить арифметическое выражение."""
if not expression.strip():
raise ValueError("empty expression")
return safe_eval(expression)
# ── нормальный случай ──
def test_calculate_basic():
assert calculate("2 + 2") == 4
assert calculate("10 / 4") == 2.5
# ── границы ──
def test_calculate_whitespace():
assert calculate(" 3 * 3 ") == 9
# ── ошибки (обязательно!) ──
def test_calculate_empty_raises():
with pytest.raises(ValueError):
calculate("")
def test_calculate_bad_input_raises():
with pytest.raises(Exception):
calculate("import os") # инъекция не должна выполниться
Типичная ошибка новичка — проверять только «счастливый путь». Но агент в проде постоянно встречает несчастливые: API отдал 500, LLM передала кривые аргументы, пришёл пустой ввод. Именно обработка ошибок инструмента определяет, устоит ли агент. На каждый инструмент — минимум один тест на нормальный вход и один на сбой/границу.
Изоляция LLM через моки
Главная сложность тестирования агента: он вызывает LLM, а она недетерминирована, медленна и стоит денег. Гонять настоящую модель в юнит-тестах — значит сделать их медленными, дорогими и флапающими. Решение — мок (mock, «заглушка»): в тесте подменяем вызов LLM на фиксированный ответ. Тогда мы тестируем свой код вокруг модели, а не саму модель.
Зачем мокать LLM в юнит-тестах:
- Детерминизм — фиксированный ответ модели → воспроизводимый тест.
- Скорость и цена — ноль сетевых вызовов, ноль токенов, миллисекунды.
- Контроль сценариев — можно заставить «модель» вернуть кривой JSON, пустоту, ошибку — и проверить, что твой код это переживёт.
from unittest.mock import patch
import json
def extract_action(llm_output: str) -> dict:
"""Парсер: достать действие из ответа модели, пережив мусор вокруг JSON."""
start = llm_output.find("{")
end = llm_output.rfind("}") + 1
return json.loads(llm_output[start:end]) # терпим текст до/после JSON
# тестируем ПАРСЕР на разных «ответах модели» — без вызова LLM
def test_parser_clean_json():
assert extract_action('{"action": "search"}') == {"action": "search"}
def test_parser_json_with_surrounding_text():
out = 'Хорошо, вот действие: {"action": "search"} — выполняю.'
assert extract_action(out) == {"action": "search"}
# мок самого агента: подменяем LLM фиксированным ответом
@patch("my_agent.llm_call")
def test_agent_uses_tool_from_llm(mock_llm):
mock_llm.return_value = '{"action": "search", "query": "погода"}'
result = run_agent_step("какая погода?")
assert result.tool == "search" # проверяем НАШУ логику,
mock_llm.assert_called_once() # а не саму модель
Важно не путать роли. Мок отвечает на вопрос «правильно ли мой код обрабатывает данный ответ модели» — это unit-тест. Он не проверяет, вернёт ли реальная LLM хороший ответ, — это работа оценки качества (judge/датасеты) и интеграционных тестов (следующий урок). Юнит-тест с моком и оценка качества дополняют друг друга: один гарантирует надёжность обвязки, другой — качество ядра.
Пирамида тестирования
Сколько каких тестов писать? Классический ориентир — пирамида тестирования, адаптированная под агентов: много дешёвых юнит-тестов внизу, меньше интеграционных в середине, совсем немного дорогих end-to-end/eval сверху.
▲ мало, дорого, медленно
╱ ╲ E2E / eval — весь агент + реальная LLM (оценка качества)
╱───╲
╱ ╲ Integration — компоненты вместе, LLM иногда реальная
╱───────╲ (следующий урок)
╱ ╲ UNIT — инструменты, парсеры, логика; LLM ЗАМОКана
╱───────────╲ быстрые, детерминированные, дешёвые
▼ много, дёшево, мгновенно
Правило: основание — юниты. Если тестов-«вершинок» больше, чем юнитов —
пирамида перевёрнута: тесты медленные, дорогие и флапают.
Смысл формы: основание (юниты) должно быть широким — они ловят большинство багов дёшево и мгновенно, дают быструю обратную связь на каждый коммит. Дорогие тесты с реальной LLM (вершина) держи малочисленными и точечными. Перевёрнутая пирамида (много e2e, мало юнитов) = медленный, дорогой и ненадёжный CI, которым перестают пользоваться.
Агента, где промпт-сборка, парсинг и вызов LLM слиты в один метод, юнит-тестировать невозможно. Хорошо тестируемый агент спроектирован под тесты: инструменты — чистые функции, парсеры отделены от вызова, LLM за интерфейсом, который легко замокать. Если тест писать тяжело — это сигнал переработать структуру, а не «пропустить тест». Тестируемость и хорошая архитектура — одно и то же.
Типичные ошибки
Настоящие вызовы модели делают юниты медленными, дорогими и флапающими из-за недетерминизма. В unit-тестах LLM всегда мокается; реальная модель — только в немногих e2e/eval-тестах.
Инструмент проверен на 2+2, но не на пустом вводе, таймауте API и кривых аргументах — а именно на них он падает в проде. Границы и ошибки обязательны.
assert answer == "Париж" для свободного текста — не unit-тест, а сломанная оценка качества. Смысл ответа проверяется judge/метриками, а == — только детерминированный выход.
Парсер, протестированный только на идеальном JSON, рухнет на реальном «почти-JSON» от модели. Гоняй парсер на мусорных/обрезанных/обёрнутых текстом примерах.
Шпаргалка
АГЕНТ = недетерм. ЯДРО (LLM → оценка/judge) + детерм. ОБВЯЗКА (код → UNIT-тесты)
не смешивать: judge для смысла · unit для кода · чёткая граница между ними
ЧТО ЮНИТ-ТЕСТИТЬ (обычный код, строгий pass/fail, мс, без токенов):
инструменты (tools) ← ГЛАВНОЕ: вход→выход + ГРАНИЦЫ + ОШИБКИ
парсеры выхода LLM (кривой/обрезанный/обёрнутый JSON)
роутеры/ветвление · форматтеры/промпт-билдеры · state/reducers · валидаторы/гардрейлы
ИНСТРУМЕНТ: тест на успех + тест на границу/ошибку (API 500, пусто, кривые args)
тестируй ошибки НЕ меньше успеха — на них агент падает в проде
МОК LLM в юнитах (unittest.mock.patch):
зачем: детерминизм · скорость/0 токенов · контроль сценариев (заставить вернуть мусор)
мок проверяет ТВОЙ КОД вокруг модели, НЕ качество модели (это judge/e2e)
ПИРАМИДА: много UNIT (LLM замокана) > меньше integration > мало e2e/eval (реальная LLM)
перевёрнутая пирамида = медленный/дорогой/флапающий CI
ТЕСТИРУЕМОСТЬ = ДИЗАЙН: tools=чистые функции, парсер отдельно от вызова, LLM за интерфейсом
тяжело писать тест → переработай структуру, не пропускай тест
ГРАБЛИ: реальная LLM в юнитах · только happy path · == для смысла ответа · непокрытые парсеры
Практическое задание
Задание: покрой агента юнит-тестами
- Возьми агента из модулей 03–05 и выпиши его детерминированные компоненты (инструменты, парсеры, роутеры, форматтеры). Что из этого сейчас не покрыто тестами?
- Для одного инструмента напиши 3 теста: нормальный вход, граница (пусто/большой), ошибка (сбой зависимости). Все ли проходят?
- Найди парсер выхода LLM. Прогони его на «плохих» примерах: текст вокруг JSON, обрезанный JSON, пустая строка. Что ломается?
- Замокай вызов LLM (
unittest.mock.patch) и напиши тест на один шаг агента: заданный ответ «модели» → ожидаемое действие твоего кода. Убедись, что реальная сеть не дёргается. - Заставь мок вернуть заведомо кривой ответ модели. Обрабатывает ли твой код это gracefully или падает с необработанным исключением?
- Оцени свою «пирамиду»: чего больше — юнитов или тестов с реальной LLM? Если перевёрнута — какие e2e можно заменить юнитами с моком?
- Со звёздочкой: найди компонент, который тяжело протестировать из-за слитой логики (промпт+вызов+парсинг в одном методе). Разбей его так, чтобы части тестировались отдельно.
Что дальше
Юнит-тесты проверяют компоненты по отдельности с замоканной LLM. Но агент — это компоненты, работающие вместе, и их стык — отдельный источник багов. Следующий урок — integration tests: как тестировать полные пайплайны агента, где и когда подключать реальную LLM.