Продакшн-разработка ИИ-агентов · Модуль 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 по умолчанию, запись — только в песочнице/ветке), обратимость (бэкап/транзакция/мягкое удаление). Вывод, который стоит усвоить: надёжность держится не на «послушности» модели, а на том, что у скомпрометированного или ошибающегося агента просто нет прав и канала на ущерб.
// 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
}// 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)
}// 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
}// Стриминг сокращает 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
}// Прерывание: 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()
}
}
}// 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 иногда отвечает таймаутом, хотя платёж на стороне банка уже прошёл. Вы добавили автоматический ретрай при таймауте.
В чём опасность и как её устранить?
Верный ответ: B
B верно. Ретраить можно только идемпотентные операции. Для платежа передавайте idempotency-ключ, чтобы повтор с тем же ключом не списал деньги второй раз. Увеличение таймаута (C) не решает дублирование, частые ретраи (D) усугубляют.
Зачем нужен circuit breaker, если уже есть ретраи с backoff?
Верный ответ: B
B верно. Ретраи лечат единичные временные сбои; circuit breaker защищает от устойчивого отказа и каскада — перестаёт дёргать падающую зависимость и переходит в фолбэк. Это дополняющие, а не взаимозаменяемые механизмы.
Какой подход надёжнее всего снижает галлюцинации в RAG-агенте?
Верный ответ: C
C верно. Грунтинг в источники + цитирование + явное разрешение признать незнание убирают большую часть выдумок. Запрет «не знаю» (B) и рост температуры (A), наоборот, провоцируют галлюцинации.
Агент выполняет долгий многошаговый прогон. Пользователь видит только спиннер, не может понять, что происходит, и не может остановить агента, если тот пошёл не туда.
Какой слой UX/интерактивности это закрывает и как реализуется в Go/SDK?
Верный ответ: B
B верно. Стриминг (в anthropic-sdk-go — client.Messages.NewStreaming, чтение дельт, проверка stream.Err()) снижает воспринимаемую задержку; показ шагов делает работу наблюдаемой (переиспользуем события трейсинга, Глава 9); отмена через context.WithCancel даёт прервать агента — cancel() завершает и стрим, и цикл. A/C/D не решают наблюдаемость и управляемость.