Урок 6. promptfoo на задаче классификации

В одном из ранних уроков мы вручную писали код, который классифицирует жалобы пользователей по пяти категориям: Software Bug, Hardware Malfunction, User Error, Feature Request, Service Outage. Жалобы такие:

  • «Whenever I open your app, my phone gets really slow» → Software Bug
  • «I can't figure out how to change my password» → User Error

Тогда мы гоняли всё самописным циклом: список словарей в Python, ручное сравнение с эталоном, самодельная сводка. Работает — но каждое новое сравнение промптов приходится программировать заново. В этом уроке мы переносим ту же оценку в promptfoo: датасет в CSV, логика проверки прямо в данных, сравнение промптов и моделей — одной командой.

Урок практический: в папке рядом с оригиналом лежат promptfooconfig.yaml, prompts.py и dataset.csv. Чтобы повторить всё руками, хватит пустой папки и трёх файлов — мы соберём их с нуля.

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

Стартуем так же, как в прошлом уроке:

npx promptfoo@latest init

Команда создаёт promptfooconfig.yaml с примером внутри. Всё содержимое смело удаляем и пишем своё — начнём с описания и провайдера:

description: "Complaint Classification Eval"

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

Берём Haiku намеренно: эту оценку мы прогоним за урок много раз, а самая быстрая и дешёвая модель здесь и нужна — задача классификации короткая, ответ в пару слов.

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

export ANTHROPIC_API_KEY=ваш_ключ

Два промпта в prompts.py

Логика та же, что в прошлом уроке: каждый промпт — это Python-функция, которая принимает переменные теста и возвращает строку промпта. Все функции живут в одном файле prompts.py.

Берём ровно те два промпта, что писали в исходном уроке про классификацию жалоб. Первый — минимальный:

def basic_prompt(complaint):
    return f"""
    Classify the following customer complaint into one or more of these categories:
    Software Bug, Hardware Malfunction, User Error, Feature Request, or Service Outage.
    Only respond with the classification.

    Complaint: {complaint}

    Classification:
    """

Второй — с ролью, расшифровкой категорий, правилом про множественные метки и девятью примерами:

def improved_prompt(complaint):
    return f"""
    You are an AI assistant specializing in customer support issue classification. Your task is to analyze customer complaints and categorize them into one or more of the following categories:

    1. Software Bug: Issues related to software not functioning as intended.
    2. Hardware Malfunction: Problems with physical devices or components.
    3. User Error: Difficulties arising from user misunderstanding or misuse.
    4. Feature Request: Suggestions for new functionalities or improvements.
    5. Service Outage: System-wide issues affecting service availability.

    Important Guidelines:
    - A complaint may fall into multiple categories. If so, list all that apply but try to prioritize picking a single category when possible.

    Examples:
    1. Complaint: "The app crashes when I try to save my progress."
    Classification: Software Bug

    2. Complaint: "My keyboard isn't working after I spilled coffee on it."
    Classification: Hardware Malfunction

    3. Complaint: "I can't find the login button on your website."
    Classification: User Error

    4. Complaint: "It would be great if your app had a dark mode."
    Classification: Feature Request

    5. Complaint: "None of your services are loading for me or my colleagues."
    Classification: Service Outage

    6. Complaint "Complaint: The app breaks every time I try to change my profile picture"
    Classification: Software Bug

    7. Complaint "The app is acting buggy on my phone and it seems like your website is down, so I'm completely stuck!"
    Classification: Software Bug, Service Outage

    8. Complaint: "Your software makes my computer super laggy and awful, I hate it!"
    Classification: Software Bug

    9. Complaint: "Your dumb app always breaks when I try to do anything with images."
    Classification: 'Software Bug'

    Now, please classify the following customer complaint:

    <complaint>{complaint}</complaint>

    Only respond with the appropriate categories and nothing else.
    Classification:
    """

Обратите внимание: во втором промпте жалоба обёрнута в теги <complaint>…</complaint> — модель точно понимает, где кончается инструкция и начинаются данные пользователя.

Теперь говорим promptfoo, что промптов два:

description: "Complaint Classification Eval"

prompts:
  - prompts.py:basic_prompt
  - prompts.py:improved_prompt

providers:
  - "anthropic:messages:claude-haiku-4-5"
Квиз 1

Что означает строка prompts.py:improved_prompt в конфиге?

Датасет с эталонами в CSV

Остался последний кусок — данные. В исходном уроке они были питоновским списком eval_data, где у каждой жалобы есть эталонный ответ golden_answer:

eval_data = [
    {
        "complaint": "The app crashes every time I try to upload a photo",
        "golden_answer": ["Software Bug"]
    },
    {
        "complaint": "I can't figure out how to change my password",
        "golden_answer": ["User Error"]
    },
    {
        "complaint": "The app is crashing and my phone is overheating",
        "golden_answer": ["Software Bug", "Hardware Malfunction"]
    },
    # ... всего 20 записей
]

Заметьте: у части жалоб эталон — это две метки сразу. «Приложение падает, и телефон греется» — это и баг, и железо.

В promptfoo самый простой способ загрузить датасет — CSV-файл. Колонки с обычными именами становятся переменными промпта, а специальная колонка __expected задаёт логику проверки строки. Создаём dataset.csv:

complaint,__expected
The app crashes every time I try to upload a photo,contains-all:Software Bug
My printer isn't recognized by my computer,contains-all:Hardware Malfunction
I can't figure out how to change my password,contains-all:User Error
The website is completely down I can't access any pages,contains-all:Service Outage
It would be great if the app had a dark mode option,contains-all:Feature Request
The software keeps freezing when I try to save large files,contains-all:Software Bug
My wireless mouse isn't working even with new batteries,contains-all:Hardware Malfunction
I accidentally deleted some important files can you help me recover them?,contains-all:User Error
None of your servers are responding is there an outage?,contains-all:Service Outage
Could you add a feature to export data in CSV format?,contains-all:Feature Request
"The app is crashing and my phone is overheating","contains-all:Software Bug,Hardware Malfunction"
I can't remember my password!,contains-all:User Error
The new update broke something and the app no longer works for me,contains-all:Software Bug
"I think I installed something incorrectly now my computer won't start at all","contains-all:User Error,Hardware Malfunction"
"Your service is down and I urgently need a feature to batch process files","contains-all:Service Outage,Feature Request"
The graphics card is making weird noises,contains-all:Hardware Malfunction
My keyboard just totally stopped working out of nowhere,contains-all:Hardware Malfunction
Whenever I open your app my phone gets really slow,contains-all:Software Bug
Can you make the interface more user-friendly? I always get lost in the menus,"contains-all:Feature Request,User Error"
The cloud storage isn't syncing and I can't access my files from other devices,"contains-all:Software Bug,Service Outage"

В файле всего две колонки:

  • complaint — входная жалоба, она подставится в аргумент функции промпта;
  • __expected — ассершен, то есть проверка ответа модели.

Разберём одну строку:

"Your service is down and I urgently need a feature to batch process files","contains-all:Service Outage,Feature Request"

Она говорит: на этом входе ответ модели должен содержать и «Service Outage», и «Feature Request». Кавычки вокруг обоих полей нужны потому, что внутри есть запятые — иначе CSV распадётся на лишние колонки.

Сравните CSV с исходным eval_data: из жалоб пропали запятые («The website is completely down, I can't access any pages»). Это не небрежность, а плата за CSV — разделитель в тексте пришлось убрать. Если ваши входы полны запятых и переносов строк, честнее держать датасет в YAML или JSON.

Ассершены: почему не equals

Главное отличие от прошлого урока — логика проверки. Раньше нам хватало точного совпадения. Здесь оно не работает: если эталон — две метки, модель может выдать их в любом порядке, добавить кавычки или перечислить через «and». equals завалит корректный ответ просто из-за перестановки.

Поэтому берём готовые встроенные ассершены promptfoo:

  • contains — ответ содержит подстроку;
  • contains-all — ответ содержит все подстроки из списка;
  • contains-any — ответ содержит хотя бы одну из подстрок;
  • contains-json — ответ содержит валидный JSON (опционально — с проверкой по схеме);
  • contains-sql — валидный SQL;
  • contains-xml — валидный XML;
  • equals — точное совпадение;
  • icontains, icontains-all, icontains-any — то же, но без учёта регистра;
  • regex — ответ подходит под регулярное выражение.

Полный список — в документации promptfoo по детерминированным метрикам.

Нам нужен contains-all: он не требует ни порядка, ни чистоты формата — только чтобы все нужные категории прозвучали.

Квиз 2

Эталон строки — две метки. Модель ответила «Service Outage, Feature Request», а в другой раз — «Feature Request, Service Outage». Какой ассершен засчитает оба ответа и при этом не пропустит ответ с одной меткой?

Осталось подключить датасет к конфигу. Финальный promptfooconfig.yaml:

description: "Complaint Classification Eval"

prompts:
  - prompts.py:basic_prompt
  - prompts.py:improved_prompt

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

tests: dataset.csv

Прогон и чтение отчёта

Команда та же, что и раньше:

npx promptfoo@latest eval

promptfoo прогонит все 20 строк через оба промпта — то есть сделает 40 вызовов — и выведет таблицу в терминал: строки это тест-кейсы, колонки — промпты, в каждой ячейке ответ модели с пометкой PASS или FAIL, а внизу сводка по каждой колонке.

На нашем прогоне результат такой: basic_prompt — 80% верных, improved_prompt — 100%. Разрыв ровно там, где мы и ожидали: базовый промпт спотыкается на жалобах с двумя категориями. Примеры в улучшенном промпте показывают модели, что «падает приложение и греется телефон» — это законный случай двух меток, а не выбор одной «самой главной».

Чтобы разглядеть каждую ячейку целиком, откройте веб-отчёт:

npx promptfoo@latest view

Это локальный интерфейс с той же матрицей: можно отфильтровать только упавшие кейсы, развернуть полный ответ модели и посмотреть, какой именно ассершен не прошёл. По моему опыту, читать провалы полезнее, чем смотреть на итоговый процент: именно там видно, промпт виноват или эталон в датасете спорный.

20 строк — учебный размер, не рабочий. На такой выборке разница между 80% и 85% — это одна строка, то есть шум. Для реальных решений держите в датасете хотя бы 100 примеров.

Несколько моделей в одном прогоне

Самое приятное в этой конструкции: чтобы сравнить не только промпты, но и модели, достаточно дописать строки в providers. Всё остальное — датасет, ассершены, команда — не меняется:

description: "Complaint Classification Eval"

prompts:
  - prompts.py:basic_prompt
  - prompts.py:improved_prompt

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

tests: dataset.csv

Теперь promptfoo строит матрицу 2 промпта × 2 модели × 20 строк = 80 вызовов и показывает четыре колонки рядом. Это ровно тот вопрос, который обычно решают на глаз: «хватит ли Haiku, или нужен Sonnet?» Здесь на него отвечают цифрой.

Часто выясняется приятное: хороший промпт на дешёвой модели обгоняет ленивый промпт на дорогой. Именно ради таких находок и стоит держать оценку под рукой.

Квиз 3

Сколько вызовов API сделает promptfoo при двух промптах, двух провайдерах и датасете из 20 строк?

Упражнения

Соберите папку с тремя файлами (promptfooconfig.yaml, prompts.py, dataset.csv), прогоните npx promptfoo@latest eval — и попробуйте следующее.

Упражнение 6.1 — Строгий регистр

Ваш классификатор иногда отвечает «software bug» с маленькой буквы. Сейчас такой ответ падает. Что поменять в датасете, чтобы регистр перестал влиять на оценку — и в каком случае этого делать не надо?

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

Достаточно заменить ассершен на его регистронезависимую версию — icontains-all:

complaint,__expected
The app crashes every time I try to upload a photo,icontains-all:Software Bug
"The app is crashing and my phone is overheating","icontains-all:Software Bug,Hardware Malfunction"

Логика проверки живёт прямо в данных, так что промпты и конфиг трогать не нужно.

А не надо этого делать, если регистр — часть требований к продукту. Например, ответ уходит дальше по пайплайну и сравнивается с фиксированным списком меток или ключом enum. Тогда «software bug» — это настоящий провал, и смягчать проверку значит прятать баг от себя. Ослаблять ассершен можно, только когда вы уверены: следующий шаг системы этот вариант переварит.

Упражнение 6.2 — Проверка на лишнее

contains-all не запрещает лишние метки. Если модель на жалобу про пароль ответит «User Error, Software Bug, Feature Request» — тест пройдёт, хотя ответ плохой. Придумайте, как поймать такое переусердствование, оставаясь в рамках встроенных ассершенов CSV.

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

Самый прямой ход в рамках встроенных проверок — regex: описать весь допустимый ответ целиком, а не только наличие подстрок.

complaint,__expected
I can't figure out how to change my password,regex:^\s*User Error\.?\s*$

Для строки с двумя метками регулярка допускает оба порядка:

complaint,__expected
"The app is crashing and my phone is overheating","regex:^\s*(Software Bug, ?Hardware Malfunction|Hardware Malfunction, ?Software Bug)\.?\s*$"

Работает — но видно, куда это катится: на трёх метках перечислять перестановки уже нелепо, а любая безобидная вольность формата («Classification: Software Bug») ломает проверку.

Это и есть граница встроенных ассершенов. Как только правило перестаёт выражаться подстрокой или регуляркой — пора писать свою логику оценки. Ровно этим займёмся в уроке 7.

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

  • Перенос ручной оценки в promptfoo — это три файла: promptfooconfig.yaml, prompts.py с функциями-промптами и dataset.csv с данными.
  • Колонка __expected в CSV хранит логику проверки рядом с данными: у каждой строки может быть своя.
  • Для классификации с несколькими метками нужен contains-all, а не equals: порядок меток в ответе непредсказуем.
  • Список промптов и список провайдеров перемножаются — сравнение «промпт × модель» стоит одной дописанной строки в конфиге.
  • На нашем датасете промпт с примерами дал 100% против 80% у базового. Примеры чинят именно те случаи, где меток должно быть две.
  • 20 строк — учебный размер. Для решений о проде нужно минимум 100.

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

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