Продакшн-разработка ИИ-агентов · Модуль 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
}
Интервал Уилсона для доли успеха: честная погрешность success rate
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 "разница в пределах шума — нужен больший датасет"
    }
}
Каркас офлайн eval-раннера: датасет → прогон → скоринг → агрегат
// 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, средние шаги/токены/стоимость
}
LLM-as-judge с явным rubric и защитой от позиционного сдвига
// 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 с токенами, латентностью и ошибкой
// 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 и мок: тестируем код вокруг модели без сети
// 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)
    }
}
Record/replay через golden-файлы в testdata/ с флагом -update
// 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 тест тула и одной итерации цикла
// 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)
    }
}
Data flywheel: прод-трейсы → разметка → eval-датасет → прогон → отчёт о регрессиях
// Пайплайн маховика улучшения: выбрать кейсы из прод-трейсов, разметить,
// пополнить 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 (точное совпадение строки) — плохая метрика качества для агента?

  • A Он слишком медленный
  • B У агентных задач обычно нет единственно верного ответа, и недетерминизм даёт разные корректные формулировки — надо проверять свойства ответа
  • C Exact-match нельзя посчитать в Go
  • D Он требует LLM-as-judge

Вы используете LLM-as-judge, чтобы сравнивать два ответа A и B, и подаёте их всегда в порядке A, затем B.

Какой системный дефект это создаёт и как его погасить?

  • A Никакого — порядок не влияет
  • B Позиционный сдвиг (position bias): судья систематически предпочитает определённую позицию; лечится перестановкой порядка и усреднением
  • C Судья станет работать медленнее
  • D Ответы нужно склеить в один

Что ловит онлайн-оценка, чего принципиально не даёт офлайн-оценка на фиксированном датасете?

  • A Регрессии между двумя версиями промпта
  • B Воспроизводимое сравнение моделей
  • C Дрейф распределения входов и новые типы запросов реального трафика, которых нет в датасете
  • D Стоимость одного вызова модели

Команда настроила CI так, что на каждый коммit прогоняется eval-набор с реальными вызовами модели. Сборка стала идти 8 минут и иногда «мигает» (то проходит, то нет) без изменений кода.

Что не так с этой настройкой и как правильно разделить проверки?

  • A Ничего; eval на каждый коммит — лучшая практика
  • B Eval вероятностен и медленен — ему не место в per-commit CI; туда ставят детерминированные тесты с замоканной моделью, а eval гоняют перед релизом/по расписанию
  • C Нужно просто отключить часть кейсов датасета
  • D Надо увеличить таймаут сборки

Что описывает «петля улучшения» (data flywheel) и как она связывает observability с релизами?

  • A Это способ ускорить инференс модели за счёт кэша
  • B Цикл: прод-трейсы → отбор/разметка → пополнение eval-датасета → переоценка тем же раннером → выкат; сильные эвалы превращают апгрейд модели из риска в возможность
  • C Это перезапуск агента при сбое инструмента
  • D Это ротация ключей API между провайдерами

На eval-датасете из 25 примеров промпт B дал 21/25 (84%), а промпт A — 20/25 (80%). Команда хочет выкатить B как «лучше на 4 п.п.».

Какой вывод корректен статистически?

  • A B однозначно лучше — 84% > 80%, выкатываем
  • B На 25 примерах доверительные интервалы долей широко перекрываются: разница в пределах шума; нужен больший датасет или парное сравнение на одних и тех же кейсах
  • C Нужно просто прогнать ещё раз и взять лучший результат
  • D Размер выборки не важен, важна только сама дельта

Две версии агента дают одинаково правильный финальный ответ на датасете. Но версия B в среднем делает 11 шагов против 4 у A, с повторными поисками и лишними вызовами инструментов.

Что показывает этот случай и как это ловить в eval?

  • A Раз ответ верный, версии эквивалентны — оценивать больше нечего
  • B Нужна оценка траектории (process eval): по результату версии неотличимы, но B неэффективна и рискованнее; проверяют свойства пути — число шагов/токенов в бюджете, отсутствие лишних/повторных вызовов, правильные инструменты и их порядок
  • C Надо просто увеличить safety-cap, чтобы B успевала
  • D Это лечится сменой модели на более крупную