Почему тестирование LLM сводит с ума нормальных людей
Ты написал промпт. Ты запустил модель. Получил ответ. Всё работает. Ты меняешь температуру с 0.7 на 0.8. Запускаешь снова. Ответ другой. Совсем другой. Не просто другой формулировкой — другой логикой, другими фактами, другим тоном.
Ты добавляешь в промпт "Будь лаконичным". Модель начинает генерировать эссе на три страницы. Ты просишь "Не упоминай про безопасность". Модель начинает каждый абзац с "С точки зрения безопасности..."
Это не баг. Это особенность. LLM — не детерминированные системы. Они статистические машины, которые генерируют вероятностные последовательности. Тестировать их традиционными методами — всё равно что измерять температуру воды линейкой.
В нашей предыдущей статье "Тестируем недетерминированные LLM" мы разбирали, почему классические тесты не работают. Сегодня покажу инструмент, который решает эту проблему.
DeepEval: когда одна LLM судит другую
DeepEval — это фреймворк для оценки LLM-приложений. Его философия проста: если человек не может объективно оценить ответ LLM (слишком субъективно, дорого, медленно), пусть это сделает другая LLM.
Это подход LLM-as-a-judge. Одна модель (судья) оценивает качество ответов другой модели (испытуемой). Звучит как рекурсивный кошмар, но на практике работает удивительно хорошо.
Что на самом деле можно тестировать
Забудь про "правильно/неправильно". В мире LLM это понятие размыто. Вместо этого тестируем:
- Релевантность — ответ соответствует вопросу?
- Фактическая точность — факты в ответе верны?
- Тон и стиль — ответ в нужном регистре (формальный, дружеский)?
- Полнота — ответ покрывает все аспекты вопроса?
- Безопасность — ответ не содержит вредоносного контента?
- Креативность — насколько оригинален ответ?
Вот пример из реального проекта — чат-бот для поддержки клиентов банка. Нам нужно проверить, что бот:
- Не раскрывает конфиденциальную информацию
- Всегда предлагает обратиться в отделение для сложных операций
- Корректно объясняет тарифы
- Сохраняет профессиональный тон
Человек проверит 100 ответов за день. LLM-судья — 10 000 за час.
Установка и настройка: 5 минут вместо 5 часов
1Ставим DeepEval
pip install deepevalНе забудь про виртуальное окружение. Серьёзно. Потом будешь плакать, когда зависимости поломают твой продакшен.
2Настраиваем API ключ
export OPENAI_API_KEY="sk-..."
# Или для других провайдеров
export ANTHROPIC_API_KEY="..."
export COHERE_API_KEY="..."DeepEval поддерживает OpenAI, Anthropic, Cohere, Gemini. Можно даже использовать локальную модель через Ollama или vLLM. Но для начала — OpenAI GPT-4. Он самый стабильный как судья.
Если тестируешь локальную модель, используй её же как судью. Но будь готов к странным результатам — модель может слишком лояльно оценивать саму себя. Как в той шутке про экзамен, который студент сам себе проверяет.
Первый тест: проверяем факты
Допустим, у нас есть RAG-система, которая отвечает на вопросы по документации. Нужно проверить, что ответы фактически точны.
Вот как НЕ надо делать:
# ПЛОХОЙ ПРИМЕР - не повторяй это
import openai
def test_fact_checking():
response = get_rag_response("Какие требования к паролю?")
expected = "Пароль должен содержать 8 символов, цифру и заглавную букву"
# Так не работает с LLM!
assert response == expected # Всегда будет падатьПочему это плохо? Потому что LLM никогда не выдаст идентичный текст. Даже с temperature=0 будут вариации формулировок.
Вот правильный подход с DeepEval:
from deepeval import evaluate
from deepeval.metrics import FactualConsistencyMetric
from deepeval.test_case import LLMTestCase
# Создаём тест-кейс
test_case = LLMTestCase(
input="Какие требования к паролю в системе?",
actual_output="Пароль должен быть не менее 8 символов, содержать хотя бы одну цифру и одну заглавную букву. Также рекомендуется использовать специальные символы.",
context=["Документация: Требования к паролю: минимум 8 символов, обязательна хотя бы одна цифра и одна заглавная буква. Специальные символы не обязательны, но рекомендуются."]
)
# Создаём метрику
metric = FactualConsistencyMetric(threshold=0.7)
# Запускаем оценку
test_result = evaluate([test_case], [metric])
print(f"Score: {test_result[0].score}") # 0.85
print(f"Passed: {test_result[0].success}") # TrueЧто здесь происходит:
- Мы задаём вопрос (input)
- Получаем ответ модели (actual_output)
- Предоставляем контекст (context) — то, что модель должна была использовать
- FactualConsistencyMetric сравнивает ответ с контекстом
- Метрика возвращает оценку от 0 до 1
Threshold=0.7 означает: если оценка выше 0.7 — тест пройден. Почему не 1.0? Потому что перфекционизм убивает проекты. LLM иногда перефразирует, добавляет пояснения — это нормально.
Семантическое тестирование: когда важен смысл, а не слова
Вот реальная проблема из продакшена. Чат-бот для e-commerce. Пользователь спрашивает: "Есть ли скидки на ноутбуки?"
Модель может ответить:
- "Да, у нас действуют скидки на ноутбуки до 30%"
- "На ноутбуки сейчас распродажа, скидки до 30%"
- "Акция на ноутбуки: экономия до 30%"
Все три ответа семантически эквивалентны. Но строковое сравнение их зафейлит.
Решение — SemanticSimilarityMetric:
from deepeval.metrics import SemanticSimilarityMetric
from deepeval.test_case import LLMTestCase
# Ожидаемый ответ в разных формулировках
expected_outputs = [
"Да, скидки на ноутбуки до 30%",
"Идут скидки на ноутбуки, максимум 30%",
"На ноутбуки действуют скидки 30%"
]
test_case = LLMTestCase(
input="Есть ли скидки на ноутбуки?",
actual_output="Сейчас распродажа ноутбуков со скидкой 30%",
expected_output=expected_outputs # Множество допустимых ответов
)
metric = SemanticSimilarityMetric(
threshold=0.8,
model="gpt-4",
include_reason=True # Показывать объяснение оценки
)
test_result = evaluate([test_case], [metric])
print(test_result[0].reason)
# "Ответ семантически близок к ожидаемому: оба упоминают скидки 30% на ноутбуки"Ключевой момент: expected_output может быть списком. Потому что в реальном мире нет одного "правильного" ответа. Есть множество допустимых вариантов.
Тестирование тона и стиля: чтобы бот не матерился с клиентами
У нас был случай. Финтех-стартап. Бот для ответов на вопросы по инвестициям. Всё работало, пока CEO не спросил: "Почему мои акции падают?"
Бот ответил: "Ну, братан, рынок иногда проседает, не парься"
Temperature была 0.9. Промпт не ограничивал стиль. Результат — неделя исправлений и красные лица на совете директоров.
Теперь тестируем стиль:
from deepeval.metrics import TonalityMetric
# Проверяем профессиональный тон для банковского бота
test_cases = [
LLMTestCase(
input="Как открыть счёт?",
actual_output="Для открытия счёта потребуется паспорт и ИНН. Заполните заявление в отделении.",
expected_output="Профессиональный, формальный, информативный"
),
LLMTestCase(
input="Привет! Как дела?",
actual_output="Приветствую! Работаю в штатном режиме. Чем могу помочь?",
expected_output="Вежливый, профессиональный, не слишком личный"
)
]
metric = TonalityMetric(
expected_tonality="professional",
threshold=0.7
)
test_result = evaluate(test_cases, [metric])
for result in test_result:
if not result.success:
print(f"Провал: {result.reason}")TonalityMetric проверяет соответствие заданному тону. Доступные варианты: professional, friendly, formal, casual, enthusiastic.
Но что если нужен специфический тон? "Дружеский, но не фамильярный"? "Формальный, но не бюрократический"?
Создаём кастомную метрику:
from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase
def check_friendly_not_familiar(output: str) -> bool:
"""Проверяем, что тон дружеский, но не фамильярный"""
familiar_words = {"братан", "чувак", "дорогой", "милый"}
friendly_indicators = {"привет", "здравствуйте", "рад помочь", "обращайтесь"}
has_friendly = any(word in output.lower() for word in friendly_indicators)
has_familiar = any(word in output.lower() for word in familiar_words)
return has_friendly and not has_familiar
class CustomToneMetric(BaseMetric):
def __init__(self, threshold: float = 0.5):
self.threshold = threshold
def measure(self, test_case: LLMTestCase):
self.success = check_friendly_not_familiar(test_case.actual_output)
self.score = 1.0 if self.success else 0.0
return self.score
def is_successful(self):
return self.success
@property
def __name__(self):
return "Custom Tone Metric"Кастомные метрики — это суперсила DeepEval. Можно тестировать что угодно:
- Не упоминает ли бот конкурентов?
- Всегда ли предлагает следующий шаг?
- Укладывается ли в N слов?
- Использует ли определённые ключевые слова?
Интеграция в пайплайн: чтобы тесты бежали сами
Писать тесты в Jupyter — это мило. Но в продакшене нужно автоматическое тестирование.
Вариант 1: Pytest + DeepEval
# test_llm_app.py
import pytest
from deepeval import assert_test
from deepeval.metrics import FactualConsistencyMetric
from deepeval.test_case import LLMTestCase
from your_app import get_llm_response
@pytest.mark.parametrize("question,context", [
("Какие документы нужны для кредита?", "Требуются паспорт, справка 2-НДФЛ..."),
("Какой срок рассмотрения заявки?", "Срок рассмотрения — 3 рабочих дня"),
])
def test_bank_responses(question, context):
# Получаем ответ от продакшен-системы
actual_output = get_llm_response(question)
test_case = LLMTestCase(
input=question,
actual_output=actual_output,
context=[context]
)
metric = FactualConsistencyMetric(threshold=0.7)
# DeepEval ассерт
assert_test(test_case, [metric])Запускаем:
pytest test_llm_app.py -vВариант 2: CI/CD пайплайн в GitHub Actions
# .github/workflows/test-llm.yml
name: Test LLM Application
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test-llm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install deepeval pytest
- name: Set API key
run: echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> $GITHUB_ENV
- name: Run LLM tests
run: pytest test_llm_app.py --tb=short
- name: Upload test report
if: always()
uses: actions/upload-artifact@v3
with:
name: llm-test-report
path: test-results/Теперь при каждом пул-реквесте автоматически проверяется качество LLM-ответов. Если оценка падает ниже threshold — тесты фейлятся, мерж блокируется.
Внимание: LLM-тесты в CI/CD могут быть дорогими. 100 тестов × GPT-4 × 2 раза в день = ощутимая сумма. Используй более дешёвые модели для рутинных проверок, GPT-4 оставь для критичных сценариев.
Ловушки и грабли: что сломает твои тесты
Ловушка 1: Слишком строгий threshold
Threshold=0.9 для SemanticSimilarityMetric? Готовься к флакки-тестам. LLM вариативны по природе. 0.7-0.8 — рабочий диапазон.
Ловушка 2: Одна модель-судья для всех случаев
GPT-4 отлично оценивает факты. Но для креативности лучше GPT-4 Turbo. Для безопасности — Claude. Для тона — возможно, даже локальная модель, fine-tuned на твоих данных.
Ловушка 3: Тестирование без контекста
FactualConsistencyMetric без context — это слепой судья. Он будет сравнивать ответ с... чем? Всегда предоставляй контекст для factual проверок.
Ловушка 4: Игнорирование стоимости
Запустил 10 000 тестов с GPT-4? Чек на $500. Мониторь стоимость. DeepEval показывает примерную цену за запуск.
test_result = evaluate(test_cases, metrics, print_cost=True)
# Estimated cost: $0.42Продвинутые сценарии: когда базовых метрик недостаточно
Сценарий 1: Мультимодальные тесты
Твой LLM принимает изображения? Нужно проверять, что описание соответствует картинке. DeepEval пока не имеет встроенных мультимодальных метрик, но можно создать кастомную.
Для тестирования мультимодальных LLM посмотри нашу статью "Готовые промпты для тестирования логики и зрения у мультимодальных LLM". Там есть промпты для оценки vision-способностей моделей.
Сценарий 2: Цепочки вызовов (Agent testing)
Агент делает 5 шагов: парсит запрос, ищет в базе, вызывает API, обрабатывает результат, форматирует ответ. Нужно тестировать каждый шаг.
# Тестируем цепочку вызовов функций
from deepeval.metrics import HallucinationMetric
def test_agent_workflow():
# Шаг 1: Парсинг запроса
parsed = agent.parse_query("Забронируй столик на 4 человека на 19:00")
assert parsed["intent"] == "restaurant_booking"
# Шаг 2: Извлечение параметров
assert parsed["people"] == 4
assert parsed["time"] == "19:00"
# Шаг 3: Вызов API (мок)
booking_result = mock_booking_api(parsed)
# Шаг 4: Форматирование ответа
final_response = agent.format_response(booking_result)
# Тестируем финальный ответ
test_case = LLMTestCase(
input="Забронируй столик на 4 человека на 19:00",
actual_output=final_response,
context=["Бронирование подтверждено. ID: 12345"]
)
# Проверяем, что нет галлюцинаций
metric = HallucinationMetric(threshold=0.8)
assert_test(test_case, [metric])Сценарий 3: A/B тестирование моделей
Выбираешь между GPT-4, Claude 3 и локальной Llama 3? DeepEval может сравнивать их объективно.
from deepeval.metrics import AnswerRelevancyMetric
import pandas as pd
models = ["gpt-4", "claude-3-opus", "llama-3-70b"]
questions = load_test_questions() # 100 разнообразных вопросов
results = []
for model in models:
for question in questions:
response = call_model(model, question)
test_case = LLMTestCase(
input=question,
actual_output=response
)
metric = AnswerRelevancyMetric()
score = evaluate([test_case], [metric])[0].score
results.append({
"model": model,
"question": question,
"score": score
})
df = pd.DataFrame(results)
# Смотрим, какая модель лучше по средней оценке
print(df.groupby("model")["score"].mean())Что делать, когда тесты падают
Ситуация: вчера все тесты проходили. Сегодня 30% упали. Что случилось?
Возможные причины:
| Симптом | Причина | Решение |
|---|---|---|
| FactualConsistency упал | Источники данных обновились | Обновить контекст в тестах |
| SemanticSimilarity упал | Модель-судья обновилась | Перекалибровать threshold |
| TonalityMetric упал | Промпт сломался | Проверить промпт на регрессии |
| Все метрики упали | API модель деградировала | Сообщить провайдеру, откатиться |
Мой workflow при падении тестов:
- Посмотреть reason от метрик — что именно не понравилось судье?
- Запустить тест локально с теми же данными
- Проверить, не изменились ли входные данные
- Проверить логи промптинга
- Если всё ок — возможно, нужно настроить threshold
Финальный совет: тестируй то, что важно
Можно потратить месяц на создание идеальной системы тестирования. 50 метрик, 1000 тест-кейсов, красивый дашборд.
А потом обнаружить, что главная проблема — не фактчекинг, а то, что бот иногда молчит 30 секунд перед ответом.
Начни с критичного. Для банковского бота — безопасность и точность фактов. Для креативного писателя — оригинальность и стиль. Для客服-бота — скорость и релевантность.
DeepEval — инструмент, а не религия. Используй его для решения реальных проблем, а не для создания бюрократии.
И последнее: иногда выключай все тесты и просто поговори со своей моделью. Как пользователь. Потому что лучший тест — это когда твоя мама может получить внятный ответ на свой вопрос. А мамы не читают метрик.