Продакшн-разработка ИИ-агентов · Модуль 7 · Урок 7.1
Capstone: агентная платформа на Go от каркаса до прода (гл. 1–15)
▶ Практическое продолжение: проектный курс «Агент-инженер по репозиторию»
Этот capstone — проектная вершина курса. Если вы хотите не только спроектировать платформу на бумаге, но и собрать одного агента руками от каркаса до прода, пройдите сквозной build-along курс «Агент-инженер по репозиторию» (курс repoagent в общем списке курсов). Там тот же материал глав 1–15 разворачивается как дельта-шаги одного агента по версиям v0…v5: agentic loop и read_file → инструменты репозитория по allowlist → гибридный retrieval → запись кода в изоляции (worktree, никогда main) → MR с верификационным гейтом и человеком в контуре (без auto-merge) → прод-сервис с аудитом и каскадом моделей. Курс — прикладной хребет, который связывает темы этого проф-курса в один работающий артефакт; финальный снимок версии v5 — собираемый репозиторий в examples/repo-agent. Рекомендуем проходить его параллельно с этим модулем: здесь — архитектурная карта, там — пошаговая сборка.
Цели главы
- Собрать агентную платформу (agent platform) на Go, в которой увязаны воедино все темы курса: надёжность, MCP, конкурентность, наблюдаемость.
- Спроектировать сервис-агент (agent service) с пулом воркеров (worker pool), durable-исполнением и корректным завершением (graceful shutdown).
- Подключить инструменты через MCP (Model Context Protocol) и собственные типизированные tool-обработчики.
- Встроить сквозные хуки (cross-cutting hooks): guardrails на входе/выходе, эвал-перехват, трейсинг каждого шага.
- Понять, какие компромиссы (trade-offs) платформа делает по стоимости, латентности и безопасности, и где их настраивать.
- Получить чек-лист готовности к проду (production readiness checklist), по которому платформу можно выкатывать.
Что нового (дельта к базовому курсу)
Базовый курс учил писать агента: один agent loop, набор инструментов, вызов модели. Этот capstone учит эксплуатировать парк агентов — то, чем продакшн отличается от прототипа.
Новизна не в отдельных приёмах (их вы уже разобрали по главам), а в их совместной работе под нагрузкой. По отдельности durable execution (гл.1), компакция контекста (гл.2), гибридный RAG (гл.3), память (гл.4), мультиагент (гл.6), MCP (гл.8), эвалы и трейсинг (гл.9), guardrails (гл.10), least privilege (гл.11), бюджеты токенов (гл.12) и конкурентность (гл.13) — это десяток независимых тем. В реальной платформе они конфликтуют за одни и те же ресурсы и точки расширения: трейсинг должен видеть retry, guardrail должен отрабатывать до списания токенов, компакция не должна ломать prompt caching, shutdown не должен бросать durable-задачу на полпути.
Поэтому capstone — это не «ещё одна фича», а архитектурный шов: единый каркас, в который все главы курса встают как слои, а не как разрозненные хаки. Главная мысль: продакшн-надёжность достигается не героическими усилиями в одном месте, а дисциплиной границ — каждый слой делает одно и отдаёт управление следующему.
Архитектура платформы: слои и потоки
Разделяем платформу на плоскость управления (control plane) и плоскость данных (data plane) — тот же принцип, что в managed-инфраструктуре.
Control plane (редко меняется, версионируется): реестр агентов (системные промпты, наборы инструментов, политики), реестр MCP-серверов, конфигурация эвалов и guardrails, версии промптов. Это конфигурация, которую вы храните в репозитории и катите через CI/CD (гл.14).
Data plane (горячий путь, каждый запрос): приём задачи → постановка в очередь → воркер берёт задачу → durable agent loop → вызовы модели и инструментов → запись результата. Именно здесь живут конкурентность (гл.13) и стоимость/латентность (гл.12).
Поток одного запроса: HTTP/очередь → Dispatcher → WorkerPool → Agent.Run(ctx, task). Внутри Agent.Run крутится agent loop из гл.1 (выход по stop_reason, бюджеты шагов и токенов), обёрнутый в durable-чекпойнты — если воркер упал, задача переподнимается с последнего сохранённого шага, а не с нуля. Каждый шаг проходит через цепочку middleware: трейс-спан → guardrail-вход → вызов модели → guardrail-выход → эвал-семпл.
Куда ложатся главы 1–8
Гл.1 (harness/durable). Ядро Agent.Run — это надёжный agent loop: выход строго по stop_reason, лимиты maxSteps и maxTokens как страховка, идемпотентные вызовы инструментов, чекпойнты состояния в durable-хранилище.
Гл.2 (context engineering). Перед каждым вызовом модели — управление окном: компакция старых tool-результатов, удержание стабильного префикса ради prompt caching, защита от context rot. Платформа держит «бюджет окна» отдельно от «бюджета стоимости».
Гл.3–4 (RAG/память). Инструмент search_kb за гибридным поиском (BM25 + вектор + re-ranking) — это просто ещё один tool в реестре. Память (эпизодическая/семантическая) подключается как durable-хранилище, доступное между сессиями; консолидация и забывание — фоновые задачи того же worker pool.
Гл.5–6 (паттерны/мультиагент). Выбор паттерна (ReAct, plan-and-execute, orchestrator-workers) — это конфигурация агента в реестре, а не хардкод. Мультиагент реализуется как агент, у которого среди инструментов есть spawn_subagent; субагент — такая же задача в очереди, со своим трейс-контекстом (родительский span_id передаётся в дочерний).
Куда ложатся главы 9–15
Гл.7–8 (инструменты/MCP). Инструменты приходят из двух источников: собственные типизированные обработчики Go и внешние MCP-серверы. Платформа на старте подключается к MCP-серверам (tools/list), кэширует их схемы и маршрутизирует tool_use-вызовы либо в локальный обработчик, либо в MCP-клиент. MCP даёт переиспользуемость: один сервер (например, GitHub или внутренний CRM) обслуживает все агенты.
Гл.9 (эвалы/observability). Каждый шаг — это span в трейсе; model_request_end несёт usage для учёта стоимости. Часть продакшн-трафика семплируется в эвал-датасет; LLM-as-judge гоняется офлайн и ловит регрессии перед выкаткой новой версии промпта.
Гл.10 (guardrails). Middleware на входе (валидация запроса, детектор prompt injection) и на выходе (схема-валидация, фильтр PII, политики). Фолбэки и circuit breaker — на уровне вызова модели: при overloaded_error ретрай с backoff, при устойчивом отказе — деградация к более дешёвой модели или к человеку (human-in-the-loop).
Гл.11 (безопасность). Каждый инструмент исполняется по принципу наименьших привилегий (least privilege): токены и секреты не попадают в контекст модели, опасные инструменты (запись, отправка) идут через подтверждение. Песочница (sandbox) для исполнения кода — отдельный изолированный процесс.
Гл.12 (стоимость/латентность). Prompt caching на стабильном префиксе, каскад моделей (дешёвая для рутины, дорогая для сложного), стриминг для длинных ответов, бюджеты токенов на задачу. Платформа считает стоимость по usage из каждого ответа.
Гл.13–15 (деплой/конкурентность/адаптация). Агент как сервис: worker pool на goroutine + каналы, состояние в durable-хранилище, очередь задач, graceful shutdown по context.Context. Версионирование промптов и A/B — через control plane. Адаптация моделей (fine-tuning/дистилляция) — отдельный офлайн-конвейер, питающийся теми же эвал-датасетами.
Главный компромисс: где проходит шов
Соблазн capstone — написать один гигантский Agent.Run на 500 строк, где перемешаны loop, ретраи, трейсинг, guardrails и MCP. Это работает на демо и разваливается в проде: невозможно протестировать слой по отдельности, невозможно отключить guardrail для отладки, невозможно поменять стратегию ретраев без риска сломать loop.
Правильный шов — middleware/цепочка обязанностей (chain of responsibility). Ядро loop ничего не знает про трейсинг и guardrails; оно дёргает абстрактный ModelCaller, а уже его оборачивают декораторы. Так каждая глава курса становится независимо включаемым слоем, а не строкой в монолите. Это и есть «продакшн-мышление»: не максимум кода, а минимум связности (coupling) между ортогональными заботами.
package platform
import (
"context"
"log/slog"
"sync"
"github.com/anthropics/anthropic-sdk-go"
)
// Task — единица работы. ID нужен для идемпотентности и durable-чекпойнтов.
type Task struct {
ID string
AgentID string // ключ в реестре агентов (control plane)
Input string
}
// Platform — корень сервиса. Держит клиента модели, реестр агентов,
// очередь задач и пул воркеров. Поля-теги json здесь опущены намеренно:
// в учебном коде конфиг грузится отдельным слоем (см. гл.14).
type Platform struct {
model anthropic.Client
agents *AgentRegistry // системные промпты + наборы инструментов
tools *ToolRouter // локальные обработчики + MCP-клиенты (гл.8)
store DurableStore // чекпойнты состояния (гл.1)
tracer Tracer // трейсинг каждого шага (гл.9)
tasks chan Task // очередь задач (data plane)
wg sync.WaitGroup
}
func New(opts ...Option) *Platform {
p := &Platform{
model: anthropic.NewClient(), // ANTHROPIC_API_KEY из окружения
tasks: make(chan Task, 256),
}
for _, o := range opts {
o(p)
}
return p
}
// Start поднимает n воркеров. Каждый воркер — goroutine, читающая из общей
// очереди. Конкурентность на каналах — идиоматичный Go (гл.13).
func (p *Platform) Start(ctx context.Context, n int) {
for i := 0; i < n; i++ {
p.wg.Add(1)
go p.worker(ctx, i)
}
}
func (p *Platform) worker(ctx context.Context, id int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return // graceful shutdown: ctx отменён — воркер выходит
case task, ok := <-p.tasks:
if !ok {
return
}
if err := p.runTask(ctx, task); err != nil {
slog.Error("задача провалена", "task", task.ID, "worker", id, "err", err)
}
}
}
}
// Submit ставит задачу в очередь (неблокирующе при наличии места в буфере).
func (p *Platform) Submit(task Task) { p.tasks <- task }
// Shutdown закрывает приём задач и ждёт, пока воркеры доработают текущие.
// Незавершённые durable-задачи переподнимутся при следующем старте.
func (p *Platform) Shutdown() {
close(p.tasks)
p.wg.Wait()
}// runTask исполняет одну задачу как durable agent loop. Цикл завершается
// СТРОГО по stop_reason (гл.1), а maxSteps/maxTokens — лишь страховка.
func (p *Platform) runTask(ctx context.Context, task Task) error {
agent := p.agents.Get(task.AgentID)
// Durable: пробуем восстановить состояние с последнего чекпойнта.
// Если воркер падал — продолжим с шага N, а не с нуля.
state, err := p.store.Load(ctx, task.ID)
if err != nil {
return err
}
if state == nil {
state = &RunState{Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(task.Input)),
}}
}
const maxSteps = 30
for state.Step < maxSteps {
// Бюджет токенов на задачу (гл.12): превысили — мягко останавливаемся.
if state.TokensUsed > agent.TokenBudget {
return p.store.Finish(ctx, task.ID, "token_budget_exceeded")
}
// Управление окном перед вызовом (гл.2): компакция старых
// tool-результатов, удержание стабильного префикса ради кэша.
state.Messages = p.compactWindow(state.Messages)
// callModel инкапсулирует цепочку middleware: трейс → guardrail →
// модель → guardrail → эвал-семпл. Ядро loop про них не знает.
resp, err := p.callModel(ctx, agent, state.Messages)
if err != nil {
return err // ретраи/фолбэки уже внутри callModel (гл.10)
}
state.TokensUsed += int(resp.Usage.InputTokens + resp.Usage.OutputTokens)
state.Messages = append(state.Messages, resp.ToParam())
// Единственный корректный критерий завершения хода.
if resp.StopReason != anthropic.StopReasonToolUse {
return p.store.Finish(ctx, task.ID, "done")
}
// Исполняем запрошенные инструменты через роутер (локально или MCP).
results := p.execTools(ctx, agent, resp.Content)
state.Messages = append(state.Messages, anthropic.NewUserMessage(results...))
state.Step++
// Чекпойнт ПОСЛЕ каждого шага: падение между шагами не теряет прогресс.
if err := p.store.Save(ctx, task.ID, state); err != nil {
return err
}
}
return p.store.Finish(ctx, task.ID, "max_steps")
}package platform
import (
"context"
"encoding/json"
"github.com/anthropics/anthropic-sdk-go"
mcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
// ToolRouter маршрутизирует tool_use: сначала ищет локальный обработчик,
// иначе — внешний MCP-сервер. Схемы MCP-инструментов кэшируются на старте.
type ToolRouter struct {
local map[string]LocalHandler // имя -> типизированный обработчик
mcp map[string]*mcp.ClientSession // имя инструмента -> MCP-сессия
}
type LocalHandler func(ctx context.Context, rawInput json.RawMessage) (string, error)
// ConnectMCP подключается к MCP-серверу (Streamable HTTP) и регистрирует все
// его инструменты в роутере. Один сервер обслуживает все агенты платформы.
func (r *ToolRouter) ConnectMCP(ctx context.Context, name, url string) error {
client := mcp.NewClient(&mcp.Implementation{Name: "agent-platform"}, nil)
session, err := client.Connect(ctx, mcp.NewStreamableClientTransport(url, nil), nil)
if err != nil {
return err
}
tools, err := session.ListTools(ctx, nil)
if err != nil {
return err
}
for _, t := range tools.Tools {
r.mcp[t.Name] = session // помечаем: этот инструмент обслуживает MCP
}
return nil
}
// Exec исполняет один tool_use. block — это anthropic.ToolUseBlock из ответа.
func (r *ToolRouter) Exec(ctx context.Context, name, id string, input json.RawMessage) anthropic.ContentBlockParamUnion {
// 1) Локальный типизированный обработчик — горячий путь.
if h, ok := r.local[name]; ok {
out, err := h(ctx, input)
if err != nil {
return anthropic.NewToolResultBlock(id, err.Error(), true)
}
return anthropic.NewToolResultBlock(id, out, false)
}
// 2) Иначе — внешний MCP-сервер (гл.8).
if session, ok := r.mcp[name]; ok {
var args map[string]any
_ = json.Unmarshal(input, &args)
res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
return anthropic.NewToolResultBlock(id, err.Error(), true)
}
return anthropic.NewToolResultBlock(id, mcpText(res), res.IsError)
}
// 3) Незнакомый инструмент — это ошибка конфигурации, не паника.
return anthropic.NewToolResultBlock(id, "unknown tool: "+name, true)
}
// mcpText сворачивает контент MCP-ответа в строку для tool_result.
func mcpText(res *mcp.CallToolResult) string {
var b []byte
for _, c := range res.Content {
if t, ok := c.(*mcp.TextContent); ok {
b = append(b, t.Text...)
}
}
return string(b)
}// callModel — единственная точка, дёргающая модель. Она оборачивает вызов
// цепочкой сквозных забот. Ядро agent loop про эту цепочку ничего не знает —
// это и есть «шов» платформы (chain of responsibility).
func (p *Platform) callModel(
ctx context.Context,
agent *Agent,
messages []anthropic.MessageParam,
) (*anthropic.Message, error) {
// 1. Трейс-спан на шаг (гл.9). Дочерние субагенты наследуют parent span.
ctx, span := p.tracer.StartSpan(ctx, "model_request")
defer span.End()
// 2. Guardrail на ВХОДЕ (гл.10): детектор prompt injection, лимиты.
// Важно: отрабатывает ДО списания токенов.
if err := agent.Guardrails.CheckInput(messages); err != nil {
span.SetError(err)
return nil, err
}
// 3. Вызов модели с ретраями/фолбэками (гл.10 + гл.12).
resp, err := p.callWithFallback(ctx, agent, messages)
if err != nil {
span.SetError(err)
return nil, err
}
span.SetUsage(resp.Usage) // usage в спан — основа учёта стоимости
// 4. Guardrail на ВЫХОДЕ (гл.10): схема-валидация, фильтр PII, политики.
if err := agent.Guardrails.CheckOutput(resp); err != nil {
span.SetError(err)
return nil, err
}
// 5. Эвал-семпл (гл.9): часть трафика уходит в офлайн-датасет для
// LLM-as-judge и ловли регрессий перед выкаткой новой версии промпта.
p.evalSink.Sample(agent.ID, messages, resp)
return resp, nil
}
// callWithFallback: каскад моделей + circuit breaker. При overloaded_error —
// ретрай с backoff; при устойчивом отказе — деградация к дешёвой модели.
func (p *Platform) callWithFallback(
ctx context.Context,
agent *Agent,
messages []anthropic.MessageParam,
) (*anthropic.Message, error) {
params := anthropic.MessageNewParams{
Model: agent.Model, // напр. anthropic.ModelClaudeOpus4_8
MaxTokens: 4096,
Messages: messages,
Tools: agent.Tools,
System: []anthropic.TextBlockParam{{
Text: agent.SystemPrompt,
CacheControl: anthropic.NewCacheControlEphemeralParam(), // prompt caching (гл.12)
}},
}
resp, err := p.model.Messages.New(ctx, params)
if err != nil {
var apiErr *anthropic.Error
// Перегрузка/5xx — ретраебельно; SDK уже ретраит, здесь — деградация.
if errorsAsOverloaded(err, &apiErr) && agent.Fallback != "" {
params.Model = agent.Fallback // более дешёвая/доступная модель
return p.model.Messages.New(ctx, params)
}
return nil, err
}
return resp, nil
}Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
Монолитный Agent.Run на 500 строк: loop, ретраи, трейсинг, guardrails и MCP в одной функции | Невозможно тестировать слой отдельно, отключать guardrail для отладки, менять стратегию ретраев без риска сломать loop; связность (coupling) растёт квадратично | Шов через middleware/цепочку обязанностей: ядро loop дёргает абстрактный ModelCaller, заботы оборачивают его декораторами — каждый слой включается независимо |
| Guardrail на выходе после вызова модели, а валидация входа — нигде | Опасный ввод (prompt injection) уже списал токены и дошёл до модели; деньги потрачены на запрос, который надо было отбить на входе | Guardrail на входе до вызова модели (и до списания токенов), на выходе — отдельно; оба — обязательные звенья цепочки callModel |
| Чекпойнт состояния раз в конце задачи (или вообще без durable-хранилища) | Падение воркера на шаге 20 из 30 теряет весь прогресс; задача переисполняется с нуля, дублируя побочные эффекты инструментов | Чекпойнт после каждого шага в durable-хранилище + идемпотентные инструменты по task.ID; при старте — store.Load и продолжение с последнего шага |
| Компакция контекста переписывает начало истории (системный префикс, список инструментов) | Любой байт в префиксе инвалидирует prompt cache целиком — компакция, призванная экономить, наоборот взрывает стоимость холодными записями кэша | Стабильный префикс держать байт-в-байт неизменным; компактить только хвост (старые tool-результаты), сортировать инструменты детерминированно |
Подключение к MCP-серверу и ListTools на каждый запрос/каждый шаг | Сетевой round-trip и парсинг схем в горячем пути добавляют латентность; при недоступности MCP падают все задачи разом | Подключаться и кэшировать схемы инструментов на старте (control plane); в data plane — только CallTool; при отказе MCP — graceful деградация инструмента, а не задачи |
Shutdown через os.Exit или закрытие очереди без ожидания воркеров | Durable-задачи бросаются на полуслове, in-flight вызовы модели обрываются, трейсы не закрываются — наблюдаемость врёт | Graceful shutdown: отмена context.Context → воркеры выходят из select → wg.Wait() дорабатывает текущие шаги; недоделанное переподнимется из чекпойнтов |
Практическое задание (CAP)
- Skeleton. Поднять
Platformс пулом воркеров на goroutine + буферизованный канал задач; реализоватьSubmit/Start/Shutdownс graceful shutdown поcontext.Context(гл.13). Прогнать пустойAgent.Run, который сразу возвращаетdone. - Durable loop. Встроить ядро agent loop из гл.1: выход по
stop_reason, лимитыmaxSteps/maxTokens, чекпойнтRunStateпосле каждого шага вDurableStore(для старта — SQLite или Postgres). Проверить восстановление: убить воркер на середине, перезапустить, убедиться, что задача продолжилась с последнего шага. - Tools + MCP. Реализовать
ToolRouterс локальными типизированными обработчиками и подключением MCP-сервера черезgithub.com/modelcontextprotocol/go-sdk(ListToolsна старте,CallToolв рантайме). Добавить хотя бы один локальный инструмент и один MCP-инструмент; проверить маршрутизацию обоих. - Context + cost. Добавить управление окном (гл.2): компакция старых tool-результатов с сохранением стабильного префикса; включить prompt caching на системном префиксе и убедиться по
usage.CacheReadInputTokens, что кэш реально читается. Завести бюджет токенов на задачу (гл.12). - Reliability + guardrails. Обернуть вызов модели в
callModelс цепочкой: guardrail-вход (детектор инъекций) → вызов → guardrail-выход (схема/PII) → фолбэк на дешёвую модель приoverloaded_errorс backoff и circuit breaker (гл.10). Для опасных инструментов — подтверждение (human-in-the-loop) и least privilege по секретам (гл.11). - Evals + observability. Завести трейсинг каждого шага как span с
usageвmodel_request_end(гл.9); прокинуть parent span_id в субагентов. Семплировать часть трафика в эвал-датасет; написать офлайн-прогон LLM-as-judge, который сравнивает новую версию промпта со старой и блокирует выкатку при регрессии. - Deploy. Вынести реестр агентов, MCP-серверов и версии промптов в control plane (конфиг в репозитории, гл.14); настроить CI/CD с прогоном эвалов как гейтом и A/B двух версий промпта в проде. Описать health-check, метрики (стоимость/латентность/доля ошибок) и алерты.
- Готовность. Пройти по чек-листу готовности к проду (ниже) и закрыть каждый пункт; зафиксировать осознанные компромиссы (какие модели в каскаде, какой бюджет токенов, какие инструменты за подтверждением).
Проверка знаний
Вы собираете платформу и хотите, чтобы трейсинг, guardrails и эвал-семплинг можно было включать/отключать независимо и тестировать по отдельности, не трогая ядро agent loop.
Какая структура кода лучше всего обеспечивает это свойство?
Верный ответ: B
Шов через middleware/декораторы (chain of responsibility) делает каждую сквозную заботу независимо включаемым слоем: ядро loop ничего не знает про трейсинг и guardrails, дёргая абстрактный ModelCaller. Это минимизирует связность и позволяет тестировать и отключать слои по отдельности. Ветвления по флагам (A) и глобальные флаги (C) растят связность и путают тестирование; копии функции (D) — комбинаторный взрыв и кошмар сопровождения.
Воркер падает на шаге 20 из 30 во время durable-задачи, которая уже отправила два письма через инструмент send_email. После перезапуска платформа переподнимает задачу.
Что обязательно должно быть в дизайне, чтобы перезапуск не отправил письма повторно и продолжил с шага 20?
Верный ответ: B
Durable-исполнение требует чекпойнта после каждого шага (иначе теряется прогресс между шагами) И идемпотентности инструментов с побочными эффектами — иначе переисполнение шагов продублирует письма. Чекпойнт только в конце (A) теряет всё при падении в середине. maxSteps (C) — страховка от зацикливания, к восстановлению отношения не имеет. Состояние только в памяти (D) исчезает вместе с упавшим воркером.
Чтобы экономить, вы добавили компакцию контекста, которая суммирует и переписывает начало истории сообщений, включая системный промпт и список инструментов. После этого счёт за токены вырос, а не упал.
В чём наиболее вероятная причина?
Верный ответ: B
Prompt caching — это префиксное совпадение: любой изменённый байт в начале (системный промпт, список инструментов) инвалидирует кэш целиком, и каждый запрос платит премию за холодную запись вместо дешёвого чтения. Компакция должна трогать только хвост (старые tool-результаты), удерживая стабильный префикс байт-в-байт. Компакция сама по себе не дороже (A); рост вызван именно потерей кэша, а не длиной ответа (C) или MCP (D).
Дальше почитать
- anthropic-sdk-go — официальный Go SDK для Claude API
- MCP Go SDK (modelcontextprotocol/go-sdk) — клиент и сервер MCP на Go
- Anthropic — Building effective agents (паттерны и когда они нужны)
- Claude Docs — Tool use overview (схемы инструментов, tool_choice, обработка результатов)
- Model Context Protocol — спецификация (сверьтесь с актуальной версией)