LLM Evals — измерять, а не верить
Источник механики: официальные воркшопы Anthropic (Claude with Code workshops) —
eval-driven-agent-development/ (двухуровневый грейдинг + baseline-дельты),
agent-battle/ (fast/full loop), rightmodel/ (аудит эвала + свип по моделям).
Всё, что помечено [вывод], — наша надстройка, в коде воркшопов этого нет.
Воркшопы — ВНЕШНИЕ репозитории, в пак они не входят. Ссылки на их файлы ниже — указание первоисточника, а не маршрут: искать эти пути у себя на диске не надо, весь рабочий порядок перенесён в этот файл целиком.
Когда использовать
- Правишь системный промпт / скилл / конфиг агента и хочешь знать: это выигрыш или шум.
- Агент «стал хуже» — нужен воспроизводимый замер, а не ощущение.
- Выбираешь модель или уровень effort под задачу (свип, режим C).
- Принимаешь чужой эвал и не доверяешь его цифрам (аудит, режим B).
- Перед выкаткой агента/пайплайна в прод — гейт на golden-set.
Не запускай эвал ради одной правки опечатки: если изменение нельзя проиграть на 5 задачах — просто сделай его.
Три режима
| Режим | Когда | Что даёт |
|---|---|---|
| A. Итерация (дефолт) | Меняю промпт/конфиг агента | Скоркарта вариант×метрика + вердикт keep/rollback |
| B. Аудит | Есть чужой/старый эвал | Список дефектов эвала до того, как поверил цифрам |
| C. Свип | «Какая модель/effort?» | Pass rate × cost × latency по сетке, одна рекомендация |
Если просят и B, и C — сначала B: свип поверх сломанного эвала множит ошибку
(rightmodel/.claude/skills/eval-audit-and-sweep/SKILL.md, шаг 2).
ПРОЦЕДУРА (режим A)
Шаг 1. Golden-set (20-50 задач, JSONL)
tasks.jsonl, одна задача на строку: {"id": "...", "prompt": "...", "expected": ...}.
В воркшопе eval-driven-agent-development/tasks.json — всего 5 задач одного шаблона
(«Create a 5-slide presentation on X»): намеренно узкий набор, чтобы различия были видны.
Правила набора (первоисточник: воркшоп rightmodel, references/audit.md §1):
- 20-50 реалистичных быстрых примеров — достаточно на старте; для сравнения близких систем ~50+ на срез, с доверительными интервалами.
- Обе стороны поведения: есть «должен искать» → должно быть и «не должен искать». Односторонний эвал = односторонняя оптимизация.
- Одна способность на задачу — иначе провал не диагностичен.
- Успех должен быть решаемым однозначно: два эксперта на одном выводе сходятся в pass/fail.
- Ответ не должен утекать в промпт / few-shot / доступные агенту файлы.
- Задачи из реального трафика > синтетика; форма взаимодействия должна совпадать с продовой (single-turn эвал не поймает агентную регрессию).
Порог качества набора: если сильные модели уже дают ~95%+ — эвал не различает верх, свип будет ранжировать только по цене; если все дают ~0% — почти всегда баг задачи или грейдера.
Шаг 2. Выбрать тип грейдера (двухуровнево)
Уровень 1 — программный грейдер (kind="code"). Детерминированный разбор структуры
артефакта. В воркшопе .pptx вскрывается как zip с XML (src/parse-pptx.ts) и даёт
пер-слайдовые факты: shapeCount, pictureCount, textChars, fontSizesPt, emojiCount.
Поверх них 7 грейдеров (src/graders/code/*.ts):
| Грейдер | Правило | scale |
|---|---|---|
| Produced result | файл есть / zip валиден | ok / missing / invalid |
| Slide count | число слайдов | good: ровно 5 |
| Slides with image | доля слайдов с картинкой | good: high |
| Text-heavy slides | слайдов с >300 символов | good: low |
| Cluttered slides | слайдов с >20 shapes | good: low |
| Small-font slides | слайдов с шрифтом <14pt | good: low |
| Emoji count | всего эмодзи в деке | good: low |
Смысл: всё, что можно посчитать кодом, судья считать не должен — дешевле, стабильнее, не плавает между прогонами.
Уровень 2 — LLM-судья (kind="judge"). Только субъективное: дизайн, связность, тон.
В воркшопе судья работает по отрендеренному результату: pptx → PDF → JPG на слайд
(LibreOffice в докере, src/render.ts), затем vision-вызов на каждый слайд, 4 критерия
0-5 (text, image, layout, color) + комментарий — один вызов на слайд, мемоизирован,
4 грейдера читают один и тот же батч (src/graders/judge/judge.ts). Отдельный текстовый
судья — title-body-coherence (0-5, тело слайда отвечает своему заголовку).
Три приёма из промпта судьи, которые стоит копировать дословно:
- жёсткая структура ответа (в воркшопе — structured outputs с zod-схемой, у нас — «верни только JSON с ключами …»);
- «Give scores across the full spectrum (0-5) instead of only good ones (3-5)» — иначе всё слипается в 4/5;
- критерии перечислены как проверяемые свойства, а не «оцени качество» (audit.md: «Concrete rubric, not vibes»), и лучше разные независимые вызовы на разные свойства.
Готовый минимальный харнесс на stdlib: scripts/eval_harness.py (см. «Код» ниже).
Шаг 3. Быстрый пробник (fast loop)
Полный прогон агента дорог; сначала — дешёвая валидация изменения.
В agent-battle это python3 my_agent.py --eval: 10 синтетических состояний
(harness/probes.py), на каждое агент отвечает одним действием, которое скорится
рубрикой ✓/⚠/✗; ~30-60 с, 4 сессии параллельно (EVAL_WORKERS), против 5-минутного
полного прогона (RUN_SECONDS = 300).
Как устроен пробник (переносится на любой агент):
- состояние подаётся как JSON + «выбери РОВНО ОДНО следующее действие»;
- харнесс перехватывает первый вызов инструмента, пропуская служебные (
read,get_state, максимум 3 пропуска), затем прерывает и архивирует сессию — побочные эффекты не успевают случиться; - скоринг — чистая функция
(имя_инструмента, аргументы) -> (метка, объяснение); объяснение важнее метки, оно и есть обратная связь; - рубрика включает анти-паттерны с причиной: «mines diamond ore with STONE pick — drops nothing», «burns tokens narrating instead of mining».
Пробники ловят решения (факты, приоритеты, дисциплина по токенам), но не ловят удачу прогона и качество финального артефакта.
Пороги fast → full [вывод] (в коде воркшопа порогов нет, там ручной выбор):
- прогонять полный цикл только если пробник не упал относительно чемпиона (score ≥ baseline);
- пробник упал ≥1 пункта → правку в мусор, полный прогон не запускать;
- пробник вырос, но full-метрики стоят на месте два круга подряд → пробник исчерпан, дописывай новые пробы, а не крути промпт;
- полный прогон обязателен перед любым keep — пробник не является основанием менять чемпиона.
Соотношение стоимости в воркшопе: fast 30-60 с против full 300 с (×5-10). Держи свой fast-loop в этом коридоре — если пробник дороже трети полного прогона, он бессмысленен.
Шаг 4. Полный прогон и baseline
- Прогони систему на всех задачах golden-set; варианты — как отдельные раунды.
В воркшопе раунды:
00-naive→01-polish(типографика + плотность + анти-AI-tells) →02-diagram(обязательная диаграмма на слайд) →03-qa-loop(агент сам рендерит дек в картинки, ищет дефекты, чинит и перепроверяет) →04-model-swap(наивный промпт, но Opus вместо Sonnet — рычаг модели против рычага промпта). - Первый прогон пиши как baseline (
--baseline), дальше все дельты считаются против закреплённого baseline, а не против предыдущего прогона — иначе меришь шум (src/eval-runner.ts:baseline.score.json). - Раунды — последовательно, задачи внутри раунда — параллельно; результаты складывать
по мере готовности, падение одной задачи не должно убивать скоркарту
(
Promise.allSettledв eval-runner; вbatch.ts— резюмируемость: задача с уже существующимoutput.pptxпропускается). - Всегда сохраняй полный трейс (все сообщения, вызовы инструментов, ошибки) — audit.md называет это самой высокоплечевой привычкой отладки эвала.
- Инфра-ошибка ≠ ошибка модели: у ошибок отдельное поле, они не считаются как fail
(в
eval_harness.py— колонкаERRи секция «Infra errors»).
Шаг 5. Отчёт
Таблица вариант×метрика с дельтами к baseline + средние по судейским колонкам
- красные ячейки + список инфра-ошибок. Шаблон — ниже, генерится
eval_harness.py.
Шаг 6. Решение keep / rollback
Не «цифра выросла», а:
- Дельта больше шумового пола? При n=20 одна задача = 5 п.п.; различия меньше run-to-run разброса не значат ничего (audit.md §2 «Statistical power»; sweep.md §4). Нет истории разброса — прогони чемпиона дважды и возьми разницу как пол.
- Программные метрики не деградировали? Судья вырос, а
Emoji countиText-heavy slidesвыросли тоже — это не улучшение, это смена вкуса судьи. - Парное сравнение прошло двусторонний прогон (см. «Защита от подтасовки»).
- Проигравший вариант — откат, не «оставим, вдруг пригодится».
Защита от подтасовки (обязательно)
Канон: skills/verifier/references/gan-adversarial-improve.md
(+ rules/quality-gates.md, раздел Position-Bias Guard). Здесь — как это ложится на эвал.
- Position bias. Судья систематически предпочитает первый вариант. Любое парное
сравнение гоняется в обоих порядках (A-vs-B и B-vs-A); keep только при перевесе
голосов, ничья → чемпион держится. Готово:
scripts/pairwise_judge.py(exit 0 = keep, 1 = rollback). Подтверждено и воркшопом: audit.md, «Position bias — randomise A/B order per example or score each pair twice with positions swapped». - Генератор не судит свою работу. Оценка — отдельный вызов/контекст, без информации об авторстве и о том, какая версия новее.
- Label deference. Не сообщай судье, какой ответ «эталонный», «базовый» или «человеческий» — он подыграет ярлыку.
- Verbosity bias. В рубрике явно: длина сама по себе не награждается (или нормируй по длине).
- Self-preference. Судья из того же семейства, что и подсудимый, любит свой стиль.
Смягчение: судья другого семейства или жюри из разных семейств с голосованием
(у нас —
multi-model-gateway/ codex-CLI как второй судья). - Судья калиброван. Прогони судью на десятке примеров, размеченных человеком; согласие заметно ниже ~90% на однозначных кейсах — чини промпт судьи, а не модель.
- Судья проверен на заведомых негативах: пустая строка, «I don't know», уверенный ответ не на тот вопрос — должны падать. Пропускает — всё дальше недействительно.
- Судья недетерминирован — прогони его дважды на одной паре; расходится, значит к дисперсии модели добавлена дисперсия грейдера, её надо мерить отдельно.
- Чит-резистентность грейдера. Думай адверсарно: хардкод ожидаемого вывода, чтение ключа с диска, спецкейс по имени теста, пустая строка под мягкий регексп, инъекция инструкций в вход судьи. Проверка на «известно-хорошем» (должен быть ~100%) и «известно-плохом» (должен быть ~случайность) — обязательна.
Код (stdlib-first)
Оба скрипта работают без внешних зависимостей; судья вызывается через claude -p
(по подписке, без API-ключа — канон claude-cli-runner), офлайн-режим EVAL_JUDGE_STUB=1
для дымового прогона харнесса.
# полный прогон + закрепить baseline
python ~/.claude/skills/llm-evals/scripts/eval_harness.py --dir ./myeval --baseline
# итерация: дельты к baseline
python ~/.claude/skills/llm-evals/scripts/eval_harness.py --dir ./myeval
# fast loop: только программные грейдеры (без вызовов судьи)
python ~/.claude/skills/llm-evals/scripts/eval_harness.py --dir ./myeval --code-only
# двусторонний pairwise: keep или rollback
python ~/.claude/skills/llm-evals/scripts/pairwise_judge.py \
--champion runs/champ/output.md --candidate runs/cand/output.md \
--criteria "точность, плотность фактов, отсутствие воды" --rounds 1
graders.py рядом с tasks.jsonl — вся «рубрика» эвала как данные:
import re
from eval_harness import Grader
# --- уровень 1: программные метрики (детерминированно, дёшево) ---
def bullets(ctx):
return sum(1 for l in ctx.artifact_text.splitlines() if l.startswith("- "))
def emoji(ctx):
return len(re.findall(r"[\U0001F300-\U0001FAFF]", ctx.artifact_text))
def produced(ctx):
return "ok" if ctx.artifact_path and ctx.artifact_text.strip() else "missing"
# --- уровень 2: судья (только субъективное), один вызов = несколько критериев ---
def _scores(ctx):
return ctx.judge(
"Оцени черновик по каждому критерию целым числом 0-5 "
"(clarity — ясность тезиса; grounding — опора на факты из текста; "
"tone — соответствие деловому тону). Используй ВЕСЬ диапазон 0-5, "
"а не только 3-5.",
ctx.artifact_text,
["clarity", "grounding", "tone"],
)
GRADERS = [
Grader("Produced", "code", "артефакт получен", produced),
Grader("Bullets", "code", "число буллетов", bullets, scale=(0, 8, 5)),
Grader("Emoji", "code", "эмодзи в тексте", emoji, scale=(0, 10, "low")),
Grader("Clarity", "judge", "судья 0-5", lambda c: _scores(c)["clarity"],
scale=(0, 5, "high"), fmt=lambda v: f"{v}/5"),
Grader("Grounding", "judge", "судья 0-5", lambda c: _scores(c)["grounding"],
scale=(0, 5, "high"), fmt=lambda v: f"{v}/5"),
]
Что харнесс делает сам: кеш судейских вызовов на диск (повтор прогона бесплатен),
score.json + baseline.score.json на задачу, дельты только к закреплённому baseline,
инфра-ошибки в отдельной секции, markdown-отчёт, параллель по задачам.
ВЫХОД — шаблон отчёта
# Eval report — <система> — <дата>
Golden-set: <N> задач (<файл>) Baseline: <раунд/коммит> Прогонов на вариант: <k>
Судья: <модель/CLI>, рубрика v<N> Шумовой пол: ±<X> п.п. (n=<N>)
## Scorecard (вариант × метрика, дельты к baseline)
| вариант | Produced | Slide count | Text-heavy ↓ | Emoji ↓ | Text judge ↑ | Layout judge ↑ | ток./зад. |
|----------------|----------|-------------|--------------|---------|--------------|----------------|-----------|
| 00-baseline | ok 5/5 | 5 | 3 | 12 | 2.6/5 | 2.9/5 | 18k |
| 01-<изменение> | ok 5/5 | 5 | 1 (-2) | 0 (-12) | 3.8/5 (+1.2) | 3.6/5 (+0.7) | 21k |
## Fast loop
Пробник: <X>/<N> ✓ (baseline <Y>/<N>). Провалены: <проба> — <почему>.
## Pairwise (двусторонний)
A-vs-B: <winner> · B-vs-A: <winner> → голоса кандидат <c> : чемпион <ч>, margin <clear|slight>
## Инфра-ошибки (не считаются как fail модели)
- <task> / <грейдер>: <ошибка>
## ВЕРДИКТ: KEEP | ROLLBACK
Основание: <какая метрика двинулась, больше ли шумового пола, что не деградировало>
Следующее узкое место: <метрика с худшим значением> → гипотеза правки: <одна>
Правило одной правки за раунд: меняешь две вещи сразу — не знаешь, какая сработала
(в воркшопе каждый раунд 01→04 добавляет ровно один рычаг).
ПРИМЕР (worked)
Цифры ниже иллюстративные — в репозитории воркшопа нет сохранённых
runs/, подтверждены только структура раундов, набор грейдеров и механика замера. Реальные числа получаются прогоном.
Задача: агент-генератор слайдов, 5 задач в golden-set, 7 программных грейдеров + 5 судейских.
Раунд 0 (naive, Sonnet). Baseline закреплён: Produced ok 5/5, Slide count 5, Slides-with-image 0%, Text-heavy 3, Emoji 12, Text judge 2.6/5, Layout judge 2.9/5. Диагноз по красным ячейкам: стены текста + эмодзи-декор + нет визуалов.
Раунд 1 (полировка промпта: типографская иерархия, кап плотности, анти-AI-tells). Fast loop сначала: пробник 7/10 → 9/10, полный прогон разрешён. Text-heavy 3→1, Emoji 12→0, Text judge 2.6→3.8 (+1.2), Layout 2.9→3.6 (+0.7), Slides-with-image по-прежнему 0%. Шумовой пол по двум прогонам чемпиона ±0.3 балла судьи → +1.2 значимо. Pairwise двусторонний: 2:0 за кандидата, margin clear. KEEP.
Раунд 2 (обязательная диаграмма на слайд). Slides-with-image 0%→100%, Image judge 1.2→3.4, но Cluttered slides 0→2 и Layout 3.6→3.3 (−0.3, ровно на шумовом полу). Вердикт: KEEP с оговоркой — Layout уходит в новое узкое место.
Раунд 3 (QA-петля: агент рендерит дек в картинки, ищет дефекты, чинит, перепроверяет).
Layout 3.3→4.1, Cluttered 2→0; стоимость прогона +40% токенов. KEEP
(качество важнее токенов в этой задаче; если бы тай-брейк шёл по токенам — как в
agent-battle, где токены решают ничью, — решение было бы обратным).
Раунд 4 (рычаг модели: наивный промпт, но Opus). Судейские метрики выше наивного Sonnet, но ниже раунда 3 при большей цене → ROLLBACK: промпт-рычаг здесь сильнее модельного. Это и есть смысл раунда — сравнивать рычаги, а не только версии промпта.
Режим B — аудит чужого эвала (первоисточник: воркшоп rightmodel, references/audit.md)
Четыре блока проверок; отчёт — наблюдениями и предложениями, не приговором.
- Task design. Три уровня: (а) программный скан всего набора — дубли, баланс классов,
распределение длин, битые строки; (б) стратифицированная выборка 20-50 задач на чтение
глазами; (в) при N > нескольких сотен — LLM-аудитор по задаче с флагами
ambiguous / gold_suspect / answerable_from_memory / grader_too_strict / grader_too_lenient / trivially_cheatable, стоимость согласовать с владельцем. - Harness design. Чистое состояние на прогон; окружение полное; детерминизм (сид, сортировка); инфра-ошибки отделены от провалов модели; «нет ответа» ≠ «отрицательный ответ»; лимиты токенов не режут длинные корректные ответы; ретраи на 429; конфиг эвала совпадает с продовым; модель и ручки вынесены в аргументы, а не зашиты; трейсы сохраняются; несколько прогонов с разбросом; харнесс проверен на known-good и known-bad.
- Metrics hygiene. Токены из
usageAPI, а не по длине строки; цена из записанных токенов (цена судьи — отдельно от подсудимого); cache-hit rate сопоставим между вариантами; TTFT/TTLT без ретраев и ожидания в очереди; ретраи и ошибки — рядом с метриками, а не внутри них; пер-turn разбивка для агентных эвалов. - Grader design. Промпт и грейдер согласованы; оценивается результат, а не маршрут;
не слишком строг (пробелы/регистр/
4vs4.0) и не слишком мягок (напиши заведомо неверный правдоподобный ответ — грейдер обязан его завалить); атомарные проверки вместо одной смешанной оценки; агрегация под вопрос (для редких критичных нарушений — worst-case, не среднее); частичный кредит не делает выгодной стратегию «ничего не делать»; грейдер версионируется вместе с задачами. Плюс блок про судью — см. «Защита от подтасовки».
Отдельный приём: прочитать глазами 10 «провалов». Если больше одного из десяти — на самом деле верные ответы, не распознанные грейдером, чинить грейдер до любого свипа.
Режим C — свип по моделям / thinking / effort (первоисточник: воркшоп rightmodel, references/sweep.md)
Вопрос режима: какая ячейка (модель, параметры) даёт лучшее качество на доллар и на секунду.
Сетка (актуальные значения — сверять с config/models.md; в sweep.md на начало 2026):
model × thinking (off / adaptive) × effort (low/medium/high/xhigh/max) × trials=3.
⚠️ Форма {"type":"enabled","budget_tokens":N} снята: на Claude 4.7+ она возвращает
400, копировать её в харнесс нельзя. Ось thinking=off доступна не везде —
на Opus 5 отключение при effort xhigh/max даёт 400, на Fable 5.1 отключить нельзя
вовсе, поэтому такие ячейки из сетки просто выпадают.
Имена уровней effort несравнимы между моделями — свип по effort гонять под каждую
модель отдельно, а не переносить вывод с одной на другую.
В примере воркшопа: 2 ячейки Haiku + 6 Sonnet + 6 Opus = 14 на трайл.
Слать нативные параметры Anthropic; обобщённый reasoning_effort LiteLLM мапится в
легаси-форму и мислейблит ячейки.
Дисциплина:
- Всё остальное держать константой: те же task-id в каждой ячейке, фиксированная модель судьи/симулятора пользователя, сид меняем только если он не влияет на выбор задач.
- Если харнесс отдаёт только
--model— пропатчить одну точку вызова LLM, читаяthinking/effortиз env; после патча проверить на 2-3 задачах, что output-токены реально изменились, иначе параметр где-то теряется. - Метрики на ячейку:
pass_rate,cost_per_task,cost_per_success,seconds_per_success,p50_latency. Две выделенные — главные: модель втрое дешевле, но проходит вдвое реже, дешевле не является. - Раннер и плоттер — разные скрипты; раннер резюмируемый (ячейка пропускается только если в её файле нужное число примеров, а не просто «файл есть»); запускать фоново из основной сессии, а не из субагента, который потом умрёт и заберёт дерево процессов.
- Ячейки, прогнанные под разной параллельностью, несравнимы по латентности (backoff раздувает время); токены и цена не страдают.
- Выход: 3 графика (pass rate по Y; по X — выходные токены / цена / TTLT на задачу),
цвет = модель, стиль линии = thinking, размер маркера = effort, +
sweep_report.htmlс таблицей и base64-картинками, + одна-две фразы рекомендации с шумовым полом: «Sonnet с effort=medium и thinking off даёт те же 94% при трети цены за успех ($0.021 против $0.068); при n=20 одна задача = 5 п.п., меньшие различия не считать».
Минимальная обёртка ячейки (адаптировать, не копировать дословно):
def run_cell(model, thinking, effort, examples, run_one, judge, price):
passes = in_tok = out_tok = 0
secs, lats = 0.0, []
for ex in examples:
t0 = time.monotonic()
pred, usage = run_one(ex, model=model, thinking=thinking, effort=effort)
dt = time.monotonic() - t0
secs += dt; lats.append(dt)
in_tok += usage.input_tokens; out_tok += usage.output_tokens
passes += judge(pred, ex.expected)
cost = in_tok / 1e6 * price[0] + out_tok / 1e6 * price[1]
n = len(examples)
return {"pass_rate": passes / n, "cost_per_task": cost / n,
"cost_per_success": cost / passes if passes else float("inf"),
"secs_per_success": secs / passes if passes else float("inf"),
"p50_latency": sorted(lats)[n // 2]}
ЧЕК-ЛИСТ (перед тем как сказать «стало лучше»)
- Golden-set 20-50 задач, обе стороны поведения, одна способность на задачу
- Ответы не утекают в промпт / файлы, доступные системе под тестом
- Всё считаемое кодом считает код; судья — только субъективное
- Судья: жёсткий JSON-вывод, конкретная рубрика, «используй весь диапазон»
- Судья проверен на заведомых негативах и калиброван на человеческой разметке
- Baseline закреплён; дельты считаются к нему, а не к предыдущему прогону
- Одна правка за раунд
- Fast loop прошёл (не упал) до запуска полного прогона
- Инфра-ошибки отделены от провалов модели и не сидят в pass rate
- Дельта больше шумового пола (иначе — не результат)
- Программные метрики не деградировали, пока судья рос
- Pairwise прогнан в обоих порядках; ничья = чемпион держится
- Генератор не судил собственную работу
- Полные трейсы сохранены; отчёт с вердиктом keep/rollback записан
- Проигравший вариант откачен, а не «оставлен на всякий случай»
Смежное
check-skill-solo — фактчек текста и механическая проверка URL/цитат.
verifier (+ skills/verifier/references/gan-adversarial-improve.md) — адверсарная проверка находок, GAN-петля улучшения.
verifier/references/rag-eval-opik.md — метрики именно RAG (recall/precision/faithfulness/relevance).
multi-model-gateway — второе мнение другой модели (в т.ч. как независимый судья).
skill-creator — у него есть свой eval-прогон для триггеров скиллов; этот скилл — про эвал систем, а не описаний скиллов.