Оценка и бенчмарки: MMLU, SWE-bench, HumanEval, LLM-as-judge, свои датасеты
Все предыдущие статьи трека учили что-то строить: промпты, структурированный вывод, RAG, агентов, мультиагентные системы. Эта статья — про единственный вопрос, который отличает инженерию от угадывания: как узнать, что стало лучше.
Проблема специфична для ИИ-систем. В обычном бэкенде у вас есть тест: функция либо возвращает 42, либо нет. В LLM-системе «правильный ответ» — это распределение приемлемых ответов, генератор недетерминирован, а изменение одного слова в системном промпте может улучшить один класс запросов и сломать другой, причём вы об этом не узнаете, пока не придёт жалоба. Без измерений разработка LLM-продукта вырождается в цикл «поменял промпт → прогнал три любимых примера → выкатил → через неделю откатил».
Дисклеймер, который стоит держать в голове всю статью: публичные бенчмарки почти не отвечают на ваш вопрос. Они отвечают на вопрос «какая модель в среднем умнее», а вам нужно «какая конфигурация лучше решает мою задачу на моих данных». Это разные вопросы, и корреляция между ними слабее, чем кажется.
Три уровня оценки
Полезно сразу разложить оценку по уровням — у каждого своя цена, своя скорость и свой класс решений, которые он имеет право принимать.
- Уровень 1 — публичные бенчмарки. Отвечают на вопрос «какие 2–3 модели вообще брать в шорт-лист». Стоят вам ноль (цифры уже опубликованы), обновляются раз в квартал, к вашей задаче относятся косвенно.
- Уровень 2 — собственный офлайн-набор. Отвечает на вопрос «этот промпт/этот RAG/эта модель лучше предыдущей на моих данных». Это ваш основной рабочий инструмент, он должен гоняться на каждом PR.
- Уровень 3 — продовая телеметрия. Отвечает на вопрос «стало ли реально лучше пользователю». Единственный уровень, где нет проблемы «мы оптимизировали не то».
Дальше по статье: сначала разбираем уровень 1 (и почему ему нельзя верить буквально), потом строим уровень 2 с нуля, потом добавляем LLM-судью и замыкаем контур на уровень 3.
Уровень 1: анатомия публичных бенчмарков
Что есть и что оно меряет
| Бенчмарк | Что измеряет | Формат | Метрика | Главное ограничение |
|---|---|---|---|---|
| MMLU | эрудиция, 57 предметов | 4 варианта ответа, ~14k вопросов | accuracy | насыщен (топ-модели 88–92 %), есть ошибки в разметке, сильно загрязнён |
| MMLU-Pro | то же, но сложнее | 10 вариантов, ~12k | accuracy | замена MMLU; чувствителен к формату промпта |
| GPQA Diamond | «гуглонепробиваемые» вопросы PhD-уровня | 4 варианта, 198 вопросов | accuracy | всего 198 примеров — ±7 п.п. шум |
| HumanEval | генерация кода по докстрингу | 164 задачи Python | pass@k | насыщен, слабые тесты, тотально в обучающих данных |
| HumanEval+ / MBPP+ | то же, но с усиленными тестами | 164 / 378 задач | pass@k | падение на 10–15 п.п. относительно оригинала — вот цена слабых тестов |
| BigCodeBench | код с реальными библиотеками | 1140 задач | pass@1 | ближе к жизни, но всё ещё «функция в вакууме» |
| LiveCodeBench | код, задачи с датами публикации | скользящее окно | pass@1 | устойчив к загрязнению — можно брать окно после релиза модели |
| SWE-bench Verified | починка реальных багов в репозиториях | 500 issue+PR из Python-проектов | % решённых | результат зависит от каркаса агента не меньше, чем от модели |
| Terminal-Bench | агент в терминале | задачи в контейнерах | % выполненных | молодой, метрики шумные |
| τ-bench | агент + инструменты + пользователь | диалоги в домене (retail/airline) | pass^k | измеряет надёжность: pass^8 показывает деградацию при повторах |
| BFCL | корректность вызова функций | множество категорий | accuracy | про синтаксис вызова, не про полезность |
| AIME / MATH | математика | численный ответ | accuracy | AIME — 15 задач в год, дисперсия огромная |
| MMMU | мультимодальность | изображение + вопрос | accuracy | смешивает восприятие и знания |
| Chatbot Arena | предпочтения людей | попарные дуэли | Elo / Bradley–Terry | измеряет приятность, а не корректность; смещён в сторону длины и форматирования |
Метрика pass@k и почему её постоянно врут
Для кодовых бенчмарков базовая метрика — pass@k: вероятность того, что хотя бы одно из $k$ независимых решений пройдёт тесты. Считать её как «сгенерировали $k$ раз, посмотрели» — оценка смещённая и шумная. В оригинальной статье про Codex используется несмещённый оценщик: генерируем $n \geq k$ решений, считаем число корректных $c$, и берём
$$\mathrm{pass@}k = \mathbb{E}_ {\text{задачи}} \left( 1 - \frac{\binom{n-c}{k}}{\binom{n}{k}} \right)$$
import numpy as np
def pass_at_k(n: int, c: int, k: int) -> float:
"""Несмещённая оценка pass@k: n сгенерированных решений, c корректных."""
if n - c < k: # корректных так много, что промах невозможен
return 1.0
# 1 - произведение (n-c-i)/(n-i) — численно устойчивее биномиальных коэффициентов
return 1.0 - np.prod(1.0 - k / np.arange(n - c + 1, n + 1))
# Модель решает задачу в 3 случаях из 10
print(pass_at_k(10, 3, 1)) # 0.300 — одна попытка
print(pass_at_k(10, 3, 5)) # 0.917 — пять попыток
Сложность: $O(n)$ по времени на задачу, память $O(1)$. Практический вывод из этих двух чисел: pass@5 = 91.7 % при pass@1 = 30 % — это не «модель почти всё умеет». Это «модель угадывает». Если в проде у вас нет верификатора, который отберёт правильное решение из пяти, релевантен только pass@1. Любой график с pass@10 или pass@100 без работающего верификатора — маркетинг.
Обратная сторона той же медали — pass^k из τ-bench: вероятность, что все $k$ прогонов успешны. Это метрика надёжности, и она падает катастрофически: модель с pass^1 = 60 % легко даёт pass^8 = 25 %. Для агента в проде именно pass^k — честная метрика, потому что пользователь не запускает задачу восемь раз.
Четыре причины, по которым публичные цифры вводят в заблуждение
1. Загрязнение обучающими данными (contamination). HumanEval вышел в 2021 году и с тех пор лежит на GitHub во всех возможных формах. Проверка простая — переформулируйте задачу или переименуйте переменные, и качество падает. Систематически это показали в «NPHardEval» и работах по декомпозиции контаминации; практический ответ — бенчмарки со скользящим окном по датам (LiveCodeBench) или приватные holdout-наборы.
2. Каркас важнее модели. SWE-bench — не тест модели, а тест системы «модель + агентный каркас + инструменты + бюджет шагов». Одна и та же модель в разных обвязках даёт разброс в 10–20 п.п. Показательно, что Agentless (arXiv:2407.01489) — три фиксированных этапа без всякой агентности — какое-то время обгонял сложные агентные фреймворки. Читая любую цифру SWE-bench, спрашивайте: чей каркас, сколько попыток, был ли доступ к тестам.
3. Чувствительность к формату. Порядок вариантов ответа, наличие пробела перед буквой, «Answer:» против «The answer is» — всё это двигает MMLU на единицы процентов. Разные харнессы (lm-evaluation-harness, HELM, собственный код лаборатории) считают по-разному, поэтому цифры из разных источников несравнимы. Сравнивать можно только внутри одного харнесса, на одном промпте, за один прогон.
4. Закон Гудхарта. Как только бенчмарк становится метрикой в пресс-релизах, он перестаёт быть измерением. Оптимизация под MMLU улучшает MMLU, а не полезность.
Как правильно пользоваться уровнем 1. Возьмите 2–4 бенчмарка, наиболее близких по форме к вашей задаче (агент с инструментами → τ-bench и BFCL; кодогенерация → LiveCodeBench и SWE-bench; извлечение фактов из документов → длинноконтекстные тесты), составьте шорт-лист моделей и на этом остановитесь. Дальше только свои данные.
Уровень 2: собственный датасет
Что такое хороший eval-набор
Это не «тестовая выборка» в смысле ML. Это набор пар «вход → критерий приёмки», где критерий может быть чем угодно проверяемым: точное совпадение, валидность по JSON-схеме, наличие цитаты из источника, прохождение unit-тестов, оценка судьи по рубрике.
Практическая структура — три подмножества, у каждого своя роль:
| Подмножество | Размер | Откуда берётся | Как используется |
|---|---|---|---|
| Golden set | 50–300 | вручную, вместе с продактом/доменным экспертом | основная метрика качества, гоняется на каждом PR |
| Регрессии | растёт вечно | каждый пойманный баг → новый пример | гейт: обязано быть 100 %, падение блокирует мерж |
| Тяжёлые случаи | 50–200 | из прода: жалобы, эскалации, отказы | диагностика; сюда смотрят, когда основная метрика встала |
Отдельно держите canary-набор из 10–20 примеров, который никогда не показываете модели при подборе промпта — иначе через двадцать итераций вы переобучите промпт под свой же eval.
Начните с пятидесяти примеров
Самая частая ошибка — «сначала соберём тысячу размеченных примеров, потом начнём мерить». Так никогда не начинают. Работающий порядок:
- 20 примеров за час. Возьмите реальные запросы (из логов, из бэклога, из головы продакта). Запишите вход и то, что считаете приемлемым ответом.
- Прогоните текущую систему. Половина примеров окажется провальной по причинам, которые вы не ожидали. Это уже ценность.
- Классифицируйте провалы. Получится 4–6 категорий: «выдумал факт», «проигнорировал ограничение по длине», «не нашёл документ», «сломал JSON». Это ваша таксономия ошибок — она важнее самой метрики.
- Достройте набор до 100–200, следя за балансом категорий и добавляя по 2–3 примера на каждую обнаруженную категорию отказа.
Формат хранения — обычный JSONL в репозитории, рядом с кодом, под ревью:
{"id": "ret-014", "category": "многошаговый факт", "input": "Какой тариф действовал для юрлиц в апреле 2024 и чем он отличался от мартовского?", "must_contain": ["4 900", "март"], "must_not_contain": ["не могу", "недостаточно информации"], "requires_citation": true, "difficulty": "hard"}
{"id": "ret-015", "category": "нет ответа в базе", "input": "Сколько сотрудников в компании?", "expected_behavior": "refuse", "must_contain": ["нет информации"], "requires_citation": false, "difficulty": "easy"}
Обратите внимание на второй пример: набор обязан содержать случаи, где правильный ответ — отказ. Без них вы оптимизируете систему в сторону галлюцинаций.
Каскад проверок: от дешёвых к дорогим
Не всё нужно отдавать судье-модели. Правильный порядок — от детерминированных проверок к вероятностным, потому что первые бесплатны, объяснимы и не дрейфуют.
цитата ⊆ источник, recall@k| E{Однозначно?} E -->|да| G[PASS / FAIL, стоимость 0] E -->|нет: открытый текст| H[LLM-судья по рубрике] H --> I{Уверенность судьи низкая
или расхождение оценок?} I -->|нет| J[Оценка засчитана] I -->|да| K[Очередь на человеческую разметку] K --> L[(Разметка пополняет
калибровочный набор судьи)] L -.-> H
Ключевой принцип: каждый пример, который можно проверить кодом, должен проверяться кодом. Если вы просите модель вернуть структурированный ответ (см. статью про структурированный вывод), то половина критериев качества превращается в обычные assert’ы.
from dataclasses import dataclass
@dataclass
class CheckResult:
passed: bool
reason: str
cost_usd: float = 0.0
def deterministic_checks(case: dict, output: str, sources: list[str]) -> CheckResult | None:
"""Возвращает результат, если ответ решается без LLM, иначе None."""
lowered = output.lower()
for phrase in case.get("must_not_contain", []):
if phrase.lower() in lowered:
return CheckResult(False, f"запрещённая фраза: {phrase!r}")
missing = [p for p in case.get("must_contain", []) if p.lower() not in lowered]
if missing:
return CheckResult(False, f"нет обязательных фрагментов: {missing}")
if case.get("requires_citation"):
# грубая, но рабочая проверка обоснованности: каждое предложение с числом
# должно иметь дословную опору в одном из источников
corpus = " ".join(sources).lower()
for sentence in output.split("."):
digits = "".join(ch for ch in sentence if ch.isdigit())
if len(digits) >= 3 and digits not in "".join(
ch for ch in corpus if ch.isdigit()
):
return CheckResult(False, f"число без опоры в источниках: {sentence[:60]!r}")
if case.get("expected_behavior") == "refuse":
return CheckResult(True, "корректный отказ")
return None # требуется семантическая оценка
Сложность здесь линейна по длине ответа и корпуса, $O(|out| + |src|)$, память $O(|src|)$ — на фоне сетевого вызова к модели это бесплатно.
LLM-as-judge: как не построить кривое зеркало
Когда критерий действительно семантический («ответ полезен», «тон соответствует бренду», «объяснение корректно»), приходится звать модель в роли судьи. Это работает — при определённых условиях. MT-Bench (arXiv:2306.05685) показал, что сильная модель-судья согласуется с людьми примерно на 80 %, что сопоставимо с согласием двух людей между собой. Но там же перечислены смещения, которые превращают судью в генератор случайных чисел, если их не лечить.
Четыре смещения и лечение
| Смещение | Проявление | Лечение |
|---|---|---|
| Позиционное | при попарном сравнении вариант A выигрывает чаще просто потому, что он первый | прогонять оба порядка, засчитывать победу только при совпадении; расхождение → «ничья» |
| Многословность | длинный ответ оценивается выше при равном содержании | явный пункт в рубрике «длина не является достоинством»; контроль корреляции оценки с длиной |
| Самопредпочтение | судья выше оценивает тексты, порождённые им самим | судья другого семейства, чем оцениваемая система, или хотя бы другой версии |
| Снисходительность | средняя оценка сползает к 4–5 из 5 | дискретная рубрика с описанием каждого уровня; штрафные критерии; попарный формат вместо баллов |
Есть и пятое, не из статьи, а из практики: дрейф. Судья на управляемой провайдером модели меняется при обновлении версии. Поэтому фиксируйте конкретный идентификатор модели судьи, а не алиас, и перепроверяйте калибровку при каждом обновлении.
Pointwise против pairwise
- Pointwise (оценить ответ по рубрике от 1 до 5) — дёшево, даёт абсолютную шкалу, но плывёт: та же система через месяц получает другие баллы. Годится для мониторинга трендов на большом объёме.
- Pairwise (какой из двух ответов лучше) — вдвое дороже, зато сильно надёжнее и прямо отвечает на вопрос «новая версия лучше старой?». Годится для гейта в CI.
Практическое правило: для решения о выкате всегда pairwise против текущего прода. Абсолютные баллы — только для дашбордов.
Реализация судьи
Судья должен возвращать структуру, а не прозу: иначе вы парсите текст регулярками и теряете половину результатов. Заодно требование обоснования до вердикта заметно повышает качество — это тот же приём, что и chain-of-thought из статьи про продвинутые техники, только с гарантией порядка полей.
import anthropic
from pydantic import BaseModel, Field
from typing import Literal
client = anthropic.Anthropic()
JUDGE_MODEL = "claude-opus-4-8" # фиксируем модель судьи и версионируем вместе с рубрикой
class Verdict(BaseModel):
# Порядок полей важен: модель заполняет их последовательно,
# поэтому обоснование пишется ДО вердикта и реально влияет на него.
factual_issues: list[str] = Field(description="Утверждения без опоры в источниках")
reasoning: str = Field(description="2-3 предложения сравнения по критериям рубрики")
winner: Literal["A", "B", "tie"]
confidence: Literal["low", "medium", "high"]
RUBRIC = """Ты — строгий оценщик ответов справочной системы. Сравни два ответа
по критериям в порядке убывания важности:
1. Фактическая корректность: каждое утверждение опирается на <sources>.
Одна выдуманная деталь перевешивает любые достоинства стиля.
2. Полнота: отвечает ли на весь вопрос, включая уточняющую часть.
3. Корректный отказ: если в источниках нет ответа, правильное поведение —
сказать об этом, а не строить догадки. Отказ при наличии ответа — ошибка.
4. Ясность.
Длина НЕ является достоинством. Более длинный ответ выигрывает, только если
дополнительный объём несёт информацию, отвечающую на вопрос.
Если ответы эквивалентны по существу — ставь "tie", не выдумывай различия."""
def judge_pair(question: str, sources: str, ans_a: str, ans_b: str) -> Verdict:
prompt = (
f"<question>{question}</question>\n"
f"<sources>{sources}</sources>\n"
f"<answer_A>{ans_a}</answer_A>\n"
f"<answer_B>{ans_b}</answer_B>"
)
response = client.messages.parse(
model=JUDGE_MODEL,
max_tokens=2000,
system=RUBRIC,
thinking={"type": "adaptive"},
output_config={"effort": "medium"},
messages=[{"role": "user", "content": prompt}],
output_format=Verdict,
)
return response.parsed_output
def judge_symmetric(question: str, sources: str, ans_a: str, ans_b: str) -> str:
"""Двусторонний прогон: победа засчитывается только при согласии обоих порядков."""
fwd = judge_pair(question, sources, ans_a, ans_b)
rev = judge_pair(question, sources, ans_b, ans_a) # A и B переставлены
flip = {"A": "B", "B": "A", "tie": "tie"}
rev_winner = flip[rev.winner]
return fwd.winner if fwd.winner == rev_winner else "tie"
Три технических замечания, которые экономят день отладки:
temperature=0больше не работает как рычаг детерминизма. На современных моделях Anthropic (Opus 4.7/4.8, Sonnet 5, Fable 5) параметры сэмплирования удалены и возвращают 400 — см. гайд по миграции. Стабильность вердиктов достигается жёсткой рубрикой, структурированным выводом и двусторонним прогоном, а не температурой.- Судья не должен видеть, какой вариант «новый». Любая утечка (порядок, имя модели в тексте, форматирование) немедленно эксплуатируется.
- Батчинг. Оценка — классическая офлайн-нагрузка: Batches API даёт 50 % скидки, а общая часть промпта (рубрика + источники) кэшируется. Про экономику этого — в статье про продакшен и стоимость.
Калибровка судьи: обязательный шаг, который все пропускают
Судья — это модель, а значит, у неё есть собственное качество, и его надо измерить. Иначе вы оптимизируете систему под мнение неизвестной точности.
Процедура: разметьте руками 50–100 примеров (или пар), прогоните судью на тех же примерах, посчитайте согласие. Сырая доля совпадений обманчива — если 80 % ответов хорошие, судья, всегда говорящий «хорошо», получит 80 %. Нужна поправка на случайное согласие — каппа Коэна:
$$\kappa = \frac{p_o - p_e}{1 - p_e}$$
где $p_o$ — наблюдаемое согласие, $p_e$ — ожидаемое при независимых оценках.
from sklearn.metrics import cohen_kappa_score, confusion_matrix
human = ["A", "B", "tie", "B", "A", "B", "B", "tie", "A", "B"]
judge = ["A", "B", "A", "B", "A", "B", "tie", "tie", "B", "B"]
kappa = cohen_kappa_score(human, judge)
print(f"kappa = {kappa:.2f}")
print(confusion_matrix(human, judge, labels=["A", "B", "tie"]))
Ориентиры интерпретации: $\kappa < 0.4$ — судья непригоден, переписывайте рубрику; $0.4 USD–$0.6 USD — годится для трендов, но не для гейта в CI; $> 0.6 USD — можно принимать решения, продолжая выборочно проверять. Матрица ошибок ценнее одного числа: она показывает систематический перекос («судья почти никогда не ставит tie» или «судья прощает выдуманные числа»), и его чинят правкой рубрики, а не сменой модели.
Ту же процедуру нужно повторять при каждом изменении рубрики и при каждом обновлении модели судьи. Рубрика — такой же версионируемый артефакт, как промпт.
Сколько нужно примеров, чтобы поверить результату
Здесь ломается больше проектов, чем на всём остальном вместе взятом. Типовая сцена: «поменяли промпт, на 40 примерах было 32 успеха, стало 35, +7.5 п.п., катим». На самом деле не произошло ничего.
Для доли успеха $\hat p$ на выборке из $n$ независимых примеров 95 % доверительный интервал в нормальном приближении:
$$\hat p \pm 1.96 \sqrt{\frac{\hat p (1 - \hat p)}{n}}$$
(на краях шкалы, при $\hat p$ около 0 или 1, приближение врёт — используйте интервал Уилсона или бутстрэп.)
Хорошая новость: вам не нужна тысяча примеров. Приведённая картинка — про два независимых эксперимента, а вы сравниваете два варианта на одних и тех же примерах. Это парная схема, и в ней важна не общая доля, а только примеры, где варианты разошлись. Критерий Макнемара:
from statsmodels.stats.contingency_tables import mcnemar
import numpy as np
# 200 примеров golden set, прогнали старый и новый промпт
both_ok = 150 # оба справились
old_only = 8 # старый справился, новый сломал (регрессии!)
new_only = 26 # новый справился, старый нет
both_fail = 16
table = np.array([[both_ok, old_only], [new_only, both_fail]])
result = mcnemar(table, exact=True)
print(f"старый: {(both_ok + old_only) / 200:.1%}") # 79.0%
print(f"новый: {(both_ok + new_only) / 200:.1%}") # 88.0%
print(f"p-value = {result.pvalue:.4f}") # 0.0034 — различие значимо
Существенно, что здесь видно то, чего не видно в агрегате: 8 регрессий. Новый промпт лучше в сумме, но сломал восемь ранее работавших случаев. Если среди них есть что-то критичное для бизнеса, «+9 п.п.» может оказаться неприемлемым размеºном. Всегда смотрите на таблицу расхождений, а не только на дельту.
Практические ориентиры:
| Задача | Минимум примеров | Комментарий |
|---|---|---|
| Заметить грубую поломку | 20–30 | смоук-тест, гоняется на каждом коммите |
| Парное сравнение промптов, разница ≥ 10 п.п. | 100–150 | McNemar, экономно |
| Парное сравнение, разница 3–5 п.п. | 300–600 | плюс контроль регрессий по категориям |
| Абсолютная оценка «мы на 85 % ± 2» | ~1000 | нужно редко, обычно для отчётности |
| Разбивка по 6 категориям | ×5–8 к базе | каждая категория — своя выборка со своим интервалом |
И отдельно: если генератор недетерминирован (а он такой), прогоняйте каждый пример 3–5 раз и фиксируйте не только среднее, но и разброс. Метрика «85 % в среднем, но от 78 % до 91 % между прогонами» — это совсем другой продукт, чем «85 % ± 1 %».
Оценка в CI: eval как обычный тест
Оценка приносит пользу только когда автоматизирована и блокирует мерж. Жизненный цикл eval-набора выглядит так:
построили таксономию ошибок Активный --> Активный: PR прогоняет набор,
сравнение с baseline по McNemar Активный --> Расширяется: провал в проде Расширяется --> Активный: пример + регрессионный тест Активный --> Насыщен: качество 98%+
несколько релизов подряд Насыщен --> Заморожен: переводим в регрессионный набор Заморожен --> Активный: собран новый набор
из более трудных случаев Активный --> Скомпрометирован: набор просочился
в подбор промптов Скомпрометирован --> Черновик: обновляем, canary в приоритете Заморожен --> [*]: домен закрыт
Два состояния здесь неочевидны и важны. «Насыщен» — когда набор перестал различать варианты (все дают 98 %), он больше не метрика, а регрессионная страховка; пора собирать новый, более трудный. «Скомпрометирован» — когда вы двадцать раз подряд правили промпт, глядя на провалы этого набора: он стал обучающей выборкой, и цифра на нём завышена. Именно от этого страхует отложенный canary.
Минимальный гейт в CI выглядит так:
# .github/workflows/eval.yml
name: eval
on: [pull_request]
jobs:
offline-eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements-eval.txt
# 1. Регрессии: жёсткий гейт, любое падение блокирует
- name: regression suite
run: python -m evals.run --suite regressions --min-pass-rate 1.0
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# 2. Golden set: сравнение с baseline текущего прода
- name: golden set vs baseline
run: |
python -m evals.run --suite golden --runs 3 \
--compare-to artifacts/baseline.json \
--max-regressions 2 \
--report eval-report.md
- uses: actions/upload-artifact@v4
with: { name: eval-report, path: eval-report.md }
- name: comment result
run: gh pr comment ${{ github.event.number }} --body-file eval-report.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Три правила, без которых это не работает:
--max-regressionsважнее средней метрики. Порог «не больше N ранее проходивших примеров сломалось» ловит то, что агрегат прячет.- Baseline — артефакт, а не число в README. Храните полный JSON с результатом по каждому примеру от текущей прод-версии; иначе не посчитать парный тест.
- Отчёт в PR, а не в логах. Комментарий с таблицей «стало лучше / стало хуже / примеры расхождений» — единственный формат, который реально читают на ревью.
Инструменты
| Инструмент | Ниша | Когда брать |
|---|---|---|
| promptfoo | декларативные YAML-тесты промптов, матрица «промпт × модель» | быстрый старт, сравнение провайдеров, CI |
| Inspect AI (UK AISI) | серьёзный фреймворк: solver’ы, скореры, агентные задачи | сложные многошаговые оценки, воспроизводимость |
| DeepEval | pytest-подобный API, встроенные метрики (в т.ч. RAG) | если хочется писать оценки как обычные тесты |
| Ragas | метрики RAG: faithfulness, context precision/recall | оценка ретривера отдельно от генератора |
| lm-evaluation-harness | академические бенчмарки, сотни задач | воспроизвести опубликованные цифры, локальные модели |
| OpenAI Evals | реестр eval’ов | исторический, для готовых определений задач |
| Braintrust / LangSmith / Langfuse | платформы: трейсы + датасеты + разметка + сравнение прогонов | когда над оценками работает команда, а не один человек |
Начинайте с promptfoo или простого скрипта на 150 строк — свой раннер на pytest часто честнее, потому что критерии приёмки у вас доменные. Переезжайте на платформу, когда появляется потребность в разметке несколькими людьми и истории прогонов.
Сколько стоит оценка
Считать надо заранее, иначе гейт в CI отключат через неделю после внедрения. Порядок величин для одного прогона:
- 200 примеров × 3 повтора = 600 генераций,
- средняя генерация: 4 000 входных токенов (промпт + контекст RAG) + 600 выходных,
- судья вызывается на 40 % примеров, двусторонне: 480 вызовов × (3 000 вход + 400 выход).
Итого ≈ 2.4M входных + 0.36M выходных токенов на систему и ≈ 1.4M + 0.19M на судью. При тарифе Opus-класса (5 USD / 25 USD за 1M) это порядка 30 USD за прогон, при использовании Batches API (−50 %) и кэширования общей части промпта (чтение кэша ≈ 0.1× цены входа) — 5 USD–10. Для набора на 200 примеров и десятка PR в неделю это абсолютно приемлемо; для 5 000 примеров на каждый коммит — нет.
Отсюда стандартная многоуровневая схема запуска:
| Триггер | Что гоняем | Стоимость | Время |
|---|---|---|---|
| каждый коммит | смоук: 20 примеров, только детерминированные проверки | ~0.3 USD | 40 с |
| PR | регрессии + golden set (200), судья на подмножестве | 5 USD–10 | 6–10 мин |
| ночью на main | полный набор ×5 повторов + разбивка по категориям | 50 USD–80 | 40 мин |
| перед релизом | всё вышеперечисленное + canary + ручной обзор 20 расхождений | 100 USD + час человека | полдня |
Уровень 3: онлайн-оценка
Офлайн-метрика всегда расходится с реальностью — распределение продовых запросов шире и грязнее любого набора. Поэтому финальное решение принимается на живом трафике.
Что мерить в проде:
- Прокси-метрики качества, доступные без разметки: доля ответов с отказом, доля срабатываний валидатора схемы, доля диалогов с переформулировкой запроса (сильный сигнал неудачи), доля эскалаций на человека, доля прерванных агентских сессий.
- Guardrail-метрики, которые не должны ухудшиться ни при каких обстоятельствах: p95 задержки, стоимость на запрос, доля срабатываний фильтров безопасности.
- Разметка сэмпла. 50–200 продовых диалогов в неделю, размеченных судьёй с выборочной человеческой проверкой. Плюс: всё, что судья отметил как плохое, — кандидаты в eval-набор.
- Явная обратная связь, если есть: 👍/👎. Помните про смещение — жалуются гораздо охотнее, чем хвалят, а нажимают вообще единицы процентов.
Как выкатывать: shadow-режим (новая версия отвечает параллельно, ответ не показывается, сравнивается офлайн) → 5 % трафика с автооткатом по guardrail → 50 % → 100 %. Для A/B на LLM-функциях помните, что дисперсия метрик высокая: чтобы поймать 2 % разницы в конверсии, нужны недели, а не дни.
И замыкание контура, ради которого всё строилось: каждый провал в проде обязан превратиться в пример в офлайн-наборе — с тем же входом, с зафиксированным ожидаемым поведением, в подмножество регрессий. Без этого шага вы будете чинить одну и ту же ошибку четвёртый раз.
Типичные ошибки
Оценка «на глазок» с тремя любимыми примерами. Дешёвый способ убедить себя в чём угодно. Три примера — это интервал шириной в половину шкалы.
Средняя метрика без разбивки по категориям. «85 %» может означать «95 % на простых и 40 % на многошаговых». Всегда считайте по категориям вашей таксономии отказов; на маленьких категориях — с интервалами.
Судья видит эталонный ответ там, где не должен. Если критерий — «согласуется ли с эталоном», это нормально. Если критерий — «полезен ли ответ», эталон в промпте превращает оценку в проверку текстового сходства, и вы штрафуете корректные ответы, сформулированные иначе.
Один и тот же набор используется и для подбора промпта, и для приёмки. Через двадцать итераций цифра завышена на 5–15 п.п. Держите отложенный canary.
Некалиброванный судья. Метрика, качество которой неизвестно, — это не метрика. Сто размеченных руками примеров окупаются в первый же месяц.
pass@k без верификатора. Если в проде одна попытка, релевантен pass@1. Всё остальное — про потенциал, а не про продукт.
Игнорирование дисперсии между прогонами. Один прогон на недетерминированной системе — это одна выборка из распределения, а не измерение.
Оценка только «счастливого пути». Набор без случаев «в базе нет ответа», без противоречивых источников, без попыток инъекции (см. статью про безопасность) систематически завышает оценку.
Набор, который никогда не обновляется. Насыщенный eval-набор перестаёт быть метрикой раньше, чем это становится заметно по цифрам.
Сравнение цифр из разных источников. MMLU от лаборатории A и MMLU от лаборатории B посчитаны разными харнессами. Сравнивать можно только свои прогоны в своей обвязке.
Чеклист
- Есть golden set 100+ примеров в репозитории, под ревью, с таксономией категорий.
- В наборе есть случаи, где правильный ответ — отказ.
- Есть отложенный canary, который не используется при подборе промпта.
- Каждый проверяемый кодом критерий проверяется кодом, а не судьёй.
- Судья откалиброван по человеческой разметке, $\kappa$ известна и записана.
- Судья прогоняется в обоих порядках; расхождение = ничья.
- Модель судьи и версия рубрики зафиксированы и версионируются вместе.
- Каждый пример прогоняется 3+ раза, разброс между прогонами публикуется.
- Сравнение вариантов — парное (McNemar), с явной таблицей регрессий.
- CI блокирует мерж по числу регрессий, а не только по средней метрике.
- Baseline хранится как артефакт с результатом по каждому примеру.
- Стоимость прогона посчитана; есть уровни смоук / PR / ночной / релизный.
- В проде считаются прокси-метрики качества и guardrail-метрики.
- Каждый продовый провал попадает в регрессионный набор.
Главный вопрос при ревью любой LLM-системы — не «какая у вас метрика», а «какое изменение эта метрика способна различить». Если ответ «мы не знаем», то и метрики нет.
Источники
- Hendrycks et al., «Measuring Massive Multitask Language Understanding» (MMLU), arXiv:2009.03300 — оригинал MMLU.
- Wang et al., «MMLU-Pro», arXiv:2406.01574 — усложнённая замена MMLU.
- Chen et al., «Evaluating Large Language Models Trained on Code» (HumanEval), arXiv:2107.03374 — HumanEval и несмещённая оценка pass@k.
- Liu et al., «Is Your Code Generated by ChatGPT Really Correct?» (EvalPlus), arXiv:2305.01210 — почему слабые тесты завышают оценку на 10–15 п.п.
- Jain et al., «LiveCodeBench», arXiv:2403.07974 — оценка со скользящим окном как ответ на загрязнение.
- Jimenez et al., «SWE-bench», arXiv:2310.06770 и SWE-bench Verified (OpenAI) — реальные баги и очищенное подмножество.
- Yao et al., «τ-bench», arXiv:2406.12045 — pass^k и измерение надёжности агентов.
- Rein et al., «GPQA», arXiv:2311.12022 — маленький, но трудный набор.
- Zheng et al., «Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena», arXiv:2306.05685 — согласие судьи с людьми и каталог смещений.
- Zheng et al., «Chatbot Arena», arXiv:2403.04132 — Elo на попарных предпочтениях и его свойства.
- Liang et al., «Holistic Evaluation of Language Models» (HELM), arXiv:2211.09110 — многомерная оценка вместо одного числа.
- Xia et al., «Agentless», arXiv:2407.01489 — почему каркас важнее агентности в цифрах SWE-bench.
- Anthropic, «Create strong empirical evaluations» — практическое руководство по построению своих наборов.
- Документация promptfoo, Inspect AI, Ragas — инструменты для CI и RAG-метрик.
- Hugging Face Evaluation Guidebook — подробный разбор харнессов, промпт-чувствительности и воспроизводимости.
Что дальше
Мы научились честно измерять качество — а значит, теперь можно осмысленно обсуждать компромиссы, где качество меняют на стоимость и приватность. Следующий шаг: Локальные модели: llama.cpp, Ollama, vLLM, LM Studio, квантизация, требования к железу.