Р. Аллахвердиев

Quality gate для LLM в CI: pytest, DeepEval, плавающие тесты и бюджет прогона

Как сделать так, чтобы плохой промпт или смена модели останавливали релиз: структура тестов, пороги по доле, повторные прогоны для борьбы с флаками, двухуровневый CI с дешёвым судьёй на PR и конфиг GitHub Actions.

опубликовано · 11 мин09367b4
Обложка: Quality gate для LLM в CI: pytest, DeepEval, плавающие тесты и бюджет прогона
Содержание

Коротко

  • Quality gate для LLM — это обычные автотесты, где вместо assert x == y стоит «метрика не ниже порога» и «не хуже прошлой версии».
  • Недетерминизм порождает плавающие тесты. Бороться с ними надо дизайном: пороги по доле на серии прогонов, фиксация версий судьи и модели, а не бесконечные перезапуски до зелёного.
  • Прогон стоит денег: число вопросов × число метрик × вызовы судьи. Делите проверки на уровни: быстрые детерминированные на каждый PR, метрики на выборке на PR, полный набор ночью и перед релизом.
  • Gate должен проверять регрессию относительно базы, а не только абсолютный порог.
  • Результат прогона сохраняйте артефактом: отчёт в PR важнее, чем зелёная галочка.

Что именно блокируем

Хороший gate смотрит на изменения, которые влияют на качество без правки кода приложения: промпт, модель, версия провайдера, параметры поиска, чанкинг, база знаний. Поэтому триггеры пайплайна не только app/**, но и файлы промптов, конфигурации и документы. Условия блокировки примерно такие:

  1. Детерминированные проверки не прошли (формат, обязательные поля, запрещённые слова).
  2. Метрики ниже абсолютного порога (например, Faithfulness ниже 0,8).
  3. Метрики упали относительно базы больше допустимого (например, на 3 пункта).
  4. Для безопасности: ASR вырос против прошлой версии.
  5. Стоимость или токены на запрос превысили бюджет.

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

Структура тестов

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 обновляйте осознанно, отдельным коммитом, когда команда принимает новый уровень качества.

Плавающие тесты: откуда они берутся и что делать

Источники нестабильности: недетерминизм модели, шум судьи, нестабильный поиск (порядок равных по близости фрагментов), сетевые ошибки провайдера. Меры от простых к сложным:

  1. Отделите инфраструктурные сбои от качества. Таймаут или 5xx провайдера — это повтор запроса с backoff, а не провал метрики. Ошибка вызова не должна превращаться в «0 баллов».
  2. Зафиксируйте всё, что фиксируется. Версия модели (если провайдер позволяет), температура, версия библиотеки метрик, модель-судья, seed где поддерживается. Помните, что гарантий воспроизводимости это не даёт.
  3. Серии прогонов. Для критичных кейсов запускайте несколько раз и принимайте решение по доле успехов.
  4. Пороги по доле и по среднему, как выше, вместо проверки каждого вопроса.
  5. Измерьте шум. Прогоните одну и ту же версию 5–10 раз и посмотрите разброс среднего. Допустимое падение MAX_DROP должно быть больше этого шума, иначе gate будет краснеть без причины.
  6. Карантин. Нестабильный кейс помечайте, выносите из блокирующего набора и разбирайте. Не заглушайте флаки автоматическими перезапусками без анализа: так вы теряете сигнал.

Повторы для критичного кейса:

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.