Модуль 8

Evals

Evals — это тесты для недетерминированной системы. Без них любое изменение промпта, модели или retrieval — гадание по трём примерам в чате. Разберём, как начать с error analysis и собрать golden dataset, когда хватает regex, как откалибровать LLM-as-a-judge и поправить его pass rate, как bootstrap отвечает на вопрос «82% или 85%?», как оценивать агентов по траекториям, как устроить A/B-тест LLM-фичи и как встроить всё это в CI и мониторинг прода.

≈ 13 часовGoJava + Python6 визуализацийна момент написания: октябрь 2026

Зачем evals

В обычном коде f(x) детерминирована, и unit-тест с одним assert доказывает поведение. В LLM-системе выход вероятностный, пространство входов открыто, а «правильный ответ» часто не единственный. Отсюда три следствия:

Практический тезис, который стоит произносить на собеседовании: evals — это главный артефакт AI-продукта, важнее промпта. Промпт можно переписать за вечер, модель поменять одной строкой, а хороший eval-набор с откалиброванными проверками копится месяцами и позволяет делать и то, и другое безопасно.

Аналогия из GoEval-набор — это table-driven тесты, где want иногда заменён функцией-предикатом, а итоговый вердикт — не «все зелёные», а «pass rate ≥ порога и нет регрессии относительно main». Прогон на реальной модели — это интеграционный тест: дорогой, медленный, под build tag или t.Skip без ключа.
Аналогия из JavaEval-набор — это @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 teamPrompt injection, jailbreak, утечки PII, токсичностьПеред релизом, после изменений guardrails

Для RAG полезно разделять оценку retrieval (recall@k, MRR — модуль 5) и генерации (faithfulness: ответ опирается на найденное; relevance: отвечает на вопрос). Так понятно, где чинить. Для агентов — оценивать и итог (задача решена? состояние БД правильное?), и процесс (число шагов, стоимость, не было ли опасных действий).

Golden dataset

Откуда брать кейсы

Размер и состав

Гигиена

{"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):

  1. Измерения. Выпишите оси, по которым реально различаются запросы и на которых система ошибается по-разному: тип задачи (возврат, доставка, смена тарифа), персона (новичок, раздражённый клиент, юрист компании), сложность (один факт, несколько фактов, ответа в базе нет), язык и канал. Оси берите из error analysis, а не из головы.
  2. Кортежи руками. Составьте около 20 кортежей — по одному значению из каждого измерения: (возврат, раздражённый, ответа нет, ru). Если кортеж трудно превратить в правдоподобный запрос — измерения выбраны плохо.
  3. Кортежи моделью. Попросите LLM сгенерировать ещё кортежи (или возьмите декартово произведение и отфильтруйте бессмысленные комбинации — так проще, когда почти все сочетания валидны).
  4. Формулировки — отдельным вызовом. Каждый кортеж превращается в текст запроса во втором промпте. Разделение «что спросить» и «как сформулировать» убирает однообразие: одна модель в одном вызове делает и то и другое одинаково.
  5. Фильтр человеком. Выбросьте неправдоподобное, прогоните систему, разметьте ответы — дальше как с реальными трассами. В отчёте держите синтетику отдельным тегом: если на ней pass rate сильно выше, чем на логах, синтетика слишком «чистая».

Контаминация

Точные проверки и эвристики

Правило: используйте самую дешёвую проверку, которая надёжно ловит дефект. Детерминированная проверка бесплатна, мгновенна и не шумит. LLM-judge — когда без понимания смысла не обойтись.

ПроверкаДля чегоПодводные камни
Exact matchКлассификация, извлечение полей, ответы из enumНормализуйте: регистр, пробелы, формат дат и чисел
Regex / contains / not_containsОбязательные элементы (ссылка, дисклеймер), запрещённые фразы, форматХрупко к перефразированию; хорошо для «запрещено», хуже для «обязательно»
JSON-схемаStructured output, аргументы инструментовПри strict/structured outputs формат гарантирован — проверяйте семантику полей
Вызван нужный инструментАгенты и tool use: имя и аргументы tool_useНесколько правильных траекторий — проверяйте итоговое состояние, а не точную последовательность
Выполнение SQLText-to-SQL: выполнить сгенерированный и эталонный запрос на тестовой БД и сравнить результатыСравнивайте множества строк (порядок, алиасы колонок); read-only реплика; таймауты
Выполнение кода / тестыКодогенерацияSandbox обязателен; pass@k
Метрики схожести (BLEU, ROUGE, cosine embedding)Грубый сигнал для перевода и суммаризацииПлохо коррелируют с человеческой оценкой; для принятия решений — только как вспомогательные
ОперационныеЛатентность, токены, стоимость, число шагов агентаЭто тоже часть eval-отчёта: «качество +2% при стоимости ×3» — другое решение

LLM-as-a-judge

Когда критерий смысловой («ответ опирается на контекст», «тон вежливый», «не обещает того, чего нет в политике»), проверку делает другая LLM по рубрике. Это масштабируемо, но judge — сам по себе модель со своими ошибками, и его нужно валидировать так же, как классификатор.

Как писать рубрику

Pointwise vs pairwise

Pointwise — оценка одного ответа по рубрике (абсолютная метрика, годится для CI-порогов). Pairwise — «какой из двух ответов лучше» (надёжнее для сравнения двух версий промпта/модели, потому что относительное суждение проще абсолютного; даёт win rate). Pairwise требует борьбы с position bias.

Калибровка против человеческой разметки

Процедура: 100–200 кейсов размечает человек (эксперт домена) → прогоняете judge → считаете согласие. Метрики:

Калибровка judge: матрица ошибок против человека

200 синтетических кейсов: человек разметил PASS/FAIL, judge выдал уверенность 0–1. Двигайте порог и «качество judge»: смотрите, как меняются agreement, kappa, precision/recall и — главное — оценка pass rate, которую judge покажет в отчёте.

Поставьте долю PASS 0,9 и качество 0: agreement останется высоким при kappa ≈ 0 — judge ничего не понимает, просто угадывает частый класс.

Процедура калибровки

  1. Разметка. 100–200 кейсов на один критерий, человек ставит PASS/FAIL с комментарием. FAIL редки — добирайте их целенаправленно из error analysis: в dev и test нужно хотя бы по 30–50 примеров каждого класса, иначе TNR меряется по десятку кейсов.
  2. Сплит. 10–20% — train (из них берутся примеры пограничных случаев в промпт judge), 40–45% — dev (на нём итерируете рубрику), 40–45% — test (смотрите один раз, в конце). Это та же гигиена, что dev/held-out для промпта приложения: judge, подогнанный под свой тест, покажет на нём красивую κ.
  3. Итерации на dev. Разбирайте отдельно FP (judge пропустил дефект) и FN (забраковал хороший ответ), уточняйте определения и примеры. Цель — TPR и TNR, а не одна κ: для CI-гейта важнее, чтобы judge ловил дефекты (TNR при положительном классе PASS).
  4. Финал на test. TPR, TNR, κ и их интервалы; версия judge (модель, промпт, рубрика) пишется в каждый отчёт прогона.
  5. Поправка на прогоне. Наблюдаемую judge долю PASS пересчитывайте в оценку истинной (ниже) и показывайте с интервалом.
  6. Перекалибровка при смене модели 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 с заданным смещением к первой позиции. Нажмите «Спросить judge» несколько раз, поменяйте порядок, затем прогоните 200 сравнений в обоих порядках.

Человеческая разметка и error analysis

Сначала смотрите на данные

Самая частая ошибка — начинать с выбора метрик и фреймворка. Правильный порядок (его настойчиво продвигают практики вроде Hamel Husain и Shreya Shankar):

  1. Соберите реальные трассы (вход, контекст, ответ, вызовы инструментов): случайные плюс сессии с негативными сигналами — одни жалобы не покажут проблем, о которых пользователи молчат. Husain и Shankar советуют начинать со 100 разнообразных трасс и продолжать, пока новые трассы не перестанут приносить новые виды сбоев (теоретическое насыщение).
  2. Просмотрите их руками и напишите свободным текстом, что не так в каждой проблемной (open coding). Фиксируйте первый сбой выше по потоку: если retrieval принёс не тот документ, неверный ответ — следствие, а не отдельная категория.
  3. Сгруппируйте заметки в категории ошибок (axial coding) и посчитайте частоты: «выдумывает номер заказа — 14%, отвечает на английском — 6%, не эскалирует злого клиента — 5%».
  4. Только теперь пишите проверки — под реальные частые режимы отказа, а не под воображаемые. Часть закроется regex, часть потребует judge.

Error analysis повторяется после каждого значимого изменения и регулярно на прод-семплах. Критерии оценки «дрейфуют»: глядя на ответы, люди уточняют, что считают хорошим (criteria drift, Shankar et al., 2024) — это нормально, рубрики версионируются.

Разбор: 20 трасс → таксономия → приоритеты → evals

Бот поддержки магазина: RAG по базе политик плюс инструменты get_order и create_refund_request. Взяли 20 трасс за неделю: 10 случайных и 10 с негативным сигналом (дизлайк, эскалация). Двадцать — учебный размер: в жизни вы продолжите до насыщения, но таксономия обычно проступает уже на первых десятках.

Open coding — заметки по первому сбою, как они записаны в таблице разметки:

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———

Приоритеты — тяжесть × частота × цена исправления, а не только частота:

  1. Обещания возврата — всего 2 из 20, но критично. Фикс в промпте и guardrail, кейсы — в набор с тегом safety и порогом 100%.
  2. Неверные факты — самая частая категория, и чинится она не в промпте: первый сбой выше по потоку — retrieval. Нужны две метрики раздельно: retrieval (нашли ли нужную статью) и faithfulness генерации, иначе непонятно, что чинить.
  3. Язык ответа — чинится одной строкой в промпте. Отдельный judge не нужен; дешёвая проверка кодом остаётся как регрессия.
  4. Пропуск инструмента — проверка по трассе, детерминированная (как в уроке про траектории ниже).

Какие evals писать. Не под каждую категорию: Husain и Shankar советуют автоматизировать проверки для сбоев, которые остаются после исправления промпта, — обычно это «обобщающие» ошибки модели, а не забытая инструкция. В нашем примере judge нужен ровно один (faithfulness), остальное закрывают код и регулярки. Итог разбора — не дашборд, а короткий список: какие кейсы добавить в golden-набор, какие проверки написать, какой judge откалибровать.

Инструкции и согласие разметчиков

Статистика: когда 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 используйте интервал Уилсона (он в визуализации ниже и в коде).

Доверительный интервал: различимы ли две версии промпта?

Двигайте размер набора и pass rate двух версий. Полосы — 95%-е интервалы Уилсона; ниже — интервал разности (непарный, консервативный) и сколько кейсов нужно, чтобы уверенно различить такую разницу.

Bootstrap: интервал для любой метрики

Формула Уилсона есть только для одной доли. А в отчёте evals встречаются средний балл judge, nDCG, стоимость успешной задачи, скорректированный pass rate, разность двух версий на одних и тех же кейсах, повторы одного кейса. Для всего этого есть один универсальный инструмент — bootstrap (Efron, 1979): считаем, что набор кейсов представляет генеральную совокупность, B раз ресемплируем из него n кейсов с возвращением, пересчитываем метрику, и 2,5-й и 97,5-й перцентили этих B значений дают 95%-й интервал.

ВопросМетодПочему
Интервал для одного 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» формулирует приоритет прямо: оценивайте то, что агент сделал, а не путь, которым он шёл; требование фиксированной последовательности вызовов делает тесты хрупкими, потому что агенты находят валидные пути, которых автор теста не предусмотрел.

Метрики

МетрикаКак считаетсяЧто показывает
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.

Воспроизводимая среда

Симулятор пользователя

Диалоговому агенту нужен собеседник. Вторая 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.

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 внутри кейса.

Аналогия из GoЭто как бенчмарки: результат шумный, поэтому сравнивают не один прогон, а распределения (benchstat с -count=10). Для evals тот же подход: несколько повторов baseline, сравнение с учётом разброса.
Аналогия из JavaЭто как бенчмарки в JMH: результат шумный, поэтому сравнивают не один прогон, а распределения — несколько форков и итераций, а в отчёте среднее с доверительным интервалом (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

Гейт: сравнение с 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-теста LLM-фичи

Пример: бот поддержки переводят с Sonnet 5 на новый промпт с Opus 5 (дороже в 2,5 раза по токенам). Дизайн пишется до запуска и фиксируется в документе эксперимента:

  1. Гипотеза и первичная метрика. «Доля обращений, решённых без оператора, вырастет минимум на 2 п.п.» Метрика одна, бизнесовая, считается на пользователя или обращение и выбрана заранее: выбор метрики после просмотра результатов — p-hacking. Pass rate judge на семплах трафика — вторичная, объясняющая метрика.
  2. Guardrail-метрики — то, что не должно ухудшиться, даже если первичная выросла: латентность p95 и TTFT, стоимость на обращение, доля эскалаций и жалоб, доля ответов, заблокированных guardrails (модуль 7), повторное обращение в течение 7 дней. Порог для каждой («p95 не хуже +20%, стоимость не выше $0,05») — тоже заранее.
  3. Единица рандомизации — пользователь (или аккаунт), а не запрос: иначе один человек видит то одну версию, то другую, и эффекты смешиваются. Анализ — на том же уровне: запросы одного пользователя коррелированы, их интервал считается кластерным bootstrap по пользователям или дельта-методом.
  4. Мощность и размер выборки. Минимальный детектируемый эффект (MDE) — бизнесовое решение: «меньше 2 п.п. нам неинтересно». Для долей та же формула, что выше: при базовом уровне 60% и MDE 2 п.п. — n ≈ 7,84 · (0,6·0,4 + 0,62·0,38) / 0,02² ≈ 9 300 пользователей на вариант (α = 0,05, мощность 80%).
  5. Длительность = требуемая выборка / поток пользователей в эксперименте, округлённая вверх до целых недель (будни и выходные ведут себя по-разному), и не меньше одной-двух недель даже при большом трафике. Не останавливайте тест, как только «стало значимо»: многократное подглядывание раздувает ложноположительные. Нужна ранняя остановка — sequential-тесты с поправкой на подглядывания; guardrail-нарушение — повод остановить сразу.
  6. SRM-проверка (sample ratio mismatch) до чтения любых метрик: если при плане 50/50 в группах 10 000 и 10 600 пользователей, χ² ≈ 17,5, p ≈ 3·10⁻⁵ — рандомизация или логирование сломаны (новая версия медленнее, и часть пользователей уходит до записи события; бот-фильтр по-разному режет группы). Результаты такого теста не читают, чинят причину. Порог обычно строгий — p < 0,001.
  7. Novelty и primacy. Новый стиль ответов сначала вызывает интерес (или раздражение у привыкших), эффект затухает. Смотрите на эффект по дням и по когортам «новые / давние пользователи»; растущий или падающий тренд — повод продлить тест.
  8. Правило решения. Катим, если первичная значимо выросла и ни одна 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 и метрикамиНе падает ли, сколько стоит и как быстро на реальных входах; где ответы расходятся со старой версиейДвойная стоимость инференса; поведение пользователя не измеряется. Инструменты с побочными эффектами — только моками
Canary1–5% трафика на новую версию, автоматический откат по guardrail-метрикамБезопасно ли выкатывать дальшеМало трафика для бизнес-метрик; это проверка безопасности, а не эффекта
A/BРандомизация пользователей, заранее заданные метрики и длительностьЛучше ли для пользователя и бизнесаНужны трафик и недели; SRM, novelty, подглядывание
InterleavingДля ранжирования (поиск, RAG-выдача): один список собирается из результатов обеих версий (team draft — версии по очереди «выбирают» позиции), клики засчитываются той версии, чей результат кликнулиКакой ранкер пользователи предпочитаютЗаметно чувствительнее A/B — обе версии видит один пользователь, межпользовательская дисперсия уходит. Но даёт только относительное предпочтение, не абсолютный эффект на бизнес-метрику; подтверждающий A/B всё равно нужен

Инструменты

ИнструментЧто этоКогда брать
promptfooOpen-source CLI: YAML-конфиг с промптами, провайдерами, тест-кейсами и assert'ами (включая llm-rubric), матрица сравнения, red teamingБыстрый старт и CI без своей инфраструктуры; языконезависим
LangfuseOpen-source (можно self-host) трейсинг LLM-приложений + датасеты, эксперименты, оценки, разметкаНужны трейсы из прода и evals в одном месте, данные у себя
BraintrustSaaS-платформа 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.

Задание 3: bootstrap-гейт в CI проекта B

Подключите evals проекта B к GitHub Actions по образцу из раздела «Evals в CI»: baseline-артефакт с main (push и nightly), smoke-набор с 3 повторами в PR, меняющих prompts/, гейт evalgate (или свой с теми же правилами), отчёт артефактом и комментарием в PR.

Задание 4: eval по траекториям для агента проекта C

Для агентного цикла из проекта C (модуль 6) превратите 15–20 агентных задач в спецификации: целевое состояние среды плюс required / allowed / forbidden инструменты, порядок там, где он важен, лимиты шагов и стоимости. Среда воспроизводима: Postgres с фикстурами в контейнере, внешние вызовы — моки, включая сценарии ошибок. Каждую задачу прогоните 4 раза и посчитайте агрегат функцией AggregateTrajectories.aggregate или аналогом.

Задание 5: дизайн A/B-теста

Напишите одностраничный документ эксперимента для изменения в проекте A (например, rerank или новая модель генерации в /ask): гипотеза, первичная метрика, guardrail-метрики с порогами, единица рандомизации, MDE, размер выборки и длительность при вашем (или предположенном) трафике, правило решения, план SRM-проверки.

Самопроверка

Почему 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) и стоимость прогона.