Продакшн-разработка ИИ-агентов · Модуль 6 · Урок 6.2
Глава 13. Деплой и инфраструктура
Цели главы
После этой главы вы сможете:
- Проектировать агента как сервис (agent as a service): stateless-обработчик плюс внешнее состояние.
- Управлять конкурентностью на Go: пул воркеров (worker pool) с ограничением параллелизма,
contextдля отмены и таймаутов, безопасность разделяемого состояния, корректное завершение (graceful shutdown). - Выбирать хранилище состояния (сессии, история) — Redis или Postgres — и понимать, почему оно должно быть устойчивым (durable).
- Применять очереди для асинхронных задач агента с идемпотентными воркерами и ретраями.
- Понимать назначение durable-workflow-движков (Temporal-подобных) для долгих надёжных сценариев (обзорно).
- Версионировать промпты как артефакт и встраивать эвалы в CI как гейт, выкатывать версии канареечно или A/B.
Что нового (дельта к базовому курсу)
В базовом курсе агент жил в одном процессе и обрабатывал один запрос за раз — состояние держалось в памяти. Это не выдерживает продакшена: процессы падают и перезапускаются, нагрузка приходит конкурентно, релизы должны откатываться.
Дельта: состояние выносится из процесса. Обработчик становится stateless, а история и сессии живут в durable-хранилище. Это даёт горизонтальное масштабирование (любой инстанс обслужит любой запрос) и переживание рестартов.
Вторая дельта — операционная зрелость: конкурентность с лимитами и таймаутами, очереди с идемпотентностью и ретраями, промпт как версионируемый артефакт, эвалы в CI как условие выката. Базовый курс этого не касался — там был happy path одного вызова.
Агент как сервис: stateless-обработчик + внешнее состояние
Продакшен-агент — это сетевой сервис (HTTP или gRPC). Ключевой принцип: обработчик не хранит состояние между запросами. Всё, что нужно помнить (история сессии, накопленный контекст, флаги режима), читается в начале обработки из внешнего хранилища и пишется обратно в конце.
Почему так: stateless-обработчик масштабируется горизонтально (балансировщик раскидывает запросы на любой инстанс), переживает рестарт пода (состояние не теряется, оно в Redis/Postgres) и упрощает откат версии (новый код не зависит от того, что лежало в памяти старого).
gRPC уместен для внутренних сервис-к-сервису вызовов (строгий контракт, стриминг), HTTP/JSON — для внешнего API и совместимости. Выбор не меняет принципа: состояние снаружи.
Конкурентность на Go
Go даёт дешёвые горутины, но «горутина на запрос без ограничений» — путь к исчерпанию памяти и упору в rate limit провайдера. Нужен worker pool с фиксированным числом воркеров: канал-семафор или пул, ограничивающий одновременные вызовы модели до безопасного N.
context.Context — обязательный инструмент: таймаут на запрос (context.WithTimeout) обрывает зависший вызов модели, отмена (context.WithCancel) распространяется вниз по цепочке, когда клиент отключился. Все вызовы SDK принимают ctx первым аргументом — пробрасывайте его сквозь.
Безопасность разделяемого состояния: любые общие счётчики, кеши, бюджеты защищайте sync.Mutex или используйте каналы для передачи владения. Гонки (data races) в Go тихие и недетерминированные — гоняйте тесты с -race.
Graceful shutdown: по сигналу (SIGTERM от оркестратора) сервис должен перестать принимать новые запросы, дать текущим доработать в пределах дедлайна и только потом выйти. http.Server.Shutdown(ctx) делает это штатно; самописные воркеры дренируют очередь по закрытию входного канала.
Хранилища, очереди и durable workflows
Хранилище состояния. Сессии и история должны быть durable — переживать рестарт. Redis хорош для быстрой сессионной памяти с TTL (горячий контекст, кеш), Postgres — для долговечной истории, аудита и транзакционных гарантий. Часто используют оба: Redis как горячий слой, Postgres как источник истины.
Очереди. Долгие или фоновые задачи агента (генерация отчёта, обработка загруженного файла) не должны блокировать HTTP-обработчик. Запрос кладёт задачу в очередь, отдельный пул воркеров её разбирает. Два требования к воркеру: идемпотентность (повторная обработка той же задачи не должна дублировать эффект — например, по ключу идемпотентности) и ретраи с backoff при сбоях. Очередь даёт развязку нагрузки и переживание пиков.
Durable workflows (обзорно). Для долгих многошаговых надёжных сценариев (агент работает минутами и часами, переживая падения воркеров) применяют движки вроде Temporal: они сохраняют состояние каждого шага (event sourcing), при сбое продолжают с места обрыва, а не с начала. Это тяжёлая инфраструктура — оправдана, когда сценарий действительно длинный и потеря прогресса дорога.
Версионирование промптов, CI/CD и A/B
Промпт — это артефакт, а не строка в коде. Версионируйте его (в репозитории или в реестре промптов) с явным номером версии, который пишется в логи каждого запроса. Тогда регрессию можно привязать к конкретной версии и откатиться (rollback), пинуя новые сессии на предыдущую версию.
Эвалы в CI как гейт. Набор эвалов (тестовых сценариев с ожидаемым поведением) прогоняется в CI на каждое изменение промпта или модели. Падение эвалов блокирует мёрдж — так же, как падение юнит-тестов. Это превращает «промпт поменяли на глаз» в контролируемый процесс.
Канареечный и A/B выкат. Новую версию промпта или модели выкатывают сначала на малую долю трафика (canary), сравнивают метрики качества и стоимости со старой (A/B), и при подтверждении раскатывают на 100% — или откатывают. Версионирование промпта делает такой раздельный выкат возможным: маршрутизатор выбирает версию по доле трафика.
Rate limits, Retry-After и backpressure
Провайдер ограничивает скорость обращений. При превышении приходит ответ 429 (rate_limit_error) с заголовком Retry-After — числом секунд, через которое можно повторить. Отдельно бывает 529 (overloaded_error) — сервис временно перегружен. Заголовки семейства anthropic-ratelimit-* (например, anthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining) показывают остаток вашей квоты — конкретные имена и числа сверяйте с актуальными доками.
Правило номер один: уважайте Retry-After, не «долбите сразу». Повтор без паузы только усугубляет перегрузку и продлевает блокировку. На ретраях используйте экспоненциальный backoff с джиттером (exponential backoff + jitter): каждая следующая пауза вдвое больше, плюс случайная добавка, чтобы тысячи клиентов не пошли на повтор синхронно (thundering herd). Если Retry-After пришёл — он приоритетнее вашей формулы. Свяжите это с circuit breaker из главы 10: после серии отказов размыкайте цепь и не шлите запросы вовсе, пока провайдер не оправится.
Важная тонкость: автоматический повтор безопасен только для идемпотентных вызовов. Обычный запрос к модели идемпотентен в смысле «повтор не создаёт лишних побочных эффектов на вашей стороне», но если вокруг вызова есть нетранзакционные сайд-эффекты (списание, запись в очередь), повтор нужно защищать ключом идемпотентности, как в воркере очереди.
anthropic-sdk-go уже умеет ретраить 429 и 5xx с backoff и сам уважает Retry-After (число ретраев настраивается опцией, например WithMaxRetries). Это снимает базовый случай. Но для своей бизнес-логики — backpressure, деградация, метрики — дублируйте аккуратно поверх, не борясь с SDK.
Backpressure (противодавление). Rate limit — это сигнал, что вы шлёте быстрее, чем можно. Реакция — не бесконечно копить запросы в памяти, а ограничивать. Ограничьте конкурентность семафором; ставьте запросы в очередь с лимитом длины; при переполнении — деградируйте или отклоняйте (load shedding): верните клиенту честный 503/«занято» вместо того, чтобы накапливать очередь до OOM и каскадного отказа. Лучше отказать быстро и предсказуемо, чем зависнуть всем сразу.
Мультитенантность, квоты и регресс при миграции модели
Мультитенантность и квоты. Если сервис агента обслуживает многих клиентов (тенантов) через один общий лимит провайдера, один прожорливый тенант способен выесть всю квоту и заморить остальных (проблема «шумного соседа»). Решение — разделить бюджеты и лимиты по тенантам: per-tenant token budget (см. главу 12) и per-tenant rate limit. Каждый тенант получает свою долю; справедливое распределение (fair share) гарантирует, что всплеск у одного не обрушит SLA у других. Технически это тот же семафор/счётчик, но ключёванный по тенанту.
Регрессионная проверка при миграции версии модели. Слаги моделей меняются от релиза к релизу, и поведение модели — тоже. Поэтому смена слага модели — это изменение, требующее гейта, а не безобидная правка строки. Перед выкатом новой версии прогоняйте на ней eval-датасет (см. главу 9) и сравнивайте метрики со старой моделью; падение ниже порога блокирует выкат — ровно как падение тестов в CI. Тогда новый релиз модели становится возможностью, а не риском: апгрейд занимает дни, а не недели, потому что у вас есть автоматический ответ на вопрос «стало лучше или хуже?».
Это замыкается на data flywheel из главы 9: продакшен-трафик и разметка пополняют eval-датасет, на котором вы и проверяете каждую новую модель. Чем богаче маховик данных, тем надёжнее регресс-гейт и тем спокойнее миграции.
package agentsvc
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/anthropics/anthropic-sdk-go"
)
// SessionStore — внешнее durable-хранилище состояния (Redis/Postgres).
// Обработчик stateless: историю читаем в начале, пишем в конце.
type SessionStore interface {
Load(ctx context.Context, sessionID string) ([]anthropic.MessageParam, error)
Save(ctx context.Context, sessionID string, msgs []anthropic.MessageParam) error
}
// Server — агент как сервис.
type Server struct {
client anthropic.Client
store SessionStore
// sem — семафор-пул: ограничивает число одновременных вызовов модели,
// чтобы не исчерпать память и не упереться в rate limit провайдера.
sem chan struct{}
}
func NewServer(client anthropic.Client, store SessionStore, maxConcurrent int) *Server {
return &Server{
client: client,
store: store,
sem: make(chan struct{}, maxConcurrent),
}
}
type chatRequest struct {
SessionID string // json:"session_id"
Message string // json:"message"
}
type chatResponse struct {
Reply string // json:"reply"
}
func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
// Таймаут на запрос: зависший вызов модели будет оборван по дедлайну,
// а отмена клиента распространится вниз по ctx.
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Занимаем слот в пуле; если все заняты — ждём или отдаём 503 по таймауту.
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
case <-ctx.Done():
http.Error(w, "busy", http.StatusServiceUnavailable)
return
}
// 1. Читаем состояние из внешнего хранилища (stateless-обработчик).
history, err := s.store.Load(ctx, req.SessionID)
if err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
// 2. Добавляем реплику пользователя и вызываем модель.
history = append(history,
anthropic.NewUserMessage(anthropic.NewTextBlock(req.Message)))
resp, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus4_8,
MaxTokens: 4096,
Messages: history,
})
if err != nil {
http.Error(w, "model error", http.StatusBadGateway)
return
}
// 3. Сохраняем обновлённую историю обратно (ToParam конвертирует ответ).
history = append(history, resp.ToParam())
if err := s.store.Save(ctx, req.SessionID, history); err != nil {
http.Error(w, "store error", http.StatusInternalServerError)
return
}
var reply string
for _, block := range resp.Content {
if t, ok := block.AsAny().(anthropic.TextBlock); ok {
reply += t.Text
}
}
_ = json.NewEncoder(w).Encode(chatResponse{Reply: reply})
}
// Run поднимает сервер и обеспечивает graceful shutdown по SIGTERM/SIGINT.
func (s *Server) Run(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/chat", s.handleChat)
srv := &http.Server{Addr: addr, Handler: mux}
// Слушаем сигналы завершения от оркестратора.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := srv.ListenAndServe(); err != nil &&
!errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
<-stop
// Перестаём принимать новые запросы, даём текущим доработать в дедлайн.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
package agentsvc
import (
"context"
"log"
"sync"
)
// Task — асинхронная задача агента (например, генерация отчёта).
// IdempotencyKey защищает от двойного выполнения при ретрае.
type Task struct {
ID string
IdempotencyKey string
Payload string
}
// ProcessedSet — хранилище уже обработанных ключей (в проде — Redis/Postgres).
type ProcessedSet interface {
// SeenOrMark возвращает true, если ключ уже обработан; иначе помечает его.
SeenOrMark(ctx context.Context, key string) (seen bool, err error)
}
// Pool — пул воркеров, разбирающий задачи из канала.
type Pool struct {
tasks chan Task
processed ProcessedSet
handle func(ctx context.Context, t Task) error
wg sync.WaitGroup
}
func NewPool(workers int, processed ProcessedSet, handle func(context.Context, Task) error) *Pool {
p := &Pool{
tasks: make(chan Task, 256),
processed: processed,
handle: handle,
}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.worker()
}
return p
}
func (p *Pool) worker() {
defer p.wg.Done()
ctx := context.Background()
// Цикл завершится, когда канал tasks закроют (graceful shutdown).
for t := range p.tasks {
// Идемпотентность: повторно пришедшую задачу не выполняем дважды.
seen, err := p.processed.SeenOrMark(ctx, t.IdempotencyKey)
if err != nil {
log.Printf("task %s: processed-check failed: %v", t.ID, err)
continue
}
if seen {
continue
}
if err := p.handle(ctx, t); err != nil {
// В проде здесь ретрай с backoff или отправка в dead-letter-очередь.
log.Printf("task %s failed: %v", t.ID, err)
}
}
}
// Submit кладёт задачу в очередь.
func (p *Pool) Submit(t Task) { p.tasks <- t }
// Shutdown закрывает вход и ждёт, пока воркеры дренируют очередь.
func (p *Pool) Shutdown() {
close(p.tasks)
p.wg.Wait()
}
package agentsvc
import (
"context"
"errors"
"math"
"math/rand"
"strconv"
"time"
"github.com/anthropics/anthropic-sdk-go"
)
// callModel — функция одного вызова модели. Она ДОЛЖНА быть идемпотентной:
// повтор не создаёт побочных эффектов на нашей стороне. Иначе ретраить нельзя
// без ключа идемпотентности (см. воркер очереди выше).
type callModel func(context.Context) (*anthropic.Message, error)
// retryAfterSeconds достаёт паузу из заголовка Retry-After ответа об ошибке.
// Возвращает 0, если заголовка нет. Заголовки 429/529 и anthropic-ratelimit-*
// сверяйте с актуальными доками — имена и числа меняются.
func retryAfterSeconds(err error) (time.Duration, bool) {
var apiErr *anthropic.Error
if !errors.As(err, &apiErr) {
return 0, false
}
// Ретраим только перегрузку/лимит: 429 (rate_limit) и 529 (overloaded).
if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 {
return 0, false
}
if v := apiErr.Response.Header.Get("Retry-After"); v != "" {
if secs, perr := strconv.Atoi(v); perr == nil {
return time.Duration(secs) * time.Second, true
}
}
// 429/529 без Retry-After: ретраим, но паузу посчитаем сами (backoff).
return 0, true
}
// CallWithRetry повторяет идемпотентный вызов с уважением к Retry-After,
// иначе — экспоненциальный backoff с джиттером. SDK уже делает базовые ретраи
// (настраиваются через option.WithMaxRetries); здесь — слой нашей бизнес-логики.
func CallWithRetry(
ctx context.Context,
call callModel,
maxAttempts int,
) (*anthropic.Message, error) {
const base = 500 * time.Millisecond
const cap = 30 * time.Second
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
msg, err := call(ctx)
if err == nil {
return msg, nil
}
lastErr = err
wait, retryable := retryAfterSeconds(err)
if !retryable {
// Не лимит и не перегрузка — повтор не поможет, отдаём ошибку.
return nil, err
}
if wait == 0 {
// Retry-After не пришёл: экспоненциальный backoff + джиттер,
// чтобы клиенты не пошли на повтор синхронно (thundering herd).
backoff := time.Duration(float64(base) * math.Pow(2, float64(attempt)))
if backoff > cap {
backoff = cap
}
wait = backoff + time.Duration(rand.Int63n(int64(base)))
}
select {
case <-time.After(wait):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, lastErr
}
package agentsvc
import (
"context"
"errors"
"sync"
)
// ErrTenantBusy возвращается, когда тенант исчерпал свою долю конкурентности.
// На уровне HTTP это превращается в 503 (load shedding), а не в бесконечную
// очередь до OOM.
var ErrTenantBusy = errors.New("tenant concurrency limit reached")
// TenantLimiter ограничивает число одновременных вызовов модели ОТДЕЛЬНО
// для каждого тенанта, чтобы «шумный сосед» не выел общий лимит провайдера.
type TenantLimiter struct {
mu sync.Mutex
perLimit int
sems map[string]chan struct{}
}
func NewTenantLimiter(perTenant int) *TenantLimiter {
return &TenantLimiter{
perLimit: perTenant,
sems: make(map[string]chan struct{}),
}
}
// semFor лениво создаёт семафор для тенанта.
func (l *TenantLimiter) semFor(tenant string) chan struct{} {
l.mu.Lock()
defer l.mu.Unlock()
sem, ok := l.sems[tenant]
if !ok {
sem = make(chan struct{}, l.perLimit)
l.sems[tenant] = sem
}
return sem
}
// Do выполняет fn под лимитом конкретного тенанта. Если слотов нет —
// немедленно отклоняет (ErrTenantBusy), а не копит очередь бесконечно.
// Так всплеск одного тенанта не обрушивает SLA остальных.
func (l *TenantLimiter) Do(
ctx context.Context,
tenant string,
fn func(context.Context) error,
) error {
sem := l.semFor(tenant)
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
// Слотов тенанта нет: отклоняем быстро (load shedding).
return ErrTenantBusy
}
// Если хотим короткое ожидание вместо мгновенного отказа — здесь можно
// добавить select с ctx и небольшим таймаутом перед тем, как сдаться.
return fn(ctx)
}
Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
| Хранить историю сессий в памяти процесса | Состояние теряется при рестарте и не масштабируется горизонтально — балансировщик ломает сессии | Stateless-обработчик + внешнее durable-хранилище (Redis/Postgres); читать в начале, писать в конце |
| Горутина на запрос без ограничения параллелизма | Исчерпание памяти под нагрузкой и упор в rate limit провайдера | Worker pool с семафором: фиксированное N одновременных вызовов модели |
| Вызовы модели без context-таймаута | Зависший вызов держит соединение и слот вечно; отмена клиента не доходит | context.WithTimeout на запрос, пробрасывать ctx во все вызовы SDK |
| Завершение процесса по SIGTERM мгновенным выходом | Текущие запросы обрываются, задачи в очереди теряются | Graceful shutdown: srv.Shutdown(ctx) и дренаж пула по закрытию канала |
| Неидемпотентный воркер очереди | Ретрай или повторная доставка дублирует эффект (двойной отчёт, двойное списание) | Ключ идемпотентности + проверка SeenOrMark перед выполнением |
| Промпт как строка в коде без версии | Регрессию нельзя привязать к версии и откатить; A/B невозможен | Промпт как версионируемый артефакт; эвалы в CI как гейт; канареечный/A-B выкат по версии |
| Повтор после 429 сразу, игнорируя Retry-After | Усугубляет перегрузку, продлевает блокировку, синхронный thundering herd от всех клиентов | Уважать Retry-After; иначе экспоненциальный backoff + джиттер; связать с circuit breaker (глава 10) |
| Бесконечно копить запросы при rate limit вместо backpressure | Очередь растёт до OOM, латентность взрывается, каскадный отказ всего сервиса | Ограничить конкурентность семафором, лимит очереди, при переполнении — load shedding (быстрый 503) |
| Один общий лимит на всех тенантов | «Шумный сосед» выедает квоту провайдера и морит остальных, ломая их SLA | Per-tenant token budget и rate limit; справедливое распределение, семафор по ключу тенанта |
| Смена слага модели без регрессионного гейта | Новая версия модели тихо ухудшает поведение; деградацию замечают в проде | Прогон eval-датасета (глава 9) как гейт перед выкатом; миграция — возможность, а не риск |
Практическое задание (pro-m6-deploy-infra)
- Реализуйте SessionStore поверх Redis (горячая сессия с TTL) или Postgres (durable история); обработчик читает историю в начале и пишет в конце.
- Ограничьте параллелизм вызовов модели семафором-пулом; параметр N вынесите в конфиг и подберите под rate limit провайдера.
- Пробросьте context.WithTimeout сквозь весь путь запроса; проверьте, что отмена клиента обрывает вызов модели.
- Добавьте graceful shutdown: по SIGTERM сервер перестаёт принимать запросы, пул дренирует очередь, процесс выходит в пределах дедлайна.
- Вынесите долгую задачу (генерацию отчёта) в очередь с идемпотентным воркером и ретраями; проверьте, что повторная доставка не дублирует эффект.
- Привяжите номер версии промпта к каждому запросу в логах и настройте прогон эвалов в CI как условие мёрджа; продумайте канареечный выкат новой версии.
- Оберните вызовы модели в ретраи, читающие Retry-After на 429/529, с backoff+jitter только для идемпотентных вызовов; убедитесь, что SDK-ретраи (WithMaxRetries) и ваша логика не дублируют друг друга бесконтрольно.
- Введите per-tenant лимиты: семафор конкурентности и token budget по ключу тенанта; при переполнении отклоняйте быстро (load shedding), а перед сменой слага модели прогоняйте eval-датасет как регресс-гейт.
Проверка знаний
Сервис агента запущен в трёх подах за балансировщиком; история сессий лежит в памяти каждого процесса.
Какая проблема проявится в первую очередь?
Верный ответ: B
Балансировщик раскидывает запросы по подам, а память у каждого своя — сессия рвётся. Лечится выносом состояния во внешнее durable-хранилище и stateless-обработчиком. Стоимость, стриминг и версионирование промпта к этой архитектурной проблеме отношения не имеют.
Воркер очереди генерирует отчёт и списывает кредиты. Из-за ретрая та же задача приходит дважды.
Что предотвращает двойное списание?
Верный ответ: C
Защита от повторной доставки — идемпотентность: по ключу идемпотентности воркер проверяет, обработана ли задача, и пропускает повтор. Число воркеров и таймаут влияют на пропускную способность и зависания, но не на дублирование эффекта.
Оркестратор посылает поду SIGTERM при выкате новой версии.
Как корректно завершить сервис, не оборвав текущие запросы?
Верный ответ: B
Graceful shutdown: по сигналу сервер закрывается для новых запросов (srv.Shutdown), текущие дорабатывают в пределах дедлайна, пул воркеров дренирует очередь. os.Exit обрывает всё на месте; игнорирование сигнала приведёт к жёсткому kill и потере прогресса.
Сервис агента под нагрузкой начал получать ответы 429 с заголовком Retry-After. Разработчик повторяет запрос немедленно в цикле, а очередь входящих запросов копится в памяти без ограничения.
Что здесь сделано неправильно и как должно быть?
Верный ответ: B
429 с Retry-After означает «подожди столько секунд». Немедленный повтор усугубляет перегрузку и вызывает thundering herd; правильно — уважать Retry-After, иначе экспоненциальный backoff с джиттером (связка с circuit breaker из главы 10), и ретраить только идемпотентные вызовы. Бесконтрольное накопление очереди ведёт к OOM — нужен backpressure: лимит конкурентности и load shedding (быстрый отказ). MaxTokens к rate limit отношения не имеет.
Дальше почитать
- anthropic-sdk-go
- Batch processing (Message Batches API)
- net/http Server.Shutdown (graceful shutdown)
- Go context package
- Temporal: durable execution (обзорно)
- Rate limits (429, Retry-After, anthropic-ratelimit-*)
- Errors (429 rate_limit, 529 overloaded)
- Create strong empirical evaluations (регресс-гейт при миграции модели)