Продакшн-разработка ИИ-агентов · Модуль 5 · Урок 5.1
Глава 9. Оценка и observability
Цели главы
- Строить eval-датасеты (eval datasets) из реальных кейсов и поддерживать их версионируемыми
- Осознанно выбирать метод скоринга: программные проверки vs LLM-as-judge — и не попадаться в ловушки судьи
- Разделять офлайн- и онлайн-оценку (offline/online evaluation) и понимать, что каждая ловит
- Ловить регрессии (regression testing) до релиза, а не от пользователей
- Инструментировать агента трейсингом (tracing) и метриками: видеть каждый шаг, токены, стоимость и латентность
Что нового (дельта к базовому курсу)
В базовом курсе оценка агентов давалась обзорно: «прогоните несколько примеров и посмотрите». Этого хватает для прототипа, но не для прода. Здесь оценка становится инженерной дисциплиной: датасет как версионируемый артефакт, скоринг как явный контракт, регрессионные прогоны как гейт в CI, и observability как отдельный слой — трейсинг шагов, метрики и онлайн-аудит на проде. Дельта в одной фразе: от «посмотрел глазами» к «измеряю, сравниваю версии и ловлю деградацию автоматически».
Почему агента нельзя тестировать как обычный код
Юнит-тест проверяет детерминированную функцию: вход X → ровно выход Y. Агент недетерминирован (одна и та же задача даёт разные траектории), и у задачи часто нет единственно верного ответа (резюме, план, ответ на вопрос можно сформулировать десятком корректных способов).
Отсюда два следствия. Первое: проверять надо не точное совпадение строки, а свойства ответа (содержит нужный факт, не противоречит источнику, уложился в формат, не вызвал лишних инструментов). Второе: оценка — это статистика на наборе, а не «прошёл/не прошёл» на одном примере. Один зелёный прогон ничего не доказывает; нужен датасет и доля успеха (success rate) на нём.
Офлайн- и онлайн-оценка: разное назначение
Офлайн-оценка — прогон на фиксированном датасете до релиза. Её задача — поймать регрессии и сравнить версии (промпт A vs промпт B, модель Haiku vs Sonnet) в воспроизводимых условиях. Это ваш гейт в CI: упала доля успеха — не выкатываем.
Онлайн-оценка — измерение на живом трафике. Реальные запросы всегда шире датасета, поэтому здесь ловят то, чего нет в офлайне: дрейф распределения входов (distribution drift), новые типы запросов, редкие сбои. Инструменты онлайна — прод-метрики (success/refusal/error rate), выборочный аудит (sampling) ответов человеком или judge-моделью, сбор «плохих» кейсов обратно в офлайн-датасет.
Мысленная модель: офлайн — лаборатория (контроль, повторяемость), онлайн — реальность (полнота, дрейф). Хорошая система оценки замыкает цикл: плохой онлайн-кейс становится новым офлайн-тестом.
Eval-датасет: как строить и поддерживать
Датасет — не «10 примеров, что пришли в голову», а продуманный набор. Принципы:
- Из реальности. Берите кейсы из логов, тикетов поддержки, инцидентов — а не выдуманные. Реальные запросы кривее и разнообразнее придуманных.
- Покрытие. Включайте типичные кейсы, граничные, негативные (агент должен отказаться/уточнить) и регрессионные (каждый пойманный баг → новый кейс, чтобы он не вернулся).
- Golden set. Для части кейсов зафиксируйте эталон (ожидаемый факт, допустимый диапазон, обязательные и запрещённые свойства). Эталон — это контракт, по которому считается скор.
- Версионирование. Датасет живёт в репозитории и версионируется вместе с кодом и промптами: иначе вы не сможете честно сравнить «было/стало».
Методы скоринга: программные проверки vs LLM-as-judge
Сначала выжимайте программный скоринг (code-based grading): он дёшев, детерминирован и не врёт. Проверяйте свойства кодом — валидный JSON, наличие обязательного поля, число шагов ≤ N, не вызван запрещённый инструмент, ответ содержит нужный идентификатор, уложился в схему. Если свойство можно проверить кодом — проверяйте кодом.
LLM-as-judge нужен там, где критерий субъективен: «ответ полезен», «не противоречит источнику», «вежлив и по делу». Судья — это отдельный вызов модели с rubric (явные критерии и шкала). Но у судьи есть систематические ловушки, которые надо гасить осознанно.
Оценка траектории: путь, а не только ответ
Скоринг выше проверяет результат — финальный ответ. Но у агента есть ещё траектория (trajectory): последовательность шагов, вызовов инструментов и промежуточных решений. Два прогона могут дать одинаково правильный ответ, но один — за 2 шага, а другой — за 14, с тремя лишними поисками и вызовом не того инструмента. По результату они неотличимы; по траектории — нет. Поэтому в проде оценивают и то, и другое.
Что проверяют в траектории (всё это — программный скоринг, дёшево и детерминированно):
- Правильные инструменты. Вызваны ли ожидаемые инструменты и не вызваны запрещённые/опасные.
- Эффективность. Число шагов и токенов в пределах бюджета; нет лишних/повторяющихся вызовов (признак топтания на месте); нет зацикливания.
- Порядок и зависимости. Например,
confirmвызван до необратимого действия, а не после. - Корректность аргументов. Аргументы инструментов валидны и согласованы с задачей.
Различают строгую проверку траектории (точное совпадение с эталонной последовательностью — хрупко, ломается на любой допустимой вариации) и свойствами (набор инвариантов: «вызвал search хотя бы раз», «не вызвал delete без confirm», «шагов ≤ 8»). Почти всегда правильно — проверять свойства, а не точный путь: у агента закономерно много корректных траекторий, и требовать одну — значит штрафовать за допустимое разнообразие. Эталонные «золотые траектории» полезны как ориентир и для отладки, но не как жёсткий ассерт.
Связь с observability (ниже): сами шаги вы и так пишете в спаны трейсинга — оценка траектории это ассерты поверх тех же спанов, перенесённые в eval-раннер.
Ловушки LLM-as-judge
- Позиционный сдвиг (position bias): при сравнении двух ответов судья чаще выбирает первый/второй независимо от качества. Лечится перестановкой и усреднением.
- Самосогласие (self-preference): модель завышает оценку текстам, похожим на свой стиль. По возможности судите моделью другого семейства/версии.
- Расплывчатый rubric: «оцени от 1 до 10» даёт шум. Нужны явные критерии и описание каждого балла.
- Нет калибровки: прогоните судью на golden set с известными оценками — совпадает ли он с человеком? Без калибровки judge-метрика измеряет неизвестно что.
Правило: judge — это тоже система, которую надо валидировать. Доверяй, но калибруй.
Observability: трейсинг и метрики
Оценка отвечает «насколько хорошо», observability — «что именно произошло». Агент — это цепочка недетерминированных шагов, и без трейсинга отладка превращается в гадание.
Трейсинг (tracing): каждый шаг цикла — отдельный span (вызов модели, вызов инструмента, retrieval). В span'е: вход, выход, токены, длительность, ошибка. Складывайте span'ы в дерево по запросу (trace) — видно всю траекторию. Складывается отраслевой стандарт — OpenTelemetry GenAI semantic conventions (атрибуты gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.response.finish_reasons и др.); опирайтесь на него, чтобы трейсы были переносимы между вендорами.
Метрики: агрегаты поверх трейсов — success rate, доля отказов/ошибок, среднее число шагов, токены и стоимость на запрос, латентность (p50/p95, отдельно time-to-first-token при стриминге). Эти метрики — ранний детектор деградации: рост числа шагов или токенов часто означает, что агент «поплыл», ещё до того как упадёт success rate.
Тесты как софт vs eval: два разных инструмента
Eval (выше) — не единственный способ проверять агента. Рядом стоит обычное тестирование кода, и его часто путают с eval, хотя задачи у них противоположные.
- Детерминированные тесты — быстрые, без сети, гоняются на каждый коммит в CI. Проверяют то, что ДОЛЖНО быть детерминированным: парсинг tool-call'ов, ветвления управляющего цикла, сборку запроса, обработку ошибок инструмента, лимит шагов. Здесь ответ модели замокан — мы тестируем свой код вокруг модели, а не саму модель.
- Eval — медленный и вероятностный: реально зовёт модель, считает долю успеха на наборе, ловит регрессии качества. Гоняется не на каждый коммит (дорого и шумно), а перед релизом и по расписанию.
Грубое правило: всё, что детерминированно, проверяйте тестами; всё, что вероятностно, — eval'ом. Если падает тест — у вас баг в коде; если падает eval — деградировало качество модели/промпта. Смешивать их вредно: eval в CI делает сборку медленной и «мигающей» (flaky), а юнит-тестом нельзя измерить качество ответа.
Мок LLM-клиента и record/replay (golden-файлы)
Чтобы тесты были быстрыми и без сети, спрячьте модель за интерфейсом Client и подменяйте его в тестах мок-реализацией. Тогда управляющую логику (разбор ответа, цикл, вызов инструментов) можно гонять table-driven тестами с фиксированными ответами — детерминированно и за миллисекунды.
Для проверки на реальных ответах применяйте record/replay: один раз записали живой ответ модели в файл-эталон (cassette/golden), дальше тест воспроизводит его из файла, не выходя в сеть. В Go это канонический паттерн golden-файлов: эталоны лежат в каталоге testdata/ (он игнорируется тулчейном Go), а флаг -update перегенерирует их, когда вывод намеренно изменился.
testdata/— служебный каталог: командаgo buildиgo testего не компилируют, туда кладут фикстуры и эталоны.- Флаг
-update(объявляется черезflag.Bool) — стандартная договорённость:go test -updateпереписывает.golden, обычный прогон лишь сравнивает. - Записать реальный ответ для эталона удобно через
net/http/httptest: поднять тестовый сервер, сохранить тело ответа вtestdata/*.golden.
Так вы получаете доверие к реальному формату ответа (спаны и трейсы из этой же главы помогают такой ответ зафиксировать), но без флака и задержек сети в CI.
Петля улучшения: data flywheel (связь с Главой 13)
Observability и eval — не разовый артефакт, а маховик (data flywheel), который крутится вместе с релизами. Замкнутый цикл:
- прод-трейсы (спаны из этой главы) копят реальные траектории и сбои;
- отбор и разметка — выбираем интересные/провальные кейсы, размечаем эталон;
- курирование — добавляем их в версионируемый eval-датасет (regression growth);
- переоценка — гоняем eval-раннер (тот же, что выше) на обновлённом датасете;
- выкат — если метрики не просели, релизим; пойманные регрессии остаются тестами навсегда.
Этот цикл напрямую связан с релизным процессом (см. Главу 13): эвалы превращают апгрейд модели из риска в возможность. Идея Anthropic: когда у вас есть сильные эвалы, новая версия модели — это шанс, а не угроза; миграцию можно сделать за дни, а не недели, потому что качество измеряется автоматически, а не «на глаз».
Статистика eval'ов: сигнал или шум
Success rate на датасете — это оценка вероятности, а не точное число. Поэтому сравнивая версии (промпт A vs B, модель Haiku vs Sonnet), вы обязаны спросить: дельта 78% → 81% — это улучшение или просто шум на маленькой выборке? Без ответа на этот вопрос вы будете катить случайные изменения и откатывать хорошие.
Три рабочих инструмента:
- Доверительный интервал (confidence interval). Доля успеха
p̂ = успехи/Nвсегда идёт с погрешностью. На малых N и долях у краёв (близко к 0 или 1) корректнее интервал Уилсона (Wilson score), чем наивная нормальная формула. Если интервалы двух версий сильно перекрываются — разницы, скорее всего, нет. - Размер выборки (sample size). Чтобы уверенно различить, скажем, 78% и 81%, нужны сотни примеров, а не 20. Грубое правило: погрешность доли ~
1/√N; хотите различать дельты в пару процентов — нужны тысячи. Маленький датасет «зелёный» ничего не доказывает. - Парное сравнение. Гоняйте обе версии на одних и тех же примерах и сравнивайте по каждому (где A лучше B и наоборот). Парный тест мощнее, чем сравнение двух независимых средних: убирает дисперсию «лёгких/трудных» кейсов.
Практический вывод для CI-гейта (Глава 13): сравнивайте не голые проценты, а интервалы, и фиксируйте порог значимости заранее. Иначе «улучшение» на 8 примерах развернётся в регресс на проде.
package eval
// Step — один шаг траектории агента (то, что вы и так пишете в спан трейсинга).
type Step struct {
Tool string // вызванный инструмент ("" — обычный ответ модели)
Args map[string]any
}
// TrajectoryCheck проверяет НАБОР СВОЙСТВ траектории, а не точный путь:
// у корректного агента много допустимых траекторий, поэтому строгое
// совпадение с эталоном хрупко. Возвращаем список нарушений (пусто = ок).
func TrajectoryCheck(steps []Step, maxSteps int) []string {
var violations []string
if len(steps) > maxSteps {
violations = append(violations, "превышен бюджет шагов")
}
// Инвариант безопасности: необратимое действие только после подтверждения.
confirmed := false
seen := map[string]int{}
for _, s := range steps {
if s.Tool == "" {
continue
}
seen[s.Tool]++
if s.Tool == "confirm_action" {
confirmed = true
}
if s.Tool == "delete_account" && !confirmed {
violations = append(violations, "delete_account вызван без предшествующего confirm_action")
}
}
// Признак топтания на месте: один и тот же инструмент с одинаковым смыслом много раз.
for tool, n := range seen {
if n > 3 {
violations = append(violations, "подозрение на зацикливание: "+tool+" вызван слишком часто")
}
}
return violations
}package eval
import "math"
// wilson возвращает нижнюю и верхнюю границы доверительного интервала Уилсона
// для доли успеха (successes из n) при заданном z (z=1.96 ≈ 95%).
// Уилсон корректнее наивной нормальной формулы на малых n и у краёв (p→0/1).
func wilson(successes, n int, z float64) (lo, hi float64) {
if n == 0 {
return 0, 1 // нет данных — ничего не утверждаем
}
phat := float64(successes) / float64(n)
z2 := z * z
denom := 1 + z2/float64(n)
center := phat + z2/(2*float64(n))
margin := z * math.Sqrt(phat*(1-phat)/float64(n)+z2/(4*float64(n)*float64(n)))
return (center - margin) / denom, (center + margin) / denom
}
// betterWithConfidence грубо отвечает: версия B лучше A или это шум?
// Если интервалы не перекрываются — разница похожа на сигнал.
func betterWithConfidence(succA, nA, succB, nB int) string {
loA, hiA := wilson(succA, nA, 1.96)
loB, hiB := wilson(succB, nB, 1.96)
switch {
case loB > hiA:
return "B лучше A (интервалы не перекрываются)"
case loA > hiB:
return "A лучше B (интервалы не перекрываются)"
default:
return "разница в пределах шума — нужен больший датасет"
}
}// Case — один кейс датасета. Эталон (Want) описывает проверяемые свойства,
// а не точную строку: для агентов точное совпадение почти бесполезно.
type Case struct {
ID string
Input string
Want Expect // что должно быть верно в ответе
}
// Expect — программно проверяемые свойства ответа (грунт для code-based grading).
type Expect struct {
MustContain []string // ответ обязан содержать эти подстроки/факты
MustNotCall []string // эти инструменты вызывать нельзя
MaxSteps int // потолок числа шагов
JSONSchema string // если задана — ответ должен быть валиден по схеме
}
// Result — исход прогона одного кейса.
type Result struct {
CaseID string
Passed bool
CodeScore float64 // программный скор 0..1
JudgeScore float64 // скор LLM-as-judge 0..1 (если применялся)
Steps int
Tokens int
Notes []string
}
// RunEval прогоняет весь датасет и возвращает агрегаты. Прогон каждого кейса
// изолирован: свежее состояние агента, общий бюджет шагов из Expect.
func RunEval(ctx context.Context, ds []Case, run AgentRunner, judge Judge) (Report, error) {
var results []Result
for _, c := range ds {
tr, err := run(ctx, c.Input, c.Want.MaxSteps) // вернёт ответ + трейс
if err != nil {
results = append(results, Result{CaseID: c.ID, Passed: false,
Notes: []string{"runtime error: " + err.Error()}})
continue
}
r := scoreCode(c, tr) // сначала дешёвый программный скоринг
if c.Want.JSONSchema == "" && needsJudge(c) {
r.JudgeScore = judge.Score(ctx, c, tr.Output) // субъективные критерии
}
r.Passed = r.CodeScore >= 1.0 && r.JudgeScore >= judgeThreshold
results = append(results, r)
}
return aggregate(results), nil // success rate, средние шаги/токены/стоимость
}// Judge оценивает субъективные свойства ответа отдельным вызовом модели.
// Ключ к надёжности — ЯВНЫЙ rubric и калибровка на golden set.
type Judge struct {
client *anthropic.Client
model anthropic.Model // судья — желательно ДРУГОГО семейства, чем оцениваемая модель
}
const rubric = `Оцени ответ агента по шкале 0..1 строго по критериям:
- Грунтинг: каждое утверждение подтверждается приведённым источником (0.4)
- Полнота: отвечает на весь вопрос пользователя (0.3)
- Формат: соблюдена требуемая структура, нет лишнего (0.3)
Верни ТОЛЬКО число от 0 до 1 с двумя знаками.`
func (j Judge) Score(ctx context.Context, c Case, answer string) float64 {
prompt := rubric + "\n\nВопрос: " + c.Input + "\nОтвет: " + answer
msg, err := j.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: j.model,
MaxTokens: 16, // ждём одно число — не даём судье «растекаться»
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(prompt))},
})
if err != nil {
return 0 // непоставленный скор = провал, а не «тихий ноль» в плюс
}
return parseScore(msg) // распарсить число; при мусоре — 0
}
// Для ПАРНОГО сравнения (A против B) вызывай судью дважды со сменой порядка
// и усредняй — иначе позиционный сдвиг исказит результат.// Span — единица трейсинга: один шаг цикла (вызов модели или инструмента).
// Имена атрибутов — по OpenTelemetry GenAI semantic conventions, чтобы трейсы
// были переносимы между вендорами наблюдаемости.
type Span struct {
Name string // напр. "llm.call" или "tool.search_orders"
Start time.Time
Attrs map[string]any // gen_ai.request.model, gen_ai.usage.input_tokens, ...
Err error
}
// traceStep оборачивает любой шаг агента и пишет span в трейс. Возвращает
// результат шага как есть — обёртка прозрачна для логики цикла.
func traceStep[T any](ctx context.Context, tr *Trace, name string, attrs map[string]any,
step func(context.Context) (T, error)) (T, error) {
s := Span{Name: name, Start: now(), Attrs: attrs}
out, err := step(ctx)
s.Err = err
s.Attrs["duration_ms"] = since(s.Start).Milliseconds()
tr.Add(s) // span ляжет в дерево запроса; агрегаты соберём поверх трейсов
return out, err
}// Client — узкий интерфейс над LLM-провайдером. Прод-код зависит от него,
// а не от конкретного SDK, поэтому в тестах модель легко подменить моком.
type Client interface {
Complete(ctx context.Context, req Request) (Response, error)
}
// mockClient — детерминированная подмена для юнит-тестов. Отдаёт заранее
// заданные ответы по очереди; никакой сети, прогон за миллисекунды.
type mockClient struct {
responses []Response
calls int
}
func (m *mockClient) Complete(_ context.Context, _ Request) (Response, error) {
if m.calls >= len(m.responses) {
return Response{}, errors.New("mock: запрошено больше ответов, чем задано")
}
r := m.responses[m.calls]
m.calls++
return r, nil
}
// Теперь управляющую логику цикла можно проверять детерминированно: подаём
// фиксированный tool-call в первом ответе и финальный текст во втором.
func TestLoop_RunsToolThenFinishes(t *testing.T) {
mc := &mockClient{responses: []Response{
{ToolCalls: []ToolCall{{Name: "search", Args: map[string]any{"q": "x"}}}},
{Text: "готово"},
}}
agent := NewAgent(mc, testTools())
out, err := agent.Run(context.Background(), "найди x", 5)
if err != nil {
t.Fatalf("неожиданная ошибка: %v", err)
}
if out != "готово" {
t.Errorf("ожидали финальный текст, получили %q", out)
}
}// update — флаг перегенерации эталонов: go test -update перепишет .golden,
// обычный прогон только сравнивает. testdata/ игнорируется тулчейном Go.
var update = flag.Bool("update", false, "перегенерировать golden-файлы")
// assertGolden сравнивает фактический вывод с эталоном из testdata/<name>.golden.
// При -update эталон переписывается (когда вывод изменился НАМЕРЕННО).
func assertGolden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join("testdata", name+".golden")
if *update {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("не записал golden: %v", err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("нет эталона (запустите go test -update): %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("вывод разошёлся с %s.golden\nполучено: %s", name, got)
}
}
// Записать РЕАЛЬНЫЙ ответ для эталона один раз можно через httptest: поднять
// фейковый сервер провайдера, сохранить тело ответа в testdata/*.golden,
// дальше тесты воспроизводят его из файла без сети.
func recordResponse(t *testing.T) []byte {
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write(fakeProviderBody) // заранее снятый/смоканный ответ провайдера
}))
defer srv.Close()
resp, _ := http.Get(srv.URL)
body, _ := io.ReadAll(resp.Body)
return body
}// Table-driven тест инструмента: набор случаев в таблице, один прогон на каждый.
// Чисто детерминированно — это код инструмента, а не модель.
func TestSearchTool(t *testing.T) {
cases := []struct {
name string
args map[string]any
want string
wantErr bool
}{
{"валидный запрос", map[string]any{"q": "order-42"}, "order-42: paid", false},
{"пустой запрос", map[string]any{"q": ""}, "", true},
{"нет аргумента q", map[string]any{}, "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := searchTool(testCtx(), c.args)
if (err != nil) != c.wantErr {
t.Fatalf("ошибка=%v, ожидали wantErr=%v", err, c.wantErr)
}
if got != c.want {
t.Errorf("получили %q, ожидали %q", got, c.want)
}
})
}
}
// Одна итерация управляющего цикла: на tool-call агент обязан вызвать инструмент
// и вернуть его результат в контекст. Модель замокана — проверяем СВОЙ код.
func TestStep_DispatchesToolCall(t *testing.T) {
mc := &mockClient{responses: []Response{
{ToolCalls: []ToolCall{{Name: "search", Args: map[string]any{"q": "order-42"}}}},
}}
st := newState(mc, testTools())
if err := st.step(context.Background()); err != nil {
t.Fatalf("step: %v", err)
}
if last := st.lastToolResult(); last != "order-42: paid" {
t.Errorf("инструмент не вызван корректно: %q", last)
}
}// Пайплайн маховика улучшения: выбрать кейсы из прод-трейсов, разметить,
// пополнить eval-датасет, прогнать тот же eval-раннер и сравнить с baseline.
func RunFlywheel(ctx context.Context, traces []Trace, ds []Case, run AgentRunner, judge Judge) (Diff, error) {
// 1) Выборка из прод-трейсов: берём провалы и нетипичные траектории —
// именно они дают новые регрессионные кейсы.
candidates := sampleInteresting(traces) // напр. high steps, ошибки, низкий judge-скор
// 2) Разметка: человек/судья фиксирует эталон (Want) для каждого кандидата.
labeled := labelCases(ctx, candidates, judge)
// 3) Курирование: пополняем версионируемый датасет (без дублей).
grown := mergeDataset(ds, labeled)
// 4) Переоценка: тот же eval-раннер из этой главы, но на расширенном наборе.
base, err := RunEval(ctx, ds, run, judge)
if err != nil {
return Diff{}, err
}
next, err := RunEval(ctx, grown, run, judge)
if err != nil {
return Diff{}, err
}
// 5) Отчёт о регрессиях: что просело относительно baseline — это и есть
// гейт для выката (см. релизный процесс, Глава 13).
return diffReports(base, next), nil
}Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
| Оценка «на глаз» на 2–3 примерах | Недетерминизм маскирует регрессии; выводы случайны | Версионируемый датасет + доля успеха на наборе |
| Exact-match как метрика для агента | У задачи нет единственно верного ответа — почти всё «не прошло» | Проверять свойства ответа, а не точную строку |
| LLM-as-judge без rubric и калибровки | Шумный скор, позиционный/само-сдвиг — измеряет неизвестно что | Явный rubric, перестановка порядка, калибровка на golden set |
| Только офлайн-оценка | Прод-трафик шире датасета; дрейф и новые кейсы не видны | Добавить онлайн-метрики и выборочный аудит, замкнуть цикл |
| Нет трейсинга шагов | Отладка недетерминированной цепочки превращается в гадание | Span на каждый шаг (OTel GenAI), трейс на запрос |
| Judge той же моделью, что и агент | Само-предпочтение завышает оценку | Судить моделью другого семейства/версии |
| Гонять eval (реальные вызовы модели) в CI на каждый коммит | Сборка медленная и flaky; вероятностный шум маскирует баги кода | Детерминированные тесты с моком в CI; eval — перед релизом/по расписанию |
| Зависеть от конкретного SDK прямо в коде цикла | Нельзя замокать модель — логику не покрыть быстрыми тестами | Спрятать модель за интерфейсом Client, в тестах подменять моком |
| Хардкодить ожидаемый ответ модели прямо в тесте | Эталон распухает в коде, обновлять больно | Golden-файлы в testdata/ + флаг -update для перегенерации |
Практическое задание
- Соберите eval-датасет из 15–20 реальных кейсов (логи/тикеты): типичные, граничные, негативные; для половины зафиксируйте golden-эталон (обязательные/запрещённые свойства).
- Напишите программный скоринг (code-based grading): валидность формата, наличие нужных фактов, потолок шагов, отсутствие запрещённых вызовов инструментов.
- Добавьте LLM-as-judge с явным rubric для одного субъективного критерия и откалибруйте его на golden set (сравните с вашими оценками).
- Оберните шаги агента трейсингом (span на вызов модели и инструмента) с атрибутами OTel GenAI; соберите метрики: success rate, шаги, токены, стоимость, латентность.
- Прогоните две версии промпта на датасете и сравните агрегаты; зафиксируйте, какая лучше и по какой метрике.
- Встройте прогон eval как гейт: падение success rate ниже порога — сборка/выкат не проходит.
- Спрячьте LLM-клиент за интерфейсом
Clientи напишите детерминированные тесты управляющего цикла с моком: разбор tool-call, лимит шагов, обработка ошибки инструмента — всё без сети, на каждый коммит. - Заведите golden-файлы в
testdata/и флаг-update: запишите реальный ответ модели один раз (можно черезhttptest), дальше тесты воспроизводят его из файла; добавьте table-driven тесты на каждый инструмент. - Постройте data flywheel: настройте выборку интересных/провальных кейсов из прод-трейсов, их разметку и автоматическое пополнение eval-датасета с отчётом о регрессиях перед выкатом (см. Главу 13).
Проверка знаний
Почему exact-match (точное совпадение строки) — плохая метрика качества для агента?
Верный ответ: B
B верно. Ответ агента можно сформулировать многими корректными способами; точное совпадение почти всегда «не прошло». Проверяйте свойства (наличие факта, формат, отсутствие запрещённых действий) программно, а субъективное — судьёй. A/C/D неверны.
Вы используете LLM-as-judge, чтобы сравнивать два ответа A и B, и подаёте их всегда в порядке A, затем B.
Какой системный дефект это создаёт и как его погасить?
Верный ответ: B
B верно. Судья-модель склонна выбирать ответ по позиции независимо от качества. Прогоняйте сравнение в обоих порядках и усредняйте. Также судите моделью другого семейства, чтобы убрать само-предпочтение.
Что ловит онлайн-оценка, чего принципиально не даёт офлайн-оценка на фиксированном датасете?
Верный ответ: C
C верно. Прод-трафик всегда шире и подвижнее датасета: онлайн ловит дрейф и новые кейсы. A и B — сильные стороны офлайна. Хорошая система замыкает цикл: плохой онлайн-кейс становится новым офлайн-тестом.
Команда настроила CI так, что на каждый коммit прогоняется eval-набор с реальными вызовами модели. Сборка стала идти 8 минут и иногда «мигает» (то проходит, то нет) без изменений кода.
Что не так с этой настройкой и как правильно разделить проверки?
Верный ответ: B
B верно. «Мигание» — это вероятностный шум реальных вызовов модели: в CI он маскирует настоящие баги кода и тормозит сборку. Детерминированное (парсинг tool-call, цикл, инструменты) проверяйте быстрыми тестами с моком Client и golden-файлами; eval — отдельный, более редкий прогон перед релизом. C и D лечат симптом, а не причину.
Что описывает «петля улучшения» (data flywheel) и как она связывает observability с релизами?
Верный ответ: B
B верно. Маховик замыкает observability (спаны/трейсы из этой главы) на релизный процесс (см. Главу 13): реальные кейсы непрерывно пополняют версионируемый датасет, а автоматическая переоценка ловит регрессии до выката. Поэтому новая версия модели — повод обновиться за дни, а не угроза. A/C/D — про другое.
На eval-датасете из 25 примеров промпт B дал 21/25 (84%), а промпт A — 20/25 (80%). Команда хочет выкатить B как «лучше на 4 п.п.».
Какой вывод корректен статистически?
Верный ответ: B
B верно. Success rate — оценка вероятности с погрешностью ~1/√N; на N=25 интервалы 80% и 84% сильно перекрываются, и дельта неотличима от шума. Нужны сотни примеров и/или парное сравнение на идентичных кейсах. A игнорирует погрешность; C — «выбрать лучший прогон» это cherry-picking; D неверно — размер выборки определяет различимость дельты.
Две версии агента дают одинаково правильный финальный ответ на датасете. Но версия B в среднем делает 11 шагов против 4 у A, с повторными поисками и лишними вызовами инструментов.
Что показывает этот случай и как это ловить в eval?
Верный ответ: B
B верно. Оценка только результата слепа к стоимости и риску пути: лишние шаги — это деньги, латентность и больше шансов на ошибку/инъекцию. Траекторию проверяют программно по свойствам (инвариантам): бюджет шагов/токенов, нет повторов/зацикливания, нужные инструменты в правильном порядке (например, confirm до необратимого). Строгое совпадение с эталонным путём хрупко — у агента много корректных траекторий. A игнорирует эффективность/риск; C маскирует проблему; D не про это.
Дальше почитать
- Anthropic — Create strong empirical evaluations
- Wilson score interval (доверительный интервал доли) — Wikipedia
- Anthropic — Reduce hallucinations
- OpenTelemetry — GenAI observability (semantic conventions)
- Anthropic — Building effective agents
- Go — пакет testing (стандартная библиотека)
- Go — net/http/httptest
- Go Wiki — Table Driven Tests
- Eli Bendersky — File-driven testing in Go (проверить)
- Anthropic — Writing tools for agents (eval-driven раздел)