Урок 5. promptfoo: эвалы по конфигу

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

Инструментов для эвалов сейчас много: promptfoo, Vellum, Scale Evaluation, PromptLayer, ChainForge — и список растёт каждый месяц. В этом уроке берём promptfoo: опенсорсный, ставится одной командой, конфигурируется одним YAML-файлом.

promptfoo — сторонний опенсорсный проект, а не продукт Anthropic. Курс использует его как удобный пример инструментария; поля конфига и синтаксис могут меняться от версии к версии — сверяйтесь с документацией проекта.

Что даёт 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"

Синтаксис читается как «файл двоеточие функция». Одна строка — один промпт-кандидат в сравнении.

Квиз 1

За что отвечает поле 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» — но с вежливым абзацем пояснений вокруг. Точное сравнение строк такой ответ засчитать не может. Промпт не глупый, он неаккуратный. Ровно эту разницу эвал и должен показывать.

Квиз 2

Модель ответила «У змеи 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-ассершен — это ровно то, что мы писали руками в прошлых уроках, только теперь оно живёт внутри готовой обвязки.

Модельные грейдеры (когда ответ оценивает другая модель) — тема следующих уроков.

Квиз 3

Чем 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. Синтаксис проверяйте по его документации.

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

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