Продакшн-разработка ИИ-агентов · Модуль 1 · Урок 1.1
Глава 1. Продвинутый harness и agent loop под прод
Цели главы
- Спроектировать agent loop с явными бюджетами (step/token budgets) и набором условий остановки (stop conditions), а не одним safety-cap.
- Уметь обнаруживать зацикливание (loop detection) и деградацию прогресса до того, как агент сожжёт бюджет.
- Сделать инструменты идемпотентными (idempotency) так, чтобы повтор вызова не дублировал side-effect.
- Реализовать durable execution: чекпойнты состояния агента в хранилище и возобновление (resume) с середины после краша.
- Развести детерминированный оркестратор и недетерминированные шаги модели как два разных слоя ответственности.
Что нового (дельта к базовому курсу)
В базовом курсе agent loop был учебным: цикл «запрос модели → выполнить tool_use → вернуть результат», обёрнутый одним предохранителем — счётчиком шагов (safety-cap), чтобы не уйти в бесконечность. Этого достаточно для демо, но не для прода.
Здесь мы углубляем три темы базового курса:
- Управление циклом. Вместо единственного
maxStepsвводим композицию условий остановки: цель достигнута, исчерпан бюджет шагов/токенов, обнаружено зацикливание, запрошена эскалация к человеку (human handoff).
- Надёжность инструментов. В базовом курсе инструмент просто «выполнялся». Теперь добавляем идемпотентность и устойчивость к повторам, потому что в проде повтор — норма (ретраи, краши, дубли событий).
- Состояние агента. В базовом курсе история сообщений жила в памяти процесса. В проде процесс падает, деплоится, масштабируется — поэтому состояние выносим в durable-хранилище и учимся возобновлять прогон.
Мысленная модель: оркестратор детерминирован, шаги модели — нет
Главная идея прод-harness: разделить два мира.
- Оркестратор — обычный Go-код: цикл, лимиты, запись чекпойнтов, диспетчеризация инструментов, обработка ошибок. Он детерминирован и тестируем как любой код.
- Шаги модели — недетерминированный «оракул»: на одинаковый вход модель может ответить по-разному. Это нормально, но именно поэтому всё, что должно быть надёжным (лимиты, повторы, запись в БД), обязано жить в оркестраторе, а не «надеяться на модель».
Практический вывод: никогда не доверяйте остановку самой модели («она сама поймёт, что закончила»). Условия остановки — это код оркестратора, который читает выход модели, но решение принимает сам. Модель предлагает действия, harness распоряжается ресурсами.
Бюджеты и условия остановки
Бюджет — это контракт «сколько ресурса агенту позволено потратить на задачу». Минимум два измерения:
- Step budget — число итераций цикла. Защищает от долгих, но «дешёвых» зависаний.
- Token budget — суммарные input+output токены. Защищает от дорогих прогонов; коррелирует с деньгами и с переполнением контекста.
Условия остановки — это дизъюнкция причин выйти из цикла, и у каждой свой исход (terminal state):
- Цель достигнута — модель вернула финальный ответ без tool_use (
stop_reason == "end_turn"). Успех.
- Исчерпан бюджет — превышен step/token лимит. Частичный результат + пометка «truncated».
- Зацикливание — повтор тех же действий без прогресса. Лучше упасть осознанно, чем сжечь весь бюджет.
- Запрос к человеку — агент дёрнул инструмент эскалации (например
ask_human) или политика запретила действие. Пауза до решения оператора.
Почему важно различать исходы: вызывающий код должен по-разному реагировать на «успех», «закончились деньги» и «застрял». Один булев done это скрывает.
Loop detection: как ловить зацикливание
Зацикливание (looping) — это когда агент повторяет шаги без продвижения к цели. Типичные формы:
- одинаковый вызов инструмента с теми же аргументами подряд несколько раз;
- чередование двух действий «туда-обратно» (A, B, A, B);
- повтор одного и того же текстового ответа.
Дешёвая и надёжная эвристика — хеш сигнатуры шага: нормализуем имя инструмента + аргументы (или текст ответа), берём хеш и считаем, сколько раз подряд он повторился. Превышен порог (например 3) — останавливаемся с причиной loop.
Почему так, а не «пусть модель сама заметит»: модель в зацикленном состоянии как раз и не замечает зацикливания — её контекст забит одинаковыми результатами. Детектор — это внешний, детерминированный наблюдатель, который не зависит от настроения модели.
Идемпотентность инструментов
В проде один и тот же tool-call может выполниться дважды: модель повторила его, harness был перезапущен после краша между «выполнили side-effect» и «записали результат», очередь доставила событие повторно. Если инструмент создаёт платёж или отправляет письмо — двойное выполнение это инцидент.
Решение — idempotency key (ключ идемпотентности): детерминированный идентификатор операции. Перед выполнением инструмент проверяет хранилище: «операция с этим ключом уже выполнена?» Если да — возвращает сохранённый прежний результат, не повторяя side-effect.
Ключ должен быть стабильным для «той же логической операции»: например хеш от (имя инструмента + нормализованные аргументы + идентификатор шага агента). Тогда легальный ретрай попадёт в тот же ключ, а новая операция — в новый.
Важно: идемпотентность — свойство записывающих инструментов (write/side-effect). Для чистого чтения она не нужна, но кэш по тому же ключу не повредит.
Durable execution: чекпойнты и resume
Durable execution означает, что прогон агента переживает падение процесса. Минимальный durable-стейт агента:
- история сообщений (
messages) — то, что мы отправляем модели на каждом шаге;
- позиция/счётчики — номер шага, потраченные токены, последний детектированный исход;
- статус — running / done / failed / waiting_human.
Стратегия проста и надёжна: сохраняем состояние после каждого шага (write-ahead перед дорогим side-effect и снимок после ответа модели). Если процесс упал — на старте читаем последний чекпойнт по runID и продолжаем цикл с того же места (resume), а не начинаем заново.
Связка с идемпотентностью обязательна: при resume мы можем повторно отправить шаг, который частично выполнился до краха. Idempotency key гарантирует, что повтор не продублирует эффект. Durable execution без идемпотентности даёт дубли; идемпотентность без durable-стейта теряет прогресс. Вместе — то, на чём строят надёжные агенты.
Практика на Go: персистентный agent loop
Ниже — скелет на github.com/anthropics/anthropic-sdk-go (v1.x; сверьтесь с актуальными доками по точным именам полей и слагам моделей). Сначала — типы состояния и стора, затем — цикл с чекпойнтами, детектором зацикливания и resume.
package agent
import "github.com/anthropics/anthropic-sdk-go"
// Status — терминальное состояние прогона (см. условия остановки).
type Status string
const (
StatusRunning Status = "running"
StatusDone Status = "done" // цель достигнута
StatusBudget Status = "budget_exhausted"
StatusLoop Status = "loop_detected"
StatusWaitingHuman Status = "waiting_human"
)
// RunState — durable-снимок агента. Сохраняется после КАЖДОГО шага.
// (в реальном коде поля сериализуются в json; теги опущены для читаемости)
type RunState struct {
RunID string // идемпотентный идентификатор прогона
Messages []anthropic.MessageParam // история для модели
Step int // номер шага (позиция возобновления)
TokensUsed int64 // суммарный бюджет токенов
LastSig string // сигнатура прошлого шага (loop detection)
RepeatRun int // сколько раз подряд повторилась сигнатура
Status Status
}
// Store — durable-хранилище. В проде это Postgres/Redis; для теста — память.
type Store interface {
Load(runID string) (*RunState, bool, error) // resume: читаем чекпойнт
Save(s *RunState) error // чекпойнт после шага
}
// Budget — лимиты прогона.
type Budget struct {
MaxSteps int
MaxTokens int64
MaxRepeat int // порог повторов сигнатуры -> loop
}// Run выполняет (или ВОЗОБНОВЛЯЕТ) прогон агента до условия остановки.
// Оркестратор детерминирован: лимиты и решения об остановке — здесь, не в модели.
func Run(ctx context.Context, cl anthropic.Client, st Store, tools Toolbox,
runID string, b Budget) (*RunState, error) {
// resume: если чекпойнт есть — продолжаем с него; иначе новый прогон.
s, ok, err := st.Load(runID)
if err != nil {
return nil, err
}
if !ok {
s = &RunState{RunID: runID, Status: StatusRunning}
}
for s.Status == StatusRunning {
// --- условия остановки по бюджету (проверяем ДО обращения к модели) ---
if s.Step >= b.MaxSteps || s.TokensUsed >= b.MaxTokens {
s.Status = StatusBudget
_ = st.Save(s)
break
}
// --- недетерминированный шаг: спрашиваем модель ---
resp, err := cl.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet4_5, // сверьтесь с актуальным слагом
MaxTokens: 1024,
Messages: s.Messages,
Tools: tools.Specs(),
})
if err != nil {
return nil, err // повтор безопасен: idempotency key защитит side-effect
}
s.TokensUsed += int64(resp.Usage.InputTokens + resp.Usage.OutputTokens)
s.Messages = append(s.Messages, resp.ToParam())
s.Step++
// --- цель достигнута: модель ответила без вызова инструментов ---
if resp.StopReason == anthropic.StopReasonEndTurn {
s.Status = StatusDone
_ = st.Save(s)
break
}
// --- loop detection: сигнатура шага = инструмент+аргументы ---
sig := signatureOf(resp)
if sig == s.LastSig {
s.RepeatRun++
} else {
s.RepeatRun = 0
s.LastSig = sig
}
if s.RepeatRun >= b.MaxRepeat {
s.Status = StatusLoop
_ = st.Save(s)
break
}
// --- выполняем инструменты (идемпотентно) и кладём результаты ---
results, handoff, err := tools.Execute(ctx, resp, runID, s.Step)
if err != nil {
return nil, err
}
if handoff { // агент запросил человека
s.Status = StatusWaitingHuman
_ = st.Save(s)
break
}
s.Messages = append(s.Messages, results)
// ЧЕКПОЙНТ после каждого шага: переживём краш -> resume с этого места.
if err := st.Save(s); err != nil {
return nil, err
}
}
return s, nil
}// executeOne запускает один tool_use с защитой от повтора side-effect.
// idemStore хранит результат по ключу; повтор возвращает прежний результат.
func executeOne(ctx context.Context, idem IdemStore, runID string, step int,
name string, args map[string]any, fn ToolFunc) (string, error) {
// Детерминированный ключ: одна логическая операция -> один ключ.
key := idemKey(runID, step, name, args)
// Уже выполняли? Возвращаем сохранённый результат, side-effect НЕ повторяем.
if prev, ok, err := idem.Get(key); err != nil {
return "", err
} else if ok {
return prev, nil
}
// Первое выполнение: делаем side-effect и фиксируем результат под ключом.
out, err := fn(ctx, args)
if err != nil {
return "", err // не записали ключ -> легальный ретрай повторит попытку
}
if err := idem.Put(key, out); err != nil {
return "", err
}
return out, nil
}
// idemKey: стабильный хеш от логической операции.
func idemKey(runID string, step int, name string, args map[string]any) string {
raw, _ := json.Marshal(args)
sum := sha256.Sum256([]byte(runID + "|" + name + "|" +
strconv.Itoa(step) + "|" + string(raw)))
return hex.EncodeToString(sum[:])
}Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
Единственный safety-cap maxSteps | Агент может зациклиться дёшево по шагам, но дорого по токенам; нет различения исходов | Композиция условий остановки: step + token budget + loop + handoff, у каждого свой terminal state |
| Доверять остановку самой модели | Модель недетерминирована и в зацикленном состоянии не замечает цикла | Решение об остановке — детерминированный код оркестратора, читающий stop_reason и сигнатуры |
| Состояние агента только в памяти процесса | Краш/деплой/скейл теряет весь прогресс задачи | Durable-стор: чекпойнт messages+позиции после каждого шага, resume по runID |
| Неидемпотентные write-инструменты | Повтор после краха или ретрай дублирует платёж/письмо/заказ | Idempotency key: повтор возвращает сохранённый результат без повтора side-effect |
| Чекпойнт раз в N шагов «для скорости» | При крахе теряются шаги между чекпойнтами, resume стартует не там | Сохранять после каждого шага; запись дешевле, чем повтор дорогих side-effect |
| Смешать оркестрацию и логику модели в одной функции | Невозможно протестировать лимиты и невозможно объяснить недетерминизм | Разделить детерминированный оркестратор и недетерминированные шаги модели |
Практическое задание (P1)
- Опишите 4 условия остановки своего агента и для каждого — terminal state и реакцию вызывающего кода (успех / truncated / loop / waiting_human).
- Реализуйте
RunStateиStore(в памяти достаточно) и сохраняйте снимок после каждого шага цикла. - Добавьте step budget и token budget; проверьте, что прогон корректно останавливается по каждому лимиту по отдельности.
- Реализуйте loop detection по сигнатуре шага и подберите порог
MaxRepeat; искусственно зациклите агента и убедитесь, что он падает сloop_detected. - Сделайте один write-инструмент идемпотентным через idempotency key; вызовите его дважды с теми же аргументами и проверьте отсутствие двойного side-effect.
- Симулируйте краш: прервите процесс в середине прогона и убедитесь, что повторный запуск возобновляет (resume) с последнего чекпойнта без дублей.
Проверка знаний
Агент с бюджетом 30 шагов начал чередовать два одинаковых вызова инструмента поиска с теми же аргументами. К шагу 12 прогресса нет.
Какой механизм наиболее уместен, чтобы остановиться осознанно, а не сжечь оставшийся бюджет?
Верный ответ: B
B верно. Зацикливание ловится детерминированным внешним наблюдателем: нормализуем имя инструмента + аргументы, хешируем, считаем повторы подряд; превышен порог — выходим с исходом loop_detected. A только продлевает агонию и тратит бюджет. C делает шаги детерминированнее, но зацикленный детерминированный агент зациклится так же стабильно. D ненадёжно: модель в зацикленном состоянии как раз не замечает цикла, потому что её контекст забит одинаковыми результатами.
Процесс агента упал сразу после того, как инструмент create_payment выполнил платёж, но до того, как harness записал результат в историю. При рестарте оркестратор делает resume и снова отправляет тот же шаг.
Что предотвращает двойной платёж при resume?
Верный ответ: C
C верно. Durable execution возобновляет прогресс, но именно идемпотентность защищает write-инструменты: перед платежом проверяется ключ операции; если он уже зафиксирован — возвращается прежний результат, side-effect не повторяется. A сохраняет историю, но не мешает повторно выполнить платёж. B не связано с дублированием эффекта. D как раз и приведёт к повторному tool_use — без idempotency key это и есть источник двойного платежа.
Почему в прод-harness условия остановки и лимиты живут в оркестраторе, а не «доверяются модели»?
Верный ответ: B
B верно. Ключевая мысленная модель главы — разделение детерминированного оркестратора (цикл, бюджеты, чекпойнты, остановка) и недетерминированных шагов модели. Лимиты, повторы и запись в БД обязаны жить в детерминированном слое, иначе надёжность зависит от «настроения» модели. A неверно фактически. C выдумка. D к делу не относится: речь о корректности и тестируемости, а не о скорости.