梦兽编程
AI_SUITE

Agent Team Architecture Deep Dive: From Monolith to Distributed Collaboration

An in-depth look at ADK Go's Agent Team architecture, covering multi-agent collaboration topology, state consistency, fault tolerance, and production best practices.

When building complex AI applications, the limits of a monolithic Agent show up quickly. Once tasks involve multiple domains, long pipelines, or high concurrency, an Agent Team (a team of multiple Agents) becomes the natural choice. This post examines the design philosophy, collaboration models, and production engineering of ADK Go’s Agent Team from an architectural perspective.

Limits of a Monolithic Agent

Capability Boundaries and Context Pressure

A single LLM Agent faces a handful of hard constraints:

ConstraintSymptomsImpact
Context windowModel token cap (4K–128K)Long pipelines truncate history
Domain focusToo many instructions dilute attentionMulti-domain tasks degrade
Tool complexityToo many tools increases decision loadTool-selection accuracy drops
ConcurrencySingle-threaded, sequential executionCannot parallelize independent sub-tasks
Fault toleranceSingle point of failureOne error takes down the whole flow

Production lesson: in an e-commerce support scenario, a monolithic Agent handling order lookup, logistics tracking, and after-sales policy saw tool-call accuracy drop from 92% to 67%. Splitting into three domain-specific Agents brought overall accuracy back up to 89%.

When to Introduce an Agent Team

Decision tree: do you need an Agent Team?
        ┌───────────┴───────────┐
        ▼                       ▼
   Steps > 3?               Multiple domains?
        │                       │
   Yes ──┤                  Yes ──┤
        ▼                       ▼
   State passing needed?    Cross-domain collaboration?
        │                       │
   Yes ──┼──→ Team needed     Yes ──┼──→ Team needed
   No ──┘                   No ──┘
        ▼                       ▼
   Sequential workflow        Parallel workflow

Topology Models for an Agent Team

Three Core Topologies

ADK Go supports three Agent Team topologies, each suited to a different class of problem.

1. Star Topology

                    ┌─────────────┐
                    │ Orchestrator │ ← Central dispatcher
                    │   (lead Agent)│
                    └──────┬──────┘
           ┌───────────────┼───────────────┐
           │               │               │
           ▼               ▼               ▼
      ┌─────────┐    ┌─────────┐    ┌─────────┐
      │ Agent A │    │ Agent B │    │ Agent C │
      │ (Query) │    │ (Writer)│    │ (Reviewer)│
      └─────────┘    └─────────┘    └─────────┘

Use when: the task has clear stages and the Orchestrator owns state management and flow control.

Production notes:

  • The Orchestrator should not carry business logic — only dispatch.
  • Avoid making the Orchestrator a bottleneck: state operations must be O(1).
  • Prefer a lightweight model for the Orchestrator (e.g. gemini-flash) and reserve stronger models for Workers.

2. Mesh Topology

      ┌─────────┐         ┌─────────┐
      │ Agent A │◄───────►│ Agent B │
      │(Source A)│         │(Source B)│
      └────┬────┘         └────┬────┘
           │                   │
           └─────────┬─────────┘
              ┌─────────┐
              │ Agent C │
              │(Aggregator)│
              └────┬────┘
              ┌─────────┐
              │ Agent D │
              │(Output) │
              └─────────┘

Use when: data flow is complex and Agents need to exchange information frequently — e.g. multi-source data analysis.

Watch-outs:

  • Topology complexity grows O(n²) with Agent count.
  • Define a clear message protocol to avoid circular dependencies.
  • Consider introducing a Message Bus to decouple Agent communication.

3. Hierarchical Topology

              ┌─────────────┐
              │ Top Orchestrator │
              │ (Strategic)      │
              └──────┬──────┘
        ┌────────────┼────────────┐
        ▼            ▼            ▼
   ┌─────────┐  ┌─────────┐  ┌─────────┐
   │ Team A  │  │ Team B  │  │ Team C  │
   │Sub-Orch │  │Sub-Orch │  │Sub-Orch │
   └────┬────┘  └────┬────┘  └────┬────┘
        │            │            │
   ┌────┴────┐  ┌────┴────┐  ┌────┴────┐
   │A1 │A2 │  │B1 │B2 │  │C1 │C2 │
   └────┴────┘  └────┴────┘  └────┴────┘

Use when: very large systems (10+ Agents), such as an enterprise support platform.

Architectural benefits:

  • Each layer manages only 3–5 children — matches cognitive-load theory.
  • Sub-teams can be deployed and scaled independently.
  • Failure isolation — a broken sub-team does not bring down the others.

State Management and Data Consistency

Shared State Architecture

ADK Go’s Agent Team shares data via a Shared State object:

// Hierarchical layout of state
type TeamState struct {
    // Global — visible to every Agent
    Global  map[string]interface{} `json:"global"`

    // Local — per-Agent private state
    Local   map[string]*AgentState `json:"local"`

    // Session — persists across turns
    Session *SessionState          `json:"session"`

    // Meta — for debugging and observability
    Meta    *StateMeta             `json:"meta"`
}

type AgentState struct {
    Output    string             `json:"output"`
    ToolCalls []ToolCallRecord   `json:"tool_calls"`
    Metrics   *ExecutionMetrics  `json:"metrics"`
    Timestamp time.Time          `json:"timestamp"`
}

Best Practices for State Passing

// Production-grade state orchestration
func orchestrateWithState(ctx context.Context, team *Team, input string) (*Result, error) {
    // 1. Initialize — use immutable snapshots to avoid races
    state := team.NewState()
    state.SetGlobal("input", input)
    state.SetGlobal("start_time", time.Now())
    state.SetGlobal("request_id", generateRequestID())

    // 2. Run Workers — each Worker gets a fork of state
    for _, worker := range team.Workers {
        workerState := state.Fork()

        result, err := worker.Run(ctx, workerState)
        if err != nil {
            // 3. On error — log and record, but do not abort the whole flow
            state.SetLocal(worker.Name(), "error", err)
            state.SetLocal(worker.Name(), "status", "failed")

            if worker.IsCritical() {
                return nil, fmt.Errorf("critical worker %s failed: %w", worker.Name(), err)
            }
            continue
        }

        // 4. Merge results — two-phase commit avoids partial updates
        if err := state.Merge(worker.Name(), result); err != nil {
            return nil, fmt.Errorf("state merge failed: %w", err)
        }
    }

    // 5. Final validation
    if err := validateFinalState(state); err != nil {
        return nil, fmt.Errorf("validation failed: %w", err)
    }

    return state.ToResult(), nil
}

Key design principles:

  1. Immutable State: Workers operate on copies; changes are committed via an explicit Merge.
  2. Versioning: every state change increments a version number for tracing and rollback.
  3. TTL Management: states carry an expiry to prevent memory leaks.
  4. Serialization safety: every state value must be JSON-serializable for persistence and debugging.

Fault Tolerance and Retry

Production-Grade Retry Policy

type RetryPolicy struct {
    MaxAttempts       int           // max retries (3–5 recommended)
    InitialBackoff    time.Duration // initial backoff (~1s)
    MaxBackoff        time.Duration // max backoff (~30s)
    BackoffMultiplier float64       // backoff multiplier (2.0 recommended)
    RetryableErrors   []string      // retryable error codes
    CircuitBreaker    *CircuitBreakerConfig
}

type CircuitBreakerConfig struct {
    FailureThreshold int           // failures before opening
    ResetTimeout     time.Duration // half-open wait
    HalfOpenRequests int           // probes in half-open
}

// Applied policy
retryPolicy := &RetryPolicy{
    MaxAttempts:       3,
    InitialBackoff:    time.Second,
    MaxBackoff:        30 * time.Second,
    BackoffMultiplier: 2.0,
    RetryableErrors:   []string{"rate_limit", "timeout", "service_unavailable"},
    CircuitBreaker: &CircuitBreakerConfig{
        FailureThreshold: 5,
        ResetTimeout:     60 * time.Second,
        HalfOpenRequests: 2,
    },
}

Degradation (Fallback)

When an Agent fails repeatedly, the system should degrade gracefully:

func (t *Team) executeWithFallback(ctx context.Context, agent Agent, input string) (string, error) {
    // 1. Try primary Agent
    result, err := t.executeWithRetry(ctx, agent, input, t.primaryPolicy)
    if err == nil {
        return result, nil
    }

    // 2. Primary failed — try fallback Agent (simplified version)
    if t.fallbackAgent != nil {
        log.Warnf("Primary agent %s failed, falling back to %s", agent.Name(), t.fallbackAgent.Name())
        result, err = t.fallbackAgent.Run(ctx, input)
        if err == nil {
            t.metrics.RecordDegradation(agent.Name(), t.fallbackAgent.Name())
            return result, nil
        }
    }

    // 3. Fallback failed too — return cached result if available
    if cached := t.cache.Get(input); cached != nil {
        log.Warnf("All agents failed, returning cached result")
        t.metrics.RecordCacheHit(input)
        return cached.(string), nil
    }

    // 4. Everything failed — friendly error
    return "", fmt.Errorf("service temporarily unavailable, please retry later")
}

Performance and Resource Management

Concurrency Control

// Semaphore-based limiter prevents resource exhaustion
type ConcurrencyLimiter struct {
    sem chan struct{}
}

func NewConcurrencyLimiter(maxConcurrent int) *ConcurrencyLimiter {
    return &ConcurrencyLimiter{sem: make(chan struct{}, maxConcurrent)}
}

func (c *ConcurrencyLimiter) Execute(ctx context.Context, fn func() error) error {
    select {
    case c.sem <- struct{}{}:
        defer func() { <-c.sem }()
        return fn()
    case <-ctx.Done():
        return ctx.Err()
    }
}

// Application
limiter := NewConcurrencyLimiter(5) // at most 5 Agents at once

var wg sync.WaitGroup
for _, agent := range agents {
    wg.Add(1)
    go func(a Agent) {
        defer wg.Done()
        err := limiter.Execute(ctx, func() error {
            _, err := a.Run(ctx, input)
            return err
        })
        if err != nil {
            log.Errorf("Agent %s failed: %v", a.Name(), err)
        }
    }(agent)
}
wg.Wait()

Token Budgeting

type TokenBudget struct {
    TotalBudget    int // total budget
    UsedTokens     int // consumed so far
    ReservedTokens int // reserved for upcoming steps
}

func (tb *TokenBudget) CanAllocate(requested int) bool {
    available := tb.TotalBudget - tb.UsedTokens - tb.ReservedTokens
    return requested <= available
}

func (tb *TokenBudget) Allocate(tokens int) error {
    if !tb.CanAllocate(tokens) {
        return fmt.Errorf("token budget exceeded: need %d, available %d",
            tokens, tb.TotalBudget-tb.UsedTokens-tb.ReservedTokens)
    }
    tb.UsedTokens += tokens
    return nil
}

// Applied inside Team execution
func (t *Team) runWithBudget(ctx context.Context, budget *TokenBudget) error {
    for i, agent := range t.agents {
        // Reserve budget for remaining steps
        remainingSteps := len(t.agents) - i - 1
        budget.ReservedTokens = remainingSteps * t.avgTokensPerStep

        if !budget.CanAllocate(t.avgTokensPerStep) {
            return fmt.Errorf("insufficient token budget for agent %s", agent.Name())
        }

        result, err := agent.Run(ctx, input)
        if err != nil {
            return err
        }

        actualTokens := estimateTokens(result)
        budget.Allocate(actualTokens)
    }
    return nil
}

Monitoring and Observability

Key Metrics

type TeamMetrics struct {
    // Latency
    TotalLatency prometheus.Histogram
    AgentLatency prometheus.Histogram

    // Success rate
    SuccessRate    prometheus.Gauge
    AgentErrorRate prometheus.CounterVec

    // Resource usage
    TokenUsage   prometheus.Counter
    ActiveAgents prometheus.Gauge

    // Business metrics
    StepCompletion prometheus.CounterVec
    RetryCount     prometheus.Counter
    FallbackCount  prometheus.Counter
}

Distributed Tracing

func (t *Team) runWithTracing(ctx context.Context, input string) (*Result, error) {
    ctx, span := tracer.Start(ctx, "team.execution",
        trace.WithAttributes(
            attribute.String("team.name", t.name),
            attribute.String("team.size", strconv.Itoa(len(t.agents))),
            attribute.String("input.hash", hashInput(input)),
        ),
    )
    defer span.End()

    for _, agent := range t.agents {
        ctx, agentSpan := tracer.Start(ctx, fmt.Sprintf("agent.%s", agent.Name()))

        result, err := agent.Run(ctx, input)

        agentSpan.SetAttributes(
            attribute.String("agent.output_hash", hashOutput(result)),
            attribute.Int("agent.output_length", len(result)),
        )

        if err != nil {
            agentSpan.RecordError(err)
            agentSpan.SetStatus(codes.Error, err.Error())
        } else {
            agentSpan.SetStatus(codes.Ok, "success")
        }
        agentSpan.End()

        if err != nil {
            return nil, err
        }

        input = result
    }

    return &Result{Output: input}, nil
}

Production Deployment Recommendations

1. Configuration Separation

# team-config.yaml
team:
  name: "customer-service-team"
  topology: "star"  # star | mesh | hierarchical

  orchestrator:
    model: "gemini-flash"
    timeout: 30s
    max_tokens: 2048

  workers:
    - name: "order-agent"
      model: "gemini-pro"
      timeout: 45s
      tools: ["order_query", "order_modify", "refund_process"]
      retry_policy:
        max_attempts: 3
        backoff: "exponential"

    - name: "logistics-agent"
      model: "gemini-flash"
      timeout: 30s
      tools: ["track_package", "estimate_delivery"]

    - name: "policy-agent"
      model: "gemini-flash"
      timeout: 20s
      tools: ["search_policy", "check_eligibility"]

  state:
    backend: "redis"  # memory | redis | etcd
    ttl: 3600s
    persistence: true

  limits:
    max_concurrent_agents: 10
    total_token_budget: 100000
    max_execution_time: 120s

2. Health Checks and Self-Healing

func (t *Team) healthCheck() *HealthStatus {
    status := &HealthStatus{}

    for _, agent := range t.agents {
        agentStatus := agent.HealthCheck()
        status.Agents = append(status.Agents, agentStatus)

        if agentStatus.State != "healthy" {
            if agentStatus.State == "degraded" {
                go t.restartAgent(agent)
            }
            status.Overall = "degraded"
        }
    }

    if status.Overall == "" {
        status.Overall = "healthy"
    }
    return status
}

Common Questions in Depth

Q: Should inter-Agent communication be synchronous or asynchronous?

A: It depends on the scenario:

  • Synchronous calls: Sequential workflows that must wait for a result to continue. Simpler and state-consistent, but blocking adds latency.
  • Asynchronous calls: Parallel workflows that decouple Agents through a message queue. Higher throughput and better fault tolerance, at the cost of added complexity (ordering, timeouts).

Production recommendation: default to synchronous; switch to async once Agent count exceeds 5 or you need high throughput.

Q: How to prevent the Orchestrator from becoming a bottleneck?

A: Three strategies:

  1. Externalize state: store State in Redis so the Orchestrator only holds a reference.
  2. Lightweight model: run the Orchestrator on a small model, push heavy reasoning to Workers.
  3. Cache routing decisions: cache routes for common input types to avoid redundant LLM calls.

Q: How is Agent Team scalability designed?

A: Follow these principles:

  • Horizontal scaling: keep Worker Agents stateless so instances can be added freely.
  • Load balancing: route by hashing on input features.
  • Dynamic scaling: adjust Worker count based on queue depth.
  • Service discovery: use Consul/etcd for Agent registration and discovery.

Next Steps

With Agent Team architecture understood, the next stop is the Sequential workflow — the inner workings and performance tuning of the sequential execution pattern.

Rewind Sessions | Sequential Workflow →


Want to learn more Go ADK hands-on? Follow the “Mengshou Programming” channel for weekly updates on Go and AI programming.

Frequently Asked Questions

When should I use an Agent Team?

When a task spans more than 3 steps, touches multiple domains, requires concurrency, or the monolith Agent shows context-window pressure and falling tool-selection accuracy.

Which topologies does ADK Go support for Agent Team?

It supports Star (central Orchestrator), Mesh (peer-to-peer Agent communication), and Hierarchical (multi-level sub-teams) topologies.

Should inter-Agent communication be synchronous or asynchronous?

In Sequential workflows use synchronous calls so downstream steps wait for upstream results; in Parallel workflows use an async message queue to decouple Agents and raise throughput.

How to prevent the Orchestrator from becoming a bottleneck?

Move state out to Redis or another external store, use a lightweight model for the Orchestrator, and cache routing decisions for common input types.

How is Agent Team scalability designed?

Keep Worker Agents stateless for horizontal scaling, route by input-feature hashing, scale workers dynamically on queue depth, and use Consul/etcd for service discovery.