В учебном интернет-магазине действует правило: товар без дефекта можно вернуть в течение 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-поиска. Прежде чем делать вывод о надёжности продукта, потребуется настоящий набор наблюдений и независимая предметная проверка.
Как понять, почему проверка не прошла
Если проверка не прошла, сначала определите, какое свойство нарушено. Двигайтесь от чтения данных к форме ответа, затем к предметному правилу и смыслу.
- Не получается прочитать JSON. Проверьте синтаксис и путь к файлу.
check_fixture.pyтакже убеждается, что у вопросов и ответов совпадают идентификаторы. Если данные не загрузились, до оценки содержания дело ещё не дошло. - Падает проверка контракта. Сравните обязательные поля и их типы. Неизвестный идентификатор в
citations, пустойanswer, повторяющиеся ссылки иTrueвместо целого числа дней должны быть отвергнуты. Исправьте адаптер или формат сохранённого ответа. - Контракт проходит, предметный тест возвращает FAIL. Откройте правило в
data/policy.json, ожидание вdata/cases.jsonи наблюдение вdata/responses.json. Например,wrong-structuredсодержит 30 вместо 14. В настоящем приложении это повод исследовать генерацию и источники; в нашем наборе — специально внесённая ошибка. - Все строгие проверки проходят, а объяснение вызывает сомнение. Прочитайте текст рядом с документом.
misleading-proseименно такой случай. Не меняйте эталон ради зелёного отчёта и не записывайтеsemantic=PASS, пока не выполнена содержательная оценка. - Корректная формулировка ошибочно отклонена. Проверьте сам оценщик: не требует ли он конкретного слова, хотя допустимо несколько выражений одного смысла? Добавьте контрпример, исправьте правило и повторите положительные и отрицательные случаи.
После изменений повторите из корня публичного проекта команды python check_fixture.py, python run_evals.py и python -m pytest -q tests (либо используйте интерпретатор созданного виртуального окружения). Сохраните новые входные файлы и вывод: старый отчёт к изменённому корпусу уже не относится.
Если позже вы подключите живую LLM, добавьте к отчёту условия прогона, версию модели и источников, а для нестабильных ответов — результаты повторных попыток. Иначе один случайный успех легко принять за исправление проблемы.
Что вы теперь можете проверить
В нашем наборе проверка формата приняла все четыре ответа, а проверка предметных полей обнаружила одну ошибку. Другая ошибка — обещание 30 дней при правильном значении return_window_days=14 — осталась за пределами автоматической оценки. Итог показывает точную границу: что именно проверено, а что ещё требует смысловой оценки.
Для начала работы с evals этого достаточно: храните вопросы отдельно от ответов, сверяйте ожидания с правилами, проверяйте строго определённые свойства через pytest и не присваивайте свободному тексту статус PASS без проверки смысла.
Для реального LLM-приложения следующий шаг — заменить вымышленные ответы фактическими наблюдениями, добавить обезличенные вопросы пользователей и независимую разметку содержания по версионированным источникам. После этого можно оценивать качество продукта, исследовать повторяемость ответов и использовать набор для регрессионных проверок.
Источники
- Методика LLM evals: цель, набор заданий, критерии, вариативность генерации и калибровка людьми
- Определения task/trial/grader, программная и человеческая оценка, ограничения модельных судей
- Официальная документация @pytest.mark.parametrize для набора сценариев
- Официальные варианты запуска pytest и выбор тестов
- Чтение и разбор JSON-фикстур в стандартной библиотеке Python
- Репозиторий для клонирования полного учебного примера с проверенным commit
- Закреплённый commit полного типизированного примера с JSON-фикстурами, pytest и CI