Батарея тестов для своей LLM: как понять, что стало хуже
Вы обновили модель, подправили промпт или поменяли параметры RAG-пайплайна — и теперь стоите перед вопросом «стало лучше или хуже?», на который можно ответить только субъективным «вроде нормально». В обычной разработке ПО для этого давно есть решение: регрессионные тесты, которые автоматически ловят непреднамеренную поломку при любом изменении кода. Для LLM-системы работает тот же принцип, только тесты придётся написать самим — и ниже разберём, как именно.
Содержание
- Шаг 1. Соберите репрезентативный набор реальных примеров
- Шаг 2. Зафиксируйте эталонное поведение — не текст, а критерии
- Шаг 3. Автоматизируйте прогон при каждом значимом изменении
- Встройте прогон в процесс изменений, а не держите отдельно
- Поддерживайте батарею живой: растите её вместе с опытом эксплуатации
Шаг 1. Соберите репрезентативный набор реальных примеров
Ключевое слово здесь — «реальных». Не абстрактные вопросы вида «столица Франции» и не придуманные «для теста» фразы, а фрагменты того, что система реально видит в проде: обезличенные обращения клиентов, реальные вопросы к RAG по вашей базе документов, реальные брифы для генерации текста, реальные вводные данные для извлечения полей из документов.
Источники для сбора:
- Логи продакшена — самый честный источник. Выгрузите за последние недели реальные запросы пользователей (обезличив персональные данные) и отберите те, что покрывают разные типы задач.
- Тикеты поддержки и жалобы — конкретные случаи, когда пользователь написал «бот ответил не то» или «ассистент выдумал цену». Это готовые пограничные случаи с известным неверным поведением.
- Известные сложные места — вопросы с опечатками, смешением языков в одном сообщении, длинным контекстом, провокационными формулировками («забудь прошлые инструкции»), запросами на грани политики модерации.
- Редкие, но важные сценарии — то, что случается нечасто, но критично при ошибке: юридически значимые формулировки, вопросы о ценах и сроках, где неточность стоит денег.
Структура репрезентативного набора обычно выглядит так:
| Категория | Доля набора | Что проверяет |
|---|---|---|
| Типовые запросы | 50-60% | Стабильность на основном потоке — то, что должно работать всегда |
| Пограничные случаи | 20-25% | Не «улетает» ли система в галлюцинацию, отказ или грубость на нестандартном вводе |
| Проверяемые факты | 10-15% | Объективно верно/неверно — цены, сроки, версии, конкретные цифры из ваших данных |
| Регрессии из прошлого | оставшееся | Случаи, где система уже один раз ошибалась — самая ценная категория, о ней ниже |
Начинать можно с 30-50 примеров — этого достаточно, чтобы поймать большинство системных проблем. Раздувать набор до тысяч кейсов на старте не нужно: лучше 40 точных примеров, которые реально отражают вашу нагрузку, чем 500 случайных, половина которых никогда не встретится в реальном использовании. Набор растёт органически по мере эксплуатации — про это в отдельном разделе ниже.
Храните набор в структурированном виде, а не в переписке или в голове. Один тестовый случай — не просто текст вопроса, а полноценная запись с метаданными: входные данные, контекст (для RAG — либо сырой вопрос без контекста, чтобы прогон включал этап поиска, либо конкретный набор найденных документов, чтобы тестировать только генерацию), причина и дата добавления. Разделение уровней тестирования RAG-конвейера — на поиск, обоснованность ответа и релевантность — подробно разобрано в статье про оценку качества RAG-ответов; для сложных пайплайнов эти слои стоит тестировать отдельными наборами.
Шаг 2. Зафиксируйте эталонное поведение — не текст, а критерии
Здесь чаще всего ломается идея собственных тестов: люди пытаются зафиксировать точный ожидаемый ответ дословно и упираются в то, что LLM никогда не выдаёт один и тот же текст два раза. Решение — не эталонный текст, а эталонные критерии: чёткое, проверяемое описание того, что делает ответ приемлемым.
Для разных категорий критерии выглядят по-разному:
- Факт с единственно верным значением — критерий: «ответ содержит цифру/значение X» (например, «1 vCPU / 1 GB RAM» для минимального тарифа). Проверяется точным совпадением или regex по ключевым значениям, без учёта остального текста ответа.
- Обязательные и запрещённые элементы — критерий вида «ответ обязан содержать упоминание Y» и «ответ не должен содержать Z». Пример: для вопроса о возврате товара ответ обязан упомянуть срок 14 дней и не должен обещать возврат наличными, если у вас только безналичный возврат.
- Поведенческий критерий — для провокаций и пограничных случаев: «модель отказывает вежливо, не переходит в грубость, не меняет заявленную роль». Такой критерий формализуется как чек-лист из 2-4 бинарных пунктов.
- LLM-as-judge с явным рубриком — когда критерий не сводится к точному совпадению (тон, полнота, структура), можно поручить оценку другой модели, но обязательно с явным, письменно зафиксированным рубриком, а не абстрактным «оцени качество ответа». Рубрик описывает конкретно, что судья должен проверить и какую оценку ставить за каждый вариант.
Формат хранения — расширение того же JSON из шага 1:
{
"id": "faq-042",
"category": "typical",
"input": "Можно ли оплатить сервер в Великобритании криптой из России?",
"expected": {
"must_include": ["крипт", "карт"],
"must_not_include": ["невозможно", "нельзя"],
"criteria": "Ответ подтверждает оба способа оплаты, тон уверенный, без оговорок про 'уточните у менеджера'"
}
}
Важно: критерии пишутся один раз при добавлении примера в батарею, и именно поэтому дальнейший прогон становится объективным. Вы сравниваете новый ответ не с расплывчатым воспоминанием «раньше вроде было лучше», а с письменно зафиксированным правилом, которое не меняется от вашего настроения или усталости в конце дня.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереШаг 3. Автоматизируйте прогон при каждом значимом изменении
Ключевая идея регрессионного тестирования — не разовая проверка, а систематический прогон при любом значимом изменении системы: смена модели, обновление версии модели у того же провайдера, правка системного промпта, изменение параметров RAG (чанкинг, top-k поиска, реранкер), смена данных в базе знаний.
Минимальный раннер на Python выглядит примерно так — идея не в конкретной реализации, а в структуре: пройтись по всем примерам, получить ответ системы, применить критерии, сохранить результат с привязкой к версии системы:
import json
import subprocess
from datetime import datetime
def load_test_cases(path="tests/battery.jsonl"):
with open(path) as f:
return [json.loads(line) for line in f]
def check_criteria(answer: str, expected: dict) -> dict:
result = {"passed": True, "reasons": []}
for phrase in expected.get("must_include", []):
if phrase.lower() not in answer.lower():
result["passed"] = False
result["reasons"].append(f"нет обязательной фразы: {phrase}")
for phrase in expected.get("must_not_include", []):
if phrase.lower() in answer.lower():
result["passed"] = False
result["reasons"].append(f"есть запрещённая фраза: {phrase}")
return result
def run_battery(system_version_tag: str):
cases = load_test_cases()
report = {"version": system_version_tag, "ts": datetime.utcnow().isoformat(), "results": []}
for case in cases:
answer = query_your_system(case["input"], case.get("context"))
check = check_criteria(answer, case["expected"])
report["results"].append({"id": case["id"], "answer": answer, **check})
return report
Функция query_your_system — заглушка под ваш реальный вызов: HTTP-запрос к вашему API, вызов через litellm, прямой запрос к vLLM/Ollama — не важно, важно, что вход и выход тестового прогона идентичны тому, что видит реальный пользователь, включая тот же системный промпт и те же параметры RAG-пайплайна.
Результат каждого прогона сохраняйте отдельным файлом с меткой версии, а не перезаписывайте предыдущий — иначе не с чем будет сравнивать:
python run_battery.py --version "model=qwen2.5-72b,prompt=v14" > reports/2026-08-20_v14.json
python run_battery.py --version "model=qwen2.5-72b,prompt=v15" > reports/2026-08-27_v15.json
Сравнение двух прогонов — отдельный небольшой скрипт, который выводит не общий процент, а конкретный список случаев, изменивших статус:
def diff_reports(old_path, new_path):
old = {r["id"]: r["passed"] for r in json.load(open(old_path))["results"]}
new = {r["id"]: r["passed"] for r in json.load(open(new_path))["results"]}
regressions = [i for i in old if old[i] and not new.get(i, False)]
fixes = [i for i in new if new[i] and not old.get(i, True)]
print(f"Регрессии ({len(regressions)}): {regressions}")
print(f"Исправления ({len(fixes)}): {fixes}")
Именно список конкретных id, а не итоговый процент — это то, что реально экономит время при разборе: вы сразу видите не «качество упало на 8%», а «faq-042 и faq-057 перестали проходить», и можете открыть эти два случая и понять, что именно сломалось в новой версии промпта.
Для случаев, требующих LLM-as-judge, встройте вызов оценивающей модели в тот же цикл — с фиксированной температурой (или 0) у судьи и с тем же явным рубриком из шага 2, чтобы оценка была воспроизводимой между прогонами, а не плавала от случая к случаю.
Встройте прогон в процесс изменений, а не держите отдельно
Батарея тестов бесполезна, если её запускают «когда вспомнили». Встройте прогон в те точки, где вы и так меняете систему:
- Перед выкаткой новой версии модели или промпта в прод — прогон обязателен, а не по желанию. Если прогон не пройден по критичным случаям (проверяемые факты, известные регрессии) — откат или доработка до выкатки, а не после жалоб пользователей.
- В CI при изменении конфигурации RAG-пайплайна — если параметры чанкинга, top-k или реранкер хранятся в репозитории конфигов, добавьте прогон батареи как шаг CI при коммите в эти файлы, аналогично тому как тесты кода гоняются при пул-реквесте.
- По расписанию, раз в неделю — даже без ручных изменений системы: провайдер модели мог тихо обновить версию за тем же именем API, изменились данные в базе знаний, истёк срок актуальности части документов. Регулярный прогон ловит и такие «невидимые» изменения.
Табличный вид сравнения версий удобен для быстрого решения «катим или нет»:
| Версия | Дата | Пройдено фактов | Пройдено границ | Регрессий к прошлой |
|---|---|---|---|---|
| model=qwen2.5-72b, prompt=v14 | 2026-08-20 | 12/12 | 9/10 | — |
| model=qwen2.5-72b, prompt=v15 | 2026-08-27 | 12/12 | 7/10 | 2 |
Такая таблица делает решение объективным: две регрессии в пограничных случаях после смены промпта v14 → v15 — это конкретный повод отложить выкатку и разобрать, что именно сломалось, а не полагаться на общее впечатление «вроде отвечает нормально».
Поддерживайте батарею живой: растите её вместе с опытом эксплуатации
Батарея, собранная один раз и забытая, устаревает быстрее, чем кажется: пользователи находят новые пограничные случаи, появляются новые типы запросов, меняется структура базы знаний. Правило простое и заимствовано напрямую из практики регрессионного тестирования в обычной разработке: каждый найденный в проде баг сначала фиксируется тестом, который его ловит, и только потом чинится.
Практически это выглядит так. Если вы ведёте мониторинг качества ответов в продакшене — через оценки пользователей, жалобы или автоматическую разметку части диалогов — каждый найденный там реальный случай ошибки становится кандидатом в батарею:
- Зафиксируйте вход (обезличенный) и то, что система ответила неверно.
- Опишите критерий правильного поведения — что должно было произойти вместо этого.
- Добавьте случай в файл
tests/battery.jsonlс пометкой"category": "regression"и датой добавления. - Убедитесь, что новый тест сейчас не проходит (воспроизводит баг) — если проходит, критерий сформулирован неверно.
- Почините проблему (промпт, данные, параметры) и прогоните батарею снова — теперь и этот случай, и все остальные должны пройти.
Такой цикл гарантирует главное свойство регрессионного набора: одна и та же ошибка не всплывает второй раз незамеченной. Если через два месяца кто-то снова поменяет промпт способом, который возвращает старый баг, прогон батареи покажет регрессию по конкретному id — до выкатки, а не после новой волны жалоб.
Держите набор под версионным контролем (обычный git-репозиторий подходит: tests/battery.jsonl + скрипты раннера) — это даёт историю изменений критериев, возможность откатить неудачно сформулированный тест и совместную работу над набором, если систему развивает не один человек. Раз в квартал стоит устраивать ревизию: убирать случаи, которые перестали быть актуальными (изменился продукт, критерий устарел), и проверять, что категория «регрессии из прошлого» не превратилась в свалку из полусотни почти одинаковых кейсов — несколько показательных примеров каждого типа бага полезнее, чем десятки дублирующих друг друга.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько тестовых случаев нужно для начала?
30-50 хорошо подобранных примеров, покрывающих типовые запросы, пограничные случаи и проверяемые факты, ловят большинство системных проблем. Дальше набор растёт органически по мере эксплуатации, а не разово — до сотен не обязательно, если каждый пример реально отражает вашу нагрузку.
Можно ли использовать одну и ту же LLM как судью для оценки своих же ответов?
Можно, но с оговорками: используйте отдельный явный рубрик, а не абстрактную просьбу «оцени качество», фиксируйте температуру ближе к нулю для воспроизводимости и держите отдельную небольшую группу случаев с ручной проверкой человеком, чтобы периодически сверять, не разъехалась ли оценка судьи с реальным качеством.
Что делать, если ответы системы недетерминированы и на один и тот же вопрос выходит разный текст?
Именно поэтому критерии в шаге 2 формулируются не как точный текст, а как обязательные/запрещённые элементы и поведенческие правила — они устойчивы к вариативности формулировок при условии, что смысл ответа не меняется. Для особо чувствительных проверяемых фактов можно снизить температуру конкретно в тестовом прогоне.
Нужно ли тестировать RAG-пайплайн и генерацию отдельно?
Да, если система сложнее простого чат-бота. Разделение на слои — качество поиска, фактическая обоснованность, релевантность ответа — описано в статье про оценку RAG-ответов и позволяет понять, что именно сломалось после изменения: поиск перестал находить нужный фрагмент или модель начала его игнорировать.
Чем это отличается от разового ручного тестирования модели перед запуском?
Разовое тестирование отвечает на вопрос «эта модель вообще подходит для задачи» на старте проекта. Регрессионная батарея решает другую задачу — систематическое сравнение состояния уже работающей системы «до» и «после» каждого изменения на протяжении всего срока её эксплуатации, с растущим набором, отражающим реальный опыт продакшена.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →