I’ve been building distributed systems for over fifteen years. Message queues, microservices, event-driven pipelines — I’ve seen most failure modes at least once. But nothing quite prepared me for the particular chaos of multi-agent AI systems in production.

Last year my team deployed a multi-agent workflow for an enterprise client: an orchestrator agent, three specialist sub-agents, a tool-calling layer, and a memory service stitched together with async message passing. On paper, it was elegant. In production, it humbled us within the first week.

Here’s what I learned, broken down into the failures we hit and the patterns that actually fixed them.

Why Multi-Agent Systems Fail

Retry Storms

The first failure was the most embarrassing because it was so avoidable. One sub-agent hit a transient 503 from an upstream API. It retried. Fine. But our orchestrator was also watching for timeouts and issued its own retry. Meanwhile, the original sub-agent’s second attempt succeeded — but the orchestrator’s retry spawned a duplicate sub-agent. Now we had two agents doing the same work, writing conflicting outputs to shared state.

Within minutes, a single 503 had cascaded into a retry storm that exhausted our token budget and corrupted three task results.

This happens because in multi-agent systems, retry logic exists at multiple layers simultaneously — the LLM SDK, your agent runner, your orchestrator, and sometimes the tool layer itself. Without coordination, each layer retries independently and multiplies the load.

Context Loss Between Agents

The second failure was subtler. Our orchestrator passed a task to a specialist agent with a minimal handoff: just the task ID and a one-line description. The specialist agent had no access to the reasoning chain that led to the task, no prior context, and made a decision that was locally reasonable but globally wrong.

We’d optimized for small message size and created an agent that was flying blind. Context is not overhead — it’s safety rail.

Tool Timeouts with No Circuit Breaker

Our agents called external tools: a search API, a code execution sandbox, a vector database. When the code sandbox started responding slowly under load, agents would hang waiting for the timeout (30 seconds by default). Meanwhile, the orchestrator interpreted the silence as the agent still working, and queued more tasks. By the time timeouts started firing, we had a backlog of 40+ hung agents, each holding a context window and a thread.

There was no circuit breaker. There was no graceful degradation. There was just a pile of spinning agents eating compute.

Patterns That Work

Circuit Breakers for Agent Tool Calls

The first thing we added was a circuit breaker around every external tool call. The pattern is not new — it’s straight from Michael Nygard’s Release It! — but it’s rarely applied at the agent layer.

import time
import asyncio
from enum import Enum
from dataclasses import dataclass, field
from typing import Callable, Any

class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing, reject calls
    HALF_OPEN = "half_open"  # Testing recovery

@dataclass
class CircuitBreaker:
    name: str
    failure_threshold: int = 5
    recovery_timeout: float = 30.0
    half_open_attempts: int = 2

    _state: CircuitState = field(default=CircuitState.CLOSED, init=False)
    _failure_count: int = field(default=0, init=False)
    _last_failure_time: float = field(default=0.0, init=False)
    _half_open_successes: int = field(default=0, init=False)

    async def call(self, fn: Callable, *args, **kwargs) -> Any:
        if self._state == CircuitState.OPEN:
            if time.monotonic() - self._last_failure_time > self.recovery_timeout:
                self._state = CircuitState.HALF_OPEN
                self._half_open_successes = 0
            else:
                raise RuntimeError(
                    f"Circuit '{self.name}' is OPEN — tool call rejected"
                )

        try:
            result = await fn(*args, **kwargs)
            self._on_success()
            return result
        except Exception as exc:
            self._on_failure()
            raise exc

    def _on_success(self):
        if self._state == CircuitState.HALF_OPEN:
            self._half_open_successes += 1
            if self._half_open_successes >= self.half_open_attempts:
                self._state = CircuitState.CLOSED
                self._failure_count = 0
        else:
            self._failure_count = 0

    def _on_failure(self):
        self._failure_count += 1
        self._last_failure_time = time.monotonic()
        if self._failure_count >= self.failure_threshold:
            self._state = CircuitState.OPEN


async def call_tool_with_backoff(
    circuit: CircuitBreaker,
    tool_fn: Callable,
    *args,
    max_retries: int = 3,
    base_delay: float = 1.0,
    **kwargs,
) -> Any:
    for attempt in range(max_retries):
        try:
            return await circuit.call(tool_fn, *args, **kwargs)
        except RuntimeError as e:
            # Circuit is open — no point retrying
            if "OPEN" in str(e):
                raise
            # Transient failure — exponential backoff with jitter
            if attempt < max_retries - 1:
                delay = base_delay * (2 ** attempt) + (0.1 * attempt)
                await asyncio.sleep(delay)
            else:
                raise

Every external tool call now goes through a circuit breaker. When a tool starts failing, the circuit opens after five failures, rejects calls fast for 30 seconds, then lets through probe requests to check recovery. This alone eliminated the hung-agent backlog problem.

Idempotent Task Design

The retry storm problem required a different fix: idempotent tasks with stable task IDs.

Every task the orchestrator issues now carries a deterministic ID derived from its inputs. If an agent picks up a task and the orchestrator also retries, both invocations carry the same task ID. The task store deduplicates on write — the second write is a no-op.

The rule is: an agent task must produce the same observable outcome whether it runs once or ten times. This means writing outputs to keyed storage (not appending), using upsert semantics everywhere, and making tool calls idempotent where possible (or at least safe to replay).

Graceful Degradation Over Hard Failure

When a specialist agent is unavailable or times out, the orchestrator now has an explicit degradation path. Instead of failing the entire workflow, it flags the sub-task as degraded, records what context it had, and continues with a reduced capability mode. A document analysis workflow might skip the deep semantic search and fall back to keyword matching. An answer is returned, marked as lower confidence.

This is a product decision as much as a technical one. Work with your stakeholders to define acceptable degradation paths before you need them in production at 2am.

Naive vs. Resilient Architecture

DimensionNaive Agent SystemResilient Agent System
Retry logicPer-layer, uncoordinatedCentralized, circuit-broken
Task identityEphemeral, anonymousStable idempotent IDs
Context handoffMinimal (task ID only)Structured context bundle
Tool failureHang until timeoutFast-fail via circuit breaker
DegradationBinary success/failTiered degradation paths
ObservabilityLLM logs onlyDistributed trace per agent turn

The naive system works fine at low load and low failure rates — which is exactly why it passes staging. Production load surfaces every assumption you made when things were going well.

Production Checklist for Multi-Agent Systems

Before you ship an agentic workflow to production, I want you to be able to answer yes to every one of these:

  • Idempotency: Can every agent task be replayed safely with the same task ID?
  • Circuit breakers: Is every external tool call protected by a circuit breaker with explicit open/half-open/closed states?
  • Retry coordination: Is retry logic defined at exactly one layer, or are multiple layers coordinating explicitly?
  • Context contracts: Does every agent receive a structured context bundle, not just a task ID?
  • Timeout budgets: Does every agent call have an explicit timeout, and does the orchestrator account for it in its own timeout budget?
  • Degradation paths: Is there a defined, tested fallback for every agent that can fail?
  • Distributed tracing: Can you trace a single user request end-to-end across all agent invocations?
  • Token budget monitoring: Do you have alerts when an agent workflow approaches its context limit?
  • Dead letter handling: What happens to a task that fails all retries? Is it logged, queued for review, or silently dropped?
  • Load testing with failure injection: Have you run your agent system with simulated tool failures and measured the blast radius?

Lessons Learned

Multi-agent systems fail in ways that are qualitatively different from standard microservices. The failures are nondeterministic, context-dependent, and often only visible after several steps of latent damage. The LLM at the center does not throw exceptions — it just makes worse decisions when given bad context.

The good news is that the underlying reliability patterns are not new. Circuit breakers, idempotency, exponential backoff, graceful degradation — these have been battle-tested in distributed systems for years. What’s new is knowing where to apply them in an agentic architecture and accepting that the LLM layer is not magic: it fails like any other remote call, and it needs to be treated that way.

Build for failure from day one. Your agents will thank you at 3am when they handle the outage gracefully instead of calling you about it.

Export for reading

Comments