← All Posts

Deploying LLM Agents in Production

Production Challenges for Agents

LLM agents are fundamentally different from stateless model endpoints. A single user request can trigger an unbounded chain of LLM calls, tool invocations, and memory lookups. This multi-step execution introduces failure modes that don't exist in traditional inference serving.

The core challenge is compound unreliability: if each LLM call succeeds with 99% probability and a single agent task requires 8 steps on average, the end-to-end success rate drops to 0.998 ≈ 92%. At 15 steps, it's 86%. At 25 steps with tool calls that can also fail, real-world success rates often dip below 70% without explicit reliability engineering.

User Request LLM Call 1 p = 0.99 Tool Call p = 0.97 LLM Call 2 p = 0.99 Result p ≈ 0.95 Compound reliability: each step multiplies failure probability
# The compound failure problem in numbers
import math

def e2e_success(step_reliability, num_steps):
    return step_reliability ** num_steps

# Typical agent task: 8 LLM calls + 4 tool calls = 12 steps
llm_p   = 0.99   # per-call success rate
tool_p  = 0.97   # tool calls can timeout, throw, etc.
steps   = [llm_p]*8 + [tool_p]*4

combined = math.prod(steps)
print(f"End-to-end success: {combined:.1%}")  # → 81.3%

# With retry logic (max 3 retries per step, success → 99.97% per step)
retry_p = 1 - (1 - 0.99)**3   # = 0.999999
print(f"With retries: {retry_p**12:.1%}")  # → 99.999%
Key insight: Agent reliability is a systems engineering problem, not a prompt engineering problem. You can have perfect prompts and still fail 30% of the time in production if you don't handle network timeouts, rate limits, malformed tool outputs, and context window overflow.

Reliability Patterns

Production agents need layered defences. The three pillars of agent reliability are idempotency, deterministic fallbacks, and graceful degradation. Each addresses a different failure mode.

🛡️ Idempotent Tool Calls

Every tool call should be safe to retry. If an agent asks to "create a JIRA ticket" and the call times out, a retry must not create a duplicate. Use idempotency keys — hash the tool name + arguments to produce a deterministic ID, and check for existing results before executing.

  • Hash tool name + serialized args → unique key
  • Store results in a cache keyed by this ID
  • On retry, return cached result if key exists
  • Set TTL on cache entries (e.g. 10 min)

🔄 Deterministic Fallbacks

When an LLM call fails or returns unparseable output, agents need a code path that doesn't require the LLM. This might be a cached response, a template-based reply, or a structured error message that lets the user know what happened.

  • Cache recent successful LLM responses by task type
  • Define template responses for common failure modes
  • Return structured error with next-step suggestions
  • Never silently drop the user's request
import hashlib, json
from typing import Any, Optional

class IdempotentToolExecutor:
    """Wraps tool calls with idempotency and caching."""

    def __init__(self, cache_backend):
        self.cache = cache_backend   # Redis, DynamoDB, etc.

    def _make_key(self, tool_name: str, args: dict) -> str:
        payload = json.dumps({"tool": tool_name, "args": args}, sort_keys=True)
        return f"tool:{hashlib.sha256(payload.encode()).hexdigest()[:16]}"

    async def execute(self, tool_name: str, args: dict) -> Any:
        key = self._make_key(tool_name, args)

        # Check for cached result (idempotency)
        cached = await self.cache.get(key)
        if cached is not None:
            return json.loads(cached)

        # Execute the tool
        result = await self._run_tool(tool_name, args)

        # Cache with 10-minute TTL
        await self.cache.set(key, json.dumps(result), ttl=600)
        return result

The second reliability pattern is structured output validation. Agents frequently produce JSON that is almost-but-not-quite valid. A production system should parse LLM output through a schema validator (Pydantic, Zod) and, on failure, re-prompt the LLM with the validation errors rather than crashing.

from pydantic import BaseModel, ValidationError

class ToolCallSchema(BaseModel):
    tool_name: str
    arguments: dict
    reasoning: str  # chain-of-thought for observability

async def parse_agent_output(raw_text: str, llm_client, max_retries=2) -> ToolCallSchema:
    for attempt in range(max_retries + 1):
        try:
            data = json.loads(raw_text)
            return ToolCallSchema(**data)
        except (json.JSONDecodeError, ValidationError) as e:
            if attempt == max_retries:
                raise
            # Re-prompt with the error message
            raw_text = await llm_client.complete(
                f"Fix this JSON. Error: {e}\nOriginal: {raw_text}"
            )
Pattern: Always validate → re-prompt → fail. Three stages. The re-prompt step alone recovers roughly 85% of malformed outputs according to benchmarks from the LangChain team.

Timeout Handling & Circuit Breakers

LLM API calls are the slowest component in any agent pipeline. A single GPT-4 call can take 5–30 seconds depending on output length. Tool calls to external APIs add more latency. Without timeouts, a stuck agent can hold resources (memory, connections, GPU slots) indefinitely.

The circuit breaker pattern prevents cascading failures: if a downstream service (LLM API, tool endpoint) fails repeatedly, the circuit "opens" and immediately rejects new calls for a cooldown period instead of letting them queue up and time out.

CLOSED Requests pass through OPEN Requests rejected fast HALF-OPEN Probe with 1 request N fails timeout probe succeeds → close probe fails → re-open
import asyncio, time
from enum import Enum

class State(Enum):
    CLOSED    = "closed"
    OPEN      = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    """Circuit breaker for LLM API and tool calls."""

    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.state            = State.CLOSED
        self.failure_count    = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.last_failure_time = 0

    async def call(self, func, *args, **kwargs):
        if self.state == State.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = State.HALF_OPEN
            else:
                raise CircuitOpenError("Circuit is open — rejecting call")

        try:
            result = await asyncio.wait_for(func(*args, **kwargs), timeout=30.0)
            self._on_success()
            return result
        except (asyncio.TimeoutError, Exception) as e:
            self._on_failure()
            raise

    def _on_success(self):
        self.failure_count = 0
        self.state = State.CLOSED

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = State.OPEN

# Usage in agent loop
llm_breaker  = CircuitBreaker(failure_threshold=3, recovery_timeout=60)
tool_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30)

async def agent_step(prompt):
    try:
        response = await llm_breaker.call(llm_client.complete, prompt)
    except CircuitOpenError:
        return fallback_response(prompt)  # graceful degradation
Tuning circuit breakers: For LLM APIs, set the failure threshold to 3–5 and recovery timeout to 60 seconds. LLM providers often recover within a minute. For tool calls to your own services, use lower thresholds (2–3) and shorter recovery (15–30s) since you control the recovery path.

Retry Logic & Backoff

Retries are the single most impactful reliability pattern for agents. The key question is how to retry: naive retries can cause thundering herd problems when hundreds of agents simultaneously hammer a recovering API.

The standard approach is exponential backoff with jitter. Each retry waits exponentially longer (1s → 2s → 4s → 8s), and a random jitter component spreads retries across time to avoid synchronized spikes.

⚠️ Naive Retry

  • Fixed 1-second delay between retries
  • All failed requests retry at the same instant
  • Creates thundering herd spikes
  • Can make an overloaded API worse
  • No differentiation between transient and permanent errors

✅ Exponential Backoff + Jitter

  • Delay doubles each attempt: 1s → 2s → 4s → 8s
  • Random jitter spreads retries over the interval
  • Gives the downstream service time to recover
  • Caps at a maximum delay (e.g. 60 seconds)
  • Classifies errors: retry transient, fail on permanent
import asyncio, random
from functools import wraps

def retry_with_backoff(
    max_retries: int = 4,
    base_delay: float = 1.0,
    max_delay: float = 60.0,
    retryable_exceptions: tuple = (TimeoutError, ConnectionError),
):
    """Decorator: exponential backoff with full jitter."""
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            for attempt in range(max_retries + 1):
                try:
                    return await func(*args, **kwargs)
                except retryable_exceptions as e:
                    if attempt == max_retries:
                        raise  # exhausted all retries
                    delay = min(base_delay * (2 ** attempt), max_delay)
                    jitter = random.uniform(0, delay)
                    log.warning(f"Retry {attempt+1}/{max_retries} after {jitter:.1f}s: {e}")
                    await asyncio.sleep(jitter)
        return wrapper
    return decorator

# Apply to LLM calls
@retry_with_backoff(max_retries=3, retryable_exceptions=(TimeoutError, RateLimitError))
async def call_llm(prompt: str, model: str = "gpt-4") -> str:
    return await llm_client.complete(prompt, model=model, timeout=30)

# Apply to tool calls with different retry params
@retry_with_backoff(max_retries=2, base_delay=0.5, retryable_exceptions=(TimeoutError,))
async def call_tool(tool_name: str, args: dict) -> dict:
    return await tool_registry.execute(tool_name, args)

Not all errors are retryable. A 400 Bad Request from the LLM API means the prompt is malformed — retrying won't help. A 429 Too Many Requests or 503 Service Unavailable is transient and should be retried. Production agents must classify errors:

# Error classification for retry decisions
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
PERMANENT_FAILURES     = {400, 401, 403, 404, 422}

def should_retry(error) -> bool:
    if isinstance(error, TimeoutError):
        return True
    if isinstance(error, APIError):
        return error.status_code in RETRYABLE_STATUS_CODES
    if isinstance(error, json.JSONDecodeError):
        return True   # LLM output parsing — worth re-prompting
    return False   # unknown errors: don't retry
Watch for retry amplification: If Agent A calls Agent B, and both have 3 retries, a single failure in B can trigger 3 × 3 = 9 total LLM calls. In multi-agent systems, set retry budgets — a global counter that limits total retries across the entire call chain.

Scaling Agent Workloads

Agents are harder to scale than standard inference endpoints because each agent task consumes variable resources over an unpredictable duration. A simple query might take 2 seconds and 1 LLM call; a complex research task might take 5 minutes and 30 LLM calls. Autoscaling based on request count alone will either over-provision or under-provision.

📐 Horizontal Scaling

Run agent workers as stateless containers behind a task queue (Celery, SQS, Cloud Tasks). Each worker pulls one task at a time, executes the full agent loop, and reports results. Scale the worker pool based on queue depth.

  • Decouples ingestion from execution
  • Natural backpressure via queue depth
  • Workers can be preemptible/spot instances
  • Easy to add capacity across regions

⚡ Concurrency Control

Each worker can run multiple agents concurrently using async I/O — since 90% of agent time is spent waiting on LLM API responses, a single worker can manage 10–50 concurrent agents.

  • Use asyncio.Semaphore to limit per-worker concurrency
  • Track active LLM calls to avoid rate limits
  • Memory grows linearly with concurrent agents (context windows)
  • Monitor per-worker latency P99 for saturation signals
import asyncio
from dataclasses import dataclass, field

@dataclass
class AgentWorkerPool:
    """Manages concurrent agent execution with backpressure."""
    max_concurrent_agents: int = 20
    max_llm_calls_per_sec: int = 50
    _semaphore: asyncio.Semaphore = field(init=False)
    _rate_limiter: asyncio.Semaphore = field(init=False)

    def __post_init__(self):
        self._semaphore    = asyncio.Semaphore(self.max_concurrent_agents)
        self._rate_limiter = asyncio.Semaphore(self.max_llm_calls_per_sec)

    async def run_agent(self, task):
        async with self._semaphore:  # limits concurrent agents
            agent = Agent(task, rate_limiter=self._rate_limiter)
            return await agent.execute()

    async def process_queue(self, queue):
        while True:
            task = await queue.get()
            asyncio.create_task(self._handle_task(task))

    async def _handle_task(self, task):
        try:
            result = await self.run_agent(task)
            await self._report_result(task.id, result)
        except Exception as e:
            await self._report_failure(task.id, e)

# Autoscaling signal: queue depth / processing rate
async def autoscale_decision(queue_depth, avg_task_duration, workers):
    current_capacity = workers * 20  # max_concurrent_agents per worker
    tasks_per_min    = current_capacity * (60 / avg_task_duration)
    if queue_depth > tasks_per_min * 2:
        return "scale_up"
    elif queue_depth < tasks_per_min * 0.3:
        return "scale_down"
    return "hold"
Cost awareness: Agent workloads can be expensive. A single complex task might cost $0.50–$2.00 in LLM API calls. At 10,000 tasks/day, that's $5K–$20K/day. Implement per-user rate limits, cost budgets per task, and model tiering (use GPT-3.5 for simple steps, GPT-4 only for reasoning-heavy steps) to control spend.

Production Monitoring

Agent observability requires tracking metrics at three levels: task-level (did the user's request succeed?), step-level (which LLM call or tool call failed?), and system-level (are we approaching rate limits or running out of memory?).

Task-Level E2E success rate Task latency P50/P99 Steps per task (avg) Cost per task ($) User satisfaction score Fallback trigger rate Step-Level LLM call latency Tool call success rate Parse failure rate Retry count per step Token usage in/out Circuit breaker trips System-Level Active agent count Queue depth Worker CPU/memory API rate limit headroom Cache hit rate Error budget remaining
import time
from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class StepTrace:
    step_type: str          # "llm_call", "tool_call", "parse"
    model: Optional[str]    = None
    latency_ms: float       = 0
    token_input: int        = 0
    token_output: int       = 0
    retries: int            = 0
    error: Optional[str]    = None
    cost_usd: float         = 0.0

@dataclass
class TaskTrace:
    task_id: str
    user_id: str
    start_time: float       = field(default_factory=time.time)
    steps: List[StepTrace]  = field(default_factory=list)
    status: str             = "running"
    total_cost_usd: float   = 0.0

    def add_step(self, step: StepTrace):
        self.steps.append(step)
        self.total_cost_usd += step.cost_usd

    def finalize(self, status: str):
        self.status = status
        self.duration_ms = (time.time() - self.start_time) * 1000

    def to_metrics(self) -> dict:
        return {
            "task_id":        self.task_id,
            "status":         self.status,
            "steps_count":    len(self.steps),
            "total_cost":     self.total_cost_usd,
            "duration_ms":    self.duration_ms,
            "llm_calls":      sum(1 for s in self.steps if s.step_type == "llm_call"),
            "retries_total":  sum(s.retries for s in self.steps),
            "tokens_total":   sum(s.token_input + s.token_output for s in self.steps),
        }

Export these traces to your observability stack — OpenTelemetry spans work well since each agent step maps naturally to a child span. Tools like LangSmith, Arize Phoenix, and Weights & Biases Traces provide agent-specific visualizations out of the box.

🚨 Alerting Thresholds

  • E2E success rate < 95%: Page on-call. Something is broken.
  • P99 latency > 120s: Investigate stuck agents or slow tools.
  • Avg cost/task spikes 3×: Agent may be in a loop (infinite tool calls).
  • Circuit breaker open > 5 min: Downstream service is down.
  • Queue depth > 2× capacity: Scale up workers immediately.

📈 Dashboards to Build

  • Agent Health: Success rate, latency P50/P99, active count
  • Cost Tracking: Per-user, per-task-type, per-model spend
  • Error Breakdown: Timeout vs parse failure vs tool error vs rate limit
  • Step Distribution: Histogram of steps-per-task (detects runaway agents)
  • Model Performance: Latency and error rate per LLM provider
# Guard against runaway agents: max steps + cost budget
class AgentGuardrails:
    def __init__(self, max_steps=25, max_cost_usd=2.0, max_duration_sec=300):
        self.max_steps        = max_steps
        self.max_cost_usd     = max_cost_usd
        self.max_duration_sec = max_duration_sec

    def check(self, trace: TaskTrace) -> Optional[str]:
        if len(trace.steps) >= self.max_steps:
            return f"Step limit reached ({self.max_steps})"
        if trace.total_cost_usd >= self.max_cost_usd:
            return f"Cost budget exceeded (${self.max_cost_usd})"
        elapsed = time.time() - trace.start_time
        if elapsed >= self.max_duration_sec:
            return f"Duration limit ({self.max_duration_sec}s)"
        return None  # all checks passed

# In the main agent loop
async def agent_loop(task, guardrails, trace):
    while True:
        violation = guardrails.check(trace)
        if violation:
            trace.finalize("terminated")
            return TerminationResult(reason=violation)

        step = await execute_next_step(task)
        trace.add_step(step)

        if step.step_type == "final_answer":
            trace.finalize("success")
            return step.result
The bottom line: Deploying agents in production is 20% prompt engineering and 80% systems engineering. The patterns here — idempotent tools, circuit breakers, exponential backoff, concurrency control, guardrails, and structured tracing — are what separate a demo from a product. Start with guardrails and retries; add circuit breakers and tracing as you scale.