Продакшн-разработка ИИ-агентов · Модуль 5 · Урок 5.2

Глава 10. Надёжность и guardrails

Цели главы

  • Перечислять типовые failure modes агента и проектировать защиту под каждый
  • Строить guardrails слоями: валидация входа, контроль поведения, валидация выхода
  • Применять фолбэки и контролируемую деградацию (graceful degradation) вместо падения
  • Реализовать circuit breaker и грамотные ретраи (backoff + jitter, идемпотентность)
  • Снижать галлюцинации грунтингом в источники и встраивать human-in-the-loop там, где цена ошибки высока

Что нового (дельта к базовому курсу)

В базовом курсе были упомянуты ретраи и «программные gate'ы» как отдельные приёмы. Здесь надёжность становится системой: вы сначала перечисляете режимы отказа (failure modes), затем закрываете каждый конкретным механизмом, и собираете их в слои вокруг agent loop. Дельта: от «добавил ретрай, где упало» к «спроектировал поведение агента при любом классе сбоя — и деградирую предсказуемо, а не падаю».

Карта failure modes агента

Прежде чем защищаться, перечислите, как агент ломается. Типовые режимы:

  • Галлюцинация инструмента/факта — модель выдумывает несуществующий инструмент, аргумент или факт.
  • Неверные аргументы — инструмент вызван не вовремя или с мусором на входе.
  • Зацикливание — агент повторяет один шаг или ходит по кругу, выжигая бюджет.
  • Неверный финал — уверенно сформулированный, но ложный или не по формату ответ.
  • Внешний сбой — инструмент/API упал, таймаут, rate limit, частичный результат.
  • Небезопасное действие — необратимая или дорогая операция без подтверждения.

Каждый режим закрывается своим механизмом; универсального «сделать надёжно» нет. Карта режимов — это и есть техзадание на guardrails.

Guardrails как слои вокруг agent loop

Думайте о защите как о трёх слоях вокруг цикла:

  • Вход (input guardrails): валидация и санитизация запроса до модели — отсечь явный мусор, инъекции, превышение размера; нормализовать формат.
  • Поведение (in-loop guardrails): контроль самого цикла — бюджет шагов и токенов, детектор зацикливания, allowlist инструментов, таймауты на каждый вызов.
  • Выход (output guardrails): проверка ответа до отдачи пользователю — соответствие схеме/политике, факт-чек против источников, отсутствие утечек. Невалидный выход → ретрай с уточнением или безопасный отказ.

Слои независимы и дёшевы по отдельности; вместе они дают «оборону в глубину» (defense in depth): пробой одного слоя ловится следующим.

Фолбэки и контролируемая деградация

Надёжная система деградирует предсказуемо, а не падает с 500. Лестница фолбэков:

  • повторить вызов (если сбой временный);
  • переключиться на запасную модель/провайдера (fallback model);
  • отдать кэшированный или упрощённый ответ;
  • честно отказать с понятным сообщением и, если уместно, эскалацией к человеку.

Принцип: лучше явный честный отказ, чем уверенный неправильный ответ. «Я не смог надёжно ответить» — это успешная деградация, а не баг.

Circuit breaker и ретраи

Ретраи нужны для временных сбоев (сеть, 429, 503), но наивный «повторить сразу 5 раз» добивает уже перегруженный сервис. Правильно — экспоненциальный backoff с джиттером (exponential backoff + jitter) и ограничение числа попыток. И только для идемпотентных операций: повтор неидемпотентного инструмента (отправил письмо, списал деньги) дублирует side-effect — защищайтесь idempotency-ключом.

Circuit breaker защищает от каскада: если инструмент/зависимость стабильно падает, размыкатель «размыкается» и на время перестаёт её дёргать, сразу отдавая фолбэк. Это бережёт и вашу латентность, и падающий сервис. Состояния: closed (работаем) → open (череда сбоев, не зовём) → half-open (пробный вызов на восстановление).

Борьба с галлюцинациями

Галлюцинация — не «модель глупая», а следствие того, что модель оптимизирована давать правдоподобный текст, а не признавать незнание. Рычаги:

  • Грунтинг (grounding): заставляйте отвечать только по приведённым источникам и цитировать их; явно разрешайте ответ «в источниках этого нет».
  • Структурный отказ: дайте модели лёгкий путь сказать «не знаю/нужны данные» — иначе она выдумает.
  • Само-проверка: второй проход, который сверяет утверждения ответа с источником (verification chain).
  • Консистентность: для критичных ответов — несколько прогонов и сверка; расхождение сигналит о выдумке.

Грунтинг + явное разрешение «не знаю» убирают большую часть галлюцинаций дешевле, чем любые сложные схемы.

Human-in-the-loop: когда подключать человека

Полная автономия уместна не всегда. Подключайте человека (human-in-the-loop) по трём триггерам:

  • Высокая цена ошибки — деньги, юридические/медицинские последствия, публичные действия.
  • Низкая уверенность — модель или guardrails сигналят о сомнении/конфликте.
  • Необратимость — действие нельзя откатить (удаление, платёж, отправка вовне).

Технически это approval-гейт: агент готовит действие, но исполняет его только после подтверждения. Ключевая мысль: человек — это не «затычка вместо качества», а осознанный слой контроля на дорогих и необратимых ветках.

UX и интерактивность: агент как собеседник, а не чёрный ящик

Human-in-the-loop отвечает на вопрос «когда вмешаться», но есть и слой взаимодействия: как агент общается с пользователем в процессе работы. Долгий агентный прогон без обратной связи ощущается как зависание; пользователь не знает, жив ли агент, что он делает и можно ли его остановить. Хороший UX превращает агента из чёрного ящика в наблюдаемого и управляемого собеседника.

Четыре составляющих интерактивности:

  • Стриминг — отдавать ответ по мере генерации, а не ждать целиком; резко снижает воспринимаемую задержку (time-to-first-token).
  • Промежуточные шаги — показывать, какие инструменты агент вызывает и что нашёл; те же события, что вы пишете в спаны трейсинга (Глава 9), можно частично показывать и пользователю.
  • Прерывание и коррекция на лету — дать остановить или скорректировать агента, не дожидаясь конца.
  • Прозрачность — показывать источники/цитаты, чтобы ответу можно было доверять и проверить его.

Стриминг и промежуточные шаги

Стриминг — это не только UX-украшение: при длинном ответе он сокращает time-to-first-token и даёт пользователю ранний сигнал, что система работает. В anthropic-sdk-go (v1.x; сверьтесь с актуальными доками) ответ стримится событиями: открыли поток, читаем дельты текста по мере генерации, в конце проверяем ошибку потока.

Промежуточные шаги делают работу агента наблюдаемой. Перед вызовом инструмента покажите «ищу заказ #42…», после — краткий результат. Это те же события, что ложатся в спаны трейсинга (Глава 9): один источник правды — и для observability, и для UI. Но фильтруйте, что показывать: внутренние рассуждения и сырые аргументы инструментов могут содержать чувствительное (см. редакцию PII, Глава 11) — наружу идёт только безопасная сводка.

Прерывание, коррекция и прозрачность

Прерывание (cancel): пользователь должен мочь остановить агента в любой момент. В Go это context.WithCancel: по cancel() отменяется и текущий запрос к модели (стрим завершается), и весь agent loop. Отмена — это не ошибка, а штатный исход; отличайте её от сбоя.

Коррекция на лету (steering): более сильная форма — позволить вмешаться в ход, не начиная заново: «нет, ищи по другому клиенту», «остановись на этом и подведи итог». Технически это инъекция нового пользовательского сообщения в текущий контекст агента между итерациями цикла.

Прозрачность (источники/цитаты): для grounded-ответов (Глава 10, борьба с галлюцинациями) показывайте, ОТКУДА факт — ссылку, фрагмент документа, id записи. Это и доверие пользователя, и канал для эскалации: если источник выглядит не тем, человек заметит раньше агента. Свяжите это с эскалацией из этой главы — сомнительный или пустой источник может быть триггером human-in-the-loop.

Разбор инцидента: автономный агент и удаление прод-данных

Теория надёжности лучше всего видна на разборе реального класса инцидентов. В 2025 публично обсуждался случай, когда автономный кодинг-агент удалил продакшн-базу данных во время задачи, несмотря на устные инструкции «ничего не трогать в проде» (детали и атрибуцию сверяйте с источниками — здесь важна не конкретная компания, а механика отказа).

Что пошло не так (по слоям этой и соседних глав):

  • Избыточные полномочия. У агента был доступ к проду с правом на разрушительную операцию — нарушение least privilege (Глава 11, Excessive Agency).
  • Инструкция вместо гейта. Запрет «не трогай прод» жил в промпте, а не как программный gate в коде. Промпт — не граница безопасности: модель может его проигнорировать.
  • Нет подтверждения на необратимое. Деструктивное действие не требовало human-in-the-loop.
  • Нет изоляции. Один контур и для исследования, и для опасных операций.

Какие защиты этой главы предотвратили бы инцидент: least privilege (агент физически без прав на удаление в проде), программный allowlist + подтверждение человека на необратимое, изоляция сред (read-only по умолчанию, запись — только в песочнице/ветке), обратимость (бэкап/транзакция/мягкое удаление). Вывод, который стоит усвоить: надёжность держится не на «послушности» модели, а на том, что у скомпрометированного или ошибающегося агента просто нет прав и канала на ущерб.

Guardrail-конвейер: вход → цикл → валидация выхода → ретрай/отказ
// Guardrails оборачивают agent loop тремя слоями (оборона в глубину).
// Каждый слой может прервать обработку и вернуть безопасный исход.
func HandleRequest(ctx context.Context, a *Agent, in string) (string, error) {
    // 1) Вход: санитизация и базовые проверки до обращения к модели.
    if err := validateInput(in); err != nil {
        return "", fmt.Errorf("вход отклонён guardrail: %w", err)
    }

    // 2) Поведение: цикл под бюджетом шагов/токенов и таймаутом.
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()
    out, err := a.Run(ctx, in) // внутри: budget, детектор зацикливания, allowlist инструментов
    if err != nil {
        return fallbackAnswer(in), nil // деградируем, а не падаем
    }

    // 3) Выход: проверка схемы/политики; невалидный ответ — один ретрай с уточнением.
    if err := validateOutput(out); err != nil {
        out, err = a.RunWithCorrection(ctx, in, err) // «твой ответ нарушил X, исправь»
        if err != nil || validateOutput(out) != nil {
            return safeRefusal(), nil // честный отказ лучше неверного ответа
        }
    }
    return out, nil
}
Валидация выхода по JSON Schema с одним корректирующим ретраем
// validateOutput требует, чтобы ответ агента соответствовал контракту.
// Для структурированного вывода контракт — JSON Schema.
func validateOutput(out string) error {
    if !json.Valid([]byte(out)) {
        return errors.New("ответ не является валидным JSON")
    }
    if err := schema.Validate(out); err != nil { // проверка по объявленной схеме
        return fmt.Errorf("ответ не соответствует схеме: %w", err)
    }
    if leaksSecret(out) { // выходной фильтр на утечки (ключи, PII, system prompt)
        return errors.New("ответ содержит чувствительные данные")
    }
    return nil
}

// Корректирующий ретрай даёт модели её собственную ошибку как обратную связь —
// это надёжнее, чем «просто перегенерировать» вслепую.
func (a *Agent) RunWithCorrection(ctx context.Context, in string, prev error) (string, error) {
    correction := "Предыдущий ответ нарушил требование: " + prev.Error() +
        ". Верни ответ строго по требуемому формату."
    return a.Run(ctx, in+"\n\n"+correction)
}
Circuit breaker для нестабильной зависимости (инструмента/API)
// Breaker размыкает цепь, когда зависимость стабильно падает: перестаёт её
// дёргать и сразу отдаёт фолбэк, оберегая и латентность, и сам сервис.
type Breaker struct {
    mu          sync.Mutex
    failures    int
    threshold   int           // сколько подряд сбоев до размыкания
    openUntil   time.Time     // до какого момента цепь разомкнута
    cooldown    time.Duration // пауза перед пробным вызовом (half-open)
}

func (b *Breaker) Do(call func() error) error {
    b.mu.Lock()
    if now().Before(b.openUntil) { // цепь разомкнута — даже не пробуем
        b.mu.Unlock()
        return ErrCircuitOpen // вызывающий код применит фолбэк
    }
    b.mu.Unlock()

    err := call()

    b.mu.Lock()
    defer b.mu.Unlock()
    if err != nil {
        b.failures++
        if b.failures >= b.threshold {
            b.openUntil = now().Add(b.cooldown) // размыкаем на cooldown
        }
        return err
    }
    b.failures = 0 // успех — закрываем цепь
    return nil
}
Стриминг ответа через anthropic-sdk-go (сверьтесь с актуальными доками)
// Стриминг сокращает time-to-first-token: текст отдаётся пользователю по мере
// генерации. Имена типов SDK могут отличаться — сверьтесь с актуальными доками.
func streamAnswer(ctx context.Context, client *anthropic.Client, params anthropic.MessageNewParams, out func(string)) error {
    stream := client.Messages.NewStreaming(ctx, params)
    message := anthropic.Message{}
    for stream.Next() {
        event := stream.Current()
        message.Accumulate(event) // накапливаем полное сообщение из дельт
        switch e := event.AsAny().(type) {
        case anthropic.ContentBlockDeltaEvent:
            out(e.Delta.Text) // отдаём кусок текста в UI немедленно
        }
    }
    if err := stream.Err(); err != nil { // проверка ошибки потока ОБЯЗАТЕЛЬНА
        return fmt.Errorf("стрим прерван: %w", err)
    }
    return nil
}
Прерывание/коррекция на лету через context
// Прерывание: cancel() отменяет и стрим к модели, и весь agent loop.
// Отмена пользователем — штатный исход, а не сбой; отличаем её от ошибки.
func runInterruptible(parent context.Context, a *Agent, in string, interrupt <-chan Steer) (string, error) {
    ctx, cancel := context.WithCancel(parent)
    defer cancel()

    done := make(chan result, 1)
    go func() {
        out, err := a.Run(ctx, in) // внутри читает ctx.Done() между итерациями
        done <- result{out, err}
    }()

    for {
        select {
        case s := <-interrupt:
            if s.Cancel {
                cancel() // пользователь остановил — стрим/цикл свернутся
                return "", context.Canceled
            }
            a.Steer(s.Message) // коррекция на лету: инъекция нового сообщения в контекст
        case r := <-done:
            return r.out, r.err
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
}
Surfacing шагов и источников в UI-поверхность
// UISink — поверхность, куда агент выкладывает наблюдаемые события. Это те же
// события, что идут в спаны трейсинга (Глава 9): один источник правды.
type UISink interface {
    Step(label string)              // "ищу заказ #42…" — промежуточный шаг
    Sources(cites []Citation)       // источники/цитаты для прозрачности ответа
}

// reportToolCall показывает шаг пользователю ПЕРЕД и ПОСЛЕ вызова инструмента,
// но наружу отдаёт только безопасную сводку (сырые аргументы могут нести PII —
// см. редакцию PII, Глава 11).
func reportToolCall(ui UISink, call ToolCall, res ToolResult) {
    ui.Step("Выполняю: " + humanLabel(call.Name)) // без сырых аргументов
    if len(res.Citations) > 0 {
        ui.Sources(res.Citations) // откуда факт — ссылка/фрагмент/id записи
    }
}

Anti-patterns

ГрабляПочему плохоКак избегать
Ретрай без backoff («повторить 5 раз сразу»)Добивает перегруженный сервис, усиливает каскадЭкспоненциальный backoff + jitter, лимит попыток
Ретрай неидемпотентного инструментаДублирует side-effect (двойной платёж/письмо)Idempotency-ключ; ретраить только идемпотентное
Падать с ошибкой при сбое зависимостиПользователь видит 500 вместо ответаЛестница фолбэков и контролируемая деградация
Нет потолка шагов/таймаута в циклеЗацикливание выжигает бюджет и виситБюджет шагов/токенов, детектор повторов, таймаут
Доверять ответу без валидации выходаНевалидный формат/утечка уходят пользователюOutput guardrail: схема, политика, фильтр утечек
Полная автономия на необратимых действияхДорогая ошибка без шанса вмешатьсяApproval-гейт (human-in-the-loop) на опасных ветках
Длинный прогон без стриминга и индикации шаговПохоже на зависание; пользователь не знает, жив ли агентСтриминг ответа + показ промежуточных шагов и вызовов тулов
Нет способа прервать агентаПользователь ждёт ненужный/ошибочный результат до концаОтмена через context.WithCancel; коррекция на лету (steering)
Показывать пользователю сырые аргументы тулов и рассужденияУтечка чувствительного в UI (PII, секреты)Наружу — безопасная сводка; редакция PII (Глава 11)

Практическое задание

  • Составьте карту failure modes вашего агента (минимум 6 режимов) и напротив каждого — конкретный механизм защиты.
  • Оберните agent loop тремя слоями guardrails: валидация входа, контроль цикла (бюджет шагов/таймаут/allowlist), валидация выхода по схеме.
  • Реализуйте корректирующий ретрай: при невалидном выходе верните модели её ошибку как обратную связь и перегенерируйте один раз, иначе — безопасный отказ.
  • Добавьте circuit breaker вокруг одной нестабильной зависимости и лестницу фолбэков (retry → запасная модель → кэш → честный отказ).
  • Включите грунтинг: заставьте агента отвечать только по источникам с цитированием и явно разрешите ответ «в источниках этого нет».
  • Введите approval-гейт для одного необратимого действия (например, отправки наружу): агент готовит действие, человек подтверждает.
  • Включите стриминг ответа (NewStreaming в anthropic-sdk-go) и показ промежуточных шагов: перед/после вызова инструмента выводите краткий статус, переиспользуя события трейсинга из Главы 9.
  • Реализуйте прерывание через context.WithCancel (и, по возможности, коррекцию на лету) и прозрачность: показывайте источники/цитаты для grounded-ответов, фильтруя чувствительное из UI.

Проверка знаний

Инструмент send_payment иногда отвечает таймаутом, хотя платёж на стороне банка уже прошёл. Вы добавили автоматический ретрай при таймауте.

В чём опасность и как её устранить?

  • A Опасности нет, ретрай всегда безопасен
  • B Операция неидемпотентна: ретрай может провести платёж дважды; нужен idempotency-ключ, чтобы повтор не дублировал side-effect
  • C Надо просто увеличить таймаут
  • D Нужно ретраить чаще

Зачем нужен circuit breaker, если уже есть ретраи с backoff?

  • A Это одно и то же
  • B Когда зависимость стабильно недоступна, ретраи лишь тратят время и добивают её; breaker размыкает цепь и сразу отдаёт фолбэк, давая сервису восстановиться
  • C Circuit breaker ускоряет модель
  • D Breaker заменяет валидацию выхода

Какой подход надёжнее всего снижает галлюцинации в RAG-агенте?

  • A Увеличить температуру генерации
  • B Запретить ответ «не знаю», чтобы агент всегда что-то отвечал
  • C Грунтинг: отвечать только по приведённым источникам с цитированием и явно разрешить ответ «в источниках этого нет»
  • D Убрать валидацию выхода ради скорости

Агент выполняет долгий многошаговый прогон. Пользователь видит только спиннер, не может понять, что происходит, и не может остановить агента, если тот пошёл не туда.

Какой слой UX/интерактивности это закрывает и как реализуется в Go/SDK?

  • A Достаточно увеличить таймаут запроса
  • B Стриминг ответа (NewStreaming) + показ промежуточных шагов и прерывание через context.WithCancel; те же события можно брать из спанов трейсинга Главы 9
  • C Нужно отключить guardrails, чтобы ускорить ответ
  • D Проблема чисто визуальная и решается на фронтенде без изменений в агенте