Продакшн-разработка ИИ-агентов · Модуль 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.

Состояние прогона и интерфейс durable-стора (теги json опущены намеренно)
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
}
Цикл с чекпойнтами, обнаружением зацикливания и resume
// 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
}
Идемпотентное выполнение инструмента через idempotency key
// 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

Типичные грабли прод-harness
ГрабляПочему плохоКак избегать
Единственный 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 прогресса нет.

Какой механизм наиболее уместен, чтобы остановиться осознанно, а не сжечь оставшийся бюджет?

  • A Увеличить maxSteps, чтобы дать агенту шанс выбраться
  • B Loop detection по сигнатуре шага с порогом повторов и отдельным terminal state loop_detected
  • C Понизить температуру модели до 0, чтобы ответы стали стабильнее
  • D Добавить в system-промпт фразу «не зацикливайся»

Процесс агента упал сразу после того, как инструмент create_payment выполнил платёж, но до того, как harness записал результат в историю. При рестарте оркестратор делает resume и снова отправляет тот же шаг.

Что предотвращает двойной платёж при resume?

  • A Durable-чекпойнт истории сообщений сам по себе
  • B Понижение step budget на единицу при рестарте
  • C Idempotency key: повтор операции с тем же ключом возвращает сохранённый результат без повторного side-effect
  • D Повтор запроса к модели с тем же messages

Почему в прод-harness условия остановки и лимиты живут в оркестраторе, а не «доверяются модели»?

  • A Потому что модель не умеет вызывать инструменты
  • B Потому что оркестратор детерминирован и тестируем, а шаги модели недетерминированы; надёжность ресурсов нельзя строить на оракуле
  • C Потому что SDK запрещает модели завершать диалог
  • D Потому что так быстрее работает инференс