Урок 3. Проверка кодом: пишем свой грейдер

Самая простая и самая надёжная оценка — та, где правильность ответа можно проверить кодом. Валидный ли это JSON, есть ли нужное поле, совпал ли ответ с эталоном. Никаких споров, никакого «ну вроде похоже»: функция вернула True или False.

В этом уроке мы пройдём весь цикл целиком на нарочно простой задаче, чтобы внимание было на процессе, а не на предметной области. Шаги такие:

  1. Собрать набор тест-кейсов с золотыми ответами.
  2. Написать первый вариант промпта.
  3. Прогнать промпт по всему набору и получить балл.
  4. Поправить промпт по результатам.
  5. Прогнать заново — и увидеть, стало ли лучше.
Код можно выполнять в блокноте или обычном .py-файле. Нужен установленный anthropic и ключ API в переменной окружения ANTHROPIC_API_KEY.

Тест-сет с золотыми ответами

Задача: по фразе о животном определить, сколько у него ног. Тест-сет — список словарей, у каждого есть вход animal_statement и эталонный ответ golden_answer.

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"},
]

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

Квиз 1

Что такое golden_answer в тест-сете?

Первый промпт и вызов модели

Промпт удобно держать не строкой, а функцией: она принимает вход из тест-сета и возвращает готовый список messages. Так один и тот же датасет легко прогонять через разные версии промпта.

def build_input_prompt(animal_statement):
    user_content = f"""Тебе дадут утверждение о животном, а твоя задача — определить, сколько у него ног.

    Вот утверждение о животном.
    <animal_statement>{animal_statement}</animal_statement>

    Сколько ног у этого животного? Ответь числом."""

    messages = [{'role': 'user', 'content': user_content}]
    return messages

Теперь функция, которая отправляет messages в API и возвращает текст ответа:

from anthropic import Anthropic

client = Anthropic()

MODEL_NAME = "claude-haiku-4-5"

def get_completion(messages):
    response = client.messages.create(
        model=MODEL_NAME,
        max_tokens=200,
        messages=messages
    )
    return response.content[0].text

Проверим на первом элементе набора — «Это человек.»:

full_prompt = build_input_prompt(eval_data[0]['animal_statement'])
get_completion(full_prompt)

В ответ приходит 2. Глазами проверку прошли — можно прогонять все 12 кейсов.

Грейдер и первый балл

Сначала собираем выходы: каждый вход из тест-сета подставляем в шаблон промпта и отправляем модели.

outputs = [get_completion(build_input_prompt(question['animal_statement'])) for question in eval_data]

Посмотреть результаты рядом с эталонами:

for output, question in zip(outputs, eval_data):
    print(f"Утверждение: {question['animal_statement']}\nЭталон: {question['golden_answer']}\nОтвет: {output}\n")

Уже видно, что часть ответов — не только числа. Но глазами такое считать нельзя: нужен грейдер. В нашем случае он предельно прост — точное совпадение строк.

def grade_completion(output, golden_answer):
    return output == golden_answer

grades = [grade_completion(output, question['golden_answer']) for output, question in zip(outputs, eval_data)]
print(f"Балл: {sum(grades)/len(grades)*100}%")

Получаем базовую точку отсчёта: 66,6%. И две отчётливые проблемы.

Проблема 1 — формат ответа

Часть ответов не числа, а предложения:

Утверждение: Это птица.
Эталон: 2
Ответ: Судя по утверждению «Это птица.», у животного 2 ноги.

Формально это провал теста, хотя по смыслу ответ верный. Лечится промптом.

Проблема 2 — неверные ответы

А часть ответов просто неправильные:

Утверждение: Это осьминог, который потерял две ноги, а потом отрастил три.
Эталон: 9
Ответ: 5

Утверждение: Это паук с двумя лишними ногами.
Эталон: 10
Ответ: 8

Это как раз «хитрые» кейсы. Их тоже попробуем вылечить промптом.

Квиз 2

Модель ответила «У животного 2 ноги», эталон — «2». Что вернёт грейдер на точное совпадение?

Вторая попытка: чиним формат

Начинаем с более простой проблемы — лишнего текста вокруг числа. Добавляем в промпт одну строку про формат:

def build_input_prompt2(animal_statement):
    user_content = f"""Тебе дадут утверждение о животном, а твоя задача — определить, сколько у него ног.

    Вот утверждение о животном.
    <animal_statement>{animal_statement}</animal_statement>

    Сколько ног у этого животного? Ответь только цифрой, например 2 или 6, и ничем больше."""

    messages = [{'role': 'user', 'content': user_content}]
    return messages

Ключевое добавление — «Ответь только цифрой, например 2 или 6, и ничем больше». Прогоняем набор заново и снова считаем балл:

outputs2 = [get_completion(build_input_prompt2(question['animal_statement'])) for question in eval_data]

grades = [grade_completion(output, question['golden_answer']) for output, question in zip(outputs2, eval_data)]
print(f"Балл: {sum(grades)/len(grades)*100}%")

Теперь на выходе исключительно числа, и балл подрос. Но содержательные ошибки никуда не делись: паук с двумя лишними ногами по-прежнему получает 8 вместо 10.

Набор из 12 кейсов очень мал — к таким процентам стоит относиться осторожно. Один случайный кейс сдвигает балл сразу на 8 пунктов.

Третья попытка: даём модели подумать

Против логических ошибок работает chain of thought: просим модель сначала рассуждать вслух, а потом дать ответ. И вот тут оценка становится по-настоящему полезной — мы наконец можем проверить, помогает ли этот приём именно на нашей задаче, а не «вообще».

Рассуждение просим складывать в теги <thinking>, а финальный ответ — в <answer>, чтобы его было легко достать кодом.

def build_input_prompt3(animal_statement):
    user_content = f"""Тебе дадут утверждение о животном, а твоя задача — определить, сколько у него ног.

    Вот утверждение о животном.
    <animal_statement>{animal_statement}</animal_statement>

    Сколько ног у этого животного?
    Сначала порассуждай о количестве ног шаг за шагом внутри тегов <thinking>.
    Затем выведи финальный ответ внутри тегов <answer>.
    Внутри <answer> верни только число ног как целое, и ничего больше."""

    messages = [{'role': 'user', 'content': user_content}]
    return messages

Ответ теперь выглядит примерно так:

Утверждение: Лис потерял ногу, но она волшебным образом отросла обратно,
а сверху выросла ещё одна загадочная нога.
Эталон: 5
Ответ: Вот моё пошаговое рассуждение:
<thinking>
1. Сначала сказано, что лис потерял ногу.
2. Затем нога отросла обратно, и сверху выросла ещё одна.
3. Значит, изначально было 4 ноги, минус 1, плюс потерянная, плюс лишняя — итого 5.
</thinking>
<answer>5</answer>

Логика выправилась, но грейдер такой ответ не примет: в нём куча лишнего текста. Достаём число из тегов регуляркой:

import re

def extract_answer(text):
    pattern = r'<answer>(.*?)</answer>'
    match = re.search(pattern, text)
    if match:
        return match.group(1)
    else:
        return None

И считаем итоговый балл уже по извлечённым ответам:

outputs3 = [get_completion(build_input_prompt3(question['animal_statement'])) for question in eval_data]
extracted_outputs3 = [extract_answer(output) for output in outputs3]

grades3 = [grade_completion(output, question['golden_answer']) for output, question in zip(extracted_outputs3, eval_data)]
print(f"Балл: {sum(grades3)/len(grades3)*100}%")

Балл — 100%. Главное здесь не сама цифра, а то, что у нас появилось основание утверждать: правки промпта действительно улучшили ответы, а не просто «стало приятнее читать».

Квиз 3

Зачем просить модель класть финальный ответ в теги <answer>?

Параллельный прогон

Списковое выражение отправляет запросы по одному: 12 кейсов — 12 последовательных вызовов. На маленьком наборе терпимо, на сотне — уже мучение. Запросы независимы, поэтому их спокойно можно пустить параллельно:

from concurrent.futures import ThreadPoolExecutor

def run_prompt(build_prompt, dataset, max_workers=8):
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        return list(executor.map(
            lambda item: get_completion(build_prompt(item['animal_statement'])),
            dataset
        ))

outputs3 = run_prompt(build_input_prompt3, eval_data)

executor.map сохраняет порядок результатов — значит, дальше zip с eval_data по-прежнему честно сопоставляет ответ с его эталоном.

Упражнения

Упражнение 3.1 — Грейдер, устойчивый к мелочам

Точное совпадение строк ломается от пробела или точки в конце. Напишите грейдер, который остаётся строгим по сути (число должно совпасть), но не считает ошибкой лишние пробелы вокруг ответа и None обрабатывает без падения.

Решение упражненияСначала попробуйте сами — потом сверьтесь
def grade_completion(output, golden_answer):
    if output is None:
        return False
    return output.strip() == golden_answer.strip()

Важно не увлечься: чем «добрее» грейдер, тем меньше он ловит. Пробелы прощаем, а вот «У животного 2 ноги» — нет, иначе оценка перестанет отражать требование к формату.

Упражнение 3.2 — Показать только провалы

На больших наборах печатать все ответы бессмысленно. Напишите код, который выводит только непройденные кейсы: утверждение, эталон и то, что ответила модель.

Решение упражненияСначала попробуйте сами — потом сверьтесь
for output, question in zip(extracted_outputs3, eval_data):
    if not grade_completion(output, question['golden_answer']):
        print(f"Утверждение: {question['animal_statement']}")
        print(f"Эталон: {question['golden_answer']}")
        print(f"Ответ: {output}\n")

Именно этот список — рабочий материал для следующей итерации промпта. Балл говорит «стало хуже или лучше», а провалившиеся кейсы — «что чинить».

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

  • Code-graded оценка работает там, где правильность проверяется кодом: точное совпадение, валидный JSON, наличие поля.
  • Цикл всегда один: тест-сет с золотыми ответами → прогон промпта → грейдер → процент → правка промпта → повтор.
  • Промпт удобно оформлять функцией: один датасет прогоняется через разные версии без копипасты.
  • Грейдер на точное совпадение требует жёстко задать формат ответа в промпте — иначе верные по смыслу ответы считаются провалом.
  • Chain of thought лечит логические ошибки, но требует тегов <answer> и извлечения ответа регуляркой.
  • Запросы независимы — гоняйте их параллельно через ThreadPoolExecutor, порядок результатов сохранится.
  • На наборе из 12 кейсов проценты шумные: маленький тест-сет — повод для осторожности, а не для выводов.

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

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