Урок 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 — маленькая уловка автора: просим модель писать строчными, чтобы потом не воевать с регистром при подсчёте.
Что означает запись {{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.
Откуда функция-грейдер берёт значение 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. Главное — решить это осознанно и один раз, а не подкручивать порог после того, как увидели результаты. Иначе эвал перестаёт быть измерительным прибором.
Зачем в 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, поэтому частичные баллы возможны. Порог зачёта определяйте до прогона, а не после.- Два провайдера в одном конфиге превращают спор «хватит ли модели поменьше» в цифру.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.