Урок 9. Свой оценщик-модель со своими критериями
В прошлом уроке мы пользовались встроенными model-graded ассертами promptfoo. Они удобны, но сидят в чужой рамке: чужой промпт-судья, чужой формат оценки. Сейчас снимем эти ограничения и напишем оценщик целиком сами — на Python, со своей рубрикой, своим разбором ответа и своим порогом.
Задача для примера простая на словах и мерзкая на практике: превратить длинную техническую статью из Википедии в короткий пересказ, понятный школьнику. Скажем, из целой статьи про свёрточные нейросети должно получиться примерно такое:
Свёрточные нейросети, или CNN, — это особый вид компьютерных программ, которые учатся узнавать изображения и закономерности. Они работают немного как человеческий мозг: информацию обрабатывают слои искусственных «нейронов».
CNN отлично справляются с задачами вроде поиска объектов на фотографиях или распознавания лиц. Для этого они разбивают картинку на маленькие кусочки и ищут важные признаки — примерно как собирают пазл.
Особенность CNN в том, что находить эти признаки они учатся сами, просто разглядывая множество примеров. Поэтому со временем они узнают вещи всё лучше и иногда догоняют человека.
Учёные и инженеры применяют CNN в самых разных штуках: помогают беспилотным машинам видеть дорогу, ищут новые лекарства и даже учат компьютеры играть в шахматы и го.
Как это померить? Ни одна проверка на коде тут не поможет: «понятно школьнику» не выражается через len() и регулярку. Значит — оценка моделью. Мы напишем свой ассерт, который оценивает пересказ по трём метрикам:
- Краткость (1–5) — максимально ли сжат пересказ?
- Точность (1–5) — всё ли соответствует исходной статье?
- Тон (1–5) — подходит ли текст школьнику без технической подготовки?
Каждая метрика даёт балл от 1 до 5. Мы усредним три балла и будем требовать среднее не ниже 4,5 из 5.
Входные данные
В папке урока лежит директория articles с восемью txt-файлами — в каждом текст одной статьи из Википедии. Это входы нашей оценки. Загляните в пару файлов, чтобы почувствовать, насколько они длинные и заумные.
Три промпта на сравнение
В файле prompts.py лежат три функции, генерирующие промпт. Их-то мы и будем сравнивать между собой:
def basic_summarize(article):
return f"Summarize this article {article}"
def better_summarize(article):
return f"""
Summarize this article for a grade-school audience: {article}"""
def best_summarize(article):
return f"""
You are tasked with summarizing long wikipedia articles for a grade-school audience.
Write a short summary, keeping it as concise as possible.
The summary is intended for a non-technical, grade-school audience.
This is the article: {article}"""
Важная оговорка авторов курса: все три промпта — посредственные. Их специально держали короткими и не следовали лучшим практикам (например, не добавляли развёрнутых примеров), чтобы прогон оценки не сжёг гору токенов. Разница между ними видна, и этого достаточно.
Конфиг promptfoo
Файл promptfooconfig.yaml почти целиком состоит из знакомых полей:
description: 'Summarization Evaluation'
prompts:
- prompts.py:basic_summarize
- prompts.py:better_summarize
- prompts.py:best_summarize
providers:
- id: anthropic:messages:claude-sonnet-5
label: "Sonnet 5"
tests:
- vars:
article: file://articles/article1.txt
- vars:
article: file://articles/article2.txt
- vars:
article: file://articles/article3.txt
- vars:
article: file://articles/article4.txt
- vars:
article: file://articles/article5.txt
- vars:
article: file://articles/article6.txt
- vars:
article: file://articles/article7.txt
- vars:
article: file://articles/article8.txt
defaultTest:
assert:
- type: python
value: file://custom_llm_eval.py
Мы говорим promptfoo: возьми три промпта из prompts.py, гоняй их через Claude Sonnet 5 и прогони по восьми тестам, где меняется переменная article.
Новое здесь — подстановка значений из файла. Статьи слишком длинные, чтобы держать их прямо в YAML, поэтому строчка
tests:
- vars:
article: file://articles/article1.txt
означает: подставь в переменную article содержимое файла article1.txt. И так восемь раз.
Что делает article: file://articles/article1.txt в блоке vars?
Пишем свой грейдер
Теперь самое интересное — последнее поле конфига:
defaultTest:
assert:
- type: python
value: file://custom_llm_eval.py
Оно говорит: для каждого без исключения теста запусти питоновскую проверку из файла custom_llm_eval.py. Синтаксис мы уже видели, когда писали кастомный ассерт на коде. Разница одна, зато принципиальная: сейчас внутри этой функции мы сами сходим к модели и попросим её оценить ответ.
В файле custom_llm_eval.py живут две функции: llm_eval() — которая зовёт Claude-судью и считает балл, и get_assert() — точка входа, которую ищет promptfoo. Разберём их с конца, так понятнее.
Функция get_assert
def get_assert(output: str, context, threshold=4.5):
article = context['vars']['article']
score, evaluation = llm_eval(output, article)
return {
"pass": score >= threshold,
"score": score,
"reason": evaluation
}
Как мы помним из урока про кастомные проверки, promptfoo сам находит в файле ассерта функцию get_assert и передаёт ей два аргумента:
output— ответ модели на конкретном тесте;context— словарь с переменными и промптом, из которых этот ответ родился.
Вернуть функция может одно из трёх: булево значение (прошёл / не прошёл), число с плавающей точкой (балл) или словарь GradingResult. Мы выбрали словарь — он обязан содержать три поля:
pass— булево;score— число;reason— строка с объяснением.
Вот та же функция с комментариями по шагам:
def get_assert(output: str, context, threshold=4.5):
# достаём из контекста конкретную статью
article = context['vars']['article']
# отдаём ответ модели и статью в нашу функцию llm_eval;
# забираем балл и текстовое объяснение
score, evaluation = llm_eval(output, article)
# возвращаем словарь: прошёл ли тест, какой балл и почему
return {
"pass": score >= threshold,
"score": score,
"reason": evaluation
}
Обратите внимание на threshold=4.5. Порог — это отдельное решение продукта, а не свойство модели. Хотите строже — поднимите до 4,8 и приготовьтесь к красным ячейкам.
Какие три поля обязаны быть в словаре, который возвращает get_assert?
Функция llm_eval и промпт-судья
Вся настоящая работа — здесь. Функция делает четыре вещи:
- собирает длинный промпт-рубрику, который объясняет, как оценивать пересказ;
- отправляет этот промпт в Anthropic API;
- разбирает ответ и считает средний балл;
- возвращает средний балл и полный текст ответа судьи.
Код целиком:
import anthropic
import os
import json
def llm_eval(summary, article):
"""
Оценить пересказ с помощью LLM (Claude).
Аргументы:
summary (str) — пересказ, который оцениваем.
article (str) — исходный текст, который пересказывали.
Возвращает:
средний балл и полный текст ответа модели-судьи.
"""
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
prompt = f"""Evaluate the following summary based on these criteria:
1. Conciseness (1-5) - is the summary as concise as possible?
- Conciseness of 1: The summary is unnecessarily long, including excessive details, repetitions, or irrelevant information. It fails to distill the key points effectively.
- Conciseness of 3: The summary captures most key points but could be more focused. It may include some unnecessary details or slightly overexplain certain concepts.
- Conciseness of 5: The summary effectively condenses the main ideas into a brief, focused text. It includes all essential information without any superfluous details or explanations.
2. Accuracy (1-5) - is the summary completely accurate based on the initial article?
- Accuracy of 1: The summary contains significant errors, misrepresentations, or omissions that fundamentally alter the meaning or key points of the original article.
- Accuracy of 3: The summary captures some key points correctly but may have minor inaccuracies or omissions. The overall message is generally correct, but some details may be wrong.
- Accuracy of 5: The summary faithfully represents the main gist of the original article without any errors or misinterpretations. All included information is correct and aligns with the source material.
3. Tone (1-5) - is the summary appropriate for a grade school student with no technical training?
- Tone of 1: The summary uses language or concepts that are too complex, technical, or mature for a grade school audience. It may contain jargon, advanced terminology, or themes that are not suitable for young readers.
- Tone of 3: The summary mostly uses language suitable for grade school students but occasionally includes terms or concepts that may be challenging. Some explanations might be needed for full comprehension.
- Tone of 5: The summary consistently uses simple, clear language that is easily understandable by grade school students. It explains complex ideas in a way that is accessible and engaging for young readers.
4. Explanation - a general description of the way the summary is evaluated
<examples>
<example>
This summary:
<summary>
Artificial neural networks are computer systems inspired by how the human brain works. They are made up of interconnected "neurons" that process information. These networks can learn to do tasks by looking at lots of examples, similar to how humans learn.
Some key things about neural networks:
- They can recognize patterns and make predictions
- They improve with more data and practice
- They're used for things like identifying objects in images, translating languages, and playing games
Neural networks are a powerful tool in artificial intelligence and are behind many of the "smart" technologies we use today. While they can do amazing things, they still aren't as complex or capable as the human brain.
</summary>
Should receive a 5 for tone, a 5 for accuracy, and a 5 for conciseness
</example>
<example>
This summary:
<summary>
Here is a summary of the key points from the article on artificial neural networks (ANNs):
1. ANNs are computational models inspired by biological neural networks in animal brains. They consist of interconnected artificial neurons that process and transmit signals.
2. Basic structure:
- Input layer receives data
- Hidden layers process information
- Output layer produces results
- Neurons are connected by weighted edges
3. Learning process:
- ANNs learn by adjusting connection weights
- Use techniques like backpropagation to minimize errors
- Can perform supervised, unsupervised, and reinforcement learning
4. Key developments:
- Convolutional neural networks (CNNs) for image processing
- Recurrent neural networks (RNNs) for sequential data
- Deep learning with many hidden layers
5. Applications:
- Pattern recognition, classification, regression
- Computer vision, speech recognition, natural language processing
- Game playing, robotics, financial modeling
6. Advantages:
- Can model complex non-linear relationships
- Ability to learn and generalize from data
- Adaptable to many different types of problems
7. Challenges:
- Require large amounts of training data
- Can be computationally intensive
- "Black box" nature can make interpretability difficult
8. Recent advances:
- Improved hardware (GPUs) enabling deeper networks
- New architectures like transformers for language tasks
- Progress in areas like generative AI
The article provides a comprehensive overview of ANN concepts, history, types, applications, and ongoing research areas in this field of artificial intelligence and machine learning.
</summary>
Should receive a 1 for tone, a 5 for accuracy, and a 3 for conciseness
</example>
</examples>
Provide a score for each criterion in JSON format. Here is the format you should follow always:
<json>
{{
"conciseness": <number>,
"accuracy": <number>,
"tone": <number>,
"explanation": <string>,
}}
</json>
Original Text: <original_article>{article}</original_article>
Summary to Evaluate: <summary>{summary}</summary>
"""
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1000,
temperature=0,
messages=[
{
"role": "user",
"content": prompt
},
{
"role": "assistant",
"content": "<json>"
}
],
stop_sequences=["</json>"]
)
evaluation = json.loads(response.content[0].text)
# оставляем только числовые значения и считаем среднее
numeric_values = [value for key, value in evaluation.items() if isinstance(value, (int, float))]
avg_score = sum(numeric_values) / len(numeric_values)
# возвращаем средний балл и полный ответ модели
return avg_score, response.content[0].text
Что здесь стоит рассмотреть под лупой
Рубрика описывает не только критерии, но и уровни. Мало сказать «оцени краткость от 1 до 5» — судья тогда придумает шкалу сам, и она поплывёт от прогона к прогону. Поэтому под каждым критерием расписано, что значит 1, что значит 3 и что значит 5. Это те же правила, по которым инструктируют живого разметчика.
Примеры делают половину работы. Два образца в блоке <examples> задают судье калибровку: вот такой пересказ — 5/5/5, а вот такой аккуратный, но перегруженный список — точность 5, зато тон 1. Без примеров модель-судья почти всегда добрая.
Формат ответа зафиксирован жёстко. Нам нужен машиночитаемый JSON, а не рассуждения. Здесь работает связка из двух приёмов:
- в промпте показан точный шаблон JSON с полями
conciseness,accuracy,tone,explanation; - в запрос добавлено предзаполненное сообщение
assistantсо значением<json>— модель продолжает с этого места и не может начать ответ с «Конечно, вот моя оценка…»; stop_sequencesобрывает генерацию на закрывающем теге, так что вresponse.content[0].textприезжает чистое тело JSON, готовое дляjson.loads().
temperature=0. Судья должен быть скучным и повторяемым. Креативность здесь — это шум в измерительном приборе.
Средний балл считается по числовым полям. В словаре четыре ключа, но explanation — строка. Строка отфильтровывается через isinstance(value, (int, float)), и среднее берётся ровно по трём числам. Приятный побочный эффект: добавите четвёртый критерий — среднее пересчитается само.
Полный текст судьи уезжает в reason. Поэтому в интерфейсе promptfoo по каждой ячейке можно прочитать, за что именно снизили балл, — а не гадать над голым числом.
Зачем в запросе к судье нужна пара «предзаполненный ответ assistant + stop_sequences»?
Запуск и чтение результатов
Команда та же, что и раньше:
npx promptfoo@latest eval
Прогон займёт заметно больше времени, чем обычно: сначала модель генерирует пересказы, потом на каждый пересказ уходит ещё один запрос — к судье. Восемь статей × три промпта = 24 генерации и 24 оценки.
Дальше открываем веб-интерфейс:
npx promptfoo@latest view
Что смотреть в дашборде:
- Лупа в ячейке — разворачивает подробности конкретного результата. Именно там виден текст судьи из поля
reason. В примере авторов один из ответов провалил проверку из-за низкого балла за тон: пересказ точный, но написан для взрослого технаря. - Верхняя строка таблицы — сводные оценки по каждому промпту. Ожидаемо лидирует
best_summarize. - Графики распределения — показывают, что
best_summarizeне просто не заваливает тесты, а обходит два других промпта на всех восьми входах.
Вот это и есть цель всего курса: вместо «мне кажется, третий промпт получше» у вас на руках число, распределение и объяснение по каждому кейсу.
Упражнения
Упражнение 9.1 — Четвёртый критерий
Добавьте в рубрику четвёртый числовой критерий — вовлечённость (engagement, 1–5): интересно ли школьнику это читать. Опишите уровни 1, 3 и 5, добавьте поле в шаблон JSON. Что при этом придётся поменять в коде подсчёта среднего?
Решение упражненияСначала попробуйте сами — потом сверьтесь
В рубрику добавляется ещё один пронумерованный блок с описанием уровней:
4. Engagement (1-5) - would a grade school student actually enjoy reading this?
- Engagement of 1: Dry and textbook-like. No hooks, no concrete images, nothing a child would remember.
- Engagement of 3: Readable, with an occasional example or comparison, but mostly flat.
- Engagement of 5: Uses vivid comparisons and concrete images a child can picture. Reads like a good explanation from a favourite teacher.
И новое поле в шаблоне ответа:
<json>
{{
"conciseness": <number>,
"accuracy": <number>,
"tone": <number>,
"engagement": <number>,
"explanation": <string>,
}}
</json>
А в коде подсчёта — ничего. Строка
numeric_values = [value for key, value in evaluation.items() if isinstance(value, (int, float))]
берёт все числовые значения словаря, какими бы они ни были. Новый критерий попадёт в среднее автоматически. Единственное, о чём стоит подумать, — порог: критериев стало четыре, распределение среднего изменится, и 4,5 может оказаться уже не тем порогом, что раньше. Проверьте это прогоном, а не на глаз.
Упражнение 9.2 — Жёсткий вес точности
Простое среднее уравнивает критерии: провал по точности можно вытянуть красивым тоном. Перепишите подсчёт так, чтобы тест не проходил, если точность ниже 4, — даже при высоком среднем.
Решение упражненияСначала попробуйте сами — потом сверьтесь
Проще всего вернуть из llm_eval ещё и сам словарь оценок, а решение о прохождении принимать в get_assert:
evaluation = json.loads(response.content[0].text)
numeric_values = [value for key, value in evaluation.items() if isinstance(value, (int, float))]
avg_score = sum(numeric_values) / len(numeric_values)
return avg_score, response.content[0].text, evaluation
def get_assert(output: str, context, threshold=4.5, min_accuracy=4):
article = context['vars']['article']
score, explanation, evaluation = llm_eval(output, article)
accuracy_ok = evaluation.get("accuracy", 0) >= min_accuracy
return {
"pass": score >= threshold and accuracy_ok,
"score": score,
"reason": explanation if accuracy_ok
else f"Точность {evaluation.get('accuracy')} ниже порога {min_accuracy}. {explanation}"
}
Смысл приёма шире синтаксиса: у метрик разная цена ошибки. Скучный пересказ — неприятность, вравший пересказ — брак. Оценка должна отражать эту разницу, а простое среднее её стирает.
Что запомнить
- Кастомный model-graded ассерт — это обычная python-функция
get_assert(output, context), подключённая черезtype: pythonвdefaultTest. - Вернуть лучше словарь
GradingResult:pass,score,reason. Вreasonкладите полный текст судьи — иначе потом не разберётесь, за что снизили балл. - Рубрика — сердце оценки. Описывайте не только критерии, но и уровни шкалы, и подкрепляйте их примерами: без калибровки судья добрый и непостоянный.
- Формат ответа судьи фиксируется тремя вещами: шаблон JSON в промпте, предзаполненный
assistantс открывающим тегом иstop_sequencesна закрывающем. temperature=0у судьи обязательна: измерительный прибор не должен фантазировать.- Длинные входные данные держите в файлах и подключайте через
file://, а не вставляйте в YAML. - Порог прохождения и способ агрегации баллов — это ваше продуктовое решение, а не свойство модели.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.
Курс пройден 🎉
Это был последний урок. Оглянитесь: вы начинали с «промпт вроде работает» — и дошли до системы, где качество промпта измеряется числом, а изменения сравниваются друг с другом на десятках кейсов. Тестовые наборы, грейдеры на коде, оценка моделью, promptfoo и собственный судья с рубрикой — весь набор у вас на руках.
Самое полезное, что можно сделать дальше, — не читать ещё один курс, а собрать маленькую оценку под свою реальную задачу. Десять честных тест-кейсов из вашей работы дадут больше, чем сто учебных.
И расскажите, что у вас получилось, — я читаю ответы в канале.