Сижу перед экраном с двумя ответами одного чат-бота. На вопрос об оплате оба выглядят правильными, но во втором модель пообещала возврат средств, хотя правила компании этого не разрешают.
Сначала решил, что виноват промпт. Переписал инструкцию, добавил ещё три уточнения и запустил тест вручную. Ответ стал лучше. На следующий день пользователь прислал почти то же самое — и модель ответила иначе.
Тут я окончательно понял: тестировать ИИ-приложение нельзя так, как обычный CRUD-сервис. Здесь нет одного правильного ответа. Есть ожидаемые свойства, пограничные случаи и целые сценарии, которые надо проверять много раз.
Сначала формулирую контракт, а не «правильный ответ»
Главная ошибка в тестировании LLM — заранее записывать в эталон единственную формулировку. Сегодня модель отвечает «Возврат оформлю в течение трёх рабочих дней», завтра — «Средства появятся на карте за три рабочих дня». Текст отличается, смысл может быть тем же.
Поэтому я начинаю не с тестов, а с контракта приложения. Что система обязана сделать? Чего делать не должна? Какие ограничения важнее красивой формулировки?
Допустим, бот отвечает на вопросы клиентов интернет-магазина. Я фиксирую несколько условий:
- ответ должен содержать срок обработки возврата;
- модель не должна обещать возврат без подтверждённого заказа;
- при отсутствии данных нужно попросить уточнить номер заказа;
- ссылки на условия компании должны соответствовать загруженному документу;
- ответ не должен содержать персональные данные из внутренней базы.
После этого конкретный ответ можно оценивать по свойствам. Я проверяю наличие срока, запрещённых обещаний и корректность числовых значений. Такой тест не гарантирует идеальную формулировку, но ловит реальные поломки.
Детерминированные проверки всегда стоят впереди. Если можно проверить JSON-схему, наличие обязательного поля, запрещённую фразу или точное совпадение с числом, не стоит звать модель и спрашивать её, «правильно ли это». Модельный судья здесь будет медленнее, дороже и менее надёжен.
Собираю набор тестов из реальной жизни
После первого набора вручную я обычно обнаруживаю, что он не отражает реальную нагрузку. Люди пишут с опечатками, присылают скриншоты вместо текста, задают три вопроса в одном сообщении и путают товары.
Поэтому я беру обезличенные диалоги из поддержки, логи обращений и примеры, которые команда добавляла вручную после жалоб. Из них получается набор из нескольких десятков сценариев: типовые случаи, сложные уточнения, противоречивые требования и откровенно вредные или бессмысленные запросы.
Такие тесты нужно хранить как обычные данные проекта. Я даю каждому сценарию идентификатор, категорию, ожидаемые критерии и пример ответа человека. Иногда добавляю версию, чтобы было видно, когда менялась логика приложения.
Один прогон мало о чём говорит. Модель может три раза подряд ответить идеально, а на четвёртый — нарушить ограничение. Для критичных сценариев я проверяю не только средний балл, но и процент успешных запусков. Если из десяти попыток требование выполняется девять раз, приложение уже сталкивается с заметной нестабильностью.
Отдельный слой — оценка стоимости и задержки. Качество ответа можно повысить, отправив модели в три раза больше токенов, но это не бесплатный подарок. В отчёте я смотрю не только на точность, но и на среднее время ответа, расход токенов и число повторных вызовов.
Выбираю инструмент под задачу, а не под модный тренд
У меня было несколько попыток собрать тестирование в одной красивой платформе. Обычно получалось слишком много настроек и слишком мало реальных проверок. Теперь я выбираю инструмент по типу приложения.
Для обычных модульных и end-to-end тестов я часто использую DeepEval. Он подходит, когда оценку хочется писать рядом с кодом, а процессы напоминают pytest. Можно запускать набор примеров, проверять структурированные ответы и использовать собственные метрики.
Promptfoo удобен, когда главная задача — сравнить промпты или модели на одинаковых входах. Я описываю тестовые случаи в YAML, запускаю несколько вариантов промпта и смотрю сводку: где регрессия, какие ответы изменились, какие проверки сработали. Для команды, которая регулярно меняет инструкции, это быстрый способ не полагаться на ощущения.
LangSmith полезен как связка трассировки и оценок. Я могу посмотреть, какие документы нашлись в RAG, что модель добавила в контекст и какой финальный ответ получился. Это помогает не только найти плохой ответ, но и понять, на каком этапе система испортила результат.
Ragas я применяю к RAG-сценариям. Он даёт возможность отдельно оценить релевантность найденных документов, полноту контекста и faithfulness ответа — то есть насколько ответ действительно опирается на источники.
Иногда ничего из этого не нужно. Для небольшого прототипа достаточно обычного Python-скрипта, CSV с примерами и десятка проверок в CI. Это не самый эффектный стек, зато его можно понять целиком.
Мой практический принцип: сначала пишу несколько проверок вручную, потом выбираю инструмент, который делает их удобнее. Не наоборот.
В RAG отдельно проверяю поиск и ответ
Когда модель отвечает плохо, я сначала проверяю не промпт, а контекст. Бывает, что релевантный документ вообще не попал в выдачу. Улучшать формулировку ответа в таком случае бессмысленно: модель пытается красиво пересказать неправильные данные.
Для RAG-приложения я разделяю тесты на два уровня. На первом проверяю сам retrieval: нашёл ли поиск нужный документ, какие ещё документы попали в выдачу, не потерялась ли информация. На втором — качество ответа: опирается ли он только на переданный контекст, отвечает ли на вопрос, не выдумывает ли источники и даты.
Особенно полезны вопросы, ответ на которые должен находиться в нескольких документах. Они хорошо показывают, умеет ли система собирать информацию или берёт только первый найденный фрагмент.
Ещё я проверяю ссылки и цитаты. Ответ может звучать убедительно, но ссылаться на третий абзац, где нет подтверждения. Для юридического или поддерживающего сценария это уже не «немного неточный ответ», а потенциально вредная ошибка.
Для агентов проверяю не только финальный текст
С агентами всё становится сложнее. Модель может вызвать неправильный инструмент, передать в него аргументы не в том формате, зациклиться на одной операции или получить правильный ответ только после лишних пяти вызовов.
Поэтому я проверяю не только содержимое ответа, но и траекторию. Какие инструменты вызывались, с какими аргументами, в каком порядке и сколько раз. Для части сценариев фиксирую конечное состояние системы: заказ должен получить конкретный статус, запись — появиться в базе, файл — существовать по ожидаемому пути.
Один из моих тестов специально даёт агенту две почти одинаковые функции с похожими названиями. Если он каждый раз выбирает не ту, проблема не в модели, а в дизайне инструментов: описания пересекаются, границы неочевидны.
LLM-as-a-judge полезен, только если у judge есть хороший rubric
Оценивать ответы другой моделью кажется удобным решением. На практике это работает, только если судье дать чёткую рубрику. Без неё результаты будут размытыми и непредсказуемыми.
Я описываю критерии так, чтобы модель-судья могла применять их без додумывания. Даю примеры хороших и плохих ответов, указываю на что обращать внимание и что игнорировать. Иногда разбиваю оценку на несколько вопросов: содержит ли ответ запрещённую информацию? опирается ли на контекст? даёт ли конкретный совет?
Для проверки самого судьи я беру несколько ответов с известной оценкой и смотрю, совпадает ли его мнение с моим. Если нет — переписываю инструкцию для судьи, а не для основной модели.
