🔥 0
0
Lesson 3 of 10 25 min +225 XP

Scaling Agent Infrastructure

It's Black Friday. Your AI shopping assistant has been handling 100 concurrent conversations smoothly. At 8 AM, traffic spikes to 10,000 concurrent conversations. Your carefully tuned agent starts timing out, queues back up, and customers see "Agent unavailable."

Scaling AI agents is fundamentally different from scaling traditional web services. Let's learn how to handle it.

The Scaling Challenge

Traditional web services have predictable latency - an API call takes ~50ms. You can calculate capacity:

Traditional: 1 server × (1000ms / 50ms) = 20 requests/second

AI agents break this model:

500ms
Simple query
"What's my order status?"
3-5s
Tool-using query
"Find blue sneakers under $100"
15-30s
Complex reasoning
"Compare these 5 laptops for my needs..."

With 500ms-30s variance, traditional capacity planning fails.

Horizontal Scaling Architecture

from kubernetes import client, config
from dataclasses import dataclass
import asyncio

@dataclass
class ScalingConfig:
    min_replicas: int = 3
    max_replicas: int = 50
    target_queue_depth: int = 10  # requests per pod
    scale_up_threshold: float = 0.8
    scale_down_threshold: float = 0.3
    cooldown_seconds: int = 60

class AgentAutoScaler:
    def __init__(self, config: ScalingConfig):
        self.config = config
        self.k8s = client.AppsV1Api()
        self.metrics = MetricsClient()

    async def evaluate_scaling(self):
        """Main scaling loop"""
        while True:
            metrics = await self.collect_metrics()

            if self.should_scale_up(metrics):
                await self.scale_up(metrics)
            elif self.should_scale_down(metrics):
                await self.scale_down(metrics)

            await asyncio.sleep(10)  # Check every 10 seconds

    def should_scale_up(self, metrics: AgentMetrics) -> bool:
        # Scale based on queue depth, not CPU
        queue_per_pod = metrics.queue_depth / metrics.current_replicas

        # Also consider p95 latency
        latency_stressed = metrics.p95_latency > 5000  # 5s

        return (
            queue_per_pod > self.config.target_queue_depth * self.config.scale_up_threshold
            or latency_stressed
        ) and metrics.current_replicas < self.config.max_replicas

    async def scale_up(self, metrics: AgentMetrics):
        # Calculate needed replicas based on queue depth
        needed = math.ceil(
            metrics.queue_depth / self.config.target_queue_depth
        )
        target = min(needed, self.config.max_replicas)

        # Scale aggressively during spikes
        if metrics.queue_growth_rate > 100:  # Queue growing fast
            target = min(target * 1.5, self.config.max_replicas)

        await self._set_replicas("agent-deployment", int(target))
        self.log_scaling_event("scale_up", metrics.current_replicas, target)

    async def collect_metrics(self) -> AgentMetrics:
        return AgentMetrics(
            current_replicas=await self._get_replica_count(),
            queue_depth=await self.metrics.get_queue_depth(),
            p95_latency=await self.metrics.get_p95_latency(),
            queue_growth_rate=await self.metrics.get_queue_growth_rate(),
            active_conversations=await self.metrics.get_active_sessions()
        )

Load Balancing Strategies

Strategy 1: Semantic Load Balancing

Route queries based on complexity to appropriate agent tiers:

from enum import Enum
from typing import Tuple

class AgentTier(Enum):
    LIGHTWEIGHT = "lightweight"  # GPT-4o-mini, no tools
    STANDARD = "standard"        # GPT-4o, basic tools
    HEAVY = "heavy"              # GPT-4, all tools, long context

class SemanticLoadBalancer:
    def __init__(self):
        self.classifier = QueryClassifier()
        self.tier_pools = {
            AgentTier.LIGHTWEIGHT: AgentPool(min_size=10, max_size=100),
            AgentTier.STANDARD: AgentPool(min_size=5, max_size=50),
            AgentTier.HEAVY: AgentPool(min_size=2, max_size=20),
        }

    async def route(self, query: str, context: dict) -> Tuple[AgentTier, Agent]:
        # Classify query complexity
        complexity = await self.classifier.analyze(query, context)

        # Simple FAQ-style queries
        if complexity.is_simple and complexity.confidence > 0.9:
            tier = AgentTier.LIGHTWEIGHT
        # Queries needing tools (search, order lookup)
        elif complexity.needs_tools and not complexity.needs_reasoning:
            tier = AgentTier.STANDARD
        # Complex multi-step reasoning
        else:
            tier = AgentTier.HEAVY

        # Get agent from appropriate pool
        agent = await self.tier_pools[tier].acquire()
        return tier, agent


class QueryClassifier:
    """Fast classifier for routing decisions"""

    SIMPLE_PATTERNS = [
        r"what('s| is) your return policy",
        r"how (do i|can i) track",
        r"what are your (hours|shipping)",
        r"do you (ship|deliver) to",
    ]

    async def analyze(self, query: str, context: dict) -> QueryComplexity:
        # Check for simple patterns first (no LLM needed)
        for pattern in self.SIMPLE_PATTERNS:
            if re.search(pattern, query.lower()):
                return QueryComplexity(
                    is_simple=True,
                    needs_tools=False,
                    needs_reasoning=False,
                    confidence=0.95
                )

        # Use fast model for ambiguous cases
        classification = await self.fast_llm.classify(
            query=query,
            context_summary=self._summarize_context(context)
        )
        return classification

Strategy 2: Session Affinity with Spillover

Keep conversations on the same agent when possible, but spill over during load:

class AffinityLoadBalancer:
    def __init__(self):
        self.session_map = {}  # session_id -> agent_id
        self.agent_loads = {}  # agent_id -> current_sessions

    async def get_agent(self, session_id: str) -> Agent:
        # Check if session has affinity
        if session_id in self.session_map:
            agent_id = self.session_map[session_id]
            agent = self.agents.get(agent_id)

            # Verify agent is still healthy and not overloaded
            if agent and agent.is_healthy and agent.current_load < agent.max_load:
                return agent

            # Affinity broken - remove mapping
            del self.session_map[session_id]

        # Find least loaded agent
        agent = self._get_least_loaded_agent()
        self.session_map[session_id] = agent.id
        return agent

    def _get_least_loaded_agent(self) -> Agent:
        healthy_agents = [a for a in self.agents.values() if a.is_healthy]
        return min(healthy_agents, key=lambda a: a.current_load / a.max_load)

Handling Traffic Spikes

Pre-Scaling for Known Events

class EventBasedScaler:
    """Pre-scale for known traffic events"""

    KNOWN_EVENTS = {
        "black_friday": {
            "start": "2024-11-29T00:00:00",
            "end": "2024-11-30T23:59:59",
            "scale_factor": 5.0,
            "pre_scale_hours": 2
        },
        "cyber_monday": {
            "start": "2024-12-02T00:00:00",
            "end": "2024-12-02T23:59:59",
            "scale_factor": 4.0,
            "pre_scale_hours": 2
        },
        "flash_sale": {
            "scale_factor": 3.0,
            "pre_scale_hours": 1
        }
    }

    async def schedule_pre_scaling(self):
        for event_name, config in self.KNOWN_EVENTS.items():
            if "start" in config:
                pre_scale_time = (
                    datetime.fromisoformat(config["start"])
                    - timedelta(hours=config["pre_scale_hours"])
                )
                self.scheduler.schedule(
                    pre_scale_time,
                    self.pre_scale,
                    args=(event_name, config["scale_factor"])
                )

    async def pre_scale(self, event_name: str, scale_factor: float):
        current = await self.get_current_replicas()
        target = int(current * scale_factor)

        self.logger.info(f"Pre-scaling for {event_name}: {current} -> {target}")
        await self.set_replicas(target)

        # Also warm up caches
        await self.warm_caches()

        # Pre-warm LLM connections
        await self.warm_llm_connections(target)

Dynamic Degradation During Spikes

class AdaptiveDegradation:
    """Automatically adjust response quality based on load"""

    def __init__(self):
        self.thresholds = {
            "normal": {"queue_depth": 50, "latency_p95": 3000},
            "elevated": {"queue_depth": 200, "latency_p95": 5000},
            "critical": {"queue_depth": 500, "latency_p95": 10000},
        }

    async def get_current_mode(self) -> DegradationMode:
        metrics = await self.get_metrics()

        if (metrics.queue_depth > self.thresholds["critical"]["queue_depth"]
            or metrics.p95_latency > self.thresholds["critical"]["latency_p95"]):
            return DegradationMode.CRITICAL
        elif (metrics.queue_depth > self.thresholds["elevated"]["queue_depth"]
              or metrics.p95_latency > self.thresholds["elevated"]["latency_p95"]):
            return DegradationMode.ELEVATED
        return DegradationMode.NORMAL

    def get_agent_config(self, mode: DegradationMode) -> AgentConfig:
        configs = {
            DegradationMode.NORMAL: AgentConfig(
                model="gpt-4o",
                max_tokens=1000,
                tools_enabled=True,
                context_window=8000,
                use_cache=True
            ),
            DegradationMode.ELEVATED: AgentConfig(
                model="gpt-4o-mini",
                max_tokens=500,
                tools_enabled=True,
                context_window=4000,
                use_cache=True,
                cache_threshold=0.7  # Lower similarity threshold
            ),
            DegradationMode.CRITICAL: AgentConfig(
                model="gpt-4o-mini",
                max_tokens=200,
                tools_enabled=False,  # No tool calls
                context_window=2000,
                use_cache=True,
                cache_threshold=0.5,
                fallback_to_templates=True
            ),
        }
        return configs[mode]

LLM Provider Load Balancing

Don't put all your eggs in one basket. Distribute across providers:

class MultiProviderBalancer:
    """Balance load across multiple LLM providers"""

    def __init__(self):
        self.providers = {
            "openai": ProviderConfig(
                weight=0.6,  # Primary
                rate_limit=10000,  # RPM
                cost_per_1k_tokens=0.03,
                latency_p50=800
            ),
            "anthropic": ProviderConfig(
                weight=0.3,  # Secondary
                rate_limit=5000,
                cost_per_1k_tokens=0.025,
                latency_p50=900
            ),
            "azure_openai": ProviderConfig(
                weight=0.1,  # Backup
                rate_limit=8000,
                cost_per_1k_tokens=0.03,
                latency_p50=700
            ),
        }
        self.usage_tracker = UsageTracker()

    async def select_provider(self, request: AgentRequest) -> str:
        # Check rate limit headroom
        available = []
        for name, config in self.providers.items():
            current_usage = await self.usage_tracker.get_current_rpm(name)
            headroom = (config.rate_limit - current_usage) / config.rate_limit

            if headroom > 0.1:  # At least 10% headroom
                available.append((name, config, headroom))

        if not available:
            raise AllProvidersExhausted()

        # Weighted selection with headroom consideration
        weights = [
            config.weight * headroom
            for name, config, headroom in available
        ]
        total = sum(weights)
        normalized = [w / total for w in weights]

        return random.choices(
            [name for name, _, _ in available],
            weights=normalized
        )[0]

    async def call_with_fallback(self, request: AgentRequest) -> Response:
        providers_tried = []

        while len(providers_tried) < len(self.providers):
            provider = await self.select_provider(request)
            providers_tried.append(provider)

            try:
                return await self._call_provider(provider, request)
            except (RateLimitError, TimeoutError, ProviderError):
                continue

        raise AllProvidersFailed(providers_tried)

Real-World: Amazon's Peak Handling

Amazon's AI assistants handle massive scale during Prime Day:

10x
Traffic spike
<2s
Response time SLA
99.99%
Availability target
Amazon's Approach
  • Cell-based architecture: Traffic isolated to independent cells
  • Predictive scaling: ML models predict traffic 30 mins ahead
  • Tiered responses: Simple queries get cached responses
  • Queue shedding: Drop lowest-priority requests under extreme load

Key Takeaways

  • Scale on queue depth, not CPU - LLM latency variance makes CPU-based scaling unreliable
  • Semantic load balancing - Route simple queries to lightweight agents
  • Pre-scale for known events - Black Friday shouldn't be a surprise
  • Multi-provider distribution - Don't depend on a single LLM provider

Next up: Cost Optimization Strategies - How to keep your AI agent costs under control.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why is auto-scaling AI agents different from traditional web services?

2

What is 'semantic load balancing' for AI agents?

3

During a Black Friday traffic spike, your agent queue depth increases from 10 to 500. What's the best immediate action?

Agent Infrastructure Patterns