1. Главная
  2. Блог
  3. Снапшот-тесты для ИИ-агента: как pytest ловит регрессы в промптах раньше, чем клиент

Снапшот-тесты для ИИ-агента: как pytest ловит регрессы в промптах раньше, чем клиент

Снапшот-тесты для ИИ-агента: как pytest ловит регрессы в промптах раньше, чем клиент

Понедельник, я только успел налить кофе — и уже открываю Telegram с сообщением от руководителя отдела продаж у одного из заказчиков. Дилер спецтехники, живой бизнес, живые заявки. Смысл сообщения был примерно такой: «Ребята, бот опять всё отправляет на ручную обработку. В пятницу работал, сегодня — нет». Я лезу в логи. За выходные кто-то поправил один абзац в системном промпте — добавил уточнение про работу с физлицами. Уточнение по делу, всё правильно. Но рядом сломалась классификация B2B-заявок: модель стала перестраховываться и почти любую крупную сделку помечать как «требует менеджера».

Два дня ИИ-агент работал в полсилы, и никто не заметил, пока не накопилась критическая масса ворчания в чатах.

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

Почему обычные unit-тесты здесь не работают

В классическом коде вы пишете assert calculate(2, 2) == 4 и спите спокойно. В мире LLM такой подход разваливается на первом же примере. Модель может ответить «4», «четыре», «два плюс два равно четырём» — и все три варианта, вообще-то, правильные. Строковое сравнение бесполезно.

Дальше — недетерминированность. Даже с temperature=0 российские LLM иногда сваливаются в плюс-минус одно слово, особенно если промпт длинный. А если тест падает раз в десять прогонов, разработчики через неделю начнут его игнорировать. Это правило, проверенное на живых людях в нашей же команде.

И третье — деньги. Тысяча тестовых вызовов GigaChat Pro — это уже не «бесплатно», как с pytest на CPU. Тестов должно быть мало, но они должны бить по живому. Дешёвый локальный ИИ-агент здесь тоже не панацея: у него свои особенности, свой дрейф, и тесты всё равно нужны.

Golden dataset: маленький набор вместо тысячи прогонов

Первое, что мы сделали, — собрали «золотой набор». Я честно потратил вечер и день, перечитал логи реальных диалогов за пару месяцев и отобрал десятки кейсов, на которых агент обязан вести себя предсказуемо. Не «все возможные», а те, где нам критично, чтобы поведение не поехало.

Категории получились такие:

  • квалификация лида: горячий, тёплый, нецелевой;
  • извлечение сущностей: ИНН, сумма, сроки;
  • «тонкие» сценарии, где раньше уже ловили баги;
  • безопасность: промпт-инъекции, провокации, попытки вытащить системный промпт.

Принцип отбора я себе сформулировал так: каждый кейс должен покрывать поведение, которое мы НЕ хотим сломать никогда. Не «протестировать функциональность», а «зафиксировать договорённость» между продактом, разработчиком и здравым смыслом.

Хранится всё в обычном YAML, который спокойно правят и продакт, и разработчик, и, если очень надо, менеджер проекта:

- id: lead_qual_b2b_large
  input: |
    Здравствуйте, мы строительная компания,
    нужен экскаватор для карьерных работ, бюджет крупный.
  expected:
    classification: "hot_b2b"
    entities:
      segment: "b2b"
    requires_manager: false
  invariants:
    - "ответ не содержит фразы про физлиц"
    - "ответ не содержит извинений"

Первая версия датасета у меня получилась плохая: я туда напихал красивых, но не важных диалогов. Через неделю половину переделал — оставил только то, что реально болит.

Семантическое сравнение, а не строковое

Здесь обычно ломаются процентов восемьдесят попыток «сделать тесты для LLM». Люди пишут assert response == "hot_b2b" и искренне удивляются, что тест падает на ответе "HOT_B2B" или "hot b2b".

Мы разбили проверки на три уровня жёсткости, и это, пожалуй, главная идея всей затеи.

Жёсткие — для структурированного вывода. Если ИИ-агент должен вернуть JSON со строгой схемой (а в проде так и должно быть, серьёзно), мы сравниваем по полям. classification — по lowercase, числа — с допуском, списки — без учёта порядка. Тут никакой магии, обычный питон.

Семантические — для свободного текста. Берём эмбеддинг ожидаемого ответа, эмбеддинг полученного, считаем косинусную близость. Если ниже 0.78 — тест падает. Цифру подобрали эмпирически, не с потолка: ниже 0.7 — это уже разные смыслы, выше 0.85 — придирки к стилистике, разработчик начнёт материться. 0.78 — компромисс, который у нас прижился.

Инвариантные — для запретов. Здесь работает или обычный regex, или отдельный мини-промпт «содержит ли этот текст признаки X?». Второе дороже, поэтому применяется только на инвариантах, которых в кейсе обычно один-два.

def test_lead_qualification(golden_case):
    response = agent.process(golden_case.input)

    # Жёсткая проверка структуры
    assert response.classification.lower() == golden_case.expected["classification"]
    assert_close(response.entities.sum_rub,
                 golden_case.expected["entities"]["sum_rub"], tol=0.01)

    # Семантика свободного текста
    if golden_case.expected.get("reply_intent"):
        sim = cosine(embed(response.reply), embed(golden_case.expected["reply_intent"]))
        assert sim > 0.78, f"Низкая близость: {sim:.2f}"

    # Инварианты
    for inv in golden_case.invariants:
        assert check_invariant(response.reply, inv), f"Нарушен инвариант: {inv}"

Первую версию я сделал только с семантикой, без структурных ассертов — и тесты стали пропускать реально сломанные ответы, потому что «по смыслу похоже». Пришлось признать, что без строгих полей никак.

Стабильность: три прогона и медиана

Чтобы тест не мигал раз в десять запусков из-за случайной флуктуации модели, мы делаем три прогона одного кейса и берём медиану по семантической близости. Структурные ассерты должны пройти все три раза — здесь поблажек нет, иначе смысл теряется.

Да, это дороже в три раза. Но всё равно дешевле, чем флакающий CI, на который через неделю все забивают. Проходили.

CI: блокировка мержа и хранение истории

Тесты запускаются в GitLab CI на каждый PR, который трогает файлы в каталогах prompts/, agents/ или схемы инструментов. На остальные изменения — не запускаются, потому что дёргать модель на правку README глупо и дорого.

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

Каждый прогон сохраняет JSON-отчёт с ответами модели в артефакты. Через пару месяцев у нас накопилась история, по которой видно интересное: вот тут промпт стал отвечать заметно суше, вот тут — стабильно длиннее и с извинениями. Эта телеметрия оказалась неожиданно полезнее самих ассертов: она показывает дрейф, который проходит мимо жёстких проверок, но в сумме меняет тон агента и восприятие клиентом.

Что изменилось после внедрения

Не буду сыпать процентами, потому что цифры у каждого проекта свои. Расскажу человеческим языком.

До этих тестов мы регулярно получали от клиента сообщения в духе «бот стал работать хуже», разбирались руками, ловили причину через логи, коммиты и интуицию. От момента поломки до момента, когда мы вообще понимали, что она есть, часто уходило больше суток — потому что об этом узнавали не мы, а менеджер по продажам, и не сразу.

После внедрения снапшот-тестов история изменилась. Мы теперь узнаём о регрессе в момент, когда разработчик открывает PR, — CI падает и говорит, какой именно кейс сломался. За время жизни системы поймали десятки регрессов до прода, и несколько из них были критичные: ломали бизнес-логику квалификации лидов или в определённых формулировках раскрывали кусок системного промпта.

Внедрение заняло у нас примерно рабочую неделю на сборку golden dataset и обвязку. Дальше — час-другой в месяц на поддержку: добавить свежий кейс после интересной поломки, переразметить пару устаревших. Для команды, которая всерьёз занимается внедрением ИИ-агентов, это, по-моему, минимальная цена входа в спокойную жизнь.

Где эта техника буксует

Честно скажу, где она не работает или работает плохо.

Креативные задачи. Если агент пишет маркетинговые тексты или сценарии, никакая семантическая близость не отличит «хорошо» от «отлично». Здесь нужна другая стратегия — экспертная оценка глазами, раз в неделю, не CI.

Сильно меняющаяся доменная область. Если у заказчика постоянно меняется прайс, ассортимент и регламент работы с заявками, golden dataset устаревает быстрее, чем мы успеваем его править. Тогда тесты превращаются в источник ложных срабатываний, и доверие к ним умирает за квартал. У нас так было на одном проекте — пришлось переосмыслить, какие именно инварианты фиксировать, а какие вынести в конфиг и не проверять снапшотами.

Многошаговые агенты с инструментами. Зафиксировать ожидаемую последовательность вызовов инструментов сложнее, чем один ответ. Мы это решаем отдельно — моками на уровне MCP-сервера, но это отдельная тема, на неё нужен свой рассказ.

Главный вывод

Промпты — это код. Их трогают чаще, чем код. Их ломают так же легко, как код. Но почему-то для кода у всех есть pytest и линтеры, а для промптов — «я перечитал и вроде нормально».

Если у вас ИИ-агент работает в проде хотя бы у одного платящего клиента, небольшой golden dataset окупится за первый же предотвращённый откат. У нас — окупился очень быстро, я даже не пытался высчитывать точнее, потому что и без цифр было очевидно.

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

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.