Урок 3. Проверка кодом: пишем свой грейдер
Самая простая и самая надёжная оценка — та, где правильность ответа можно проверить кодом. Валидный ли это JSON, есть ли нужное поле, совпал ли ответ с эталоном. Никаких споров, никакого «ну вроде похоже»: функция вернула True или False.
В этом уроке мы пройдём весь цикл целиком на нарочно простой задаче, чтобы внимание было на процессе, а не на предметной области. Шаги такие:
- Собрать набор тест-кейсов с золотыми ответами.
- Написать первый вариант промпта.
- Прогнать промпт по всему набору и получить балл.
- Поправить промпт по результатам.
- Прогнать заново — и увидеть, стало ли лучше.
.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"},
]
Обратите внимание: часть кейсов нарочно с подвохом — вроде истории про лиса, который потерял ногу, отрастил её обратно и получил ещё одну сверху. Такие кейсы и покажут разницу между версиями промпта.
Что такое 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». Что вернёт грейдер на точное совпадение?
Вторая попытка: чиним формат
Начинаем с более простой проблемы — лишнего текста вокруг числа. Добавляем в промпт одну строку про формат:
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.
Третья попытка: даём модели подумать
Против логических ошибок работает 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%. Главное здесь не сама цифра, а то, что у нас появилось основание утверждать: правки промпта действительно улучшили ответы, а не просто «стало приятнее читать».
Зачем просить модель класть финальный ответ в теги <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 кейсов проценты шумные: маленький тест-сет — повод для осторожности, а не для выводов.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.