Урок 8. Когда оценивает модель
До сих пор все наши оценки были кодовыми: точное совпадение, число в диапазоне, валидный JSON, регулярка. Это лучший вид проверки — дешёвый, быстрый, детерминированный. Если задачу можно проверить кодом, проверяйте кодом.
Проблема в том, что кодом проверяется не всё. Представьте чат-бота для школьников: вы хотите, чтобы он говорил понятным для подростка языком, держал учебный тон, не лез в темы вне школьной программы и объяснял на подходящем уровне сложности. Ни одно из этих требований не сводится к == или регулярке. Они субъективны и зависят от контекста.
Тут и появляется model-graded evaluation — оценка, где судьёй становится другая модель. Приём известен как LLM-as-a-judge.
npx и ключ Anthropic в переменной окружения.
Идея: оценка как языковая задача
Логика простая: понимание языка, которое породило ответ, годится и на то, чтобы этот ответ оценить. Мы отдаём модели-судье комбинацию из четырёх вещей:
- исходный промпт или вопрос;
- ответ, который надо оценить;
- критерий или список требований;
- инструкцию, как именно оценивать и выставлять оценку.
Так проверяются вещи, до которых код не дотягивается: тон, уместность, релевантность, соблюдение гайдлайнов, даже креативность. Типичные вопросы к судье:
- Насколько ответ извиняющийся?
- Фактически ли он верен — с учётом переданного контекста?
- Не слишком ли часто он ссылается на свой контекст?
- Ответил ли он на вопрос по существу?
- Насколько соблюдены наши правила тона, бренда, стиля?
Когда стоит брать оценку моделью вместо кодовой?
Ассершен llm-rubric
В promptfoo, как обычно, есть несколько рабочих подходов. Начнём с самого простого — встроенных ассершенов. Универсальный грейдер для LLM-as-a-judge называется llm-rubric. Пользоваться им — это дописать в promptfooconfig.yaml буквально четыре строки:
assert:
- type: llm-rubric
# Модель, которая будет судьёй
provider: anthropic:messages:claude-opus-5
# Критерий, по которому оцениваем ответ:
value: Не извиняется
Такой ассершен возьмёт Opus в роли судьи и попросит его решить, извиняющийся ли перед нами ответ. Обратите внимание на две вещи. Первая: provider здесь — не та модель, чей промпт мы тестируем, а отдельная модель-оценщик. Вторая: value — это критерий на человеческом языке, а не код и не регулярка.
Критерий формулируют как утверждение об ответе, которое судья подтверждает или опровергает: «Отказывается отвечать и уводит к учебным темам», «Не извиняется». Не «оцени качество» и не «хороший ли это ответ» — на размытом критерии судья начнёт голосовать настроением.
Кейс: школьный ассистент
Соберём оценку целиком. Строим чат-бота, который отвечает на вопросы по школьным предметам и отказывается от всего остального. Начальный промпт:
Ты — интерактивный ассистент-репетитор для школьников средних классов.
Ученик задаёт вопрос, твоя задача — объяснить понятным для этого возраста языком.
Отвечай только на вопросы, связанные со школьной программой.
Вот вопрос ученика: {{question}}
Мы хотим убедиться, что на вопросы вроде «Как уговорить родителей купить мне телефон?» или «Какие есть классные бесплатные игры?» модель отказывается отвечать. Теоретически можно написать кодовую проверку с регуляркой на «извини», «не могу помочь» и прочие фразы отказа — но это хрупко до неприличия: любая новая формулировка отказа ломает проверку. Судья-модель справляется заметно лучше.
Инициализируем проект в пустой папке:
npx promptfoo@latest init
Чтобы не плодить файлы, промпты и тесты напишем прямо в конфиге. Стираем содержимое promptfooconfig.yaml и кладём туда:
description: "School Assistant Eval"
prompts:
- >-
Ты — интерактивный ассистент-репетитор для школьников средних классов.
Ученик задаёт вопрос, твоя задача — объяснить понятным для этого возраста языком.
Отвечай только на вопросы, связанные со школьной программой.
Вот вопрос ученика: {{question}}
providers: [anthropic:messages:claude-sonnet-5]
defaultTest:
assert:
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Отказывается отвечать на вопрос и уводит разговор к учебным темам'
tests:
- vars:
question: Какая самая лучшая бесплатная мобильная игра?
- vars:
question: Как уговорить родителей разрешить ложиться позже?
- vars:
question: Какие есть классные темы для пижамной вечеринки?
- vars:
question: Какие есть безобидные идеи розыгрышей?
- vars:
question: Кто из известных футболистов играет левой ногой?
Что здесь есть:
prompts— тот самый базовый промпт. Обычно промпты живут в отдельном Python-файле, но их можно вписать и прямо в конфиг.providers— модель, через которую гоняем промпт. У нас Sonnet.defaultTest— ассершен, который применится ко всем тестам:llm-rubricс Opus в роли судьи проверяет, что ответ — отказ с переводом на учебные темы.tests— набор кейсов. Тесты тоже часто выносят в CSV, но inline в YAML работает так же. Все пять вопросов — те, на которые бот отвечать не должен.
Запускаем:
npx promptfoo@latest eval
На первом прогоне промпт справляется с большей частью датасета — но на футболистах ломается: вопрос звучит достаточно «энциклопедически», и модель охотно отвечает. В веб-интерфейсе видно и сам ответ, и рассуждение судьи, почему он засчитал провал.
Второй промпт
Добавим второй вариант промпта — с явным перечнем разрешённых тем:
Ты — интерактивный ассистент-репетитор для школьников средних классов.
Ученик задаёт вопрос, твоя задача — объяснить понятным для этого возраста языком.
Отвечай только на вопросы, связанные со школьной программой.
Допустимые темы: математика, чтение, естественные науки,
иностранные языки, обществознание и искусство.
Отказывайся отвечать на вопросы вне этих тем в учебном контексте.
Вот вопрос ученика: {{question}}
В promptfooconfig.yaml он просто добавляется вторым элементом списка prompts — конфиг и тесты не меняются. Теперь promptfoo прогонит каждый тест через оба промпта и покажет их рядом. И да, второй вариант закрывает дыру с футболистами.
Здесь важная оговорка из оригинала, которую авторы повторяют трижды: пять тестов — это игрушечный датасет. Для реальной оценки его категорически мало, любой вывод на такой выборке — совпадение.
Где в конфиге указывается модель-судья?
Ловим извинения
Смотрим ответы внимательнее и замечаем неприятное: почти все отказы начинаются с «Извини» или «К сожалению, я не могу». Для продукта это так себе опыт — ассистент выглядит виноватым вместо того, чтобы бодро вернуть ребёнка к учёбе. Пробуем третий промпт, добавив к предыдущему строку:
Не извиняйся и не используй извиняющийся тон при отказе.
Вместо этого мягко возвращай ученика к школьным темам.
А заодно добавляем в defaultTest второй ассершен llm-rubric:
defaultTest:
assert:
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Отказывается отвечать на вопрос и уводит разговор к учебным темам'
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Не извиняется'
Теперь у нас три промпта, и каждый тест проверяет два независимых свойства ответа: это отказ — и он не извиняющийся. Итог прогона предсказуем: первые два промпта валят ассершен про извинения, третий проходит оба.
Веб-интерфейс запускается командой:
npx promptfoo@latest view
Клик по лупе разворачивает детали конкретного ответа. Там видно ровно то, что нужно для отладки: ответ проходит первый ассершен (отказ действительно есть) и валит второй, потому что судья пишет — «ответ начинается с „Извини“, а это извиняющаяся формулировка». Такое объяснение и есть главная ценность model-graded оценок: вы получаете не только «провал», но и причину.
Заметьте и другое: один ассершен на два свойства мы не писали. Каждое требование — отдельный llm-rubric. Иначе судья вернёт одно «не прошло», и вы не поймёте, что именно сломалось.
Судья тоже ошибается
Оценка моделью — не бесплатный обед, и оригинал честно на это указывает. Что стоит держать в голове:
- Судья — та же LLM. Он ошибается, галлюцинирует и иногда меняет вердикт между прогонами. Ваш ассершен теперь недетерминирован.
- У судьи есть перекосы. Модели склонны выше оценивать длинные, уверенные, красиво отформатированные ответы — независимо от содержания. Есть и известная слабость к текстам, похожим на собственные.
- Размытый критерий = случайная оценка. «Хороший ответ» каждый прогон означает что-то своё.
- Это дорого и медленно. Каждый тест — дополнительный вызов модели, часто более крупной.
Как смягчать:
- Формулируйте критерий узко и проверяемо, одно свойство на ассершен.
- Судите более сильной моделью, чем та, что генерирует: в примере генерирует Sonnet, судит Opus.
- Проверьте самого судью: разметьте руками десяток ответов и сравните с его вердиктами. Если он расходится с вами — чините критерий, а не промпт.
- Оставляйте кодовые проверки везде, где они возможны, — модель зовите только на остаток.
- Читайте объяснения судьи, а не только зелёные и красные ячейки.
Вы подозреваете, что судья ставит оценки не по делу. Что сделать первым?
Упражнения
Упражнение 8.1 — Почините критерий
Коллега написал такой ассершен и жалуется, что результаты скачут от прогона к прогону:
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Ответ хороший и подходит детям, не слишком длинный и вежливый'
Что здесь не так и как переписать?
Решение упражненияСначала попробуйте сами — потом сверьтесь
Проблем две. Во-первых, в один критерий упаковано четыре разных свойства — судья вернёт одно «не прошло», и вы не узнаете, какое именно сломалось. Во-вторых, «хороший» и «не слишком длинный» ничего не значат: у судьи нет определения, и он каждый раз придумывает своё.
Разбиваем по одному свойству на ассершен, размытое выкидываем, измеримое отдаём коду:
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Объяснение понятно ученику 12 лет: без терминов без расшифровки'
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Не извиняется'
# длину проверяем кодом, а не моделью
- type: javascript
value: output.length < 800
Правило простое: сначала выжимаем всё, что проверяется кодом, а модели оставляем только то, что действительно требует понимания языка.
Упражнение 8.2 — Свой критерий
Вы делаете ассистента поддержки. Он обязан отвечать только по переданному в промпт фрагменту документации и не досочинять от себя. Напишите ассершен, который это ловит.
Решение упражненияСначала попробуйте сами — потом сверьтесь
defaultTest:
assert:
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: >-
Каждое фактическое утверждение ответа подтверждается
приведённым фрагментом документации. Ответ не добавляет
фактов, которых в этом фрагменте нет.
- type: llm-rubric
provider: anthropic:messages:claude-opus-5
value: 'Если в документации нет ответа, прямо сообщает об этом вместо догадки'
Два ассершена, а не один: первый ловит выдумки, второй — молчаливую подмену «не знаю» правдоподобной догадкой. Это разные поломки, и лечатся они разными правками промпта.
И добавьте в датасет хотя бы несколько кейсов, где документация ответа заведомо не содержит. Без них второй ассершен всегда зелёный и ничего не проверяет.
Что запомнить
- Код проверяет точные совпадения и числа; тон, уместность и «без выдумок» — работа модели-судьи.
llm-rubric— встроенный универсальный грейдер promptfoo:providerзадаёт судью,value— критерий на человеческом языке.- Одно свойство — один ассершен. Иначе вы не поймёте, что именно сломалось.
- Ассершен в
defaultTestприменяется ко всем тестам сразу. - Судья недетерминирован и предвзят: сверяйте его с ручной разметкой, судите моделью сильнее генератора, читайте его объяснения.
- Пять тестов — не датасет. Для настоящей оценки нужно сильно больше.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.