Зачем юнит-тесты, если есть оценка качества

Прошлый раздел дал инструменты для недетерминированной части — качества ответов LLM. Но большая доля багов агента живёт не в модели, а в обычном коде вокруг неё, и эти баги — детерминированные, воспроизводимые, чинятся классическими тестами. Смешивать эти два мира — ошибка: judge'ем проверять парсер JSON дорого и глупо, а unit-тестом «правильность ответа по смыслу» — невозможно.

Unit-тесты (этот урок)Оценка качества (пред. раздел)
Что проверяютДетерминированный код: инструменты, парсеры, логикуСмысл ответа LLM
РезультатСтрого pass/fail (==, исключения)Score, судья, метрики
Скорость / ценаМиллисекунды, бесплатноСекунды, токены
ДетерминизмПолный — тот же вход → тот же результатЕсть разброс
ℹ️ Агент = LLM + много обычного кода

Мысленно раздели агента на две части. Недетерминированное ядро — вызовы LLM (оцениваются метриками/judge). Детерминированная обвязка — инструменты, парсинг, роутинг, форматирование, управление состоянием: это обычный Python, который обязан работать предсказуемо. Unit-тесты покрывают вторую часть строго и дёшево. Чем чётче граница между ними, тем проще тестировать и то, и другое.

Что юнит-тестировать у агента

Пройдёмся по компонентам агента, которые обязаны тестироваться как обычный код — без всякой LLM.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Что тестировать у агента как код LLM-вызов (ядро) НЕ unit — оценка/judge + МОК в тестах Инструменты (tools) поиск, калькулятор, API-клиент вход→выход, ошибки, границы Парсеры выхода LLM JSON/структура из ответа кривой ввод → корректная обработка Роутеры / логика ветвления выбор ветки по условию детерминированные правила Форматтеры / промпт-билдеры сборка промпта из шаблона подстановка переменных, эскейпинг Управление состоянием reducers, память, State (LangGraph) обновление/слияние состояния Валидаторы / гардрейлы проверка входа/выхода, лимиты срабатывают ли правила Всё это — обычный код: строгий pass/fail, миллисекунды, без токенов LLM в этих тестах ЗАМОКана — тестируем свой код, а не модель

Особое внимание — инструментам: это чаще всего чистые функции (вход → выход), и именно из-за них агент падает в проде. Для каждого инструмента тестируй: нормальный случай, границы (пустой/огромный ввод), ошибки (API недоступен, таймаут, некорректные аргументы). И — парсеры выхода LLM: модель иногда возвращает почти-JSON, лишний текст, обрезанную структуру — парсер обязан это переживать, и это идеально проверяется юнит-тестами на «плохих» примерах.

Пример: тест инструмента

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

Unit-тесты инструмента (pytest)
python
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, пустоту, ошибку — и проверить, что твой код это переживёт.
Мок LLM: тестируем парсер и обработку плохого ответа
python
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 за интерфейсом, который легко замокать. Если тест писать тяжело — это сигнал переработать структуру, а не «пропустить тест». Тестируемость и хорошая архитектура — одно и то же.

Типичные ошибки

❌ Ошибка 1: гонять реальную LLM в юнит-тестах

Настоящие вызовы модели делают юниты медленными, дорогими и флапающими из-за недетерминизма. В unit-тестах LLM всегда мокается; реальная модель — только в немногих e2e/eval-тестах.

❌ Ошибка 2: тестировать только «счастливый путь»

Инструмент проверен на 2+2, но не на пустом вводе, таймауте API и кривых аргументах — а именно на них он падает в проде. Границы и ошибки обязательны.

❌ Ошибка 3: юнит-тестом проверять качество ответа

assert answer == "Париж" для свободного текста — не unit-тест, а сломанная оценка качества. Смысл ответа проверяется judge/метриками, а == — только детерминированный выход.

❌ Ошибка 4: непокрытые парсеры выхода LLM

Парсер, протестированный только на идеальном JSON, рухнет на реальном «почти-JSON» от модели. Гоняй парсер на мусорных/обрезанных/обёрнутых текстом примерах.

Шпаргалка

Unit-тесты агента — всё в одном месте
text
АГЕНТ = недетерм. ЯДРО (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 · == для смысла ответа · непокрытые парсеры

Практическое задание

Задание: покрой агента юнит-тестами

  1. Возьми агента из модулей 03–05 и выпиши его детерминированные компоненты (инструменты, парсеры, роутеры, форматтеры). Что из этого сейчас не покрыто тестами?
  2. Для одного инструмента напиши 3 теста: нормальный вход, граница (пусто/большой), ошибка (сбой зависимости). Все ли проходят?
  3. Найди парсер выхода LLM. Прогони его на «плохих» примерах: текст вокруг JSON, обрезанный JSON, пустая строка. Что ломается?
  4. Замокай вызов LLM (unittest.mock.patch) и напиши тест на один шаг агента: заданный ответ «модели» → ожидаемое действие твоего кода. Убедись, что реальная сеть не дёргается.
  5. Заставь мок вернуть заведомо кривой ответ модели. Обрабатывает ли твой код это gracefully или падает с необработанным исключением?
  6. Оцени свою «пирамиду»: чего больше — юнитов или тестов с реальной LLM? Если перевёрнута — какие e2e можно заменить юнитами с моком?
  7. Со звёздочкой: найди компонент, который тяжело протестировать из-за слитой логики (промпт+вызов+парсинг в одном методе). Разбей его так, чтобы части тестировались отдельно.

Что дальше

Юнит-тесты проверяют компоненты по отдельности с замоканной LLM. Но агент — это компоненты, работающие вместе, и их стык — отдельный источник багов. Следующий урок — integration tests: как тестировать полные пайплайны агента, где и когда подключать реальную LLM.