Quality gate для LLM в CI: pytest, DeepEval, плавающие тесты и бюджет прогона
Как сделать так, чтобы плохой промпт или смена модели останавливали релиз: структура тестов, пороги по доле, повторные прогоны для борьбы с флаками, двухуровневый CI с дешёвым судьёй на PR и конфиг GitHub Actions.
09367b4
Содержание
Коротко
- Quality gate для LLM — это обычные автотесты, где вместо
assert x == yстоит «метрика не ниже порога» и «не хуже прошлой версии». - Недетерминизм порождает плавающие тесты. Бороться с ними надо дизайном: пороги по доле на серии прогонов, фиксация версий судьи и модели, а не бесконечные перезапуски до зелёного.
- Прогон стоит денег: число вопросов × число метрик × вызовы судьи. Делите проверки на уровни: быстрые детерминированные на каждый PR, метрики на выборке на PR, полный набор ночью и перед релизом.
- Gate должен проверять регрессию относительно базы, а не только абсолютный порог.
- Результат прогона сохраняйте артефактом: отчёт в PR важнее, чем зелёная галочка.
Что именно блокируем
Хороший gate смотрит на изменения, которые влияют на качество без правки кода приложения: промпт, модель, версия провайдера, параметры поиска, чанкинг, база знаний. Поэтому триггеры пайплайна не только app/**, но и файлы промптов, конфигурации и документы. Условия блокировки примерно такие:
- Детерминированные проверки не прошли (формат, обязательные поля, запрещённые слова).
- Метрики ниже абсолютного порога (например, Faithfulness ниже 0,8).
- Метрики упали относительно базы больше допустимого (например, на 3 пункта).
- Для безопасности: ASR вырос против прошлой версии.
- Стоимость или токены на запрос превысили бюджет.
Пороги выбирайте так, как описано в разборе четырёх метрик: прогнали рабочую версию, зафиксировали базу, задали порог чуть ниже. А метрике-судье доверяйте после калибровки.
Структура тестов
tests/
├── conftest.py # фикстуры: клиент приложения, датасет, судья
├── test_deterministic.py # быстрые проверки без LLM-судьи
├── test_quality.py # метрики на выборке (PR) и на полном наборе (ночь)
├── test_security.py # набор атак, ASR
└── baseline.json # зафиксированные значения предыдущей версии
Маркеры pytest разделяют уровни:
# pytest.ini
[pytest]
markers =
fast: детерминированные проверки, без вызова судьи
quality: метрики через LLM-судью
full: полный набор, ночью и перед релизом
Детерминированный уровень
Всё, что проверяется кодом, проверяйте кодом: валидный JSON, обязательные поля, отсутствие запрещённых фраз, наличие ссылки на источник, длина. Это бесплатно, быстро и не флакает.
import json, pytest
from conftest import GOLDEN, ask
@pytest.mark.fast
@pytest.mark.parametrize("case", GOLDEN, ids=lambda c: c["id"])
def test_answer_format(case):
out = ask(case["question"])
data = json.loads(out) # ответ обязан быть валидным JSON
assert set(data) >= {"answer", "sources"}
assert len(data["answer"]) < 1500
if case["type"] != "no_answer":
assert data["sources"], "ответ без источников"
Метрики на DeepEval
Общая форма такая: тест-кейс, набор метрик с порогами, assert_test. Названия классов и параметры могут отличаться между версиями DeepEval, поэтому сверяйтесь с документацией установленной версии.
import pytest
from deepeval import assert_test
from deepeval.metrics import FaithfulnessMetric, AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase
from conftest import SAMPLE, rag_answer, JUDGE
@pytest.mark.quality
@pytest.mark.parametrize("case", SAMPLE, ids=lambda c: c["id"])
def test_rag_quality(case):
answer, chunks = rag_answer(case["question"])
test_case = LLMTestCase(
input=case["question"],
actual_output=answer,
expected_output=case["expected_answer"],
retrieval_context=chunks,
)
assert_test(test_case, [
FaithfulnessMetric(threshold=0.8, model=JUDGE),
AnswerRelevancyMetric(threshold=0.7, model=JUDGE),
])
Запуск: deepeval test run tests/test_quality.py (команда DeepEval поверх pytest) или обычный pytest -m quality.
Минус тестов «по одному вопросу на тест»: любой провал в одном кейсе краснит весь PR. Для LLM это слишком жёстко. Поэтому добавляют второй уровень: общий тест по доле.
Пороги по доле
Вместо «каждый вопрос обязан пройти» формулируем «не менее 90% вопросов проходят и средняя Faithfulness не ниже 0,8». Это приближает gate к тому, что мы реально хотим знать.
import json, statistics, pytest
from deepeval.metrics import FaithfulnessMetric
from deepeval.test_case import LLMTestCase
from conftest import SAMPLE, rag_answer, JUDGE
BASELINE = json.load(open("tests/baseline.json", encoding="utf-8"))
MAX_DROP = 0.03 # допустимое падение относительно базы
MIN_PASS_RATE = 0.9
@pytest.mark.quality
def test_faithfulness_gate():
scores = []
for case in SAMPLE:
answer, chunks = rag_answer(case["question"])
m = FaithfulnessMetric(threshold=0.8, model=JUDGE)
m.measure(LLMTestCase(input=case["question"], actual_output=answer,
retrieval_context=chunks))
scores.append(m.score)
mean = statistics.mean(scores)
pass_rate = sum(s >= 0.8 for s in scores) / len(scores)
print(f"faithfulness mean={mean:.3f} pass_rate={pass_rate:.2f} n={len(scores)}")
assert pass_rate >= MIN_PASS_RATE, f"доля прошедших {pass_rate:.2f} ниже {MIN_PASS_RATE}"
assert mean >= BASELINE["faithfulness_mean"] - MAX_DROP, \
f"регрессия: {mean:.3f} против базы {BASELINE['faithfulness_mean']:.3f}"
Файл baseline.json обновляйте осознанно, отдельным коммитом, когда команда принимает новый уровень качества.
Плавающие тесты: откуда они берутся и что делать
Источники нестабильности: недетерминизм модели, шум судьи, нестабильный поиск (порядок равных по близости фрагментов), сетевые ошибки провайдера. Меры от простых к сложным:
- Отделите инфраструктурные сбои от качества. Таймаут или 5xx провайдера — это повтор запроса с backoff, а не провал метрики. Ошибка вызова не должна превращаться в «0 баллов».
- Зафиксируйте всё, что фиксируется. Версия модели (если провайдер позволяет), температура, версия библиотеки метрик, модель-судья,
seedгде поддерживается. Помните, что гарантий воспроизводимости это не даёт. - Серии прогонов. Для критичных кейсов запускайте несколько раз и принимайте решение по доле успехов.
- Пороги по доле и по среднему, как выше, вместо проверки каждого вопроса.
- Измерьте шум. Прогоните одну и ту же версию 5–10 раз и посмотрите разброс среднего. Допустимое падение
MAX_DROPдолжно быть больше этого шума, иначе gate будет краснеть без причины. - Карантин. Нестабильный кейс помечайте, выносите из блокирующего набора и разбирайте. Не заглушайте флаки автоматическими перезапусками без анализа: так вы теряете сигнал.
Повторы для критичного кейса:
import pytest
def pass_rate(run_once, n: int = 5) -> float:
return sum(bool(run_once()) for _ in range(n)) / n
@pytest.mark.full
def test_refusal_on_unknown_topic():
def once() -> bool:
reply = ask("Какая у вас гарантия на планеты Марса?")
return any(w in reply.lower() for w in ("не нашёл", "нет информации", "не знаю"))
assert pass_rate(once, n=5) >= 0.8
Подход с ключевыми словами грубый и хрупкий; он показывает механику, а в боевом наборе такую проверку лучше заменить откалиброванной метрикой.
Бюджет прогона
Стоимость полного прогона по метрикам на LLM-судье складывается из числа кейсов, числа метрик и числа вызовов судьи на метрику (каждая метрика делает несколько вызовов). Цифры иллюстративные: 100 кейсов × 4 метрики × 3 вызова — это порядка 1200 обращений к судье на прогон, помимо вызовов самой системы. Отсюда уровни:
| Уровень | Когда | Что гоняем | Судья |
|---|---|---|---|
| Fast | каждый коммит и PR | детерминированные проверки | не нужен |
| Quality (выборка) | PR, изменивший промпт, модель или поиск | 20–30 вопросов, ключевые метрики | дешёвый |
| Full | ночью и перед релизом | весь датасет, все метрики, red team | основной |
Способы сэкономить: запускать уровень Quality только при изменении релевантных файлов, кэшировать ответы и оценки неизменившихся кейсов, брать стратифицированную выборку (по типам вопросов), использовать дешёвого судью на PR и дорогого перед релизом. Помните: смена судьи — смена измерительного прибора, поэтому базу для дешёвого судьи нужно измерить отдельно.
GitHub Actions
Конфигурация с триггерами по путям, секретом и сохранением отчёта (адаптируйте под свой проект):
name: llm-quality
on:
pull_request:
paths: ["app/**", "prompts/**", "data/**", "tests/**"]
schedule:
- cron: "0 3 * * *" # ночной полный прогон
workflow_dispatch:
jobs:
fast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install -r requirements.txt
- run: pytest -m fast -q
quality:
needs: fast
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # ключ только в секретах репозитория
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install -r requirements.txt
- run: pytest -m quality -q --junitxml=report.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: quality-report, path: report.xml }
Замечания: секреты не доступны PR из форков по умолчанию, поэтому для внешних контрибьюторов уровень Quality придётся запускать вручную после ревью. Версии действий (@v4, @v5) проверьте на актуальность в момент настройки. Если ночной полный прогон падает, пайплайн должен писать в канал команды, иначе красный статус никто не увидит.
Как проверить сам gate
Gate нужно тестировать так же, как любой тест. Сделайте два PR: с «плохим» промптом (например, убрана инструкция отвечать только по контексту) и с исправлением. Первый должен краснеть, второй — зеленеть. Если «плохой» PR прошёл, пороги слишком мягкие или датасет не покрывает этот класс дефектов, и это повод дополнить датасет.
Ограничения
Gate ловит то, что умеет измерять датасет и метрики. Новые типы вопросов в проде он не увидит, для этого нужен мониторинг. Судья остаётся приближением, а выбранные пороги — компромиссом между пропущенными регрессиями и ложными тревогами. Пересматривайте их по мере роста датасета.
Что дальше
- Заведите
baseline.jsonпо текущей рабочей версии и измерьте шум: пять прогонов подряд. - Добавьте в CI уровень Fast, а затем Quality на выборке.
- Для безопасности используйте тот же подход: red teaming по OWASP и атаки через документы RAG.
