Продакшн-разработка ИИ-агентов · Модуль 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 складывают задержки.
  • Отладка. Поведение размазано по нескольким агентам и общему состоянию; воспроизвести инцидент труднее, трейсинг обязателен, а не желателен.

Поэтому ключевой вопрос: когда мультиагент НЕ нужен? Чаще всего — один хороший агент с хорошо спроектированными инструментами лучше. Мультиагент оправдан узко: когда нужна изоляция контекста (узкие субагенты точнее универсала), когда подзадачи реально независимы и выигрывают от параллелизма, когда разные части требуют разных инструментов/прав. Если же вы дробите задачу «для красоты архитектуры» или потому что «так делают в статьях» — вы покупаете токены, латентность и боль отладки без отдачи. Начинайте с одного агента; вводите второго только тогда, когда измеримо упёрлись в потолок первого.

Две разные оси: MCP подключает инструменты, A2A связывает агентов
flowchart LR
  subgraph Org1["Ваша система"]
    AG1["Агент-планировщик"]
    AG1 -->|MCP| T1["Инструменты / ресурсы"]
  end
  subgraph Org2["Партнёр"]
    AG2["Агент-букинг"]
    AG2 -->|MCP| T2["Инструменты / ресурсы"]
  end
  AG1 <-->|"A2A: задача / статус / результат"| AG2
Разделяемое состояние под защитой sync: доска (blackboard) без гонок данных
// 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 ./...
Типизированное handoff-сообщение: контракт между агентами как struct, а не строка
// 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 для пересылки между процессами по известной схеме
}
Координатор → воркеры: общение через каналы (share memory by communicating), а не через общую память
// 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, оправдал ли мультиагент свою цену.

Проверка знаний

Команда переписала рабочего агента-помощника на систему из координатора и четырёх субагентов «потому что это современнее». Качество ответов не выросло, но счёт за токены утроился, а ответы стали заметно медленнее.

Какой вывод корректен?

  • A Нужно добавить ещё субагентов — текущих четырёх недостаточно для качества.
  • B Мультиагент введён без потребности; для этой задачи один хороший агент с инструментами был лучше — дешевле и быстрее при том же качестве.
  • C Проблема в координаторе — взять модель помощнее, и стоимость окупится.
  • D Так и должно быть: мультиагент всегда дороже, это нормальная цена зрелой архитектуры.

Несколько агентов-горутин в Go параллельно пишут промежуточные факты в общую map без какой-либо синхронизации. Иногда сервис падает с паникой, иногда выдаёт испорченные данные; воспроизвести стабильно не удаётся.

Что является причиной и какое решение корректно?

  • A Гонка данных при конкурентной записи в map; закрыть доступ sync.Mutex/RWMutex или общаться через каналы, тесты гонять с -race.
  • B Недостаточно памяти — увеличить лимиты контейнера.
  • C Случайные сбои модели — добавить ретраи вокруг вызовов.
  • D Проблема в координаторе — сделать запись в map только из него, оставив чтение свободным.

При передаче управления от одного агента к другому разработчик пересылает принимающему весь сырой лог диалога и всю историю инструментов «чтобы ничего не потерять». Принимающий агент стал медленнее, дороже и чаще ошибается.

Как правильно проектировать handoff?

  • A Передавать ещё больше контекста — ошибки от нехватки информации.
  • B Передавать сжатую релевантную сводку (цель, сделанное, факты, требуемое), а не сырую историю; handoff — это дисциплина context engineering.
  • C Не передавать ничего — пусть принимающий запрашивает всё сам.
  • D Передавать историю как одну строку — так проще парсить.

Ваш агент должен ставить задачи агенту партнёрской компании, которым вы не управляете и чей код не видите.

Что здесь уместнее и о чём нельзя забывать?

  • A Расшарить общую Go-struct и память между системами — так проще всего.
  • B Открытый межагентный протокол (A2A): карточка возможностей, контракт задача/статус/результат; при этом чужой агент — недоверенный источник, его ответы нельзя слепо исполнять, нужны аутентификация и версионирование на границе.
  • C Подключить их агента к себе как MCP-инструмент и не думать о доверии.
  • D Обмениваться свободным текстом без схемы — модель сама разберётся.