Продакшн-разработка ИИ-агентов · Модуль 3 · Урок 3.2
Глава 6. Мультиагентные системы под прод
Цели главы
После этой главы вы сможете:
- Трезво оценивать, нужен ли мультиагент вообще — и в большинстве случаев предпочитать одного хорошего агента с инструментами.
- Понимать прод-аспекты координации: разделяемое состояние (shared state), его гонки и рассинхрон, и как их предотвращать.
- Проектировать handoffs (передачу управления и контекста) — решать, что именно передавать между агентами.
- Вводить типобезопасные протоколы общения между агентами вместо «строк в свободной форме».
- Осознавать реальную стоимость мультиагента: рост токенов, латентность, усложнение отладки.
- Реализовать на Go защищённое разделяемое состояние (sync), типизированное handoff-сообщение и координатор → воркеры.
Что нового (дельта к базовому курсу)
В базовом курсе были азы мультиагентов и оркестрации: hub-and-spoke, субагент как инструмент координатора, пайплайны. Это «как соединить агентов».
Здесь дельта — прод-аспекты и трезвость. «Соединить» легко; заставить это надёжно работать под нагрузкой — нет. Мы разбираем то, что в базовом курсе опущено: опасности разделяемого состояния (гонки данных, рассинхрон), что и как передавать при handoff, типобезопасные протоколы общения, и — отдельным крупным блоком — реальную цену мультиагента (токены, латентность, сложность отладки). Главная установка главы намеренно охлаждающая: чаще всего один хороший агент с инструментами лучше нескольких. Мультиагент — не цель и не признак зрелости, а инструмент под конкретную потребность; вводить его без потребности — значит покупать сложность за свои деньги.
Координация нескольких агентов
Координация — это вопрос «кто, когда и на основании чего действует». В базовом hub-and-spoke координатор последовательно зовёт субагентов и собирает их ответы — этого достаточно, пока агенты независимы и не делят состояние. Проблемы начинаются, когда агенты работают параллельно и/или над общими данными.
Три модели координации стоит различать. Последовательная (пайплайн): выход одного — вход следующего; просто, детерминированно, но медленно и без параллелизма. Иерархическая (координатор → воркеры): координатор раздаёт независимые куски, воркеры считают параллельно, координатор собирает; быстрее, но координатор — узкое место и точка отказа. Через общую доску (blackboard): агенты читают и пишут в общее хранилище фактов; гибко, но именно здесь живут самые коварные баги прода. Чем больше общего состояния и параллелизма — тем дороже обходится координация и тем тщательнее её надо проектировать.
Разделяемое состояние (shared state / blackboard) и его опасности
Разделяемое состояние — общая структура (доска, blackboard), куда несколько агентов пишут и откуда читают: промежуточные факты, частичные результаты, флаги прогресса. Идея привлекательна — агенты «видят» работу друг друга без явной пересылки. Но в проде это источник тяжёлых багов.
- Гонки данных (data races). Два агента (горутины) одновременно пишут в общую map или счётчик — в Go это неопределённое поведение и паника, в любой системе — порча данных. Запускайте тесты с
-race. - Рассинхрон (stale reads). Агент B читает доску, пока агент A ещё дописывает результат; B действует по неполной картине и принимает неверное решение.
- Скрытые зависимости. Через общее состояние агенты связываются неявно: поменяли одного — сломался другой, хотя по коду они «не знают» друг о друге. Отлаживать такое мучительно.
Защита: минимизировать разделяемое состояние (лучший общий стейт — его отсутствие; передавайте данные явно через сообщения), а неизбежно общий — закрывать примитивами синхронизации (sync.Mutex/sync.RWMutex) или вообще не делить память, а общаться через каналы (Go-девиз «share memory by communicating»).
Handoffs: передача управления и контекста
Handoff — это передача управления от одного агента другому: «дальше работает агент X». Ключевой вопрос не «как передать», а что именно передать. Передать слишком много (весь сырой контекст, всю историю) — раздуть окно принимающего агента, протащить шум и context rot. Передать слишком мало — принимающий не поймёт задачу и переспросит или ошибётся.
Правильный handoff передаёт сжатую релевантную сводку, а не сырую историю: какая цель, что уже сделано, какие факты установлены, что именно требуется от принимающего. Это та же дисциплина, что и context engineering из Части I, только на границе между агентами. Полезно фиксировать handoff как явную структуру (а не вольный текст): кто передаёт, кому, зачем, с какой полезной нагрузкой. Тогда передачу можно валидировать, логировать и трейсить — а в инциденте видно, на каком handoff контекст потерялся.
Протоколы общения: типобезопасные сообщения
Когда агенты обмениваются «строками в свободной форме», система хрупка: принимающий парсит текст эвристиками, малейшее изменение формата у отправителя ломает приёмник молча. В проде нужен протокол — согласованная структура сообщений между агентами.
В Go это естественно ложится на типизированные структуры: сообщение — это struct с явными полями (тип, отправитель, получатель, полезная нагрузка), а не string или map[string]any. Преимущества: компилятор ловит несоответствия на сборке, а не в рантайме; поля самодокументируют контракт; сериализация в JSON для пересылки между процессами идёт по известной схеме. Это снижает класс «тихих» ошибок интеграции — тех, что не падают, а портят данные. Правило: граница между агентами — это API, и проектировать её надо как API: строгие типы, явные контракты, версионирование.
Межагентные протоколы: A2A и интероперабельность
Типизированные сообщения выше — это контракт внутри вашей системы, где вы владеете и отправителем, и приёмником. Но агенты всё чаще должны общаться через границы организаций и вендоров: ваш агент-планировщик вызывает чужой агент-букинг, агент партнёра дергает ваш агент-биллинг. Здесь нужен открытый протокол, а не ваши внутренние struct'ы.
Эту нишу занимает класс стандартов agent-to-agent (A2A) — протоколы межагентного взаимодействия (например, A2A, изначально от Google, и аналоги; спецификации развиваются — сверяйтесь с актуальными доками). Их идея: агент публикует карточку возможностей (что умеет, какие задачи принимает, как аутентифицироваться), а другие агенты находят его и ставят задачи по единому контракту — с понятиями задачи, статуса, артефактов результата.
A2A и MCP — не конкуренты, а разные оси. MCP (Глава 7) подключает к агенту инструменты и ресурсы (агент → возможности). A2A связывает агента с агентом как равноправных исполнителей (агент ↔ агент). Часто они работают вместе: внутри агент берёт инструменты по MCP, а наружу принимает задачи от других агентов по A2A.
Цена та же, что у любого распределённого взаимодействия: версионирование контракта, аутентификация и авторизация на границе, обработка отказов чужого агента, и — критично — доверие: чужой агент по ту сторону протокола это недоверенный источник (вспомните безопасность, Глава 11), его ответы нельзя слепо исполнять.
Стоимость мультиагента и когда он НЕ нужен
Мультиагент дорог по трём осям, и эту цену надо считать до внедрения.
- Токены. Каждый агент несёт свой системный промпт и контекст; координатор пересылает данные туда-обратно. Накладные расходы складываются — мультиагентная система часто тратит в разы больше токенов, чем один агент на ту же задачу.
- Латентность. Цепочка агентов = цепочка round-trip к модели. Даже при параллелизме координатор ждёт самого медленного воркера; последовательные handoffs складывают задержки.
- Отладка. Поведение размазано по нескольким агентам и общему состоянию; воспроизвести инцидент труднее, трейсинг обязателен, а не желателен.
Поэтому ключевой вопрос: когда мультиагент НЕ нужен? Чаще всего — один хороший агент с хорошо спроектированными инструментами лучше. Мультиагент оправдан узко: когда нужна изоляция контекста (узкие субагенты точнее универсала), когда подзадачи реально независимы и выигрывают от параллелизма, когда разные части требуют разных инструментов/прав. Если же вы дробите задачу «для красоты архитектуры» или потому что «так делают в статьях» — вы покупаете токены, латентность и боль отладки без отдачи. Начинайте с одного агента; вводите второго только тогда, когда измеримо упёрлись в потолок первого.
flowchart LR
subgraph Org1["Ваша система"]
AG1["Агент-планировщик"]
AG1 -->|MCP| T1["Инструменты / ресурсы"]
end
subgraph Org2["Партнёр"]
AG2["Агент-букинг"]
AG2 -->|MCP| T2["Инструменты / ресурсы"]
end
AG1 <-->|"A2A: задача / статус / результат"| AG2// Blackboard — общая «доска» фактов, в которую пишут несколько агентов-горутин.
// Без защиты конкурентная запись в map — это гонка данных и паника. Поэтому ВСЕ
// обращения идут через мьютекс. Лучший общий стейт — его минимум; но если он есть,
// он обязан быть потокобезопасным.
type Blackboard struct {
mu sync.RWMutex // RWMutex: много читателей ИЛИ один писатель
facts map[string]string
}
func NewBlackboard() *Blackboard {
return &Blackboard{facts: make(map[string]string)}
}
// Put — запись факта. Берём writer-lock: на время записи никто не читает и не пишет.
func (b *Blackboard) Put(key, value string) {
b.mu.Lock()
defer b.mu.Unlock()
b.facts[key] = value
}
// Get — чтение факта. Reader-lock допускает параллельные чтения, но блокирует на
// время записи — это защищает от stale read «в момент дописывания».
func (b *Blackboard) Get(key string) (string, bool) {
b.mu.RLock()
defer b.mu.RUnlock()
v, ok := b.facts[key]
return v, ok
}
// Snapshot — согласованная копия всей доски. Возвращаем КОПИЮ, а не саму map:
// иначе вызывающий получил бы ссылку на общую структуру и обошёл мьютекс.
func (b *Blackboard) Snapshot() map[string]string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[string]string, len(b.facts))
for k, v := range b.facts {
out[k] = v
}
return out
}
// Тесты конкурентного доступа запускайте с флагом -race: go test -race ./...// agentID — типизированный идентификатор агента (не «голая» строка).
type agentID string
// HandoffKind — что именно за передача. Закрытый набор значений: компилятор и
// читатель видят полный контракт, а не угадывают по тексту.
type HandoffKind string
const (
HandoffDelegate HandoffKind = "delegate" // координатор поручает подзадачу воркеру
HandoffReturn HandoffKind = "return" // воркер возвращает результат координатору
HandoffEscalate HandoffKind = "escalate" // воркер не справился — эскалация
)
// Handoff — ПРОТОКОЛ передачи управления и контекста между агентами. Это API
// границы между агентами: строгие типы вместо вольного текста ловят рассогласования
// на сборке, а не в рантайме. Поле Summary несёт СЖАТУЮ сводку, а не сырую историю,
// — иначе раздуем контекст принимающего и протащим шум.
type Handoff struct {
Kind HandoffKind // `json:"kind"`
From agentID // `json:"from"`
To agentID // `json:"to"`
Goal string // `json:"goal"` — что требуется от принимающего
Summary string // `json:"summary"` — сжатый релевантный контекст, не сырьё
Payload string // `json:"payload"` — полезная нагрузка (например, подзадача)
}
// validate — handoff можно проверить, потому что он типизирован. Свободный текст
// так не проверишь: ошибка контракта всплыла бы молча у принимающего.
func (h Handoff) validate() error {
if h.From == "" || h.To == "" {
return errors.New("handoff: не указаны from/to")
}
if h.Goal == "" {
return errors.New("handoff: пустая цель — принимающий не поймёт задачу")
}
return nil // сериализуем в JSON для пересылки между процессами по известной схеме
}// worker — узкий агент: получает делегирующий handoff, возвращает результат.
// Воркеры НЕ делят память между собой — общаются только через каналы координатора.
func worker(
ctx context.Context,
id agentID,
in <-chan Handoff, // вход: задачи от координатора
out chan<- Handoff, // выход: результаты обратно координатору
run func(context.Context, Handoff) (string, error),
) {
for h := range in {
result, err := run(ctx, h) // здесь — реальный вызов модели/инструментов
kind := HandoffReturn
if err != nil {
kind = HandoffEscalate // не справился — честно эскалируем, не молчим
}
out <- Handoff{Kind: kind, From: id, To: h.From, Goal: h.Goal, Payload: result}
}
}
// orchestrate — координатор раздаёт независимые подзадачи воркерам параллельно
// и собирает результаты. Параллелизм оправдан ИМЕННО потому, что подзадачи
// независимы; если бы они делили состояние, выигрыш съели бы блокировки.
func orchestrate(
ctx context.Context,
tasks []Handoff,
run func(context.Context, Handoff) (string, error),
) []Handoff {
in := make(chan Handoff)
out := make(chan Handoff)
// Пул воркеров: общаемся сообщениями по каналам, общей памяти между ними нет.
const numWorkers = 4
for i := 0; i < numWorkers; i++ {
go worker(ctx, agentID("worker"), in, out, run)
}
// Раздаём задачи в отдельной горутине, чтобы не упереться в блокировку канала.
go func() {
for _, t := range tasks {
in <- t
}
close(in) // закрываем вход: воркеры завершат range и остановятся
}()
// Собираем ровно столько результатов, сколько отправили задач.
results := make([]Handoff, 0, len(tasks))
for range tasks {
results = append(results, <-out)
}
return results
}Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
| Мультиагент «для красоты архитектуры» | Дробление без потребности покупает токены, латентность и боль отладки без отдачи; один агент решил бы задачу дешевле и проще. | Начинать с одного хорошего агента с инструментами; вводить второго, только когда измеримо упёрлись в потолок первого. |
| Незащищённое разделяемое состояние | Конкурентная запись в общую map/счётчик — гонка данных, паника и порча данных; баг недетерминированный и трудноуловимый. | Минимизировать общий стейт; неизбежный — закрывать sync.Mutex/RWMutex или общаться через каналы. Тесты гонять с -race. |
| Handoff сырой историей вместо сводки | Принимающий агент получает раздутый контекст, шум и context rot; растёт стоимость, падает точность. | Передавать сжатую релевантную сводку (цель, сделанное, факты, требуемое), а не всю историю. Handoff — это дисциплина context engineering. |
| Общение агентов строками в свободной форме | Принимающий парсит текст эвристиками; изменение формата у отправителя молча ломает приёмник — тихие ошибки интеграции. | Типизированный протокол: сообщение — struct с явными полями; компилятор ловит рассогласования на сборке, JSON по известной схеме. |
| Координатор без обработки отказа воркера | Воркер падает или зависает — координатор ждёт вечно или теряет результат; вся система встаёт из-за одного узла. | Явная эскалация (HandoffEscalate), таймауты/контекст, сбор ровно ожидаемого числа результатов; координатор — точка отказа, проектируйте её отказоустойчиво. |
| Игнорирование стоимости и латентности мультиагента | Система незаметно тратит в разы больше токенов и времени, чем один агент; счёт и SLA узнаёте уже в проде. | Считать цену до внедрения по трём осям (токены, латентность, отладка); сравнивать с бейзлайном «один агент»; трейсить каждый handoff. |
Практическое задание
- Возьмите задачу, которую сейчас решает один агент, и честно ответьте: даёт ли дробление на несколько агентов измеримый выигрыш (изоляция контекста, параллелизм независимых подзадач, разные инструменты)? Если нет — оставьте один.
- Если выигрыш есть — реализуйте координатор → воркеры на каналах (без общей памяти между воркерами); подзадачи должны быть реально независимыми.
- Введите типизированный Handoff-struct как протокол между координатором и воркерами; добавьте validate() и сериализацию в JSON.
- Если общее состояние неизбежно — реализуйте Blackboard под sync.RWMutex и прогоните конкурентные тесты с флагом -race, убедитесь, что гонок нет.
- Спроектируйте handoff так, чтобы он передавал сжатую сводку, а не сырую историю; залогируйте размер контекста на каждой передаче.
- Измерьте мультиагентную версию против бейзлайна «один агент» на 10–20 примерах: токены, латентность, качество. Зафиксируйте в README, оправдал ли мультиагент свою цену.
Проверка знаний
Команда переписала рабочего агента-помощника на систему из координатора и четырёх субагентов «потому что это современнее». Качество ответов не выросло, но счёт за токены утроился, а ответы стали заметно медленнее.
Какой вывод корректен?
Верный ответ: B
Мультиагент дорог по токенам, латентности и отладке; вводить его «для современности» без измеримой потребности — значит покупать сложность без отдачи. Раз качество не выросло, а стоимость и задержка — да, дробление здесь не оправдано: один хороший агент с инструментами был лучше. Остальные варианты усугубляют расход или рационализируют его без обоснования.
Несколько агентов-горутин в Go параллельно пишут промежуточные факты в общую map без какой-либо синхронизации. Иногда сервис падает с паникой, иногда выдаёт испорченные данные; воспроизвести стабильно не удаётся.
Что является причиной и какое решение корректно?
Верный ответ: A
Конкурентная запись в общую map без синхронизации — классическая гонка данных: в Go это неопределённое поведение, отсюда паника и порча данных, причём недетерминированные и трудновоспроизводимые. Решение — защитить общий стейт примитивами синхронизации (Mutex/RWMutex) или вообще не делить память, а общаться через каналы; обязательно прогонять тесты с -race. (D) не спасает: свободное чтение при записи всё равно гонка.
При передаче управления от одного агента к другому разработчик пересылает принимающему весь сырой лог диалога и всю историю инструментов «чтобы ничего не потерять». Принимающий агент стал медленнее, дороже и чаще ошибается.
Как правильно проектировать handoff?
Верный ответ: B
Сырая история раздувает контекст принимающего, тащит шум и провоцирует context rot — отсюда рост стоимости и падение точности. Правильный handoff передаёт сжатую релевантную сводку: какая цель, что уже сделано, какие факты установлены, что требуется. Это тот же context engineering, только на границе между агентами. (A) усугубляет раздувание; (C) ломает задачу лишними переспросами; (D) — антипаттерн вольного текста вместо типизированного протокола.
Ваш агент должен ставить задачи агенту партнёрской компании, которым вы не управляете и чей код не видите.
Что здесь уместнее и о чём нельзя забывать?
Верный ответ: B
B верно. Через границу организаций нужен открытый протокол (класс A2A), а не внутренние struct'ы (A невозможно — общей памяти нет). MCP — это про подключение инструментов к агенту, другая ось (C путает оси и игнорирует доверие). Свободный текст (D) хрупок. Ключевое: агент по ту сторону — недоверенный источник (Глава 11), плюс аутентификация, версионирование контракта и обработка его отказов.
Дальше почитать
- Anthropic — Building effective agents (когда мультиагент оправдан, а когда хватит одного)
- A2A Protocol — спецификация межагентного взаимодействия (сверяйтесь с актуальной версией)
- Anthropic — How we built our multi-agent research system (координация, стоимость, handoffs на практике)
- Anthropic — Tool use overview (инструменты для одного хорошего агента)
- anthropic-sdk-go (конкурентность и интеграция в Go — сверьтесь с актуальными доками)