Урок 7. Свои грейдеры в promptfoo

До этого мы обходились встроенными ассершенами promptfoo: exact-match, contains-all, is-json и им подобными. Штука полезная, но список конечный. Как только критерий чуть сложнее — «слово должно встретиться ровно семь раз», «сумма в чеке должна сойтись», «все id из входа должны быть в выходе» — встроенных ассершенов не хватает. Тогда мы пишем свой грейдер.

Демонстрировать будем на нарочито простой задаче. Шаблон промпта такой:

Напиши короткий абзац про {{topic}}. Упомяни {{topic}} ровно {{count}} раз — не больше и не меньше.

Подставим {{topic}} = «tweezers» (пинцет) и {{count}} = 7 — получим конкретный промпт. Чтобы оценить ответ, нужна логика: посчитать, сколько раз слово «tweezers» реально встретилось в тексте, и сравнить с семёркой. Ни один встроенный ассершен так не умеет — считать вхождения с учётом границ слова придётся самим.

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

Инициализация и провайдеры

Как всегда, начинаем с инициализации:

npx promptfoo@latest init

Команда создаёт файл promptfooconfig.yaml. Всё, что там лежит по умолчанию, можно смело стереть — писать будем с нуля.

Первым делом — провайдеры. Мы хотим прогнать один и тот же тест на двух моделях и сравнить, кто справится лучше:

description: Count mentions

providers:
  - anthropic:messages:claude-haiku-4-5
  - anthropic:messages:claude-sonnet-5

И не забудьте про ключ — promptfoo читает его из переменной окружения:

export ANTHROPIC_API_KEY=ваш_ключ

Промпт прямо в YAML

В прошлых уроках мы писали промпты функциями в Python-файле — это по-прежнему рекомендуемый способ для всего сложного. Но promptfoo умеет и проще: текст промпта можно положить прямо в конфиг.

description: Count mentions
prompts:
  - >-
    Write a short paragraph about {{topic}}. Make sure you mention {{topic}} exactly {{count}} times, no more or fewer. Only use lower case letters in your output.
providers:
  - anthropic:messages:claude-haiku-4-5
  - anthropic:messages:claude-sonnet-5

Два момента. Первый: >- — это YAML-способ записать многострочный текст, схлопнув переносы в пробелы. Второй, важнее: {{topic}} и {{count}} в двойных фигурных скобках — это синтаксис шаблонов Nunjucks. Promptfoo подставит туда значения переменных из тест-кейсов. Запомните эти двойные скобки, они ещё пригодятся.

Про Only use lower case letters — маленькая уловка автора: просим модель писать строчными, чтобы потом не воевать с регистром при подсчёте.

Квиз 1

Что означает запись {{topic}} в промпте внутри promptfooconfig.yaml?

Тест-кейсы в конфиге

Раньше мы держали тест-кейсы и логику проверки в CSV. Promptfoo гибкий — тесты можно описать и прямо в YAML. Добавим пять кейсов:

description: Count mentions
prompts:
  - >-
    Write a short paragraph about {{topic}}. Make sure you mention {{topic}} exactly {{count}} times, no more or fewer. Only use lower case letters in your output.
providers:
  - anthropic:messages:claude-haiku-4-5
  - anthropic:messages:claude-sonnet-5
tests:
  - vars:
      topic: sheep
      count: 3
  - vars:
      topic: fowl
      count: 2
  - vars:
      topic: gallows
      count: 4
  - vars:
      topic: tweezers
      count: 7
  - vars:
      topic: jeans
      count: 6

У каждого кейса свои topic и count. Promptfoo прогонит все пять, подставляя значения в шаблон, — и сделает это для каждого провайдера, то есть получится десять прогонов.

Логики проверки пока нет, но запустить уже можно — просто чтобы убедиться, что переменные подставляются как надо:

npx promptfoo@latest eval

В выводе увидим таблицу: строка на тест-кейс, колонка на модель. Для topic: sheep в ячейках лежат абзацы про овец — значит, шаблон работает. Теперь нужна логика, которая скажет, сколько раз там на самом деле встретилось «sheep».

Функция-грейдер на Python

Promptfoo умеет вызывать наши собственные Python-функции в роли грейдера. Создадим файл count.py:

import re

def get_assert(output, context):
    topic = context["vars"]["topic"]
    goal_count = int(context["vars"]["count"])
    pattern = fr'(^|\s)\b{re.escape(topic)}\b'

    actual_count = len(re.findall(pattern, output.lower()))

    pass_result = goal_count == actual_count

    result = {
        "pass": pass_result,
        "score": 1 if pass_result else 0,
        "reason": f"Expected {topic} to appear {goal_count} times. Actual: {actual_count}",
    }
    return result

Разберём контракт — это главное, что стоит унести из урока.

  • Promptfoo сам ищет в файле функцию с именем get_assert. Название не наше — так договорились.
  • Он передаёт ей два аргумента: output — ответ конкретной модели, и context — словарь с переменными и промптом, которые этот ответ породили. Значения тест-кейса лежат в context["vars"].
  • Вернуть функция может одно из трёх: bool (прошёл / не прошёл), float (балл) или словарь GradingResult.

Мы выбрали третий вариант — словарь, потому что он самый информативный. В нём три поля: pass_ — булево, прошёл ли тест; score — число с плавающей точкой; reason — строка-объяснение, которая потом окажется в отчёте.

В коде оригинального урока ключ записан как "pass", а в описании интерфейса — pass_: подчёркивание нужно, когда результат собирают как Python-объект, а не как обычный словарь. Если ваш грейдер молча не срабатывает — первым делом проверьте именно это написание в своей версии promptfoo.

Что делает тело функции: достаёт из контекста тему и целевое число, собирает регулярку с границами слова (\b и re.escape, чтобы «sheep» не совпал внутри «sheepdog» и чтобы спецсимволы в теме не сломали шаблон), считает вхождения в output.lower() и сравнивает с целью. reason написан так, что по отчёту сразу видно не только «упало», но и насколько промахнулись: ожидали 7, получили 4.

Квиз 2

Откуда функция-грейдер берёт значение count для текущего теста?

Подключаем грейдер

Грейдер написан — осталось сказать про него promptfoo. Добавляем в конфиг блок defaultTest:

description: Count mentions
prompts:
  - >-
    Write a short paragraph about {{topic}}. Make sure you mention {{topic}} exactly {{count}} times, no more or fewer. Only use lower case letters in your output.
providers:
  - anthropic:messages:claude-haiku-4-5
  - anthropic:messages:claude-sonnet-5
defaultTest:
  assert:
    - type: python
      value: file://count.py
tests:
  - vars:
      topic: sheep
      count: 3
  - vars:
      topic: fowl
      count: 2
  - vars:
      topic: gallows
      count: 4
  - vars:
      topic: tweezers
      count: 7
  - vars:
      topic: jeans
      count: 6

defaultTest означает «примени это ко всем тестам». Тип ассершена — python, значение — путь к файлу через file://. Никаких списков вроде «применить к тесту 1, 3 и 5» писать не нужно: один блок покрывает всю таблицу.

Запуск и результаты

Команда всё та же:

npx promptfoo@latest eval

А чтобы разглядывать результаты в браузере, а не в терминале:

npx promptfoo@latest view

В веб-интерфейсе у каждой ячейки есть иконка лупы: нажимаете — видите полный входной промпт и полный ответ модели. Это и есть главный смысл прогона: не просто цифра «20%», а возможность посмотреть, что именно модель написала не так.

В оригинальном прогоне картина вышла показательная: старшая модель взяла 100%, младшая — 20% (то есть один кейс из пяти). Считать что-то ровно N раз — задача на аккуратность, и модели поменьше на ней сыпятся. Ровно за этим и нужны эвалы: гипотеза «Haiku тут справится, сэкономим» проверяется за одну команду, а не за месяц жалоб пользователей.

Конкретные проценты у вас будут другими — модели обновляются. Важен сам жест: одна конфигурация, два провайдера, объективный числовой ответ на вопрос «какая модель достаточно хороша для этой задачи».

Частичные баллы и пороги

Обратите внимание на строчку "score": 1 if pass_result else 0. Это бинарная оценка: либо идеально, либо ноль. Но score — это float, и никто не мешает вернуть промежуточное значение. Скажем, промах на одно упоминание — это ведь не то же самое, что абзац, где темы нет вовсе.

diff = abs(goal_count - actual_count)
score = max(0.0, 1 - diff / goal_count)
result = {
    "pass": diff == 0,
    "score": score,
    "reason": f"Expected {topic} to appear {goal_count} times. Actual: {actual_count}",
}

Здесь pass и score расходятся, и это нормально: pass отвечает на вопрос «зачёт или незачёт», score — «насколько близко». Порог зачёта тоже ваш выбор: можно потребовать точного совпадения, можно засчитывать diff <= 1. Главное — решить это осознанно и один раз, а не подкручивать порог после того, как увидели результаты. Иначе эвал перестаёт быть измерительным прибором.

Квиз 3

Зачем в GradingResult держать score отдельно от pass?

Упражнения

Упражнение 7.1 — Грейдер длины

Напишите свой get_assert для другой задачи: промпт просит модель уложиться в заданное число предложений ({{sentences}}). Грейдер должен посчитать предложения в ответе и вернуть GradingResult с внятным reason. Считайте, что промах на одно предложение — ещё зачёт.

Решение упражненияСначала попробуйте сами — потом сверьтесь
import re

def get_assert(output, context):
    goal = int(context["vars"]["sentences"])
    actual = len([s for s in re.split(r'[.!?]+', output) if s.strip()])
    diff = abs(goal - actual)

    return {
        "pass": diff <= 1,
        "score": max(0.0, 1 - diff / goal),
        "reason": f"Ожидали {goal} предложений, получили {actual}",
    }

Каркас тот же, что в count.py: достали переменную из context["vars"], посчитали факт, вернули словарь с pass, score и reason. Меняется только содержимое подсчёта — это и есть вся идея кастомных грейдеров.

Подключается он так же: type: python, value: file://sentences.py.

Упражнение 7.2 — Почему падает грейдер

Коллега написал грейдер, который проверяет, что в ответе упомянута тема нужное число раз, вот так:

def get_assert(output, context):
    topic = context["vars"]["topic"]
    return output.count(topic) == int(context["vars"]["count"])

Тесты то проходят, то валятся без видимой причины, а в отчёте невозможно понять почему. Назовите три проблемы.

Решение упражненияСначала попробуйте сами — потом сверьтесь
  • Нет границ слова. str.count("sheep") найдёт вхождение и внутри «sheepdog», и внутри «sheepish». Отсюда плавающие результаты. Лечится регуляркой с \b, как в count.py.
  • Не учтён регистр. «Sheep» в начале предложения не совпадёт с «sheep». В оригинале это лечится двумя способами сразу: инструкцией писать строчными и output.lower() в грейдере.
  • Возвращается голый bool. Формально promptfoo это принимает, но в отчёте остаётся только «упало» — без reason и без score. Вы не узнаете, промахнулись на одно упоминание или на пять. Верните GradingResult.

Что запомнить

  • Встроенные ассершены покрывают типовое. Всё остальное — своя функция-грейдер, и вместе они закрывают практически любую код-проверку.
  • Promptfoo ищет в вашем файле функцию get_assert(output, context): первый аргумент — ответ модели, второй — контекст, где в context["vars"] лежат переменные тест-кейса.
  • Вернуть можно bool, float или словарь GradingResult с полями pass_, score, reason. Возвращайте словарь — reason потом сэкономит часы разбирательств.
  • Подключается грейдер через defaultTest: assert: - type: python / value: file://count.py — одним блоком на все тесты.
  • score — float, поэтому частичные баллы возможны. Порог зачёта определяйте до прогона, а не после.
  • Два провайдера в одном конфиге превращают спор «хватит ли модели поменьше» в цифру.

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

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