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:
| Constraint | Symptoms | Impact |
|---|---|---|
| Context window | Model token cap (4K–128K) | Long pipelines truncate history |
| Domain focus | Too many instructions dilute attention | Multi-domain tasks degrade |
| Tool complexity | Too many tools increases decision load | Tool-selection accuracy drops |
| Concurrency | Single-threaded, sequential execution | Cannot parallelize independent sub-tasks |
| Fault tolerance | Single point of failure | One 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:
- Immutable State: Workers operate on copies; changes are committed via an explicit Merge.
- Versioning: every state change increments a version number for tracing and rollback.
- TTL Management: states carry an expiry to prevent memory leaks.
- 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:
- Externalize state: store State in Redis so the Orchestrator only holds a reference.
- Lightweight model: run the Orchestrator on a small model, push heavy reasoning to Workers.
- 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.
