Урок 8. Когда оценивает модель

До сих пор все наши оценки были кодовыми: точное совпадение, число в диапазоне, валидный JSON, регулярка. Это лучший вид проверки — дешёвый, быстрый, детерминированный. Если задачу можно проверить кодом, проверяйте кодом.

Проблема в том, что кодом проверяется не всё. Представьте чат-бота для школьников: вы хотите, чтобы он говорил понятным для подростка языком, держал учебный тон, не лез в темы вне школьной программы и объяснял на подходящем уровне сложности. Ни одно из этих требований не сводится к == или регулярке. Они субъективны и зависят от контекста.

Тут и появляется model-graded evaluation — оценка, где судьёй становится другая модель. Приём известен как LLM-as-a-judge.

В уроке есть папка с кодом — если хотите запускать примеры вместе с нами, скачайте её целиком. Всё, что нужно из инструментов, — npx и ключ Anthropic в переменной окружения.

Идея: оценка как языковая задача

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

  • исходный промпт или вопрос;
  • ответ, который надо оценить;
  • критерий или список требований;
  • инструкцию, как именно оценивать и выставлять оценку.

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

  • Насколько ответ извиняющийся?
  • Фактически ли он верен — с учётом переданного контекста?
  • Не слишком ли часто он ссылается на свой контекст?
  • Ответил ли он на вопрос по существу?
  • Насколько соблюдены наши правила тона, бренда, стиля?
Квиз 1

Когда стоит брать оценку моделью вместо кодовой?

Ассершен 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 прогонит каждый тест через оба промпта и покажет их рядом. И да, второй вариант закрывает дыру с футболистами.

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

Квиз 2

Где в конфиге указывается модель-судья?

Ловим извинения

Смотрим ответы внимательнее и замечаем неприятное: почти все отказы начинаются с «Извини» или «К сожалению, я не могу». Для продукта это так себе опыт — ассистент выглядит виноватым вместо того, чтобы бодро вернуть ребёнка к учёбе. Пробуем третий промпт, добавив к предыдущему строку:

Не извиняйся и не используй извиняющийся тон при отказе.
Вместо этого мягко возвращай ученика к школьным темам.

А заодно добавляем в 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.
  • Проверьте самого судью: разметьте руками десяток ответов и сравните с его вердиктами. Если он расходится с вами — чините критерий, а не промпт.
  • Оставляйте кодовые проверки везде, где они возможны, — модель зовите только на остаток.
  • Читайте объяснения судьи, а не только зелёные и красные ячейки.
Квиз 3

Вы подозреваете, что судья ставит оценки не по делу. Что сделать первым?

Упражнения

Упражнение 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 применяется ко всем тестам сразу.
  • Судья недетерминирован и предвзят: сверяйте его с ручной разметкой, судите моделью сильнее генератора, читайте его объяснения.
  • Пять тестов — не датасет. Для настоящей оценки нужно сильно больше.

Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.

Перевод и адаптация урока «Promptfoo: Model-Graded Evals» курса Prompt Evaluations © Anthropic, лицензия CC BY-NC 4.0. Перевод: Дарья Воронкина (@aishipuchka). Материал изменён: переведён на русский, примеры и названия моделей актуализированы, добавлены квизы. Используется некоммерчески.