Урок 5. promptfoo: эвалы по конфигу
В прошлых уроках мы писали эвалы руками: цикл по датасету, вызов API, функция-грейдер, подсчёт процентов, вывод отчёта. Это работает — но львиная доля кода там не про качество промпта, а про обвязку. Её уже написали за нас.
Инструментов для эвалов сейчас много: promptfoo, Vellum, Scale Evaluation, PromptLayer, ChainForge — и список растёт каждый месяц. В этом уроке берём promptfoo: опенсорсный, ставится одной командой, конфигурируется одним YAML-файлом.
Что даёт promptfoo из коробки: батч-прогон датасета по нескольким промптам и моделям сразу, готовые ассершены, таблица результатов в терминале и веб-дашборд, где видно каждый ответ и причину провала. Мы возьмём знакомую задачу «сколько ног у этого животного» из урока про code-graded эвалы и просто перенесём её в promptfoo.
Напомню датасет — двенадцать утверждений и эталонные ответы:
eval_data = [
{"animal_statement": "Это животное — человек.", "golden_answer": "2"},
{"animal_statement": "Это животное — змея.", "golden_answer": "0"},
{"animal_statement": "Лис потерял ногу, но потом волшебным образом отрастил потерянную ногу и ещё одну загадочную сверху.", "golden_answer": "5"},
{"animal_statement": "Это животное — собака.", "golden_answer": "4"},
{"animal_statement": "Это животное — кошка с двумя лишними ногами.", "golden_answer": "6"},
{"animal_statement": "Это животное — слон.", "golden_answer": "4"},
{"animal_statement": "Это животное — птица.", "golden_answer": "2"},
{"animal_statement": "Это животное — рыба.", "golden_answer": "0"},
{"animal_statement": "Это животное — паук с двумя лишними ногами.", "golden_answer": "10"},
{"animal_statement": "Это животное — осьминог.", "golden_answer": "8"},
{"animal_statement": "Это животное — осьминог, потерявший две ноги и отрастивший три.", "golden_answer": "9"},
{"animal_statement": "Это животное — двухголовое восьминогое мифическое существо.", "golden_answer": "8"},
]
Установка и первый конфиг
Заходим в папку, где будет жить эвал, и инициализируем проект:
npx promptfoo@latest init
Команда создаёт файл promptfooconfig.yaml — единственное место, где мы объясняем инструменту, что вообще происходит:
providers— какие модели прогоняем;prompts— какие промпты сравниваем;tests— на каких данных и с какой логикой проверки.
Ключ к API promptfoo берёт из переменной окружения:
export ANTHROPIC_API_KEY=ваш_ключ
providers: какие модели
Модель задаётся строкой определённого формата: anthropic:messages:имя-модели. Начнём с самой дешёвой — Haiku. Стираем содержимое promptfooconfig.yaml и пишем:
description: "Animal Legs Eval"
providers:
- "anthropic:messages:claude-haiku-4-5"
Разбор по строкам:
description— необязательная подпись, чтобы через месяц понять, что это за прогон;providers— список моделей. Их может быть несколько, к этому вернёмся в конце урока.
prompts: что оцениваем
Промпты можно хранить по-разному: прямо в YAML текстом, в JSON, в txt, в отдельном YAML или в Python-файле. Удобнее всего — Python-файл, где каждый промпт это функция, возвращающая строку. Создаём prompts.py:
def simple_prompt(animal_statement):
return f"""Тебе дано утверждение о животном, твоя задача — определить, сколько у него ног.
Вот утверждение о животном.
<animal_statement>{animal_statement}</animal_statement>
Сколько ног у животного? Ответь числом."""
def better_prompt(animal_statement):
return f"""Тебе дано утверждение о животном, твоя задача — определить, сколько у него ног.
Вот утверждение о животном.
<animal_statement>{animal_statement}</animal_statement>
Сколько ног у животного? Ответь одним числом, например 2 или 9."""
Каждая функция принимает параметр animal_statement, подставляет его внутрь и возвращает готовый промпт. Теперь показываем promptfoo, где их искать:
description: "Animal Legs Eval"
prompts:
- prompts.py:simple_prompt
- prompts.py:better_prompt
providers:
- "anthropic:messages:claude-haiku-4-5"
Синтаксис читается как «файл двоеточие функция». Одна строка — один промпт-кандидат в сравнении.
За что отвечает поле providers в promptfooconfig.yaml?
tests: датасет в CSV
Тесты можно описать прямо в YAML, но для табличных датасетов удобнее CSV. Заводим файл animal_legs_tests.csv с двумя колонками:
animal_statement— вход, который подставится в промпт (имя колонки совпадает с именем параметра функции);__expected— ожидаемый ответ. Двойное подчёркивание — это специальный синтаксис promptfoo: колонка задаёт не просто данные, а проверку.
animal_statement,__expected
"Это животное — человек.","2"
"Это животное — змея.","0"
"Лис потерял ногу, но потом волшебным образом отрастил потерянную ногу и ещё одну загадочную сверху.","5"
"Это животное — собака.","4"
"Это животное — кошка с двумя лишними ногами.","6"
"Это животное — слон.","4"
"Это животное — птица.","2"
"Это животное — рыба.","0"
"Это животное — паук с двумя лишними ногами.","10"
"Это животное — осьминог.","8"
"Это животное — осьминог, потерявший две ноги и отрастивший три.","9"
"Это животное — двухголовое восьминогое мифическое существо.","8"
По умолчанию __expected без префикса означает точное совпадение строк. Подключаем файл к конфигу:
description: "Animal Legs Eval"
prompts:
- prompts.py:simple_prompt
- prompts.py:better_prompt
providers:
- "anthropic:messages:claude-haiku-4-5"
tests: animal_legs_tests.csv
Запуск и дашборд
Провайдер есть, промпты есть, тесты есть — запускаем:
npx promptfoo@latest eval
Для каждого промпта promptfoo сделает четыре вещи: возьмёт animal_statement из CSV, соберёт полный промпт, отправит запрос в API и сравнит ответ с __expected. Результат печатается таблицей в терминале: слева входы, дальше по колонке на каждый промпт с ответом и оценкой.
Картина на нашем датасете предсказуемая: simple_prompt валит вообще все двенадцать кейсов, better_prompt проходит большинство и спотыкается на логически хитрых.
Чтобы понять, почему валится, открываем веб-отчёт:
npx promptfoo@latest view
Инструмент спросит, поднимать ли сервер (отвечаем «y»), и откроет дашборд в браузере. Сверху — сводка по прогону, ниже таблица; клик по лупе в ячейке разворачивает модалку с полным ответом и деталями скоринга.
И вот тут видно главное: simple_prompt на самом деле считает правильно. Он отвечает «0» — но с вежливым абзацем пояснений вокруг. Точное сравнение строк такой ответ засчитать не может. Промпт не глупый, он неаккуратный. Ровно эту разницу эвал и должен показывать.
Модель ответила «У змеи 0 ног, ведь она ползает». В __expected стоит «0». Что покажет эвал?
Chain of Thought и transform
В уроке про code-graded эвалы лучший результат дал промпт с рассуждением вслух. Добавим его третьим кандидатом в prompts.py:
def chain_of_thought_prompt(animal_statement):
return f"""Тебе дано утверждение о животном, твоя задача — определить, сколько у него ног.
Вот утверждение о животном.
<animal_statement>{animal_statement}</animal_statement>
Сколько ног у животного?
Сначала порассуждай о количестве ног шаг за шагом внутри тегов <thinking>.
Затем выведи финальный ответ внутри тегов <answer>.
Внутри <answer> верни только число ног как целое, и ничего больше."""
Тут появляется проблема: ответ такого промпта — это простыня рассуждений с тегами <thinking> и <answer>. Сравнивать её со строкой «5» бессмысленно. Нужно перед сравнением вытащить число.
Для этого в promptfoo есть transform — своя функция, которая правит вывод модели до проверки. Создаём transform.py:
def get_transform(output, context):
if "<thinking>" in output:
try:
return output.split("<answer>")[1].split("</answer>")[0].strip()
except Exception as e:
print(f"Ошибка в get_transform: {e}")
return output
return output
Логика простая: если в выводе есть <thinking> — значит это ответ CoT-промпта, достаём содержимое <answer>. Иначе возвращаем вывод как есть, чтобы не сломать остальные два промпта. Параметр context нам пока не нужен, он пригодится в следующих уроках.
Финальный конфиг:
description: "Animal Legs Eval"
prompts:
- prompts.py:simple_prompt
- prompts.py:better_prompt
- prompts.py:chain_of_thought_prompt
providers:
- anthropic:messages:claude-haiku-4-5
tests: animal_legs_tests.csv
defaultTest:
options:
transform: file://transform.py
Блок defaultTest применяет трансформацию ко всем тестам разом. Имя функции указывать не нужно: promptfoo по умолчанию ищет в файле get_transform.
Гоняем npx promptfoo@latest eval ещё раз — в таблице теперь четыре колонки, и CoT-промпт берёт 100% даже на Haiku.
Сравнение моделей
Мы вложились в промпт-инжиниринг, чтобы вытянуть Haiku на сотню. А что, если просто взять модель поумнее? В promptfoo это одна строка:
providers:
- anthropic:messages:claude-haiku-4-5
- anthropic:messages:claude-sonnet-5
Запускаем тот же eval — и получаем шесть колонок вместо трёх: три промпта × две модели. Сильная модель проходит эвал даже на simple_prompt, который на Haiku давал ноль.
Это и есть ценность инструмента: он отвечает не на вопрос «какой промпт лучше», а на вопрос «какая связка промпт+модель лучше для этой задачи» — с учётом того, что дешёвая модель с хорошим промптом часто бьёт дорогую с плохим.
Ассершены: не только точное совпадение
Колонка __expected — это сокращённая запись ассершена. Без префикса это equals, точное совпадение. Но можно указывать тип проверки явно, прямо в ячейке CSV или в блоке assert в YAML:
equals— точное совпадение строк;contains— вывод содержит подстроку;icontains— то же без учёта регистра;regex— вывод матчится регуляркой;is-json— вывод разбирается как JSON (можно со схемой);javascript/python— своя функция-грейдер, возвращающая true/false или число от 0 до 1.
Пример в ячейке CSV: contains: 8 вместо голого 8 — тогда «ног: 8» тоже засчитается. А python-ассершен — это ровно то, что мы писали руками в прошлых уроках, только теперь оно живёт внутри готовой обвязки.
Модельные грейдеры (когда ответ оценивает другая модель) — тема следующих уроков.
Чем transform отличается от ассершена?
Упражнения
Заведите пустую папку, поставьте promptfoo и попробуйте руками — на конфиге всё быстро.
Упражнение 5.1 — Перенести свой эвал в конфиг
Возьмите любую свою задачу с коротким ожидаемым ответом (классификация тикетов, извлечение числа, да/нет) и соберите минимальный promptfooconfig.yaml: один провайдер, два варианта промпта, десять строк в CSV. Запустите и посмотрите отчёт.
Решение упражненияСначала попробуйте сами — потом сверьтесь
Скелет, который заводится с нуля за пять минут:
description: "Ticket Priority Eval"
prompts:
- prompts.py:short_prompt
- prompts.py:strict_format_prompt
providers:
- "anthropic:messages:claude-haiku-4-5"
tests: tickets.csv
CSV — две колонки, имя первой совпадает с параметром функции промпта:
ticket_text,__expected
"Не приходит письмо для сброса пароля","high"
"Хочу поменять аватарку","low"
Запуск: npx promptfoo@latest eval, затем npx promptfoo@latest view. Типичная первая находка — та же, что у нас в уроке: модель отвечает верно по сути, но добавляет слова, и точное сравнение всё валит. Это не повод сразу чинить эвал — сначала посмотрите, не проще ли ужесточить промпт.
Упражнение 5.2 — Смягчить проверку
Не меняя промпт simple_prompt (тот, что отвечает с пояснениями), добейтесь, чтобы он проходил хотя бы часть тестов. Подумайте: чинить формат ответа или чинить проверку — и когда какой вариант честный.
Решение упражненияСначала попробуйте сами — потом сверьтесь
Самый простой ход — заменить точное совпадение на contains в CSV:
animal_statement,__expected
"Это животное — змея.","contains: 0"
"Это животное — собака.","contains: 4"
Оценка сразу подскочит. Но честно ли это? Зависит от того, что вы строите. Если ответ модели читает человек — contains адекватен. Если ответ уходит в парсер, которому нужно чистое число, — вы только что научились обманывать собственный эвал: contains: 0 засчитает и «10 ног», где ноль просто попал в подстроку.
Более аккуратный вариант — оставить строгую проверку и добавить нормализацию через transform, вытаскивая первое число из ответа. Правило простое: эвал должен требовать ровно того формата, который дальше по пайплайну реально нужен. Подгонять критерий под текущий результат — это не улучшение промпта, а порча измерительного прибора.
Что запомнить
- promptfoo забирает на себя обвязку эвала: прогон, сравнение, отчёт. Пишете вы только промпты, данные и критерий.
- Весь эвал живёт в
promptfooconfig.yaml:providers(модели),prompts(кандидаты),tests(датасет и проверки). - Колонка
__expectedв CSV задаёт проверку. Без префикса — точное совпадение; естьcontains,regex,is-json,javascript,python. transformнормализует вывод модели до проверки — например, достаёт число из<answer>у CoT-промпта.- Две команды:
eval— прогнать,view— открыть дашборд и увидеть, почему кейс упал. - Добавление модели в
providers— одна строка, а ответ получаете на вопрос «какая связка промпт+модель лучше», а не «какой промпт лучше». - promptfoo — сторонний опенсорсный проект, не продукт Anthropic. Синтаксис проверяйте по его документации.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.