XNOVA
← Все статьи

СТАТЬЯ / engineering

Как тестировать LLM-приложения: pytest, evals и проверка ответов

На примере помощника по правилам возврата разбираем тестирование LLM-приложения: контракты pytest, набор eval-сценариев и проверку фактов по источникам.

Автор: XNova

В учебном интернет-магазине действует правило: товар без дефекта можно вернуть в течение 14 дней после получения. Покупатель спрашивает помощника о сроке возврата, а в ответе получает: «Возврат доступен в течение 30 дней».

В учебном наборе этот ответ сохранён в корректном JSON: поле answer заполнено, в citations указан существующий идентификатор документа. Проверки формата проходят, хотя в тексте ошибка. Обращения к сервису здесь нет: мы проверяем заранее подготовленные ответы.

Чтобы тестировать такое приложение, придётся проверить и форму ответа, и его содержание. Для формата достаточно точных правил; для свободного текста нужно понять, подтверждается ли утверждение источником. Обычный assert полезен, когда мы можем сформулировать проверяемое условие.

Мы соберём небольшой набор evals — проверок поведения приложения на заранее описанных случаях. На Python и pytest увидим, как проверяющий обнаруживает неверное значение в поле и почему другой неправильный ответ остаётся формально успешным: структурированные поля у него правильные, а текст противоречит правилу.

Все правила, вопросы и ответы ниже вымышлены. Никакую реальную LLM мы в этом опыте не вызываем: сначала полезно убедиться, что сам механизм проверки действительно отличает ожидаемое поведение от ошибки.

Какие свойства ответа можно проверить

Для нашего примера договоримся о четырёх полях ответа. answer содержит текст для покупателя, citations — идентификаторы документов, abstained показывает, что помощник не располагает достаточными сведениями, а return_window_days хранит число дней либо null. Каждое поле даёт повод для проверки, но проверяют они разные свойства.

УровеньЧто спрашиваемПример проверкиЧего не доказывает
КонтрактОтвет пригоден для обработки?У всех полей правильные типы; citations — список известных IDЧто текст правдив
Предметные правилаСоблюдены известные ограничения?return_window_days == 14; при отсутствии правила abstained == trueЧто свободный текст не противоречит полям
Смысл ответаПодтверждаются ли утверждения источником?Прочитать объяснение и сравнить с правилом возвратаЧто все будущие ответы модели будут правильными

Представьте ответ с return_window_days=14 и фразой «у вас есть 30 дней». Первые две группы проверок его пропустят, потому что сравнивают типы и значения полей. Они ничего не знают о противоречии внутри текста.

Такой набор проверок называют evaluation, или eval. В руководстве OpenAI по оценке LLM-приложений рекомендуется начинать с понятной задачи и критериев, а затем дополнять автоматические проверки человеческой оценкой. Anthropic описывает три типа оценщиков: программные, модельные и человеческие. Они не взаимозаменяемы: программа хороша там, где правило можно выразить точно, а смысл открытого текста часто нуждается в более сложной проверке.

Для первого набора выберем свойства, которые действительно можно проверить без интерпретации языка. Всё остальное честно отметим как NOT_VERIFIED, чтобы зелёный отчёт не создавал ложной уверенности.

Подготавливаем четыре случая и независимый эталон

Сначала нужен документ, относительно которого мы будем судить об ответах. В data/policy.json лежит короткое учебное правило:

{
  "id": "returns-policy-v1",
  "scope": "Учебные заказы внутри страны, товары без дефектов",
  "return_window_days": 14,
  "international_shipping": "not_specified"
}

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

В data/cases.json мы сохраняем вопрос и ожидаемые свойства, в data/responses.json — сохранённый для проверки ответ. Ожидания и ответы нужно хранить раздельно: сохранённый ответ не становится эталоном лишь потому, что лежит в JSON. Все четыре ответа в этом примере специально написаны для демонстрации разных исходов. Проверяющий сравнивает ответ с expected_days из cases.json, а не выводит число 14 из policy.json самостоятельно. Поэтому ожидания тоже необходимо вручную сверить с правилом.

ID случаяСохранённый ответЧто нужно увидеть
valid-14В тексте и поле указано 14 днейПроверки полей проходят
unknown-internationalПомощник сообщает, что сведений нетОтказ без выдуманного срока и ссылки
wrong-structuredВ тексте и поле указано 30 днейПроверка предметного поля обнаруживает ошибку
misleading-proseВ поле указано 14, в тексте — 30 днейПоля проходят, содержание требует проверки

Для каждого случая указаны id, question, expected_days, expected_abstained и allowed_citations. Например, ожидание для первого вопроса выглядит так:

{
  "id": "valid-14",
  "question": "Сколько дней есть на возврат товара без дефекта?",
  "expected_days": 14,
  "expected_abstained": false,
  "allowed_citations": ["returns-policy-v1"]
}

Эталон здесь содержит правило, а не единственную допустимую фразу. «Возврат возможен в течение двух недель» и «у вас есть 14 дней» могут быть одинаково корректными ответами. Сравнение строк по точному совпадению ошибочно отклонит одну из корректных формулировок.

В публичном примере все три файла находятся в каталоге data/. В настоящем проекте источник стоит дополнить версией, датой действия, владельцем и подтверждающим фрагментом. Иначе вчерашняя верная оценка может стать неверной после изменения правил.

Пишем первые проверки с pytest

Теперь достаточно небольшого проверяющего на Python. Он читает фикстуры через стандартный модуль json, проверяет наличие обязательных полей, типы и известные ссылки, а затем сравнивает строго заданные предметные свойства. Внутри нет вызова LLM, сетевого запроса и попытки «понять» свободный текст.

Ключевой фрагмент из llm_evals/evaluation.py показывает разделение проверок. Функция принимает типизированные неизменяемые объекты из llm_evals/models.py, а не произвольные словари:

def check_structured(case: EvaluationCase, response: ModelResponse) -> bool:
    return (
        response.abstained is case.expected_abstained
        and response.return_window_days == case.expected_days
        and set(response.citations) == case.allowed_citations
    )

До создания ModelResponse загрузчик строго проверяет точный набор полей, а check_contract дополнительно сверяет ссылки с известными источниками. Он убеждается, что answer — непустая строка, citations содержит уникальные идентификаторы, abstained — именно bool, а число дней — именно int или null. Это важно в Python: True == 1, поэтому одной проверки isinstance(value, int) или обычного == для типового контракта недостаточно.

Для разных случаев используем параметризацию pytest. Упрощённый тест выглядит так:

@pytest.mark.parametrize("case", CASES, ids=[case.id for case in CASES])
def test_response_contract(case: EvaluationCase) -> None:
    response = RESPONSES[case.id]
    assert check_contract(response, {POLICY.id})

Особый приём — отрицательный контроль. Для wrong-structured мы ожидаем, что предметный оценщик вернёт False, потому что в ответе 30 вместо 14. Сам pytest-тест при этом должен пройти: он подтверждает, что ошибку обнаружили. В полноценном конвейере такое нарушение нужно показывать отдельным FAIL и принимать по нему решение, а не воспринимать успешный тест оценщика как одобрение ответа.

Теперь воспроизведём проверку. Понадобятся Git и Python 3.12. Сначала получите пример на проверенном commit, перейдите в его каталог и закрепите версию исходников:

git clone https://github.com/xnova-tech/llm-testing-pytest-evals-example
cd llm-testing-pytest-evals-example
git checkout --detach 84b9432b6948458ea5e8ef621645038d3ddab7b3
python3.12 -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt

На Windows используйте .venv\\Scripts\\python.exe вместо .venv/bin/python. Из корня клонированного проекта запустите:

.venv/bin/python check_fixture.py
.venv/bin/python run_evals.py
.venv/bin/python -m pytest -q tests

Первая команда проверяет согласованность набора вопросов и ответов, вторая выводит результат по четырём случаям, третья запускает 20 тестов самого проверяющего. Документация pytest объясняет варианты запуска тестов. Установка зависимостей из команды выше потребует доступ к индексу пакетов.

Почему правильная ссылка ещё не делает ответ правдивым

Вернёмся к случаю misleading-prose, который проходит структурированные проверки. В нём сохранён такой ответ:

{
  "answer": "У вас есть 30 дней, чтобы вернуть товар без дефекта.",
  "citations": ["returns-policy-v1"],
  "abstained": false,
  "return_window_days": 14
}

Идентификатор документа существует. Число в структурированном поле правильное. Флаг отказа соответствует ожиданию. Если тестировать только эти свойства, проверка пройдёт. Но объяснение нарушает учебное правило: допустимы 14 дней, а не 30.

Можно добавить проверку, встречается ли строка "14" внутри answer. Но такой оценщик тоже ошибётся. Фраза «срок возврата — не 14, а 30 дней» содержит искомое число, но утверждает обратное. И наоборот, правильный ответ «в течение двух недель» не содержит числа 14. Это примеры ложного успеха и ложного отказа проверки по совпадению слов.

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

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

В этом учебном запуске смыслового оценщика нет. Все четыре текста получают пометку semantic=NOT_VERIFIED. Мы видим ошибку в misleading-prose, когда читаем текст вместе с правилом; программа такого вывода не делает. Поэтому structured=PASS следует читать именно как «проверены указанные поля», а не как одобрение ответа целиком.

Читаем отчёт: один FAIL и четыре непроверенных ответа

При запуске python run_evals.py из корня публичного примера получаем следующий результат.

valid-14: contract=PASS structured=PASS semantic=NOT_VERIFIED
unknown-international: contract=PASS structured=PASS semantic=NOT_VERIFIED
wrong-structured: contract=PASS structured=FAIL semantic=NOT_VERIFIED
misleading-prose: contract=PASS structured=PASS semantic=NOT_VERIFIED
cases: 4
contract: 4/4 PASS
structured: 3/4 PASS
semantic: 4 NOT_VERIFIED

Первая строка говорит, что обычный ответ соответствует структуре и ожидаемым полям. Третья показывает намеренно неверный срок. Четвёртая показывает границу автоматизации: формальные проверки прошли, хотя текст противоречит политике. Сам оценщик не объявляет этот текст верным; он оставляет его для содержательного просмотра.

При запуске python -m pytest -q tests из корня публичного проекта прошло 20 тестов. Они проверяют положительные случаи, неизвестные и повторяющиеся ссылки, отсутствие и избыток полей, пустой ответ, неправильные типы, отрицательный контроль и известную слепую зону. Это проверка работы нашего оценщика и загрузчика, а не измерение точности какой-либо языковой модели.

Обращайте внимание на знаменатель. Здесь четыре учебных случая, не случайная выборка пользовательских обращений. 3/4 PASS относится только к строгим предметным полям. 4 NOT_VERIFIED означает, что свободные ответы не получили автоматического смыслового вердикта. Складывать эти значения в один «процент качества LLM» нельзя.

В рекомендациях OpenAI по evals подчёркивается важность предметных критериев и человеческой калибровки. Сохраняйте оценку по каждому вопросу вместе с входными данными и источником. Иначе число на панели будет выглядеть убедительно, но не покажет, что именно система делает неправильно.

Как перейти от фикстур к реальному LLM-приложению

Следующим шагом можно заменить data/responses.json на настоящие ответы сервиса. Здесь меняется источник данных, но правила оценки должны остаться независимыми от наблюдения. Иначе проверяющий начнёт подстраиваться под то, что вернула модель, и потеряет возможность обнаружить ошибку.

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

В терминологии Anthropic task — случай с заданными входами и критериями; trial — одна попытка его выполнения; grader — процедура оценки. Один вопрос можно задавать несколько раз, поскольку генерация может меняться от попытки к попытке. Но десять попыток одного вопроса — это десять попыток, а не десять независимых вопросов.

Начните с небольшого набора случаев, где ожидаемое поведение однозначно: ограничения доступа, отсутствие ответа в документах, точные расчёты, запрет выдумывать условия. Затем добавьте обезличенные реальные вопросы и спорные формулировки. Для свободного текста составьте критерии оценки и сверяйте спорные вердикты с людьми. OpenAI рекомендует использовать задачи, близкие к реальной работе приложения, и дополнять автоматические оценки человеческими.

В этой статье вызовов живой модели нет. Наши четыре вымышленных ответа показывают, как работают проверки, но не доказывают качество LLM, провайдера, промпта или RAG-поиска. Прежде чем делать вывод о надёжности продукта, потребуется настоящий набор наблюдений и независимая предметная проверка.

Как понять, почему проверка не прошла

Если проверка не прошла, сначала определите, какое свойство нарушено. Двигайтесь от чтения данных к форме ответа, затем к предметному правилу и смыслу.

  1. Не получается прочитать JSON. Проверьте синтаксис и путь к файлу. check_fixture.py также убеждается, что у вопросов и ответов совпадают идентификаторы. Если данные не загрузились, до оценки содержания дело ещё не дошло.
  2. Падает проверка контракта. Сравните обязательные поля и их типы. Неизвестный идентификатор в citations, пустой answer, повторяющиеся ссылки и True вместо целого числа дней должны быть отвергнуты. Исправьте адаптер или формат сохранённого ответа.
  3. Контракт проходит, предметный тест возвращает FAIL. Откройте правило в data/policy.json, ожидание в data/cases.json и наблюдение в data/responses.json. Например, wrong-structured содержит 30 вместо 14. В настоящем приложении это повод исследовать генерацию и источники; в нашем наборе — специально внесённая ошибка.
  4. Все строгие проверки проходят, а объяснение вызывает сомнение. Прочитайте текст рядом с документом. misleading-prose именно такой случай. Не меняйте эталон ради зелёного отчёта и не записывайте semantic=PASS, пока не выполнена содержательная оценка.
  5. Корректная формулировка ошибочно отклонена. Проверьте сам оценщик: не требует ли он конкретного слова, хотя допустимо несколько выражений одного смысла? Добавьте контрпример, исправьте правило и повторите положительные и отрицательные случаи.

После изменений повторите из корня публичного проекта команды python check_fixture.py, python run_evals.py и python -m pytest -q tests (либо используйте интерпретатор созданного виртуального окружения). Сохраните новые входные файлы и вывод: старый отчёт к изменённому корпусу уже не относится.

Если позже вы подключите живую LLM, добавьте к отчёту условия прогона, версию модели и источников, а для нестабильных ответов — результаты повторных попыток. Иначе один случайный успех легко принять за исправление проблемы.

Что вы теперь можете проверить

В нашем наборе проверка формата приняла все четыре ответа, а проверка предметных полей обнаружила одну ошибку. Другая ошибка — обещание 30 дней при правильном значении return_window_days=14 — осталась за пределами автоматической оценки. Итог показывает точную границу: что именно проверено, а что ещё требует смысловой оценки.

Для начала работы с evals этого достаточно: храните вопросы отдельно от ответов, сверяйте ожидания с правилами, проверяйте строго определённые свойства через pytest и не присваивайте свободному тексту статус PASS без проверки смысла.

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

Источники