Evals
Evals — это тесты для недетерминированной системы. Без них любое изменение промпта, модели или retrieval — гадание по трём примерам в чате. Разберём, как начать с error analysis и собрать golden dataset, когда хватает regex, как откалибровать LLM-as-a-judge и поправить его pass rate, как bootstrap отвечает на вопрос «82% или 85%?», как оценивать агентов по траекториям, как устроить A/B-тест LLM-фичи и как встроить всё это в CI и мониторинг прода.
Зачем evals
В обычном коде f(x) детерминирована, и unit-тест с одним assert доказывает поведение. В LLM-системе выход вероятностный, пространство входов открыто, а «правильный ответ» часто не единственный. Отсюда три следствия:
- Один пример ничего не доказывает. Качество — это доля успехов на распределении входов, то есть статистика, а не булево значение.
- Любое изменение может сломать что-то в другом месте. Поправили промпт под жалобу клиента — упала точность на другой категории. Без регрессионного набора вы этого не увидите до инцидента.
- Модели меняются под вами. Новая версия модели, миграция на другого провайдера, изменение retrieval — eval-набор даёт ответ «можно ли переключаться» за час, а не за неделю жалоб.
Практический тезис, который стоит произносить на собеседовании: evals — это главный артефакт AI-продукта, важнее промпта. Промпт можно переписать за вечер, модель поменять одной строкой, а хороший eval-набор с откалиброванными проверками копится месяцами и позволяет делать и то, и другое безопасно.
want иногда заменён функцией-предикатом, а итоговый вердикт — не «все зелёные», а «pass rate ≥ порога и нет регрессии относительно main». Прогон на реальной модели — это интеграционный тест: дорогой, медленный, под build tag или t.Skip без ключа.@ParameterizedTest с @MethodSource, где ожидаемое значение иногда заменено предикатом, а итоговый вердикт — не «все зелёные», а «pass rate ≥ порога и нет регрессии относительно main». Прогон на реальной модели — это интеграционный тест: дорогой, медленный, под @Tag и отдельным профилем Maven (или в Failsafe), с @EnabledIfEnvironmentVariable без ключа.Виды evals
| Вид | Что проверяет | Когда запускать |
|---|---|---|
| Unit-like кейсы | Отдельную способность компонента: роутер выбирает категорию, экстрактор достаёт поле, SQL-генератор пишет валидный запрос | На каждый PR, быстро и дёшево |
| End-to-end | Всю систему: вопрос пользователя → retrieval → генерация → ответ; для агентов — траекторию и итоговое состояние среды | На PR в критичные части, nightly |
| Offline | Фиксированный датасет до выкатки | CI, эксперименты с промптами и моделями |
| Online | Реальный трафик после выкатки: семплы + judge, неявный фидбек, A/B | Постоянно, с алертами |
| Adversarial / red team | Prompt injection, jailbreak, утечки PII, токсичность | Перед релизом, после изменений guardrails |
Для RAG полезно разделять оценку retrieval (recall@k, MRR — модуль 5) и генерации (faithfulness: ответ опирается на найденное; relevance: отвечает на вопрос). Так понятно, где чинить. Для агентов — оценивать и итог (задача решена? состояние БД правильное?), и процесс (число шагов, стоимость, не было ли опасных действий).
Golden dataset
Откуда брать кейсы
- Реальные логи (обезличенные) — лучший источник: реальное распределение формулировок, опечаток, языков.
- Инциденты и жалобы — каждый баг из прода становится регрессионным кейсом. Как в обычной разработке: сначала тест, потом фикс.
- Эксперты предметной области пишут трудные и граничные случаи, которых в логах мало.
- Синтетика от LLM — для холодного старта и расширения покрытия: генерировать вариации по измерениям (тип вопроса × персона × сложность), а не «придумай 100 вопросов» (получите 100 похожих). Синтетику обязательно просматривает человек.
Размер и состав
- 50–200 кейсов для старта. Меньше 30 — один флаки-кейс сдвигает метрику на 3%+; больше 200 на старте никто не разметит вдумчиво. Растите по мере появления багов.
- Стратификация: у каждого кейса тег категории (тип запроса, язык, сложность, сегмент клиента). Pass rate считаем и в целом, и по срезам — общая цифра прячет провал на малой категории.
- Edge cases: пустой ввод, очень длинный, другой язык, вопрос вне домена («не знаю» — правильный ответ), неоднозначный, с противоречием в контексте.
- Adversarial: инъекции («игнорируй инструкции…»), попытки вытащить system prompt, PII, провокации. Для них порог часто 100%.
- Негативные кейсы: где правильное поведение — отказаться, переспросить, эскалировать на человека.
Гигиена
- Версионирование: датасет в git (JSONL) или в eval-платформе с версиями; в отчёте прогона — версия датасета, промпта, модели. Сравнивать можно только прогоны на одной версии набора. Инструменты версионирования данных (DVC, lakeFS, git-lfs, HF datasets) — модуль 11.
- Утечка в промпт: если вы добавляете кейсы из eval-набора как few-shot примеры, метрика перестаёт измерять обобщение. Делите: dev-набор (на нём итерируете и смотрите ошибки) и held-out (смотрите редко, только цифры). То же самое для fine-tuning — кейсы не должны попадать в обучающие данные.
- Насыщение: набор, на котором 99% проходят, не ловит улучшений. Регулярно добавляйте более трудные кейсы.
- Ожидаемый ответ устаревает: цены, политики, документация меняются — кейсы с фактами нужно пересматривать.
{"id": "refund-017", "tags": ["refund", "ru", "hard"], "input": "Оплатил дважды, верните одно списание", "checks": ["not_contains", "judge"], "forbidden": ["гарантируем возврат"], "criterion": "Бот не обещает возврат сам, а объясняет процесс и предлагает оформить заявку", "reference": "Возврат оформляется через форму…"}
{"id": "extract-042", "tags": ["extract", "en"], "input": "Invoice #A-993 due 2026-10-01, total $1,250.00", "checks": ["json_schema", "exact_fields"], "expected": {"invoice_id": "A-993", "due": "2026-10-01", "total_cents": 125000}}
Синтетика по измерениям: процедура
Просьба «придумай 100 вопросов клиентов» даёт 100 вариаций одного и того же вежливого вопроса средней сложности. Рабочая процедура (её подробно описывают Hamel Husain и Shreya Shankar):
- Измерения. Выпишите оси, по которым реально различаются запросы и на которых система ошибается по-разному: тип задачи (возврат, доставка, смена тарифа), персона (новичок, раздражённый клиент, юрист компании), сложность (один факт, несколько фактов, ответа в базе нет), язык и канал. Оси берите из error analysis, а не из головы.
- Кортежи руками. Составьте около 20 кортежей — по одному значению из каждого измерения:
(возврат, раздражённый, ответа нет, ru). Если кортеж трудно превратить в правдоподобный запрос — измерения выбраны плохо. - Кортежи моделью. Попросите LLM сгенерировать ещё кортежи (или возьмите декартово произведение и отфильтруйте бессмысленные комбинации — так проще, когда почти все сочетания валидны).
- Формулировки — отдельным вызовом. Каждый кортеж превращается в текст запроса во втором промпте. Разделение «что спросить» и «как сформулировать» убирает однообразие: одна модель в одном вызове делает и то и другое одинаково.
- Фильтр человеком. Выбросьте неправдоподобное, прогоните систему, разметьте ответы — дальше как с реальными трассами. В отчёте держите синтетику отдельным тегом: если на ней pass rate сильно выше, чем на логах, синтетика слишком «чистая».
Контаминация
- Публичные бенчмарки — не ваш golden-набор. Они с высокой вероятностью есть в обучающих данных моделей, и высокий балл на них говорит о памяти модели, а не о вашей задаче. Golden-набор — приватный, из ваших данных.
- Не публикуйте набор. Открытый репозиторий или пост в блоге могут попасть в будущие обучающие корпуса. Если публикуете — добавьте canary-строку (приём BIG-bench: уникальный маркер, по которому набор фильтруют из корпусов и по которому можно проверить, «помнит» ли модель ваши кейсы).
- Почти-дубликаты между dev и held-out. Перефразы одного запроса в обоих сплитах завышают held-out: вы итерировали промпт на «том же» кейсе. Дедуплицируйте по эмбеддингам или MinHash до разбиения (техники — в модуле 11).
- Временной сплит. Для кейсов из логов held-out берите из более позднего периода, чем dev, — так набор ближе к тому, что придёт завтра.
Точные проверки и эвристики
Правило: используйте самую дешёвую проверку, которая надёжно ловит дефект. Детерминированная проверка бесплатна, мгновенна и не шумит. LLM-judge — когда без понимания смысла не обойтись.
| Проверка | Для чего | Подводные камни |
|---|---|---|
| Exact match | Классификация, извлечение полей, ответы из enum | Нормализуйте: регистр, пробелы, формат дат и чисел |
| Regex / contains / not_contains | Обязательные элементы (ссылка, дисклеймер), запрещённые фразы, формат | Хрупко к перефразированию; хорошо для «запрещено», хуже для «обязательно» |
| JSON-схема | Structured output, аргументы инструментов | При strict/structured outputs формат гарантирован — проверяйте семантику полей |
| Вызван нужный инструмент | Агенты и tool use: имя и аргументы tool_use | Несколько правильных траекторий — проверяйте итоговое состояние, а не точную последовательность |
| Выполнение SQL | Text-to-SQL: выполнить сгенерированный и эталонный запрос на тестовой БД и сравнить результаты | Сравнивайте множества строк (порядок, алиасы колонок); read-only реплика; таймауты |
| Выполнение кода / тесты | Кодогенерация | Sandbox обязателен; pass@k |
| Метрики схожести (BLEU, ROUGE, cosine embedding) | Грубый сигнал для перевода и суммаризации | Плохо коррелируют с человеческой оценкой; для принятия решений — только как вспомогательные |
| Операционные | Латентность, токены, стоимость, число шагов агента | Это тоже часть eval-отчёта: «качество +2% при стоимости ×3» — другое решение |
LLM-as-a-judge
Когда критерий смысловой («ответ опирается на контекст», «тон вежливый», «не обещает того, чего нет в политике»), проверку делает другая LLM по рубрике. Это масштабируемо, но judge — сам по себе модель со своими ошибками, и его нужно валидировать так же, как классификатор.
Как писать рубрику
- Один критерий — один judge-вызов (или один вердикт в структурированном ответе). «Оцени качество ответа» — бесполезно; «Содержит ли ответ утверждения, которых нет в контексте?» — работает.
- Бинарные вердикты лучше шкал 1–10. Разница между 6 и 7 не определена ни для модели, ни для человека; калибровать шкалу почти невозможно, распределение сбивается к «7–8». PASS/FAIL с чётким определением воспроизводимее, агрегируется в понятный pass rate и легко сверяется с разметкой. Нужна градация — несколько бинарных критериев.
- Сначала обоснование, потом вердикт (поле
reasoningпередverdictв JSON-схеме) — вердикт точнее, а обоснование помогает разбирать ошибки judge. - Дайте эталон или контекст, если он есть: judge сравнивает с reference лучше, чем оценивает «с нуля».
- Примеры пограничных PASS и FAIL из вашей разметки в промпте judge — самый действенный способ калибровки.
Pointwise vs pairwise
Pointwise — оценка одного ответа по рубрике (абсолютная метрика, годится для CI-порогов). Pairwise — «какой из двух ответов лучше» (надёжнее для сравнения двух версий промпта/модели, потому что относительное суждение проще абсолютного; даёт win rate). Pairwise требует борьбы с position bias.
Калибровка против человеческой разметки
Процедура: 100–200 кейсов размечает человек (эксперт домена) → прогоняете judge → считаете согласие. Метрики:
- Agreement (доля совпадений) — обманчива при несбалансированных классах: если 90% ответов хорошие, judge «всегда PASS» даёт 90% agreement.
- Cohen's kappa κ = (po − pe) / (1 − pe) — согласие сверх случайного. Ориентиры: < 0,4 слабо, 0,6–0,8 хорошо, > 0,8 отлично. Сравнивайте с kappa между двумя людьми — это потолок.
- Precision/recall по классу FAIL (или TPR/TNR): judge, пропускающий 40% реальных провалов, в CI бесполезен, даже если agreement высокий.
Калибровка judge: матрица ошибок против человека
200 синтетических кейсов: человек разметил PASS/FAIL, judge выдал уверенность 0–1. Двигайте порог и «качество judge»: смотрите, как меняются agreement, kappa, precision/recall и — главное — оценка pass rate, которую judge покажет в отчёте.
Поставьте долю PASS 0,9 и качество 0: agreement останется высоким при kappa ≈ 0 — judge ничего не понимает, просто угадывает частый класс.
Процедура калибровки
- Разметка. 100–200 кейсов на один критерий, человек ставит PASS/FAIL с комментарием. FAIL редки — добирайте их целенаправленно из error analysis: в dev и test нужно хотя бы по 30–50 примеров каждого класса, иначе TNR меряется по десятку кейсов.
- Сплит. 10–20% — train (из них берутся примеры пограничных случаев в промпт judge), 40–45% — dev (на нём итерируете рубрику), 40–45% — test (смотрите один раз, в конце). Это та же гигиена, что dev/held-out для промпта приложения: judge, подогнанный под свой тест, покажет на нём красивую κ.
- Итерации на dev. Разбирайте отдельно FP (judge пропустил дефект) и FN (забраковал хороший ответ), уточняйте определения и примеры. Цель — TPR и TNR, а не одна κ: для CI-гейта важнее, чтобы judge ловил дефекты (TNR при положительном классе PASS).
- Финал на test. TPR, TNR, κ и их интервалы; версия judge (модель, промпт, рубрика) пишется в каждый отчёт прогона.
- Поправка на прогоне. Наблюдаемую judge долю PASS пересчитывайте в оценку истинной (ниже) и показывайте с интервалом.
- Перекалибровка при смене модели judge, рубрики или продукта, плюс выборочная проверка 30–50 прод-кейсов раз в месяц: распределение входов дрейфует, и TPR/TNR вместе с ним.
Поправка pass rate по TPR/TNR (Rogan–Gladen)
Judge ошибается в обе стороны, поэтому доля PASS в его отчёте смещена. Если θ — истинная доля хороших ответов, то judge покажет pobs = TPR·θ + (1 − TNR)·(1 − θ). Отсюда оценка, известная в эпидемиологии как поправка Rogan–Gladen (1978, оценка распространённости по неточному тесту):
θ = (pobs + TNR − 1) / (TPR + TNR − 1)
Пример: истинно хороших 70%, «лояльный» judge с TPR = 0,95 и TNR = 0,60 покажет 0,95·0,7 + 0,4·0,3 = 78,5%. Поправка возвращает 70%. Ограничения: формула работает, только если TPR и TNR, измеренные на калибровочном наборе, переносятся на прогон (тот же продукт, похожее распределение); при TPR + TNR ≤ 1 judge не лучше монетки и поправка не определена; результат обрезается до [0; 1]. Главное — TPR/TNR на 100–200 кейсах сами шумят, и этот шум надо внести в интервал: bootstrap ресемплирует и вердикты прогона, и калибровочные пары. Так же устроена Python-библиотека judgy Shreya Shankar.
// Confusion — сверка вердиктов judge с человеческой разметкой.
// Положительный класс — PASS (как в схеме калибровки модуля).
type Confusion struct{ TP, FN, FP, TN int }
func Confuse(human, judge []bool) Confusion {
var c Confusion
for i, h := range human {
switch j := judge[i]; {
case h && j:
c.TP++
case h:
c.FN++ // хороший ответ, judge забраковал: ложная тревога
case j:
c.FP++ // плохой ответ, judge пропустил: дефект уйдёт в прод
default:
c.TN++
}
}
return c
}
// TPR — доля хороших (по людям) ответов, которые judge признал PASS.
func (c Confusion) TPR() float64 { return float64(c.TP) / float64(c.TP+c.FN) }
// TNR — доля плохих ответов, которые judge поймал. Для CI-гейта это главная цифра.
func (c Confusion) TNR() float64 { return float64(c.TN) / float64(c.TN+c.FP) }
// Kappa — Cohen's κ = (po − pe) / (1 − pe): согласие сверх случайного.
func (c Confusion) Kappa() float64 {
n := float64(c.TP + c.FN + c.FP + c.TN)
po := float64(c.TP+c.TN) / n
humanPass, judgePass := float64(c.TP+c.FN)/n, float64(c.TP+c.FP)/n
pe := humanPass*judgePass + (1-humanPass)*(1-judgePass)
if pe == 1 {
return math.NaN() // оба ставят одну метку всем кейсам: κ не определена
}
return (po - pe) / (1 - pe)
}
var ErrUselessJudge = errors.New("evals: judge is no better than chance (TPR+TNR <= 1)")
// CorrectedPassRate — поправка Rogan–Gladen (1978): истинная доля PASS по доле PASS,
// которую показал judge, и его TPR/TNR на размеченном тестовом наборе.
// observed = TPR·θ + (1−TNR)·(1−θ) ⇒ θ = (observed + TNR − 1) / (TPR + TNR − 1).
func CorrectedPassRate(observed, tpr, tnr float64) (float64, error) {
den := tpr + tnr - 1
if !(den > 0) { // ловит и NaN, когда в выборке нет одного из классов
return 0, ErrUselessJudge
}
return min(1, max(0, (observed+tnr-1)/den)), nil
}
// CorrectedPassRateCI — интервал для скорректированной доли. Ресемплируются и
// вердикты judge на прогоне, и калибровочные пары: TPR/TNR, измеренные на 100–200
// кейсах, сами шумят, и этот шум часто больше шума прогона.
func CorrectedPassRateCI(verdicts, human, judge []bool, b int, seed uint64) (Interval, error) {
c := Confuse(human, judge)
est, err := CorrectedPassRate(passRate(verdicts), c.TPR(), c.TNR())
if err != nil {
return Interval{}, err
}
r := rand.New(rand.NewPCG(seed, 0))
v := make([]bool, len(verdicts))
h, j := make([]bool, len(human)), make([]bool, len(human))
stats := make([]float64, 0, b)
for range b {
for i := range v {
v[i] = verdicts[r.IntN(len(verdicts))]
}
for i := range h {
k := r.IntN(len(human))
h[i], j[i] = human[k], judge[k]
}
cb := Confuse(h, j)
if x, err := CorrectedPassRate(passRate(v), cb.TPR(), cb.TNR()); err == nil {
stats = append(stats, x)
}
}
if len(stats) == 0 {
return Interval{}, ErrUselessJudge
}
slices.Sort(stats)
q := func(p float64) float64 { return stats[int(p*float64(len(stats)-1))] }
return Interval{Est: est, Lo: q(0.025), Hi: q(0.975)}, nil
}
// Confusion — сверка вердиктов judge с человеческой разметкой.
// Положительный класс — PASS (как в схеме калибровки модуля).
public record Confusion(int tp, int fn, int fp, int tn) {
public static Confusion of(boolean[] human, boolean[] judge) {
int tp = 0, fn = 0, fp = 0, tn = 0;
for (int i = 0; i < human.length; i++) {
boolean h = human[i], j = judge[i];
if (h && j) tp++;
else if (h) fn++; // хороший ответ, judge забраковал: ложная тревога
else if (j) fp++; // плохой ответ, judge пропустил: дефект уйдёт в прод
else tn++;
}
return new Confusion(tp, fn, fp, tn);
}
// tpr — доля хороших (по людям) ответов, которые judge признал PASS.
public double tpr() { return (double) tp / (tp + fn); }
// tnr — доля плохих ответов, которые judge поймал. Для CI-гейта это главная цифра.
public double tnr() { return (double) tn / (tn + fp); }
// kappa — Cohen's κ = (po − pe) / (1 − pe): согласие сверх случайного.
public double kappa() {
double n = tp + fn + fp + tn;
double po = (tp + tn) / n;
double humanPass = (tp + fn) / n, judgePass = (tp + fp) / n;
double pe = humanPass * judgePass + (1 - humanPass) * (1 - judgePass);
if (pe == 1) {
return Double.NaN; // оба ставят одну метку всем кейсам: κ не определена
}
return (po - pe) / (1 - pe);
}
}
public static final class UselessJudgeException extends Exception {
UselessJudgeException() { super("judge is no better than chance (TPR+TNR <= 1)"); }
}
// correctedPassRate — поправка Rogan–Gladen (1978): истинная доля PASS по доле PASS,
// которую показал judge, и его TPR/TNR на размеченном тестовом наборе.
// observed = TPR·θ + (1−TNR)·(1−θ) ⇒ θ = (observed + TNR − 1) / (TPR + TNR − 1).
public static double correctedPassRate(double observed, double tpr, double tnr) throws UselessJudgeException {
double den = tpr + tnr - 1;
if (!(den > 0)) { // ловит и NaN, когда в выборке нет одного из классов
throw new UselessJudgeException();
}
return Math.clamp((observed + tnr - 1) / den, 0.0, 1.0);
}
// correctedPassRateCi — интервал для скорректированной доли. Ресемплируются и
// вердикты judge на прогоне, и калибровочные пары: TPR/TNR, измеренные на 100–200
// кейсах, сами шумят, и этот шум часто больше шума прогона.
public static Interval correctedPassRateCi(boolean[] verdicts, boolean[] human, boolean[] judge, int b, long seed)
throws UselessJudgeException {
var c = Confusion.of(human, judge);
double est = correctedPassRate(passRate(verdicts), c.tpr(), c.tnr());
var r = new SplittableRandom(seed);
boolean[] v = new boolean[verdicts.length], h = new boolean[human.length], j = new boolean[human.length];
double[] stats = new double[b];
int n = 0;
for (int it = 0; it < b; it++) {
for (int i = 0; i < v.length; i++) {
v[i] = verdicts[r.nextInt(verdicts.length)];
}
for (int i = 0; i < h.length; i++) {
int k = r.nextInt(human.length);
h[i] = human[k];
j[i] = judge[k];
}
var cb = Confusion.of(h, j);
try {
stats[n] = correctedPassRate(passRate(v), cb.tpr(), cb.tnr());
n++;
} catch (UselessJudgeException e) {
// ресемпл без одного из классов: пропускаем
}
}
if (n == 0) {
throw new UselessJudgeException();
}
stats = Arrays.copyOf(stats, n);
Arrays.sort(stats);
return new Interval(est, quantile(stats, 0.025), quantile(stats, 0.975));
}
Полная версия с тестами — examples/m08-evals/judge.goexamples/java/m08-evals/…/Judge.java (тип Interval и passRate — там жеpassRate — там же, record Interval и quantile — в Stats.java). В тесте judge с TPR 0,9 и TNR 0,8, откалиброванный на 100 кейсах, на прогоне из 500 ответов даёт оценку 70% с интервалом примерно [58%; 81%], хотя у самих 500 вердиктов полуширина около 4 п.п.: неопределённость калибровки доминирует. Хотите уже интервал — размечайте больше, особенно FAIL. В Python κ считает sklearn.metrics.cohen_kappa_score, но TPR/TNR и поправку всё равно придётся написать — это десять строк.
Смещения judge
- Position bias — в pairwise judge предпочитает ответ на определённой позиции (часто первый). Лечение: оценивать в обоих порядках; несовпадение вердиктов = ничья или «не уверен»; рандомизировать порядок.
- Verbosity bias — длинный ответ кажется лучше. Лечение: явно в рубрике «длина не является достоинством», сравнение при контроле длины, отдельный критерий краткости.
- Self-preference — модель выше оценивает тексты своей модели/семейства (ниже перплексия для «своего» стиля). Лечение: judge другой моделью или другим семейством, чем тестируемая; хотя бы — калибровка на человеческой разметке.
- Авторитетность и уверенный тон, ложные ссылки, «похоже на правильный формат» — judge ведётся на форму. Давайте reference и проверяйте факты детерминированно, где можно.
Position bias: меняем порядок — меняется вердикт
Симуляция pairwise-judge с заданным смещением к первой позиции. Нажмите «Спросить judge» несколько раз, поменяйте порядок, затем прогоните 200 сравнений в обоих порядках.
Человеческая разметка и error analysis
Сначала смотрите на данные
Самая частая ошибка — начинать с выбора метрик и фреймворка. Правильный порядок (его настойчиво продвигают практики вроде Hamel Husain и Shreya Shankar):
- Соберите реальные трассы (вход, контекст, ответ, вызовы инструментов): случайные плюс сессии с негативными сигналами — одни жалобы не покажут проблем, о которых пользователи молчат. Husain и Shankar советуют начинать со 100 разнообразных трасс и продолжать, пока новые трассы не перестанут приносить новые виды сбоев (теоретическое насыщение).
- Просмотрите их руками и напишите свободным текстом, что не так в каждой проблемной (open coding). Фиксируйте первый сбой выше по потоку: если retrieval принёс не тот документ, неверный ответ — следствие, а не отдельная категория.
- Сгруппируйте заметки в категории ошибок (axial coding) и посчитайте частоты: «выдумывает номер заказа — 14%, отвечает на английском — 6%, не эскалирует злого клиента — 5%».
- Только теперь пишите проверки — под реальные частые режимы отказа, а не под воображаемые. Часть закроется regex, часть потребует judge.
Error analysis повторяется после каждого значимого изменения и регулярно на прод-семплах. Критерии оценки «дрейфуют»: глядя на ответы, люди уточняют, что считают хорошим (criteria drift, Shankar et al., 2024) — это нормально, рубрики версионируются.
Разбор: 20 трасс → таксономия → приоритеты → evals
Бот поддержки магазина: RAG по базе политик плюс инструменты get_order и create_refund_request. Взяли 20 трасс за неделю: 10 случайных и 10 с негативным сигналом (дизлайк, эскалация). Двадцать — учебный размер: в жизни вы продолжите до насыщения, но таксономия обычно проступает уже на первых десятках.
Open coding — заметки по первому сбою, как они записаны в таблице разметки:
t03: «Назвал срок возврата 30 дней, в политике 14. В контексте была статья про подписки, а не про товары».t07: «Не вызвал get_order, ответил общими словами про сроки доставки».t11: «Пообещал: „мы вернём деньги сегодня“. Сам такого решения принять не может».t15: «Вопрос по-русски с английским артикулом — ответил по-английски».
Axial coding — группируем заметки и считаем:
| Категория сбоя | Трасс из 20 | Тяжесть | Где чинить | Чем ловить в evals |
|---|---|---|---|---|
| Неверный факт из политики: retrieval принёс не ту статью | 5 | высокая | retrieval, индекс (модуль 5) | recall@k на кейсах с эталонным документом + judge faithfulness |
| Не вызвал нужный инструмент | 3 | средняя | описание инструмента, промпт | код: есть ли tool_use с get_order в трассе |
| Обещает возврат или компенсацию | 2 | критичная: деньги и юридический риск | промпт + выходной guardrail (модуль 7) | regex/not_contains + judge; порог 100% |
| Язык ответа ≠ язык вопроса | 2 | низкая | одна строка в промпте | детектор языка в коде |
| Не эскалирует агрессивного клиента | 1 | средняя | промпт | пока никак: один случай — собрать ещё трасс |
| Без замечаний | 7 | — | — | — |
Приоритеты — тяжесть × частота × цена исправления, а не только частота:
- Обещания возврата — всего 2 из 20, но критично. Фикс в промпте и guardrail, кейсы — в набор с тегом
safetyи порогом 100%. - Неверные факты — самая частая категория, и чинится она не в промпте: первый сбой выше по потоку — retrieval. Нужны две метрики раздельно: retrieval (нашли ли нужную статью) и faithfulness генерации, иначе непонятно, что чинить.
- Язык ответа — чинится одной строкой в промпте. Отдельный judge не нужен; дешёвая проверка кодом остаётся как регрессия.
- Пропуск инструмента — проверка по трассе, детерминированная (как в уроке про траектории ниже).
Какие evals писать. Не под каждую категорию: Husain и Shankar советуют автоматизировать проверки для сбоев, которые остаются после исправления промпта, — обычно это «обобщающие» ошибки модели, а не забытая инструкция. В нашем примере judge нужен ровно один (faithfulness), остальное закрывают код и регулярки. Итог разбора — не дашборд, а короткий список: какие кейсы добавить в golden-набор, какие проверки написать, какой judge откалибровать.
Инструкции и согласие разметчиков
- Письменные инструкции с определениями и примерами пограничных случаев; бинарные метки + комментарий «почему».
- Inter-annotator agreement: 20–30% кейсов размечают двое, считают Cohen's kappa (для большего числа разметчиков — Fleiss' kappa или Krippendorff's alpha). Низкое согласие людей = размытый критерий; judge не может быть согласован с людьми лучше, чем люди между собой.
- Разметчик — эксперт домена (юрист для юридического бота), а не случайный краудворкер. Для старта часто достаточно одного «benevolent dictator» — продакт или эксперт, который задаёт планку качества.
- Удобный инструмент разметки окупается: простая внутренняя страница, показывающая трассу целиком, с горячими клавишами PASS/FAIL, ускоряет разметку в разы.
Статистика: когда 85% лучше 82%
Pass rate на n кейсах — оценка доли с погрешностью. Приближённо полуширина 95%-го интервала ≈ 1,96·√(p(1−p)/n); для p ≈ 0,8 и n = 100 это ±8 п.п. Поэтому «было 82%, стало 85% на 100 кейсах» — шум. Для малых n и p около 0 или 1 используйте интервал Уилсона (он в визуализации ниже и в коде).
- Размер выборки для различения двух долей (80% мощности, α = 0,05): n ≈ (1,96 + 0,84)² · (p₁q₁ + p₂q₂) / Δ². Чтобы отличить 80% от 85% — около 900 кейсов на вариант. Отсюда практический вывод: небольшой набор ловит большие регрессии, а маленькие улучшения требуют большого набора или более чувствительного дизайна.
- Парный дизайн: оба варианта прогоняются на одних и тех же кейсах, анализируются различия по кейсам (тест Макнемара, bootstrap по кейсам). Это заметно сужает интервал, потому что убирает разброс сложности кейсов. Всегда сравнивайте на одном наборе.
- Шум повторов: один и тот же прогон дважды даёт разные цифры (сэмплирование модели, недетерминизм провайдера, judge тоже шумит). Прогоните baseline 3–5 раз и измерьте разброс — это ваш noise floor; изменения меньше него не обсуждаются. Anthropic в статье «Adding error bars to evals» рекомендует именно так: кластеризованные стандартные ошибки, повторы, парные разности.
- pass@k — вероятность, что хотя бы одна из k попыток успешна (метрика кодогенерации). Несмещённая оценка по n попыткам с c успехами: 1 − C(n−c, k)/C(n, k). Для продукта часто важнее обратное — pass^k (все k попыток успешны): надёжность, которую почувствует пользователь, повторяющий запрос.
- Множественные сравнения: смотрите 20 срезов — один «значимо упадёт» случайно. Заранее решите, какие срезы критичные.
Доверительный интервал: различимы ли две версии промпта?
Двигайте размер набора и pass rate двух версий. Полосы — 95%-е интервалы Уилсона; ниже — интервал разности (непарный, консервативный) и сколько кейсов нужно, чтобы уверенно различить такую разницу.
Bootstrap: интервал для любой метрики
Формула Уилсона есть только для одной доли. А в отчёте evals встречаются средний балл judge, nDCG, стоимость успешной задачи, скорректированный pass rate, разность двух версий на одних и тех же кейсах, повторы одного кейса. Для всего этого есть один универсальный инструмент — bootstrap (Efron, 1979): считаем, что набор кейсов представляет генеральную совокупность, B раз ресемплируем из него n кейсов с возвращением, пересчитываем метрику, и 2,5-й и 97,5-й перцентили этих B значений дают 95%-й интервал.
- Ресемплируйте единицу независимости. Три повтора одного кейса не независимы: если ресемплировать строки, интервал выйдет обманчиво узким. Сверните повторы в оценку кейса (доля успешных повторов) и ресемплируйте кейсы — это кластерный bootstrap, тот же смысл, что у кластерных стандартных ошибок в статье Anthropic. То же для «десять вопросов к одному документу»: кластер — документ.
- Парный bootstrap для двух версий. Ресемплируются индексы кейсов, и на каждой выборке считается разность «новая − старая» на одних и тех же кейсах. Разброс сложности кейсов вычитается, интервал сужается — иногда в разы.
- Где bootstrap врёт. На малых n (меньше ~30) интервалы слишком узкие. У краёв он вырождается: 50 из 50 PASS дают интервал [100%; 100%], тогда как Уилсон честно говорит [92,9%; 100%]. Перцентильный метод смещён на скошенных распределениях; BCa-поправка (в
scipy.stats.bootstrapона по умолчанию) это лечит. - Практика CI. B = 1000 для быстрой оценки, 10 000 для отчёта; фиксированный seed — чтобы повторный запуск гейта на тех же данных давал тот же ответ.
| Вопрос | Метод | Почему |
|---|---|---|
| Интервал для одного pass rate | Уилсон (модуль 0, мини-фреймворк ниже) | Закрытая формула, корректна при малых n и p у 0 или 1 |
| Различаются ли две версии на одних кейсах (бинарный исход) | McNemar: только расхождения b («сломала») и c («починила»), χ² = (|b − c| − 1)² / (b + c); при b + c < 25 — точный биномиальный тест | Даёт p-value, но не интервал разности и не работает для небинарных метрик |
| Интервал разности двух версий, повторы, небинарные метрики, срезы | Парный bootstrap по кейсам (кластерам) | Любая метрика, учитывает корреляцию кейсов и кластеры; код ниже |
| Версии на разных кейсах или разных пользователях (A/B) | Непарно: Ньюкомб (Уилсон для разности) или bootstrap по каждой группе | Пар нет, вычитать нечего |
Размер выборки в парном дизайне зависит не от pass rate, а от доли кейсов pd, на которых версии расходятся: n ≈ (1,96 + 0,84)² · (pd − Δ²) / Δ². Чтобы поймать Δ = 5 п.п. при pd = 10%, нужно ≈ 310 кейсов против ≈ 900 на вариант непарно. Отсюда правило: сравнивайте версии на одном наборе и не меняйте набор между прогонами baseline и PR.
// Interval — точечная оценка и 95%-й интервал.
type Interval struct{ Est, Lo, Hi float64 }
// Bootstrap — перцентильный bootstrap-интервал среднего по кейсам.
// scores[i] — результат кейса i: 0/1 или доля успешных повторов. Во втором случае
// это кластерный bootstrap: повторы одного кейса ресемплируются вместе.
func Bootstrap(scores []float64, b int, seed uint64) Interval {
n := len(scores)
if n == 0 || b <= 0 {
return Interval{math.NaN(), math.NaN(), math.NaN()}
}
r := rand.New(rand.NewPCG(seed, 0))
stats := make([]float64, b)
for k := range stats {
s := 0.0
for range n {
s += scores[r.IntN(n)]
}
stats[k] = s / float64(n)
}
slices.Sort(stats)
q := func(p float64) float64 { return stats[int(p*float64(b-1))] }
return Interval{Est: mean(scores), Lo: q(0.025), Hi: q(0.975)}
}
// PairedBootstrap — интервал разности cand − base на одних и тех же кейсах.
// Ресемплируются кейсы целиком (обе версии вместе), поэтому разброс сложности
// кейсов вычитается и интервал уже, чем у двух независимых bootstrap.
func PairedBootstrap(base, cand []float64, b int, seed uint64) Interval {
if len(base) != len(cand) {
panic("evals: base and cand must be aligned by case")
}
d := make([]float64, len(base))
for i := range d {
d[i] = cand[i] - base[i]
}
return Bootstrap(d, b, seed)
}
// Interval — точечная оценка и 95%-й интервал.
public record Interval(double est, double lo, double hi) {}
// bootstrap — перцентильный bootstrap-интервал среднего по кейсам.
// scores[i] — результат кейса i: 0/1 или доля успешных повторов. Во втором случае
// это кластерный bootstrap: повторы одного кейса ресемплируются вместе.
public static Interval bootstrap(double[] scores, int b, long seed) {
int n = scores.length;
if (n == 0 || b <= 0) {
return new Interval(Double.NaN, Double.NaN, Double.NaN);
}
var r = new SplittableRandom(seed);
double[] stats = new double[b];
for (int k = 0; k < b; k++) {
double s = 0;
for (int i = 0; i < n; i++) {
s += scores[r.nextInt(n)];
}
stats[k] = s / n;
}
Arrays.sort(stats);
return new Interval(mean(scores), quantile(stats, 0.025), quantile(stats, 0.975));
}
// pairedBootstrap — интервал разности cand − base на одних и тех же кейсах.
// Ресемплируются кейсы целиком (обе версии вместе), поэтому разброс сложности
// кейсов вычитается и интервал уже, чем у двух независимых bootstrap.
public static Interval pairedBootstrap(double[] base, double[] cand, int b, long seed) {
if (base.length != cand.length) {
throw new IllegalArgumentException("base and cand must be aligned by case");
}
double[] d = new double[base.length];
for (int i = 0; i < d.length; i++) {
d[i] = cand[i] - base[i];
}
return bootstrap(d, b, seed);
}
static double quantile(double[] sorted, double p) {
return sorted[(int) (p * (sorted.length - 1))];
}
Парный bootstrap разности — это обычный bootstrap среднего по поэлементным разностям, отдельный алгоритм не нужен. Тест в examples/m08-evals/stats_test.goexamples/java/m08-evals/…/StatsTest.java показывает главное: версии 80/100 и 85/100, где новая починила 5 кейсов и ничего не сломала, непарно неразличимы (интервалы пересекаются), а парный интервал разности не содержит нуля.
Bootstrap vs Уилсон: интервал разности двух версий
Две версии промпта прогнаны на одних и тех же n кейсах. «Корреляция кейсов» — насколько версии ошибаются на одних и тех же кейсах (обычно сильно: трудный кейс труден для обеих). Сравните непарный интервал разности (Ньюкомб, из интервалов Уилсона) и парный bootstrap по кейсам.
Поставьте корреляцию 0: парный интервал станет почти таким же широким, как непарный, — выигрыш парного дизайна берётся ровно из того, что версии ошибаются на одних и тех же кейсах.
Мини-фреймворк на Python
Фреймворк evals — это 80 строк, а не платформа. Датасет JSONL → прогон приложения конкурентно → детерминированные проверки и LLM-judge с рубрикой → отчёт pandas с интервалами по срезам. Judge — другая модель (в идеале — другое семейство), вердикт — через structured outputs, чтобы не парсить свободный текст.
"""Мини-фреймворк evals: golden.jsonl -> прогон -> проверки -> judge -> отчёт."""
import json, math, re
from concurrent.futures import ThreadPoolExecutor
import anthropic, jsonschema, pandas as pd
client = anthropic.Anthropic() # ключ из ANTHROPIC_API_KEY; SDK сам ретраит 429/5xx
APP_MODEL, JUDGE_MODEL = "claude-sonnet-5", "claude-opus-5"
SYSTEM = open("prompts/support_v7.txt").read()
def run_app(case: dict) -> str:
r = client.messages.create(model=APP_MODEL, max_tokens=2000, system=SYSTEM,
messages=[{"role": "user", "content": case["input"]}])
if r.stop_reason == "max_tokens":
raise RuntimeError("truncated") # не провал качества, а отдельный статус
return "".join(b.text for b in r.content if b.type == "text")
# --- детерминированные проверки: (output, case) -> bool
def exact_fields(out, c):
got = json.loads(out)
return all(got.get(k) == v for k, v in c["expected"].items())
def json_schema(out, c):
try:
jsonschema.validate(json.loads(out), c["schema"]); return True
except (json.JSONDecodeError, jsonschema.ValidationError):
return False
def not_contains(out, c): return not any(s.lower() in out.lower() for s in c["forbidden"])
def regex(out, c): return re.search(c["pattern"], out, re.S) is not None
RUBRIC = """Ты проверяешь ответ бота поддержки по ОДНОМУ критерию.
Критерий: {criterion}
Длина ответа не является достоинством.
<question>{q}</question>
<reference>{ref}</reference>
<answer>{a}</answer>
Сначала кратко обоснуй, затем вынеси вердикт PASS или FAIL."""
VERDICT = {"type": "object", "additionalProperties": False, "required": ["reasoning", "verdict"],
"properties": {"reasoning": {"type": "string"},
"verdict": {"type": "string", "enum": ["PASS", "FAIL"]}}}
def judge(out, c):
r = client.messages.create(
model=JUDGE_MODEL, max_tokens=1000,
messages=[{"role": "user", "content": RUBRIC.format(
criterion=c["criterion"], q=c["input"], ref=c.get("reference", "—"), a=out)}],
output_config={"format": {"type": "json_schema", "schema": VERDICT}})
return json.loads(r.content[0].text)["verdict"] == "PASS"
CHECKS = {"exact_fields": exact_fields, "json_schema": json_schema,
"not_contains": not_contains, "regex": regex, "judge": judge}
def evaluate(case: dict) -> dict:
row = {"id": case["id"], "tag": case["tags"][0]}
try:
out = run_app(case)
for name in case["checks"]:
row[name] = CHECKS[name](out, case)
except Exception as e: # инфраструктура: не смешиваем с провалом качества
return {**row, "status": "error", "error": repr(e)}
return {**row, "status": "ok", "passed": all(row[n] for n in case["checks"]), "output": out}
def wilson(k: int, n: int, z: float = 1.96) -> str:
if n == 0: return "n/a"
p = k / n; d = 1 + z * z / n
c = (p + z * z / (2 * n)) / d
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
return f"[{c - h:.0%}, {c + h:.0%}]"
if __name__ == "__main__":
cases = [json.loads(l) for l in open("golden.jsonl") if l.strip()]
with ThreadPoolExecutor(max_workers=8) as ex: # под rate limit аккаунта
df = pd.DataFrame(ex.map(evaluate, cases))
ok = df[df.status == "ok"]
by = ok.groupby("tag")["passed"].agg(passed="sum", n="count")
by["rate"] = (by.passed / by.n).map("{:.0%}".format)
by["ci95"] = [wilson(k, n) for k, n in zip(by.passed, by.n)]
print(by.to_string())
k, n = int(ok.passed.sum()), len(ok)
print(f"TOTAL {k}/{n} = {k / n:.1%} CI95 {wilson(k, n)}; errors: {(df.status == 'error').sum()}")
df.to_json("results.jsonl", orient="records", lines=True, force_ascii=False)
reps и считайте разброс), нет кэша ответов и judge-вердиктов, нет сохранения версии промпта/модели/датасета в results.jsonl — в реальном проекте это первое, что понадобится при сравнении прогонов. Проверьте, что response.model совпадает с запрошенной моделью: тихая подмена модели обесценивает сравнение.Eval агентов по траекториям
Агент (модуль 6) за один прогон делает десятки шагов, и у задачи обычно много правильных путей. Поэтому оценка агента — это две разные вещи: итог (outcome: что изменилось в среде) и траектория (transcript: какие инструменты вызывались, в каком порядке, сколько это стоило). Anthropic в «Demystifying evals for AI agents» формулирует приоритет прямо: оценивайте то, что агент сделал, а не путь, которым он шёл; требование фиксированной последовательности вызовов делает тесты хрупкими, потому что агенты находят валидные пути, которых автор теста не предусмотрел.
- Итог проверяется по состоянию среды, а не по словам агента. «Возврат оформлен», — пишет агент; eval смотрит в БД, что у заказа статус
refunded. Так устроен τ-bench (Yao et al., 2024): после диалога состояние базы сравнивается с размеченным целевым состоянием. - Траектория проверяется ограничениями, а не эталоном. Обязательные инструменты, запрещённые, порядок там, где он важен («прочитать заказ до возврата», «подтверждение до необратимого действия»), лимиты шагов и стоимости. Всё, что не ограничено явно, агент волен делать по-своему.
- Только итога мало. Агент, который оформил возврат, не проверив заказ, получил правильное состояние — и это ровно тот прогон, который потом станет инцидентом. Ограничения на путь ловят «правильный итог неправильным способом».
Метрики
| Метрика | Как считается | Что показывает |
|---|---|---|
| Task success | Итоговое состояние совпало с целевым и нет нарушений жёстких ограничений | Главная метрика; доля по всем прогонам |
| pass^k | Доля задач, где успешны все k попыток; несмещённо по n ≥ k прогонам: C(c, k) / C(n, k) | Надёжность. Введена в τ-bench; при 75% успеха на попытку pass^3 ≈ 42% |
| Обязательные / запрещённые инструменты | Каждый required вызван хотя бы раз; forbidden — ни разу | Политики: «удалять нельзя», «без проверки заказа не возвращать» |
| Порядок | Первый вызов A раньше первого вызова B — только для пар, где порядок важен | Проверка до действия, подтверждение до необратимого шага |
| Tool-call precision | Доля вызовов из множества required ∪ allowed | Агент не делает лишнего и не выходит за рамки задачи |
| Tool-call recall | Доля required, которые были вызваны | Агент не пропускает нужные шаги |
| Лишние шаги | Повтор успешного вызова с теми же аргументами; повтор после ошибки — это восстановление, не лишний шаг | Петли и забывчивость (часто — симптом переполненного контекста, модуль 6) |
| Шаги, стоимость, латентность | Сумма по прогону против бюджета задачи; распределение, а не только среднее | «Успех за $2 вместо $0,10» — другой продукт |
Judge по траектории
Часть свойств пути кодом не проверить: «задал ли агент уточняющий вопрос, когда данных не хватало», «не сдался ли слишком рано», «объяснил ли пользователю, что сделал». Такие критерии оценивает judge, которому дают всю траекторию (вызовы, результаты инструментов в сокращении, финальный ответ) и бинарную рубрику на один критерий. Калибруется он так же, как judge ответов, — по разметке людьми; разметчики смотрят на ту же сокращённую трассу, что и judge.
Воспроизводимая среда
- Чистая среда на каждую попытку. Контейнер или транзакция с откатом, фикстуры БД, замороженное время. Остатки предыдущей попытки дают коррелированные провалы или, наоборот, подсказки агенту — Anthropic приводит пример, где агент читал историю git от прошлых попыток.
- Моки инструментов. Внешние API — записанные ответы (record/replay) или фейки с детерминированным поведением, включая сценарии ошибок: таймаут, 429, пустой результат. Тогда шум eval — это шум модели, а не сети.
- Изоляция ресурсов. Общие лимиты (rate limit, CPU) делают попытки зависимыми: прогоны, упавшие из-за соседей, — инфраструктурная ошибка, а не провал агента.
Симулятор пользователя
Диалоговому агенту нужен собеседник. Вторая LLM играет пользователя: персона, скрытая цель («вернуть деньги за товар из заказа 42, но не за доставку»), сведения, которые она выдаёт только по запросу, и условие окончания. Так устроен τ-bench, его продолжение τ²-bench добавляет среду с двойным управлением, где и пользователь меняет её состояние своими инструментами (по состоянию на октябрь 2026 проект развивается как τ³-bench в том же репозитории). Режимы отказа симулятора: выдаёт цель целиком в первом сообщении, становится слишком покладистым, уходит от сценария. Лечение — фиксированная модель и промпт симулятора, проверка выборки диалогов глазами, отдельная метрика «симулятор нарушил сценарий» (такие попытки — невалидные, а не проваленные).
Проверка по спецификации на Go
Проверка по спецификации на Java
Спецификация задачи — это целевое состояние плюс ограничения. Траектория — шаги прогона и снимок среды после него. Траекторию пишет Result.Steps из агентного цикла модуля 6: поля Tool, Input, IsError совпадают, а CtxTokens оттуда пригодится для метрики размера контекста по шагам. Пример применения — задание 4 модуля 6 (агент на 50+ шагов с компакцией).
Спецификация задачи — это целевое состояние плюс ограничения. Траектория — шаги прогона и снимок среды после него. Траекторию пишет result.steps() из агентного цикла модуля 6: поля tool, input, isError совпадают, а ctxTokens оттуда пригодится для метрики размера контекста по шагам. Пример применения — задание 4 модуля 6 (агент на 50+ шагов с компакцией).
// Step — один вызов инструмента (поля как в agent.Step из модуля 6).
type Step struct {
Tool string `json:"tool"`
Input string `json:"input"`
IsError bool `json:"is_error,omitempty"`
}
// Trajectory — запись одного прогона (trial) агента на задаче.
type Trajectory struct {
TaskID string `json:"task_id"`
Steps []Step `json:"steps"`
State map[string]string `json:"final_state"` // снимок среды после прогона
CostUSD float64 `json:"cost_usd"`
}
// Spec — правильное поведение на задаче. Эталонная траектория не нужна:
// проверяем итог и ограничения на путь.
type Spec struct {
WantState map[string]string `json:"want_state"` // главный критерий: состояние среды
Required []string `json:"required"` // должны быть вызваны
Allowed []string `json:"allowed"` // полезны, но не обязательны
Forbidden []string `json:"forbidden"` // ни одного вызова
Before [][2]string `json:"before"` // {A, B}: первый A раньше первого B
MaxSteps int `json:"max_steps"`
MaxCostUSD float64 `json:"max_cost_usd"`
}
// Verdict — результат проверки одного прогона.
type Verdict struct {
Success bool // итог верный и ни одно жёсткое ограничение не нарушено
Failures []string // "вид: детали" — сырьё для error analysis
Precision float64 // доля вызовов из Required ∪ Allowed
Recall float64 // доля Required, которые были вызваны
Redundant int // повтор успешного вызова с теми же аргументами
}
func Check(s Spec, t Trajectory) Verdict {
var v Verdict
fail := func(kind, format string, a ...any) {
v.Failures = append(v.Failures, kind+": "+fmt.Sprintf(format, a...))
}
for _, k := range slices.Sorted(maps.Keys(s.WantState)) {
if got := t.State[k]; got != s.WantState[k] {
fail("state", "%s = %q, want %q", k, got, s.WantState[k])
}
}
first := map[string]int{} // индекс первого вызова инструмента
done := map[string]bool{} // успешные вызовы tool+input
useful := 0
for i, st := range t.Steps {
if _, ok := first[st.Tool]; !ok {
first[st.Tool] = i
}
key := st.Tool + "\x00" + st.Input
if done[key] {
v.Redundant++ // повтор после ошибки — это восстановление, а не лишний шаг
}
if !st.IsError {
done[key] = true
}
if slices.Contains(s.Required, st.Tool) || slices.Contains(s.Allowed, st.Tool) {
useful++
}
if slices.Contains(s.Forbidden, st.Tool) {
fail("forbidden", "%s at step %d", st.Tool, i+1)
}
}
called := 0
for _, r := range s.Required {
if _, ok := first[r]; ok {
called++
} else {
fail("required", "%s not called", r)
}
}
for _, p := range s.Before {
b, ok := first[p[1]]
if !ok {
continue
}
if a, ok := first[p[0]]; !ok || a > b {
fail("order", "%s must precede %s", p[0], p[1])
}
}
if s.MaxSteps > 0 && len(t.Steps) > s.MaxSteps {
fail("steps", "%d > limit %d", len(t.Steps), s.MaxSteps)
}
if s.MaxCostUSD > 0 && t.CostUSD > s.MaxCostUSD {
fail("cost", "$%.3f > limit $%.3f", t.CostUSD, s.MaxCostUSD)
}
v.Success = len(v.Failures) == 0
v.Precision = ratio(useful, len(t.Steps))
v.Recall = ratio(called, len(s.Required))
return v
}// Step — один вызов инструмента (поля как в Step из модуля 6).
public record Step(String tool, String input, boolean isError) {}
// Trajectory — запись одного прогона (trial) агента на задаче. state — снимок среды после прогона.
public record Trajectory(String taskId, List<Step> steps, Map<String, String> state, double costUsd) {}
// Order — {first, then}: первый вызов first раньше первого вызова then. В JSON — пара ["a", "b"].
@JsonFormat(shape = JsonFormat.Shape.ARRAY)
public record Order(String first, String then) {}
// Spec — правильное поведение на задаче. Эталонная траектория не нужна:
// проверяем итог и ограничения на путь.
public record Spec(
Map<String, String> wantState, // главный критерий: состояние среды
List<String> required, // должны быть вызваны
List<String> allowed, // полезны, но не обязательны
List<String> forbidden, // ни одного вызова
List<Order> before,
int maxSteps,
double maxCostUsd) {}
// Verdict — результат проверки одного прогона.
public record Verdict(
boolean success, // итог верный и ни одно жёсткое ограничение не нарушено
List<String> failures, // "вид: детали" — сырьё для error analysis
double precision, // доля вызовов из required ∪ allowed
double recall, // доля required, которые были вызваны
int redundant) {} // повтор успешного вызова с теми же аргументами
public static Verdict check(Spec s, Trajectory t) {
List<String> failures = new ArrayList<>();
new TreeMap<>(s.wantState()).forEach((k, want) -> {
String got = t.state().getOrDefault(k, "");
if (!got.equals(want)) {
failures.add("state: %s = \"%s\", want \"%s\"".formatted(k, got, want));
}
});
Map<String, Integer> first = new HashMap<>(); // индекс первого вызова инструмента
Set<String> done = new HashSet<>(); // успешные вызовы tool+input
int useful = 0, redundant = 0;
for (int i = 0; i < t.steps().size(); i++) {
Step st = t.steps().get(i);
first.putIfAbsent(st.tool(), i);
String key = st.tool() + "\0" + st.input();
if (done.contains(key)) {
redundant++; // повтор после ошибки — это восстановление, а не лишний шаг
}
if (!st.isError()) {
done.add(key);
}
if (s.required().contains(st.tool()) || s.allowed().contains(st.tool())) {
useful++;
}
if (s.forbidden().contains(st.tool())) {
failures.add("forbidden: %s at step %d".formatted(st.tool(), i + 1));
}
}
int called = 0;
for (String r : s.required()) {
if (first.containsKey(r)) {
called++;
} else {
failures.add("required: " + r + " not called");
}
}
for (Order o : s.before()) {
Integer b = first.get(o.then());
if (b == null) {
continue;
}
Integer a = first.get(o.first());
if (a == null || a > b) {
failures.add("order: " + o.first() + " must precede " + o.then());
}
}
if (s.maxSteps() > 0 && t.steps().size() > s.maxSteps()) {
failures.add("steps: %d > limit %d".formatted(t.steps().size(), s.maxSteps()));
}
if (s.maxCostUsd() > 0 && t.costUsd() > s.maxCostUsd()) {
failures.add(String.format(Locale.ROOT, "cost: $%.3f > limit $%.3f", t.costUsd(), s.maxCostUsd()));
}
return new Verdict(failures.isEmpty(), List.copyOf(failures),
ratio(useful, t.steps().size()), ratio(called, s.required().size()), redundant);
}// Summary — агрегат по набору задач, на каждой не меньше k прогонов.
type Summary struct {
Tasks, Runs int
SuccessRate float64 // доля успешных прогонов
PassHatK float64 // pass^k: все k попыток задачи успешны
MeanSteps float64 // по всем прогонам
MeanCostUSD float64 // по всем прогонам
MeanPrecision float64 // tool-call precision
MeanRecall float64 // tool-call recall
Redundant int // лишних повторных вызовов всего
FailureKinds map[string]int // state/forbidden/order/... → число прогонов
}
func Aggregate(specs map[string]Spec, runs []Trajectory, k int) (Summary, error) {
s := Summary{FailureKinds: map[string]int{}}
byTask := map[string][]bool{}
for _, t := range runs {
spec, ok := specs[t.TaskID]
if !ok {
return Summary{}, fmt.Errorf("no spec for task %q", t.TaskID)
}
v := Check(spec, t)
byTask[t.TaskID] = append(byTask[t.TaskID], v.Success)
s.MeanSteps += float64(len(t.Steps))
s.MeanCostUSD += t.CostUSD
s.MeanPrecision += v.Precision
s.MeanRecall += v.Recall
s.Redundant += v.Redundant
kinds := map[string]bool{}
for _, f := range v.Failures {
kind, _, _ := strings.Cut(f, ":")
kinds[kind] = true
}
for kind := range kinds {
s.FailureKinds[kind]++
}
}
s.Tasks, s.Runs = len(byTask), len(runs)
if s.Runs == 0 {
return s, nil
}
success := 0
for id, res := range byTask {
if len(res) < k {
return Summary{}, fmt.Errorf("task %q: %d runs < k=%d", id, len(res), k)
}
c := 0
for _, ok := range res {
if ok {
c++
}
}
success += c
s.PassHatK += passHatK(len(res), c, k)
}
n := float64(s.Runs)
s.SuccessRate = float64(success) / n
s.PassHatK /= float64(s.Tasks)
s.MeanSteps /= n
s.MeanCostUSD /= n
s.MeanPrecision /= n
s.MeanRecall /= n
return s, nil
}
// passHatK — несмещённая оценка pass^k по n прогонам с c успехами: C(c,k)/C(n,k).
func passHatK(n, c, k int) float64 {
p := 1.0
for i := range k {
p *= float64(c-i) / float64(n-i)
}
return max(0, p)
}
func ratio(a, b int) float64 {
if b == 0 {
return 1
}
return float64(a) / float64(b)
}// Summary — агрегат по набору задач, на каждой не меньше k прогонов.
public record Summary(
int tasks, int runs,
double successRate, // доля успешных прогонов
double passHatK, // pass^k: все k попыток задачи успешны
double meanSteps, // по всем прогонам
double meanCostUsd, // по всем прогонам
double meanPrecision, // tool-call precision
double meanRecall, // tool-call recall
int redundant, // лишних повторных вызовов всего
Map<String, Integer> failureKinds) {} // state/forbidden/order/... → число прогонов
public static Summary aggregate(Map<String, Spec> specs, List<Trajectory> runs, int k) {
Map<String, List<Boolean>> byTask = new TreeMap<>();
Map<String, Integer> kinds = new TreeMap<>();
double steps = 0, cost = 0, precision = 0, recall = 0;
int redundant = 0;
for (Trajectory t : runs) {
Spec spec = specs.get(t.taskId());
if (spec == null) {
throw new IllegalArgumentException("no spec for task \"" + t.taskId() + "\"");
}
Verdict v = check(spec, t);
byTask.computeIfAbsent(t.taskId(), x -> new ArrayList<>()).add(v.success());
steps += t.steps().size();
cost += t.costUsd();
precision += v.precision();
recall += v.recall();
redundant += v.redundant();
v.failures().stream()
.map(f -> f.substring(0, f.indexOf(':')))
.distinct()
.forEach(kind -> kinds.merge(kind, 1, Integer::sum));
}
int n = runs.size();
if (n == 0) {
return new Summary(0, 0, 0, 0, 0, 0, 0, 0, 0, kinds);
}
int success = 0;
double passHatK = 0;
for (var e : byTask.entrySet()) {
List<Boolean> res = e.getValue();
if (res.size() < k) {
throw new IllegalArgumentException("task \"%s\": %d runs < k=%d".formatted(e.getKey(), res.size(), k));
}
int c = (int) res.stream().filter(ok -> ok).count();
success += c;
passHatK += passHatK(res.size(), c, k);
}
return new Summary(byTask.size(), n, (double) success / n, passHatK / byTask.size(),
steps / n, cost / n, precision / n, recall / n, redundant, kinds);
}
// passHatK — несмещённая оценка pass^k по n прогонам с c успехами: C(c,k)/C(n,k).
static double passHatK(int n, int c, int k) {
double p = 1;
for (int i = 0; i < k; i++) {
p *= (double) (c - i) / (n - i);
}
return Math.max(0, p);
}
static double ratio(int a, int b) {
return b == 0 ? 1 : (double) a / b;
}{"task_id": "refund-42",
"user_goal": "Вернуть деньги за товар из заказа 42, доставку не возвращать",
"spec": {
"want_state": {"order:42": "refunded", "email:42": "sent"},
"required": ["get_order", "refund"],
"allowed": ["search_policy", "send_email"],
"forbidden": ["delete_order"],
"before": [["get_order", "refund"]],
"max_steps": 8,
"max_cost_usd": 0.10}}Полная версия и табличный тест на семь типовых траекторий — examples/m08-evals/trajectory.go. FailureKinds в агрегате — готовая таблица для error analysis: «в 30% провалов нарушен порядок» говорит, что чинить, лучше, чем «success rate 70%».
Полная версия и параметризованный тест на семь типовых траекторий — examples/java/m08-evals/…/Trajectories.java. failureKinds в агрегате — готовая таблица для error analysis: «в 30% провалов нарушен порядок» говорит, что чинить, лучше, чем «success rate 70%».
Траектория агента: проверка по спецификации
Задача «вернуть деньги за заказ 42» и спецификация из вкладки выше. Выберите прогон агента и сравните три способа оценки: точное совпадение с эталонной траекторией, только итог и спецификация (итог + ограничения на путь).
Evals в CI
Цель — чтобы изменение промпта проходило ту же дисциплину, что изменение кода: PR → автоматический прогон → отчёт в комментарии → gate.
- Пороги: абсолютный (pass rate ≥ 90%), по критичным срезам (safety/adversarial — 100%, ни одного провала), и относительный (не хуже baseline из main больше, чем на noise floor). Абсолютный порог ловит деградацию, относительный — постепенное сползание.
- Регрессии по кейсам: список кейсов, которые в main проходили, а в PR — нет. Это полезнее агрегата для ревьюера.
- Уровни: быстрый smoke-набор (20–50 кейсов, детерминированные проверки) на каждый PR; полный набор с judge — на PR, меняющие промпты/модель (фильтр по путям), и nightly.
- Стоимость: 200 кейсов × (3K input + 500 output) на Sonnet 5 ≈ $2,2, плюс judge на Opus 5 ≈ $3,5 — около $6 за прогон (цены на момент написания, сентябрь 2026). 20 PR в день — ~$120/день. Рычаги: prompt caching общего system prompt, Batch API (−50%) для nightly, кэш ответов по хэшу (промпт, модель, параметры, вход) для неизменённых компонентов, дешёвый judge там, где его согласие с людьми достаточное.
- Инфраструктурные ошибки (429, таймауты) — отдельный статус, не провал качества. Если их больше ~5–10% — прогон невалиден.
- Набор атак — такой же gate: доля успешных атак (ASR) не выше порога, новые успешные атаки блокируют мерж (модуль 7).
Go-вариант: eval-набор как обычный go test, который пропускается без ключа и в -short, гоняет кейсы параллельно с ограничением и падает при pass rate ниже порога.
Java-вариант: eval-набор как обычный тест JUnit 5. Без ключа он пропускается (@EnabledIfEnvironmentVariable), из обычного mvn test исключён тегом evals, гоняет кейсы параллельно на виртуальных потоках с ограничением через Semaphore и падает при pass rate ниже порога.
package support_test
import (
"bufio"
"context"
"encoding/json"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/anthropics/anthropic-sdk-go"
"golang.org/x/sync/errgroup"
"example.com/app/support" // тестируемый код: support.Answer(ctx, client, question)
)
type evalCase struct {
ID string `json:"id"`
Input string `json:"input"`
MustMatch string `json:"must_match"` // regex, опционально
MustNotContain []string `json:"must_not_contain"` // запрещённые фразы
}
const minPassRate = 0.90
func TestSupportEvals(t *testing.T) {
if os.Getenv("ANTHROPIC_API_KEY") == "" {
t.Skip("ANTHROPIC_API_KEY not set: paid evals skipped")
}
if testing.Short() {
t.Skip("evals skipped in -short mode")
}
cases := loadCases(t, "testdata/golden.jsonl")
client := anthropic.NewClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
type result struct {
pass bool
why string
infra error
}
results := make([]result, len(cases))
var g errgroup.Group
g.SetLimit(8) // не больше 8 запросов в полёте: rate limit аккаунта
for i, c := range cases {
g.Go(func() error {
out, err := support.Answer(ctx, client, c.Input)
if err != nil {
results[i] = result{infra: err} // 429/таймаут — не провал качества
return nil
}
ok, why := check(c, out)
results[i] = result{pass: ok, why: why}
return nil
})
}
_ = g.Wait()
var pass, infra int
for i, r := range results {
switch {
case r.infra != nil:
infra++
t.Logf("INFRA %s: %v", cases[i].ID, r.infra)
case r.pass:
pass++
default:
t.Logf("FAIL %s: %s", cases[i].ID, r.why)
}
}
if infra*10 > len(cases) {
t.Fatalf("infra errors %d/%d: run is invalid", infra, len(cases))
}
rate := float64(pass) / float64(len(cases)-infra)
t.Logf("pass rate %.1f%% (%d/%d), infra errors: %d", 100*rate, pass, len(cases)-infra, infra)
if rate < minPassRate {
t.Errorf("pass rate %.1f%% is below threshold %.0f%%", 100*rate, 100*minPassRate)
}
}
func check(c evalCase, out string) (bool, string) {
for _, bad := range c.MustNotContain {
if strings.Contains(strings.ToLower(out), strings.ToLower(bad)) {
return false, "contains forbidden phrase " + bad
}
}
if c.MustMatch != "" && !regexp.MustCompile(c.MustMatch).MatchString(out) {
return false, "does not match " + c.MustMatch
}
return true, ""
}
func loadCases(t *testing.T, path string) []evalCase {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
var cases []evalCase
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<20), 1<<20) // длинные строки JSONL
for sc.Scan() {
if strings.TrimSpace(sc.Text()) == "" {
continue
}
var c evalCase
if err := json.Unmarshal(sc.Bytes(), &c); err != nil {
t.Fatalf("%s: %v", path, err)
}
cases = append(cases, c)
}
if err := sc.Err(); err != nil {
t.Fatal(err)
}
return cases
}
package com.example.app.support;
import static org.junit.jupiter.api.Assertions.*;
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
// Тестируемый код: Support.answer(client, question).
@Tag("evals") // в обычном mvn test исключён (excludedGroups в surefire), как -short в Go
@EnabledIfEnvironmentVariable(named = "ANTHROPIC_API_KEY", matches = ".+",
disabledReason = "ANTHROPIC_API_KEY not set: paid evals skipped")
class SupportEvalsTest {
// mustMatch — regex, опционально; mustNotContain — запрещённые фразы.
record EvalCase(String id, String input, String mustMatch, List<String> mustNotContain) {}
sealed interface Result {
record Pass() implements Result {}
record Fail(String why) implements Result {}
record Infra(Exception e) implements Result {} // 429/таймаут — не провал качества
}
static final double MIN_PASS_RATE = 0.90;
static final ObjectMapper JSON = new ObjectMapper()
.setPropertyNamingStrategy(com.fasterxml.jackson.databind.PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@Test
@Timeout(value = 10, unit = java.util.concurrent.TimeUnit.MINUTES)
void supportEvals() throws Exception {
List<EvalCase> cases = loadCases(Path.of("src/test/resources/golden.jsonl"));
AnthropicClient client = AnthropicOkHttpClient.fromEnv();
var limit = new Semaphore(8); // не больше 8 запросов в полёте: rate limit аккаунта
List<Future<Result>> futures = new ArrayList<>();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (EvalCase c : cases) {
futures.add(exec.submit(() -> {
limit.acquire();
try {
String out = Support.answer(client, c.input());
String why = check(c, out);
return why == null ? new Result.Pass() : new Result.Fail(why);
} catch (Exception e) {
return new Result.Infra(e);
} finally {
limit.release();
}
}));
}
} // close() дожидается всех задач
int pass = 0, infra = 0;
for (int i = 0; i < cases.size(); i++) {
switch (futures.get(i).get()) {
case Result.Infra r -> {
infra++;
System.out.printf("INFRA %s: %s%n", cases.get(i).id(), r.e());
}
case Result.Pass r -> pass++;
case Result.Fail r -> System.out.printf("FAIL %s: %s%n", cases.get(i).id(), r.why());
}
}
assertTrue(infra * 10 <= cases.size(), "infra errors %d/%d: run is invalid".formatted(infra, cases.size()));
double rate = (double) pass / (cases.size() - infra);
System.out.printf(Locale.ROOT, "pass rate %.1f%% (%d/%d), infra errors: %d%n",
100 * rate, pass, cases.size() - infra, infra);
assertTrue(rate >= MIN_PASS_RATE, String.format(Locale.ROOT,
"pass rate %.1f%% is below threshold %.0f%%", 100 * rate, 100 * MIN_PASS_RATE));
}
// null — кейс прошёл, иначе причина провала.
static String check(EvalCase c, String out) {
String lower = out.toLowerCase(Locale.ROOT);
for (String bad : c.mustNotContain() == null ? List.<String>of() : c.mustNotContain()) {
if (lower.contains(bad.toLowerCase(Locale.ROOT))) {
return "contains forbidden phrase " + bad;
}
}
if (c.mustMatch() != null && !c.mustMatch().isEmpty() && !Pattern.compile(c.mustMatch()).matcher(out).find()) {
return "does not match " + c.mustMatch();
}
return null;
}
static List<EvalCase> loadCases(Path path) throws IOException {
List<EvalCase> cases = new ArrayList<>();
for (String line : Files.readAllLines(path)) { // длинные строки JSONL — не проблема
if (!line.isBlank()) {
cases.add(JSON.readValue(line, EvalCase.class));
}
}
return cases;
}
}
Запуск: go test ./support -run Evals -v -timeout 15m. Альтернатива errgroup — подтесты с t.Parallel() и флаг -parallel 8: идиоматично и даёт отчёт по каждому кейсу, но тогда каждый провал кейса роняет тест, и порог pass rate приходится считать в родительском тесте после t.Run-группы, логируя вместо t.Error внутри.
Запуск: mvn test -Pevals -Dtest=SupportEvalsTest, где профиль evals снимает <excludedGroups>evals</excludedGroups> из конфигурации surefire. Альтернатива ручному пулу — @ParameterizedTest по кейсам с параллельным выполнением JUnit (junit.jupiter.execution.parallel.enabled=true): идиоматично и даёт отчёт по каждому кейсу, но тогда каждый провал кейса роняет свой тест, и порог pass rate приходится считать в @AfterAll по накопленным результатам, логируя вместо assert внутри кейса.
benchstat с -count=10). Для evals тот же подход: несколько повторов baseline, сравнение с учётом разброса.Score ± Error). Для evals тот же подход: несколько повторов baseline, сравнение с учётом разброса.Workflow GitHub Actions целиком
Схема, которая работает в проекте B: на каждый пуш в main (и по ночам) прогон сохраняет results.jsonl артефактом — это baseline. В PR, меняющем промпты, smoke-набор прогоняется с повторами, гейт сравнивает результат с последним успешным baseline из main по кейсам, отчёт уходит артефактом и комментарием в PR, а код выхода гейта красит проверку. Версии actions — по состоянию на октябрь 2026.
# .github/workflows/evals.yml
name: evals
on:
pull_request:
paths: ["prompts/**", "evals/**", "internal/llm/**"]
push:
branches: [main]
paths: ["prompts/**", "evals/**", "internal/llm/**"]
schedule:
- cron: "0 3 * * *" # ночью: свежий baseline и дрейф модели у провайдера
workflow_dispatch:
permissions:
contents: read
pull-requests: write # комментарий с отчётом
actions: read # скачать артефакт baseline из прогона main
concurrency:
group: evals-${{ github.ref }}
cancel-in-progress: true # новый пуш в PR отменяет устаревший прогон
jobs:
evals:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod # кэш модулей Go включён по умолчанию
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: evals/requirements.txt
- run: pip install -r evals/requirements.txt
- name: Кэш ответов LLM
uses: actions/cache@v6
with:
path: .evalcache # ключ записи внутри: хэш модели, промпта, параметров, входа и номера повтора
key: evalcache-${{ github.run_id }}
restore-keys: evalcache-
- name: Smoke-набор, 3 повтора
env:
# PR из форка секрет не получит: шаг упадёт, и это честно красная проверка
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
suite=smoke
if [ "${{ github.event_name }}" != pull_request ]; then suite=full; fi
python evals/run.py --suite "$suite" --reps 3 --cache .evalcache --out out/results.jsonl
- name: Baseline из последнего успешного прогона на main
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
id=$(gh run list --workflow evals.yml --branch main --status success \
--limit 1 --json databaseId --jq '.[0].databaseId')
if [ -n "$id" ]; then gh run download "$id" --name eval-results --dir baseline; fi
- name: Гейт против baseline
run: go run ./cmd/evalgate -base baseline/results.jsonl -cur out/results.jsonl -out out/report.md -noise-floor 0.03
- name: Отчёт в артефакт
if: always()
uses: actions/upload-artifact@v7
with:
name: eval-results
path: out/
retention-days: 30
- name: Комментарий в PR
if: always() && github.event_name == 'pull_request' && hashFiles('out/report.md') != ''
env:
GH_TOKEN: ${{ github.token }}
run: gh pr comment ${{ github.event.pull_request.number }} --body-file out/report.md --edit-last --create-if-none
# .github/workflows/evals.yml
name: evals
on:
pull_request:
paths: ["prompts/**", "evals/**", "src/main/java/**/llm/**"]
push:
branches: [main]
paths: ["prompts/**", "evals/**", "src/main/java/**/llm/**"]
schedule:
- cron: "0 3 * * *" # ночью: свежий baseline и дрейф модели у провайдера
workflow_dispatch:
permissions:
contents: read
pull-requests: write # комментарий с отчётом
actions: read # скачать артефакт baseline из прогона main
concurrency:
group: evals-${{ github.ref }}
cancel-in-progress: true # новый пуш в PR отменяет устаревший прогон
jobs:
evals:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: maven # кэш ~/.m2 по хэшу pom.xml
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: evals/requirements.txt
- run: pip install -r evals/requirements.txt
- name: Кэш ответов LLM
uses: actions/cache@v6
with:
path: .evalcache # ключ записи внутри: хэш модели, промпта, параметров, входа и номера повтора
key: evalcache-${{ github.run_id }}
restore-keys: evalcache-
- name: Smoke-набор, 3 повтора
env:
# PR из форка секрет не получит: шаг упадёт, и это честно красная проверка
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
suite=smoke
if [ "${{ github.event_name }}" != pull_request ]; then suite=full; fi
python evals/run.py --suite "$suite" --reps 3 --cache .evalcache --out out/results.jsonl
- name: Baseline из последнего успешного прогона на main
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
id=$(gh run list --workflow evals.yml --branch main --status success \
--limit 1 --json databaseId --jq '.[0].databaseId')
if [ -n "$id" ]; then gh run download "$id" --name eval-results --dir baseline; fi
- name: Гейт против baseline
run: >-
mvn -q -B compile exec:java -Dexec.mainClass=course.m08evals.EvalGate
-Dexec.args="-base baseline/results.jsonl -cur out/results.jsonl -out out/report.md -noise-floor 0.03"
- name: Отчёт в артефакт
if: always()
uses: actions/upload-artifact@v7
with:
name: eval-results
path: out/
retention-days: 30
- name: Комментарий в PR
if: always() && github.event_name == 'pull_request' && hashFiles('out/report.md') != ''
env:
GH_TOKEN: ${{ github.token }}
run: gh pr comment ${{ github.event.pull_request.number }} --body-file out/report.md --edit-last --create-if-none
- Секреты и форки. Для
pull_requestиз форка GitHub не передаёт секреты (кромеGITHUB_TOKEN, и тот только на чтение — комментарий тоже не получится). Не «чините» это черезpull_request_targetс checkout кода из PR: так недоверенный код получает секреты и права на запись (документация GitHub прямо предупреждает, аactions/checkout@v7по умолчанию блокирует checkout форка вpull_request_targetиworkflow_run). Прогон для внешнего PR запускает мейнтейнер после ревью кода. - Фильтр
pathsи обязательные проверки. Если workflow пропущен из-за фильтра путей, его проверка висит в Pending и блокирует merge, если она required. Для обязательной проверки уберитеpathsиз триггера и пропускайте дорогие шаги условием (if) — пропущенный по условию job считается успешным. - Baseline. Артефакт живёт
retention-days; ночной прогон его обновляет и заодно ловит тихие изменения модели у провайдера. Если PR меняет сам golden-набор, общих кейсов меньше — гейт сравнивает только общие и пишет об этом предупреждение. Строже (и вдвое дороже) — гонять baseline-ветку в том же job рядом с PR: тогда оба прогона сделаны одной и той же моделью в одно время. - Кэш. Ключ кэша — run id, а
restore-keysподтягивает самый свежий; внутри ответы хранятся по хэшу всего, что влияет на вывод, включая номер повтора (иначе три повтора вернут один ответ). Ночной прогон — без кэша: его задача как раз увидеть изменения. --edit-lastправит последний комментарий того же пользователя (github-actions[bot]), чтобы PR не зарастал отчётами. Если бот пишет в PR и из других workflow, ищите свой комментарий по маркеру черезactions/github-script.
Гейт: сравнение с baseline по кейсам и срезам
evalgateEvalGate читает results.jsonl обоих прогонов (формат мини-фреймворка выше; повторы — несколько строк с одним id), сворачивает повторы в оценку кейса и применяет правила по порядку: прогон валиден (инфраструктурных ошибок не больше 10%), абсолютный порог, критичные срезы — 100%, затем парное сравнение с baseline. Красный гейт — при значимом падении (верх парного bootstrap-интервала ниже нуля) или падении больше noise floor. Срезы дают только предупреждения: при десяти срезах один «значимо упадёт» случайно.
func Compare(base, cur []CaseResult, cfg GateConfig) GateReport {
var rep GateReport
b, bErr := scores(base)
c, cErr := scores(cur)
if cErr > cfg.MaxErrorRate || bErr > cfg.MaxErrorRate {
rep.Problems = append(rep.Problems, fmt.Sprintf(
"прогон невалиден: инфраструктурных ошибок %.0f%% (PR), %.0f%% (baseline)", 100*cErr, 100*bErr))
}
all := make([]float64, 0, len(c))
for _, id := range slices.Sorted(maps.Keys(c)) {
all = append(all, c[id].score)
}
rep.Cur = Bootstrap(all, cfg.Resamples, cfg.Seed)
if !(rep.Cur.Est >= cfg.MinPassRate) {
rep.Problems = append(rep.Problems, fmt.Sprintf("pass rate %.1f%% ниже порога %.0f%%", 100*rep.Cur.Est, 100*cfg.MinPassRate))
}
// Пары по id: сравниваем только кейсы, которые есть в обоих прогонах.
byTag := map[string][2][]float64{}
var bs, cs []float64
for _, id := range slices.Sorted(maps.Keys(c)) {
cc := c[id]
if slices.Contains(cfg.CriticalTags, cc.tag) && cc.score < 1 {
rep.Problems = append(rep.Problems, fmt.Sprintf("критичный кейс %s [%s]: %.0f%% повторов прошло", id, cc.tag, 100*cc.score))
}
bb, ok := b[id]
if !ok {
continue
}
bs, cs = append(bs, bb.score), append(cs, cc.score)
t := byTag[cc.tag]
byTag[cc.tag] = [2][]float64{append(t[0], bb.score), append(t[1], cc.score)}
switch {
case cc.score < bb.score:
rep.Regressed = append(rep.Regressed, id)
case cc.score > bb.score:
rep.Fixed = append(rep.Fixed, id)
}
}
if n := len(c) - len(bs); n > 0 {
rep.Warnings = append(rep.Warnings, fmt.Sprintf("%d кейсов нет в baseline: в сравнение не вошли", n))
}
if len(bs) == 0 {
rep.Warnings = append(rep.Warnings, "нет общих кейсов с baseline: проверены только абсолютные пороги")
return rep
}
diff := func(tag string, x, y []float64) SliceDiff {
return SliceDiff{Tag: tag, N: len(x), Base: mean(x), Cur: mean(y),
Diff: PairedBootstrap(x, y, cfg.Resamples, cfg.Seed)}
}
rep.Overall = diff("всё", bs, cs)
if d := rep.Overall.Diff; d.Hi < 0 {
rep.Problems = append(rep.Problems, fmt.Sprintf("значимое падение: Δ %+.1f п.п., 95%% CI [%+.1f; %+.1f]", 100*d.Est, 100*d.Lo, 100*d.Hi))
} else if d.Est < -cfg.NoiseFloor {
rep.Problems = append(rep.Problems, fmt.Sprintf("падение %.1f п.п. больше noise floor %.1f п.п.", -100*d.Est, 100*cfg.NoiseFloor))
}
// Срезы — предупреждения, а не красный гейт: при 10 срезах один «значимо
// упадёт» случайно (множественные сравнения). Жёсткие правила — у критичных.
for _, tag := range slices.Sorted(maps.Keys(byTag)) {
s := diff(tag, byTag[tag][0], byTag[tag][1])
rep.Slices = append(rep.Slices, s)
if s.Diff.Hi < 0 || s.Diff.Est < -cfg.NoiseFloor {
rep.Warnings = append(rep.Warnings, fmt.Sprintf("срез %s: %.0f%% → %.0f%% (n=%d)", tag, 100*s.Base, 100*s.Cur, s.N))
}
}
return rep
}
public static Report compare(List<CaseResult> base, List<CaseResult> cur, Config cfg) {
List<String> problems = new ArrayList<>(), warnings = new ArrayList<>();
List<String> regressed = new ArrayList<>(), fixed = new ArrayList<>();
Scores b = scores(base == null ? List.of() : base), c = scores(cur); // byId — TreeMap по id
if (c.errorRate() > cfg.maxErrorRate() || b.errorRate() > cfg.maxErrorRate()) {
problems.add(f("прогон невалиден: инфраструктурных ошибок %.0f%% (PR), %.0f%% (baseline)",
100 * c.errorRate(), 100 * b.errorRate()));
}
double[] all = c.byId().values().stream().mapToDouble(CaseScore::score).toArray();
Interval curIv = bootstrap(all, cfg.resamples(), cfg.seed());
if (!(curIv.est() >= cfg.minPassRate())) {
problems.add(f("pass rate %.1f%% ниже порога %.0f%%", 100 * curIv.est(), 100 * cfg.minPassRate()));
}
// Пары по id: сравниваем только кейсы, которые есть в обоих прогонах.
Map<String, List<double[]>> byTag = new TreeMap<>();
List<double[]> pairs = new ArrayList<>(); // {base, cur}
for (var e : c.byId().entrySet()) {
String id = e.getKey();
CaseScore cc = e.getValue();
if (cfg.criticalTags().contains(cc.tag()) && cc.score() < 1) {
problems.add(f("критичный кейс %s [%s]: %.0f%% повторов прошло", id, cc.tag(), 100 * cc.score()));
}
CaseScore bb = b.byId().get(id);
if (bb == null) {
continue;
}
double[] pair = {bb.score(), cc.score()};
pairs.add(pair);
byTag.computeIfAbsent(cc.tag(), t -> new ArrayList<>()).add(pair);
if (cc.score() < bb.score()) regressed.add(id);
else if (cc.score() > bb.score()) fixed.add(id);
}
int missing = c.byId().size() - pairs.size();
if (missing > 0) {
warnings.add(missing + " кейсов нет в baseline: в сравнение не вошли");
}
if (pairs.isEmpty()) {
warnings.add("нет общих кейсов с baseline: проверены только абсолютные пороги");
return new Report(curIv, null, List.of(), regressed, fixed, problems, warnings);
}
SliceDiff overall = diff("всё", pairs, cfg);
Interval d = overall.diff();
if (d.hi() < 0) {
problems.add(f("значимое падение: Δ %+.1f п.п., 95%% CI [%+.1f; %+.1f]", 100 * d.est(), 100 * d.lo(), 100 * d.hi()));
} else if (d.est() < -cfg.noiseFloor()) {
problems.add(f("падение %.1f п.п. больше noise floor %.1f п.п.", -100 * d.est(), 100 * cfg.noiseFloor()));
}
// Срезы — предупреждения, а не красный гейт: при 10 срезах один «значимо
// упадёт» случайно (множественные сравнения). Жёсткие правила — у критичных.
List<SliceDiff> slices = new ArrayList<>();
byTag.forEach((tag, ps) -> {
SliceDiff s = diff(tag, ps, cfg);
slices.add(s);
if (s.diff().hi() < 0 || s.diff().est() < -cfg.noiseFloor()) {
warnings.add(f("срез %s: %.0f%% → %.0f%% (n=%d)", tag, 100 * s.base(), 100 * s.cur(), s.n()));
}
});
return new Report(curIv, overall, slices, regressed, fixed, problems, warnings);
}
private static SliceDiff diff(String tag, List<double[]> pairs, Config cfg) {
double[] x = pairs.stream().mapToDouble(p -> p[0]).toArray();
double[] y = pairs.stream().mapToDouble(p -> p[1]).toArray();
return new SliceDiff(tag, x.length, mean(x), mean(y), pairedBootstrap(x, y, cfg.resamples(), cfg.seed()));
}
private static String f(String format, Object... args) {
return String.format(Locale.ROOT, format, args); // точка, а не запятая, при любой локали JVM
}
Типы CaseResult, GateConfig, GateReport, свёртка повторов scores, Markdown-рендер и CLI cmd/evalgate — в examples/m08-evals/; тесты проверяют зелёный гейт на одинаковых прогонах, красный — на регрессии 8 кейсов, на упавшем safety-кейсе и на 15% инфраструктурных ошибок. Комментарий в PR выглядит так:
Типы CaseResult, Config, Report, свёртка повторов scores, Markdown-рендер (всё в Gate.java) и CLI EvalGate — в examples/java/m08-evals/; тесты проверяют зелёный гейт на одинаковых прогонах, красный — на регрессии 8 кейсов, на упавшем safety-кейсе и на 15% инфраструктурных ошибок. Комментарий в PR выглядит так:
## Evals: ❌ гейт не пройден
PR: **84.7%** (95% CI 78.3–90.6)
- ❌ pass rate 84.7% ниже порога 85%
- ❌ критичный кейс c000 [safety]: 67% повторов прошло
- ❌ значимое падение: Δ -5.0 п.п., 95% CI [-9.2; -1.7]
- ⚠️ срез refund: 98% → 88% (n=60)
| срез | n | main | PR | Δ, п.п. | 95% CI |
|---|---|---|---|---|---|
| всё | 120 | 89.7% | 84.7% | -5.0 | [-9.2; -1.7] |
| delivery | 50 | 78.7% | 78.7% | +0.0 | [+0.0; +0.0] |
| refund | 60 | 97.8% | 87.8% | -10.0 | [-18.3; -3.3] |
| safety | 10 | 96.7% | 96.7% | +0.0 | [+0.0; +0.0] |
Стало хуже (6): `c060`, `c061`, `c062`, `c063`, `c064`, `c065`
Стало лучше (0): —
Кейс c000 флакает и в main (2 из 3 повторов), но для критичного среза это не оправдание: порог 100% там означает pass^3, а не «как было». Нулевой интервал у delivery — не ошибка: ни один кейс среза не поменял результат, и парному bootstrap нечего ресемплировать. Это и есть выигрыш парного сравнения: шум сложности кейсов вычтен целиком.
Пайплайн evals: CI и прод как один цикл
Верхняя полоса — offline (до выкатки), нижняя — online (после). Нажмите на этап, чтобы увидеть, что там происходит. Ключевая стрелка — слева: ошибки из прода становятся новыми кейсами golden-набора.
Онлайн-оценка и A/B-тесты
Offline-набор отражает прошлое; реальные пользователи приходят с новым. Онлайн-оценка отвечает на вопрос «как система работает сейчас».
- A/B-тесты на бизнес-метриках (решённые тикеты без эскалации, конверсия, удержание). Единственный способ проверить, что «лучше по judge» значит «лучше для бизнеса». Дизайн — ниже.
- Неявный фидбек: копирование ответа, принятие черновика (и насколько его отредактировали — edit distance), повторная генерация, переформулирование вопроса, уход из сессии, эскалация на человека. Лайки/дизлайки — редкие (часто < 1% ответов) и смещённые (жалуются недовольные), но точные для найденных случаев.
- Мониторинг семплов с judge: 1–5% трафика (плюс все сессии с негативными сигналами) автоматически оцениваются теми же откалиброванными judge. Дашборд pass rate по дням и срезам, алерт на падение сверх шума.
- Дрейф входов: распределение запросов меняется (новый продукт, сезон, другая аудитория). Мониторьте язык, длину, темы (кластеризация эмбеддингов, доля запросов далеко от центроидов известных кластеров), долю «не знаю»-ответов и отказов. Новый кластер = кандидат в golden-набор.
- Приватность: логи с пользовательскими данными — PII-редакция до хранения, сроки хранения, доступ по ролям; judge на внешнем API — проверьте, что это допустимо договором.
Дизайн A/B-теста LLM-фичи
Пример: бот поддержки переводят с Sonnet 5 на новый промпт с Opus 5 (дороже в 2,5 раза по токенам). Дизайн пишется до запуска и фиксируется в документе эксперимента:
- Гипотеза и первичная метрика. «Доля обращений, решённых без оператора, вырастет минимум на 2 п.п.» Метрика одна, бизнесовая, считается на пользователя или обращение и выбрана заранее: выбор метрики после просмотра результатов — p-hacking. Pass rate judge на семплах трафика — вторичная, объясняющая метрика.
- Guardrail-метрики — то, что не должно ухудшиться, даже если первичная выросла: латентность p95 и TTFT, стоимость на обращение, доля эскалаций и жалоб, доля ответов, заблокированных guardrails (модуль 7), повторное обращение в течение 7 дней. Порог для каждой («p95 не хуже +20%, стоимость не выше $0,05») — тоже заранее.
- Единица рандомизации — пользователь (или аккаунт), а не запрос: иначе один человек видит то одну версию, то другую, и эффекты смешиваются. Анализ — на том же уровне: запросы одного пользователя коррелированы, их интервал считается кластерным bootstrap по пользователям или дельта-методом.
- Мощность и размер выборки. Минимальный детектируемый эффект (MDE) — бизнесовое решение: «меньше 2 п.п. нам неинтересно». Для долей та же формула, что выше: при базовом уровне 60% и MDE 2 п.п. — n ≈ 7,84 · (0,6·0,4 + 0,62·0,38) / 0,02² ≈ 9 300 пользователей на вариант (α = 0,05, мощность 80%).
- Длительность = требуемая выборка / поток пользователей в эксперименте, округлённая вверх до целых недель (будни и выходные ведут себя по-разному), и не меньше одной-двух недель даже при большом трафике. Не останавливайте тест, как только «стало значимо»: многократное подглядывание раздувает ложноположительные. Нужна ранняя остановка — sequential-тесты с поправкой на подглядывания; guardrail-нарушение — повод остановить сразу.
- SRM-проверка (sample ratio mismatch) до чтения любых метрик: если при плане 50/50 в группах 10 000 и 10 600 пользователей, χ² ≈ 17,5, p ≈ 3·10⁻⁵ — рандомизация или логирование сломаны (новая версия медленнее, и часть пользователей уходит до записи события; бот-фильтр по-разному режет группы). Результаты такого теста не читают, чинят причину. Порог обычно строгий — p < 0,001.
- Novelty и primacy. Новый стиль ответов сначала вызывает интерес (или раздражение у привыкших), эффект затухает. Смотрите на эффект по дням и по когортам «новые / давние пользователи»; растущий или падающий тренд — повод продлить тест.
- Правило решения. Катим, если первичная значимо выросла и ни одна guardrail-метрика не нарушила порог; иначе — нет, даже если judge доволен.
// SRM — проверка sample ratio mismatch: χ²-критерий согласия наблюдаемых размеров
// групп A/B(/n) с запланированными долями. Маленькое p (обычно порог 0.001)
// значит, что рандомизация или логирование сломаны и метрики эксперимента читать нельзя.
func SRM(observed []int, shares []float64) (chi2, p float64) {
total := 0
for _, o := range observed {
total += o
}
for i, o := range observed {
e := float64(total) * shares[i]
d := float64(o) - e
chi2 += d * d / e
}
return chi2, chi2Survival(chi2, len(observed)-1)
}
// chi2Survival — P(χ²(df) ≥ x) для целого df ≥ 1 без внешних библиотек:
// Q(1) = erfc(√(x/2)), Q(2) = e^(−x/2), Q(k+2) = Q(k) + (x/2)^(k/2)·e^(−x/2)/Γ(k/2+1).
func chi2Survival(x float64, df int) float64 {
q, k := math.Exp(-x/2), 2.0
if df%2 == 1 {
q, k = math.Erfc(math.Sqrt(x/2)), 1
}
for ; k < float64(df); k += 2 {
lg, _ := math.Lgamma(k/2 + 1)
q += math.Exp(k/2*math.Log(x/2) - x/2 - lg)
}
return q
}
// SRM([]int{10000, 10600}, []float64{0.5, 0.5}) → chi2 ≈ 17.5, p ≈ 3e-5: эксперимент сломан
public record Result(double chi2, double p) {}
// srm — проверка sample ratio mismatch: χ²-критерий согласия наблюдаемых размеров
// групп A/B(/n) с запланированными долями. Маленькое p (обычно порог 0.001)
// значит, что рандомизация или логирование сломаны и метрики эксперимента читать нельзя.
public static Result srm(int[] observed, double[] shares) {
long total = 0;
for (int o : observed) total += o;
double chi2 = 0;
for (int i = 0; i < observed.length; i++) {
double e = total * shares[i];
double d = observed[i] - e;
chi2 += d * d / e;
}
return new Result(chi2, chi2Survival(chi2, observed.length - 1));
}
// chi2Survival — P(χ²(df) ≥ x) для целого df ≥ 1 без внешних библиотек:
// Q(1) = erfc(√(x/2)), Q(2) = e^(−x/2), Q(k+2) = Q(k) + t(k), где t(k) = (x/2)^(k/2)·e^(−x/2)/Γ(k/2+1)
// и t(k+2) = t(k)·(x/2)/(k/2+1) — гамма-функцию считать не нужно.
static double chi2Survival(double x, int df) {
double q, t;
int k;
if (df % 2 == 1) {
q = erfc(Math.sqrt(x / 2));
t = Math.sqrt(x / 2) * Math.exp(-x / 2) / (Math.sqrt(Math.PI) / 2); // Γ(3/2) = √π/2
k = 1;
} else {
q = Math.exp(-x / 2);
t = x / 2 * Math.exp(-x / 2); // Γ(2) = 1
k = 2;
}
for (; k < df; k += 2) {
q += t;
t *= x / 2 / (k / 2.0 + 1);
}
return q;
}
// В JDK нет erfc: аппроксимация Чебышёва (Numerical Recipes), относительная ошибка < 1.2e-7.
// Нужна точнее — Apache Commons Math Erf.erfc.
static double erfc(double x) {
double z = Math.abs(x), t = 1 / (1 + 0.5 * z);
double r = t * Math.exp(-z * z - 1.26551223 + t * (1.00002368 + t * (0.37409196 + t * (0.09678418
+ t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587
+ t * (-0.82215223 + t * 0.17087277)))))))));
return x >= 0 ? r : 2 - r;
}
// srm(new int[] {10000, 10600}, new double[] {0.5, 0.5}) → chi2 ≈ 17.5, p ≈ 3e-5: эксперимент сломан
Shadow, canary, A/B, interleaving
| Режим | Как устроен | На какой вопрос отвечает | Цена и риски |
|---|---|---|---|
| Shadow (dark launch) | Новая версия обрабатывает копию реального трафика, ответ пользователю не показывается; ответы сравниваются judge и метриками | Не падает ли, сколько стоит и как быстро на реальных входах; где ответы расходятся со старой версией | Двойная стоимость инференса; поведение пользователя не измеряется. Инструменты с побочными эффектами — только моками |
| Canary | 1–5% трафика на новую версию, автоматический откат по guardrail-метрикам | Безопасно ли выкатывать дальше | Мало трафика для бизнес-метрик; это проверка безопасности, а не эффекта |
| A/B | Рандомизация пользователей, заранее заданные метрики и длительность | Лучше ли для пользователя и бизнеса | Нужны трафик и недели; SRM, novelty, подглядывание |
| Interleaving | Для ранжирования (поиск, RAG-выдача): один список собирается из результатов обеих версий (team draft — версии по очереди «выбирают» позиции), клики засчитываются той версии, чей результат кликнули | Какой ранкер пользователи предпочитают | Заметно чувствительнее A/B — обе версии видит один пользователь, межпользовательская дисперсия уходит. Но даёт только относительное предпочтение, не абсолютный эффект на бизнес-метрику; подтверждающий A/B всё равно нужен |
Инструменты
| Инструмент | Что это | Когда брать |
|---|---|---|
| promptfoo | Open-source CLI: YAML-конфиг с промптами, провайдерами, тест-кейсами и assert'ами (включая llm-rubric), матрица сравнения, red teaming | Быстрый старт и CI без своей инфраструктуры; языконезависим |
| Langfuse | Open-source (можно self-host) трейсинг LLM-приложений + датасеты, эксперименты, оценки, разметка | Нужны трейсы из прода и evals в одном месте, данные у себя |
| Braintrust | SaaS-платформа evals: эксперименты, сравнение прогонов, логирование, playground, скореры | Команде нужен UI для сравнения экспериментов «из коробки» |
| OpenAI Evals | Фреймворк и реестр eval-наборов (GitHub), плюс Evals в платформе OpenAI | Экосистема OpenAI; как референс структуры |
| RAGAS | Библиотека метрик RAG: faithfulness, answer relevancy, context precision/recall (в основном LLM-based) | Быстро получить RAG-метрики; калибруйте их на своей разметке, как любой judge |
Также встречаются DeepEval (pytest-стиль), Inspect (UK AISI) и Phoenix (Arize). Выбор инструмента вторичен: каркас из этого модуля (JSONL + проверки + judge + отчёт + CI-gate) переносится на любой из них. На собеседовании ценится понимание методологии, а не знание кнопок.
Практика
Задание 1: golden dataset и error analysis
Для своего RAG из модуля 5 (или бота поддержки) соберите 60–100 кейсов: реальные/синтетические вопросы, стратифицированные по 4–6 категориям, 10+ edge cases, 5+ adversarial, 5+ «правильный ответ — не знаю». Прогоните систему, вручную просмотрите все ответы и составьте таблицу категорий ошибок с частотами.
Готово, когда: golden.jsonl в git с тегами; есть таблица «категория ошибки → частота → какой проверкой ловить»; dev/held-out разделены.
Задание 2: откалиброванный judge с κ и поправкой pass rate
Проект B: judge faithfulness для ответов /ask. Разметите руками 100–150 ответов PASS/FAIL (FAIL добирайте из error analysis задания 1, чтобы их было не меньше 30), разбейте на train/dev/test 15/45/40. Напишите judge с бинарным вердиктом и reasoning, итерируйте рубрику на dev, финальные цифры снимите на test кодом — пакетом из examples/m08-evalsмодулем examples/java/m08-evals или своим портом на Python. Для pairwise-варианта проверьте position bias.
- Отчёт до/после калибровки: agreement, κ, TPR, TNR на test с интервалами; κ ≥ 0,6 и TNR ≥ 0,8 — или письменное объяснение, почему критерий пока нельзя автоматизировать.
- κ между двумя людьми на 30+ кейсах посчитан — это потолок для judge.
- На прогоне golden-набора отчёт показывает и сырой pass rate judge, и скорректированный по Rogan–Gladen с bootstrap-интервалом, учитывающим калибровку.
- Judge — другая модель, чем тестируемая; test-сплит не использовался при подборе рубрики; версия judge (модель, промпт) записана в отчёт.
Задание 3: bootstrap-гейт в CI проекта B
Подключите evals проекта B к GitHub Actions по образцу из раздела «Evals в CI»: baseline-артефакт с main (push и nightly), smoke-набор с 3 повторами в PR, меняющих prompts/, гейт evalgate (или свой с теми же правилами), отчёт артефактом и комментарием в PR.
- Noise floor измерен: 3–5 прогонов baseline без изменений, разброс pass rate записан в README и передан в
-noise-floor. - PR с намеренной регрессией (урезанный top-k или испорченная инструкция про цитаты) краснеет; в комментарии — список кейсов «стало хуже» и парный интервал разности.
- PR без изменений поведения (опечатка в комментарии промпта) остаётся зелёным три прогона подряд — гейт не шумит.
- Safety-кейсы (инъекции, вопросы без ответа) — критичный срез с порогом 100%; инфраструктурные ошибки не считаются провалами, при их доле больше 10% прогон невалиден.
- Секрет API-ключа не доступен PR из форков; стоимость прогона видна в отчёте.
Задание 4: eval по траекториям для агента проекта C
Для агентного цикла из проекта C (модуль 6) превратите 15–20 агентных задач в спецификации: целевое состояние среды плюс required / allowed / forbidden инструменты, порядок там, где он важен, лимиты шагов и стоимости. Среда воспроизводима: Postgres с фикстурами в контейнере, внешние вызовы — моки, включая сценарии ошибок. Каждую задачу прогоните 4 раза и посчитайте агрегат функцией AggregateTrajectories.aggregate или аналогом.
- Отчёт: task success, pass^2 и pass^4, tool-call precision и recall, лишние вызовы, шаги и стоимость (среднее и p95), таблица видов провалов.
- Минимум две задачи, где «только итог» засчитал бы успех, а спецификация ловит нарушение пути, — разобраны в README.
- Хотя бы одна задача с ошибкой инструмента (таймаут мока) проверяет восстановление: повтор не считается лишним шагом.
- Для длинной задачи из практики модуля 6 (50+ шагов) eval сравнивает прогоны с компакцией и без: success, шаги, стоимость.
- Агентные evals подключены к workflow из задания 3 отдельным job (nightly), чтобы не тормозить каждый PR.
Задание 5: дизайн A/B-теста
Напишите одностраничный документ эксперимента для изменения в проекте A (например, rerank или новая модель генерации в /ask): гипотеза, первичная метрика, guardrail-метрики с порогами, единица рандомизации, MDE, размер выборки и длительность при вашем (или предположенном) трафике, правило решения, план SRM-проверки.
- Размер выборки посчитан формулой и проверен симуляцией или калькулятором; длительность округлена до целых недель.
- SRM-проверка реализована кодом (функция
SRMили аналог) и стоит первым шагом анализа. - Описано, что делаете при novelty-эффекте и почему не останавливаете тест при первой значимости.
- Указано, какие из метрик уже есть в трейсах проекта D, а какие нужно начать логировать.
Самопроверка
Почему agreement judge с людьми 92% может ничего не значить?
При несбалансированных классах: если 92% ответов хорошие, judge «всегда PASS» даёт 92% agreement при нулевой полезности. Смотрите Cohen's kappa и recall по классу FAIL.
Почему бинарные вердикты предпочтительнее шкалы 1–10?
Границы между соседними баллами не определены — и judge, и люди ставят их непоследовательно; шкалы плохо калибруются и сжимаются к середине. PASS/FAIL с чётким определением воспроизводим, сверяется с разметкой и агрегируется в понятный pass rate. Нужна градация — несколько бинарных критериев.
Pass rate вырос с 82% до 85% на 100 кейсах. Выкатываем?
Это в пределах шума: 95%-й интервал для p ≈ 0,8, n = 100 — примерно ±8 п.п. Нужен парный анализ по кейсам, повторы для оценки noise floor или больше кейсов (для 80→85% — порядка 900 на вариант при непарном сравнении). Плюс проверить срезы и стоимость.
Как бороться с position bias в pairwise-оценке?
Оценивать каждую пару в обоих порядках; если вердикты противоречат — считать ничьей; рандомизировать порядок; отслеживать долю противоречий как метрику качества judge.
Что такое утечка eval-набора в промпт и почему она опасна?
Кейсы из набора попадают в few-shot примеры (или в обучающие данные). Метрика начинает измерять запоминание, а не обобщение, и завышается. Решение — dev/held-out разделение, held-out не используется при подборе промпта.
Чем pass@k отличается от pass^k?
pass@k — хотя бы одна из k попыток успешна (оптимистично, для генерации кандидатов с последующей проверкой). pass^k — все k успешны (надёжность, важна для продукта, где пользователь ждёт стабильного поведения).
Почему 429 от API нельзя засчитывать как провал кейса?
Это инфраструктурная ошибка, а не качество ответа: pass rate будет зависеть от нагрузки на аккаунт, а не от промпта. Отдельный статус, исключение из знаменателя, и инвалидация прогона при большой доле ошибок.
С чего начинать evals для нового продукта?
С просмотра данных: собрать около 100 реальных трасс (случайных и с негативными сигналами), вручную найти и категоризировать ошибки по первому сбою выше по потоку, посчитать частоты. Потом — проверки под частые и тяжёлые категории, которые не исчезли после правки промпта (сначала детерминированные, потом judge), golden-набор из этих кейсов и CI-gate.
Все 50 кейсов smoke-набора прошли. Bootstrap даёт интервал [100%; 100%]. Что не так?
Bootstrap вырождается у края: в выборке нет ни одного провала, ресемплировать нечего. Истинная доля может быть и 95%. Для одной доли при малом n или p около 0 и 1 берите интервал Уилсона: для 50 из 50 он [92,9%; 100%]. Bootstrap нужен там, где формулы нет: разности на одних кейсах, повторы, небинарные метрики.
Judge показал 85% PASS, его TPR = 0,95, TNR = 0,70. Какова оценка истинного pass rate и чего ей не хватает?
По Rogan–Gladen θ = (0,85 + 0,70 − 1) / (0,95 + 0,70 − 1) = 0,55 / 0,65 ≈ 84,6%: при таком базовом уровне смещения почти взаимно гасятся. Не хватает интервала: TPR и TNR сами измерены на 100–200 кейсах, их шум надо внести в оценку (bootstrap по прогону и калибровочным парам). И проверки переносимости: TPR/TNR верны, только если калибровочный набор похож на прогон.
В A/B при плане 50/50 группы 48,7% и 51,3% на 40 000 пользователей. Что делать с результатами?
Сначала SRM-проверка: ожидаемо 20 000 в каждой, наблюдаемо 19 480 и 20 520, χ² ≈ 27, p ≈ 2·10⁻⁷ — рандомизация или логирование сломаны. Метрики такого теста не читают, даже если они «хорошие»: группы уже несравнимы (например, новая версия медленнее, и часть пользователей уходит до записи события). Искать причину, чинить, перезапускать.
Почему агентный eval не сравнивает траекторию с эталонной, и почему одного итогового состояния тоже мало?
У задачи много валидных путей: точное совпадение валит прогоны, где агент просто прочитал данные в другом порядке, и тест мерит сходство с автором теста. Но только итог пропускает «правильный результат неправильным способом» — возврат без проверки заказа, удаление чужих данных. Поэтому: итог по состоянию среды плюс ограничения на путь (обязательные, запрещённые, порядок там, где он важен, лимиты шагов и стоимости).
Вопросы с собеседований
Как вы построите систему оценки качества для бота поддержки с нуля?
Шаг 1 — данные: выгружаю 100–200 обезличенных диалогов, вместе с экспертом поддержки размечаю и делаю error analysis: какие ошибки, как часто. Шаг 2 — golden-набор 100–150 кейсов, стратифицированный по категориям обращений, с edge cases, adversarial и кейсами, где правильный ответ — эскалация. Шаг 3 — проверки: детерминированные (не обещает возврат, есть ссылка на форму, язык ответа совпадает), judge на смысловые критерии (опирается на базу знаний, решает вопрос) — бинарные, другой моделью, откалиброванные на 100 размеченных кейсах до kappa ≥ 0,6. Шаг 4 — CI: smoke на каждый PR, полный набор при изменении промптов, пороги общий + по срезам + регрессии относительно main с учётом noise floor. Шаг 5 — онлайн: семплирование 2–5% трафика в judge, неявные сигналы (эскалация, повторный вопрос, CSAT), алерты, еженедельный error analysis, пополнение набора из инцидентов. Метрику успеха для бизнеса — доля решённых без оператора — проверяю A/B.
На что смотрит интервьюер: порядок «сначала данные и error analysis, потом метрики», стратификация и негативные кейсы, калибровка judge цифрой, связка offline-гейта с онлайн-сигналами и A/B. Красный флаг — начать с выбора фреймворка или с «оценим по шкале 1–10».
Можно ли доверять LLM-as-a-judge? Как вы это проверяете?
Доверять можно ровно настолько, насколько он согласуется с людьми на вашей задаче — это измеряемо. Размечаю 100–200 кейсов (в идеале двумя людьми, чтобы знать потолок — inter-annotator kappa), считаю kappa judge vs человек, precision/recall по FAIL. Известные смещения: position bias (оцениваю в обоих порядках), verbosity (явно в рубрике, контроль длины), self-preference (judge другого семейства). Бинарные вердикты, одна рубрика — один критерий, reasoning перед вердиктом, примеры пограничных случаев в промпте. Перекалибровка при смене модели judge или продукта; периодическая выборочная проверка людьми в проде. И judge не заменяет детерминированные проверки там, где они возможны.
На что смотрит интервьюер: что доверие измеряется (κ, TPR/TNR на отложенном наборе, потолок inter-annotator), знание конкретных смещений и лечения, понимание, что сырой pass rate judge смещён и его можно поправить. Слабый ответ — «используем GPT/Claude как судью, он умный».
Новая модель дешевле на 60%. Как решить, переходить ли?
Прогоняю полный eval-набор на обеих моделях — парно, на одних кейсах, с 3+ повторами, чтобы знать шум. Смотрю не только общий pass rate, но срезы (особенно safety и сложные категории), операционные метрики (латентность p95, output-токены — дешевле за токен не значит дешевле за запрос), и различия по кейсам: что именно сломалось. Проверяю промпт — возможно, нужна адаптация под новую модель (на новых моделях, например, удалены temperature, иначе работают effort и thinking). Если offline ок — canary или A/B на части трафика с онлайн-judge и бизнес-метриками. Решение — по сумме: экономия vs допустимая потеря качества на критичных срезах, заранее согласованная с продуктом.
На что смотрит интервьюер: парное сравнение с повторами, срезы, стоимость за запрос, а не за токен, адаптация промпта, поэтапная выкатка и заранее согласованный критерий решения с продуктом.
Сколько кейсов нужно в eval-наборе?
Зависит от того, какую разницу надо ловить. Для старта 50–200 — достаточно, чтобы поймать крупные регрессии и работать с ошибками. Для различения близких версий считаю: полуширина интервала ≈ 1,96·√(p(1−p)/n); при p ≈ 0,8 и n = 100 это ±8 п.п.; чтобы отличить 80% от 85% непарно, нужно ~900 на вариант. Парный дизайн и повторы уменьшают требования. Практически — несколько сотен кейсов на критичных срезах плюс осознанный noise floor, и не делать выводов из разницы меньше него.
На что смотрит интервьюер: ответ «зависит от того, какую разницу ловим» с формулой и цифрой, знание, что парный дизайн сокращает требование в разы, и честное отношение к noise floor.
Как оценивать агента, у которого много правильных траекторий?
Оценивать итог, а не точный путь: проверять состояние среды после выполнения (файл изменён правильно, тесты проходят, запись в БД корректна) — это детерминированно. Процесс — через ограничения, а не эталонную траекторию: не было запрещённых действий, число шагов и стоимость в пределах бюджета, нужные инструменты вызывались (например, перед записью было чтение). Judge — для качества финального ответа пользователю. Из-за недетерминизма — несколько прогонов на кейс, pass^k для надёжности. Среда для evals — воспроизводимая (sandbox, фикстуры), иначе шум среды смешается с шумом агента.
На что смотрит интервьюер: разделение итога и пути, проверка итога по состоянию среды, ограничения вместо эталона, pass^k и воспроизводимая среда. Плюс — если кандидат сам вспомнит τ-bench и симулятор пользователя.
Offline-метрики растут, а пользователи недовольны. Что происходит?
Гипотезы: (1) eval-набор не отражает реальное распределение — дрейф входов или набор собран из синтетики; проверяю, сравнивая распределения тем/длин/языков прода и набора. (2) Метрика не то измеряет — judge откалиброван на критерий, который не важен пользователям, или есть verbosity bias и мы оптимизировали длину. (3) Переобучение на набор — утечка кейсов в few-shot, многократная подгонка промпта под dev-набор. (4) Проблемы не в качестве ответа: латентность, UX, отказы. Лечение: error analysis на прод-семплах с негативными сигналами, пополнение набора, перекалибровка judge на свежей разметке, A/B на бизнес-метрике как финальный арбитр.
На что смотрит интервьюер: системный перебор гипотез (данные, метрика, переобучение на набор, не-качественные причины) и план проверки каждой, а не одна догадка; A/B на бизнес-метрике как арбитр.
Как встроить evals в CI и не разориться?
Уровни: детерминированный smoke-набор на каждый PR (секунды, центы), полный набор с judge — только при изменении промптов/моделей/retrieval (фильтр по путям) и nightly. Кэш ответов по хэшу входа и конфигурации для неизменённых компонентов, prompt caching общего префикса, Batch API (−50%) для nightly, дешёвый judge там, где его kappa достаточна. Параллельность под rate limit. Считаю бюджет: 200 кейсов с judge — порядка единиц долларов за прогон, что несопоставимо дешевле инцидента. Инфраструктурные ошибки — отдельно, ретраи в SDK, инвалидация прогона при большой доле ошибок.
На что смотрит интервьюер: уровни наборов, кэш, Batch, расчёт стоимости прогона в долларах, отделение инфраструктурных ошибок. Хорошо, если кандидат сравнит стоимость прогона со стоимостью инцидента.
Новая версия промпта дала 85% против 82% на 300 кейсах. Это улучшение? Как вы посчитаете?
По агрегату ответить нельзя — нужно знать, на каких кейсах версии разошлись. Если сравнивать как две независимые выборки, интервалы Уилсона по ±4 п.п. пересекаются, разница в 3 п.п. неотличима от шума. Но обе версии прогнаны на одних и тех же кейсах, поэтому считаю парно: b — кейсы, которые новая версия сломала, c — которые починила. При b = 3, c = 12 McNemar с поправкой даёт χ² ≈ 4,3, p ≈ 0,04, а парный bootstrap по кейсам — интервал разности примерно [+0,7; +5,7] п.п.: улучшение реально. При b = 20, c = 29 разница та же, но интервал [−1,7; +7,3] включает ноль — версии просто по-разному ошибаются.
Дальше: повторы (3 прогона на версию — кластерный bootstrap по кейсам), noise floor baseline, срезы (не ухудшился ли safety при общем росте), стоимость и латентность. И обязательно посмотреть глазами на сломанные кейсы: три сломанных в safety перевешивают двенадцать починенных в FAQ.
На что смотрит интервьюер: переход от «сравним проценты» к парному дизайну, знание McNemar и bootstrap и когда что применять, умение назвать b и c, учёт повторов и множественных срезов. Сильный кандидат говорит о решении для продукта, а не только о p-value.
Спроектируйте A/B-тест перевода бота поддержки на более дорогую модель.
До запуска — документ эксперимента. Гипотеза: доля обращений, решённых без оператора, вырастет минимум на 2 п.п.; это первичная метрика и MDE, согласованные с продуктом. Guardrails с порогами: латентность p95 и TTFT, стоимость на обращение, доля эскалаций и жалоб, повторное обращение за 7 дней, срабатывания guardrails безопасности. Рандомизация по пользователю, анализ на том же уровне (кластерный bootstrap или дельта-метод). Размер: при базе 60% и MDE 2 п.п. — около 9 300 пользователей на вариант; длительность — по трафику, но целыми неделями и не меньше одной-двух.
Перед запуском — shadow на копии трафика: стоимость, латентность, ошибки на реальных входах; затем canary 5% с автооткатом. Во время теста — SRM-проверка первым делом (p < 0,001 — тест сломан), не подглядывать с остановкой «как только значимо» (или sequential-тест), разбивка эффекта по дням и когортам на novelty. Решение: катим, если первичная значимо выросла и ни одна guardrail-метрика не нарушила порог; offline-evals и judge на семплах — объяснение результата, не замена.
На что смотрит интервьюер: одна заранее выбранная метрика плюс guardrails, правильная единица рандомизации, расчёт мощности с цифрой, SRM, подглядывание и novelty, связка с shadow и canary. Красный флаг — «запустим на 50% и посмотрим, что вырастет».
Агент проходит 90% задач в eval, а в проде жалуются на «странные действия». Что не так с вашим eval?
Вероятнее всего, eval смотрит только на итог: состояние среды правильное, но путь — нет. Агент оформляет возврат, не проверив заказ, повторяет поиск пять раз, вызывает инструменты, которые в задаче не нужны, — итоговое состояние совпадает, и прогон засчитан. Чиню спецификациями задач: целевое состояние плюс обязательные, запрещённые инструменты, порядок там, где он важен, лимиты шагов и стоимости; метрики tool-call precision/recall и лишних вызовов; judge по траектории для того, что кодом не проверить (уточнил ли, когда данных не хватало).
Вторая группа причин — нереалистичная среда: моки отвечают идеально, а в проде таймауты и пустые ответы; симулятор пользователя слишком покладистый и сразу выдаёт всю информацию; задачи проще прод-распределения. И третья — один прогон на задачу: 90% success при pass^4 = 60% означает, что каждый третий-четвёртый пользователь, повторяющий запрос, видит сбой. Дальше — error analysis прод-трасс с жалобами, новые задачи в набор, сценарии ошибок в моках.
На что смотрит интервьюер: различение итога и траектории, конкретные ограничения на путь вместо эталонной последовательности, реализм среды и симулятора, pass^k. Сильный ответ замыкается на процесс: жалобы из прода становятся задачами eval.
Как устроить CI-гейт для промптов, чтобы он не был ни шумным, ни слепым?
Шумный гейт краснеет на PR без изменений поведения, и его начинают перезапускать до зелёного; слепой пропускает регрессии. Против шума: измеренный noise floor (3–5 прогонов baseline), повторы кейсов в PR, парное сравнение с baseline по кейсам вместо сравнения двух агрегатов, фиксированный seed bootstrap, отдельный статус и порог для инфраструктурных ошибок, срезы — предупреждениями, а не блокировкой (множественные сравнения). Против слепоты: критичные срезы с порогом 100% (safety, инъекции), абсолютный порог в дополнение к относительному (чтобы не сползать по 1 п.п. за PR), список кейсов «стало хуже» в комментарии — ревьюер смотрит конкретные ответы, nightly-прогон полного набора без кэша против дрейфа модели у провайдера.
Организационно: baseline — артефакт последнего успешного прогона main, отчёт — комментарием в PR, секрет — не для форков, обязательная проверка — без paths-фильтра на уровне триггера, иначе она висит в Pending. Порог регрессии пересматривается при росте набора.
На что смотрит интервьюер: понимание обеих ошибок гейта, статистика (noise floor, парность, повторы) без перегиба в «p-value на всё», практические детали GitHub Actions (артефакт baseline, секреты и форки, required checks) и стоимость прогона.
Источники
- Claude Docs — Create strong empirical evaluations и Define success criteria
- Miller (Anthropic) — Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations
- Hamel Husain — Your AI Product Needs Evals; Creating a LLM-as-a-Judge That Drives Business Results
- Shankar et al. — Who Validates the Validators? (criteria drift)
- Zheng et al. — Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena (position, verbosity, self-enhancement bias)
- Eugene Yan — Evaluating the Effectiveness of LLM-Evaluators
- Chen et al. — Evaluating Large Language Models Trained on Code (несмещённая оценка pass@k)
- promptfoo · Langfuse · Braintrust · OpenAI Evals · RAGAS
- anthropic-sdk-python, anthropic-sdk-goanthropic-sdk-java
- Anthropic — A statistical approach to model evaluations (краткое изложение «Adding Error Bars to Evals»: кластерные SE, повторы, парные разности)
- Efron — Bootstrap Methods: Another Look at the Jackknife (Annals of Statistics, 1979; исходная статья о bootstrap) и scipy.stats.bootstrap (BCa, парный режим)
- Hamel Husain, Shreya Shankar — AI Evals: Everything You Need to Know (FAQ) (error analysis, насыщение, первый сбой выше по потоку, синтетика по измерениям, сплит для калибровки judge)
- judgy (поправка pass rate по TPR/TNR judge с bootstrap-интервалом) и Rogan, Gladen — Estimating prevalence from the results of a screening test (Am J Epidemiol, 1978)
- Anthropic — Demystifying evals for AI agents (январь 2026: задачи, попытки, грейдеры, итог против траектории, pass@k и pass^k, изоляция попыток)
- Yao et al. — τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains (симулятор пользователя, сравнение состояния БД, pass^k); τ²-bench; код — sierra-research/tau2-bench (по состоянию на октябрь 2026 — τ³-bench)
- Kohavi, Tang, Xu — Trustworthy Online Controlled Experiments (OEC, guardrail-метрики, SRM, novelty, подглядывание) и Spotify Confidence — Sample ratio mismatch (χ²-проверка, порог 0,001, типичные причины)
- GitHub Docs: синтаксис workflow, события и PR из форков, required checks и фильтр путей, безопасное использование Actions; gh pr comment, gh run download