Scaling AI Applications¶
Prerequisites¶
- Completed Lessons 1–9 (all prior LLMOps lessons)
- Familiarity with Python async/await patterns
- Basic understanding of message queues (Redis, SQS, or similar)
What You'll Learn¶
| Objective | Outcome |
|---|---|
| Explain why LLM applications have unique scaling constraints | Can articulate provider rate limits, variable latency, and cost as scaling factors |
| Design a queue-based architecture for burst traffic | Can decouple request intake from LLM processing |
| Implement async batch processing with concurrency control | Can process large workloads efficiently without hitting rate limits |
| Calculate cost at scale and identify optimization opportunities | Can build a cost forecast for production deployments |
| Configure auto-scaling triggers appropriate for AI workloads | Can scale on queue depth and error rate, not just CPU/memory |
Intuition First: Scaling Is Not Just About Throughput¶
In traditional web applications, scaling means "handle more requests faster." Add more servers, add load balancers, done. Throughput and latency are the primary concerns.
LLM applications have three additional constraints that don't exist in traditional scaling:
Provider rate limits: You cannot scale past the limits set by OpenAI, Anthropic, or Google. Adding more servers doesn't help if your provider allows 3,500 requests per minute and you're already at 3,400. The limit is external and cannot be escaped with infrastructure.
Cost scales linearly with usage: Every request costs real money. Traditional web apps have near-zero marginal cost once infrastructure is paid for. LLM apps pay per token regardless of whether you're at 10% or 100% capacity. Scaling without cost control is financial exposure.
Variable latency: A simple query might return in 500ms; a complex reasoning task might take 30 seconds. Load balancers designed for uniform latency don't work well with this distribution. A single slow request in a synchronous handler blocks a thread for 30 seconds.
Traditional web scaling constraint: infrastructure throughput
→ Add more servers → solved
LLM scaling constraints: provider rate limits + cost + variable latency
→ Add more servers → doesn't help with rate limits
→ Optimize cost and caching → lower cost per request
→ Use async queues → survive variable latency gracefully
The Math of Scale: Know Before You Deploy¶
Before designing your architecture, calculate what "scale" means for your application:
def calculate_scale_requirements(
daily_active_users: int,
queries_per_user_per_day: float,
avg_input_tokens: int,
avg_output_tokens: int,
model: str = "gpt-4o-mini",
agent_steps: int = 1, # 1 for simple chatbot, 3-8 for agents
cache_hit_rate: float = 0.4, # Expected cache hit rate
) -> dict:
"""
Calculate daily request volume, cost, and provider quota requirements.
Use this before architecture decisions to size your infrastructure.
"""
PRICING = {
"gpt-4o-mini": {"input": 0.15, "output": 0.60, "tpm_default": 200_000},
"gpt-4o": {"input": 2.50, "output": 10.00, "tpm_default": 800_000},
}
rates = PRICING.get(model, PRICING["gpt-4o-mini"])
daily_requests = daily_active_users * queries_per_user_per_day
daily_llm_calls = daily_requests * agent_steps * (1 - cache_hit_rate)
peak_rpm = daily_llm_calls / (12 * 60) # Assume 12-hour peak window
cost_per_call = (avg_input_tokens / 1e6 * rates["input"] +
avg_output_tokens / 1e6 * rates["output"])
daily_cost = daily_llm_calls * cost_per_call
monthly_cost = daily_cost * 30
daily_input_tokens = daily_llm_calls * avg_input_tokens
peak_tpm = daily_input_tokens / (12 * 60) # Peak tokens per minute
return {
"daily_requests": int(daily_requests),
"daily_llm_calls_after_cache": int(daily_llm_calls),
"peak_rpm_needed": int(peak_rpm),
"peak_tpm_needed": int(peak_tpm),
"rate_limit_headroom": f"{rates['tpm_default'] / peak_tpm:.1f}x",
"daily_cost_usd": round(daily_cost, 2),
"monthly_cost_usd": round(monthly_cost, 2),
"cost_per_request_usd": round(cost_per_call, 5),
}
# Example: Support bot, 10k DAU, 3 queries each, agent with 4 steps
metrics = calculate_scale_requirements(
daily_active_users=10_000,
queries_per_user_per_day=3,
avg_input_tokens=800,
avg_output_tokens=250,
model="gpt-4o-mini",
agent_steps=4,
cache_hit_rate=0.45,
)
# → daily_requests: 30,000
# → daily_llm_calls_after_cache: 66,000
# → peak_rpm_needed: 92
# → monthly_cost_usd: ~$8,910
# → Plenty of rate limit headroom on gpt-4o-mini default tier
Run this calculation before committing to an architecture. At 10k DAU you might not need queuing; at 100k DAU you almost certainly do.
Queue-Based Architecture¶
For most LLM applications at significant scale, a synchronous request-response pattern is inadequate. An LLM call that takes 5–15 seconds blocks a web server thread for that duration, limiting concurrency. A queue-based architecture decouples the request intake from LLM processing.
Synchronous (naive):
User HTTP Request → Web Server → LLM API (5-15s wait) → Response
Problem: Thread blocked for 5-15s; low concurrency; no retry on provider errors
Queue-based (production):
User HTTP Request → Web Server → Job Queue → Instant ACK (job_id)
↓
Worker Pool → LLM API (async)
↓
Result Store ← Poll or Webhook
Benefits: Web server is never blocked; workers control concurrency;
automatic retry on provider errors; burst traffic absorbed by queue
import asyncio
import uuid
import time
from enum import Enum
from dataclasses import dataclass, field
from typing import Optional, Callable, Any
class JobStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
RETRYING = "retrying"
@dataclass
class Job:
job_id: str
prompt: str
model: str
system_prompt: str = ""
max_tokens: int = 500
priority: int = 1 # Higher = more urgent
status: JobStatus = JobStatus.PENDING
result: Optional[str] = None
error: Optional[str] = None
retries: int = 0
max_retries: int = 3
submitted_at: float = field(default_factory=time.time)
completed_at: Optional[float] = None
@property
def latency_ms(self) -> Optional[float]:
if self.completed_at:
return (self.completed_at - self.submitted_at) * 1000
return None
class AsyncJobQueue:
"""
Async job queue for LLM requests.
Separates intake (submit) from processing (workers).
Workers are bounded by max_concurrent to avoid provider rate limits.
"""
def __init__(self, max_concurrent: int = 20, max_queue_size: int = 1_000):
self._queue: asyncio.PriorityQueue = asyncio.PriorityQueue(max_queue_size)
self._jobs: dict[str, Job] = {}
self._max_concurrent = max_concurrent
self._active = 0
self._lock = asyncio.Lock()
self._stats = {
"submitted": 0, "completed": 0, "failed": 0, "retried": 0
}
async def submit(self, prompt: str, model: str = "gpt-4o-mini",
system_prompt: str = "", priority: int = 1,
max_tokens: int = 500) -> str:
job_id = str(uuid.uuid4())
job = Job(
job_id=job_id, prompt=prompt, model=model,
system_prompt=system_prompt, max_tokens=max_tokens,
priority=priority,
)
self._jobs[job_id] = job
# PriorityQueue: lower priority number = higher queue priority
await self._queue.put((-priority, time.time(), job_id))
self._stats["submitted"] += 1
return job_id
def get_status(self, job_id: str) -> dict:
job = self._jobs.get(job_id)
if not job:
return {"error": "job_not_found"}
return {
"job_id": job.job_id,
"status": job.status,
"result": job.result if job.status == JobStatus.COMPLETED else None,
"error": job.error,
"latency_ms": job.latency_ms,
"retries": job.retries,
}
async def process_worker(self, llm_call: Callable):
"""
Single worker coroutine. Run multiple workers with asyncio.gather().
Controls concurrency: at most max_concurrent simultaneous LLM calls.
"""
while True:
# Get next job from priority queue
_, _, job_id = await self._queue.get()
job = self._jobs[job_id]
async with self._lock:
if self._active >= self._max_concurrent:
# Requeue if at capacity
await self._queue.put((-job.priority, time.time(), job_id))
await asyncio.sleep(0.1)
continue
self._active += 1
try:
job.status = JobStatus.PROCESSING
result = await asyncio.to_thread(llm_call, job)
job.result = result
job.status = JobStatus.COMPLETED
job.completed_at = time.time()
self._stats["completed"] += 1
except Exception as e:
if job.retries < job.max_retries:
job.retries += 1
job.status = JobStatus.RETRYING
# Exponential backoff before retry
await asyncio.sleep(2 ** job.retries)
await self._queue.put((-job.priority, time.time(), job_id))
self._stats["retried"] += 1
else:
job.error = str(e)
job.status = JobStatus.FAILED
job.completed_at = time.time()
self._stats["failed"] += 1
finally:
async with self._lock:
self._active -= 1
self._queue.task_done()
@property
def stats(self) -> dict:
return {
**self._stats,
"queue_depth": self._queue.qsize(),
"active_workers": self._active,
"success_rate": (
f"{self._stats['completed'] / max(self._stats['submitted'], 1):.1%}"
),
}
FastAPI Integration¶
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
job_queue = AsyncJobQueue(max_concurrent=25)
def llm_call_sync(job: Job) -> str:
"""Synchronous LLM call wrapped for asyncio.to_thread()."""
response = openai_client.chat.completions.create(
model=job.model,
messages=[
{"role": "system", "content": job.system_prompt},
{"role": "user", "content": job.prompt},
],
max_tokens=job.max_tokens,
temperature=0.0,
)
return response.choices[0].message.content
@app.on_event("startup")
async def start_workers():
"""Launch the worker pool on application startup."""
asyncio.create_task(
asyncio.gather(*[
job_queue.process_worker(llm_call_sync)
for _ in range(25) # 25 concurrent workers
])
)
@app.post("/api/generate")
async def submit_job(request: dict) -> dict:
"""Submit a generation job. Returns immediately with a job_id."""
job_id = await job_queue.submit(
prompt=request["prompt"],
model=request.get("model", "gpt-4o-mini"),
priority=request.get("priority", 1),
)
return {
"job_id": job_id,
"status": "pending",
"poll_url": f"/api/jobs/{job_id}",
"estimated_wait_seconds": job_queue._queue.qsize() * 3, # Rough estimate
}
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str) -> dict:
"""Poll for job status and result."""
return job_queue.get_status(job_id)
@app.get("/api/queue/stats")
async def queue_stats() -> dict:
return job_queue.stats
Batch Processing for Non-Real-Time Workloads¶
For workloads that don't need real-time responses—document processing, data enrichment, nightly reports—batch processing dramatically improves throughput:
import asyncio
class BatchProcessor:
"""
Process a large list of prompts with controlled concurrency.
Uses asyncio semaphore to avoid hitting provider rate limits.
"""
def __init__(self, llm_client, max_concurrent: int = 10,
requests_per_minute: int = 600):
self.client = llm_client
self.semaphore = asyncio.Semaphore(max_concurrent)
self.rpm_interval = 60.0 / requests_per_minute # Minimum seconds between requests
async def _process_one(self, item: dict) -> dict:
async with self.semaphore:
await asyncio.sleep(self.rpm_interval) # Rate limiting
try:
response = await asyncio.to_thread(
self.client.chat.completions.create,
model=item.get("model", "gpt-4o-mini"),
messages=[{"role": "user", "content": item["prompt"]}],
max_tokens=item.get("max_tokens", 200),
temperature=0.0,
)
return {
"id": item["id"],
"status": "success",
"result": response.choices[0].message.content,
"tokens": response.usage.total_tokens,
}
except Exception as e:
return {"id": item["id"], "status": "error", "error": str(e)}
async def process_all(self, items: list[dict]) -> dict:
"""Process all items concurrently within semaphore limits."""
tasks = [self._process_one(item) for item in items]
results = await asyncio.gather(*tasks)
successes = [r for r in results if r["status"] == "success"]
failures = [r for r in results if r["status"] == "error"]
total_tokens = sum(r.get("tokens", 0) for r in successes)
return {
"total": len(items),
"succeeded": len(successes),
"failed": len(failures),
"total_tokens": total_tokens,
"results": results,
}
Auto-Scaling for AI Workloads¶
Traditional auto-scaling triggers on CPU and memory. LLM workloads need different signals:
class LLMAwareAutoScaler:
"""
Auto-scaling logic that accounts for LLM-specific signals:
queue depth, provider error rate (proxy for rate limit pressure),
and cost per hour.
"""
def __init__(self, min_workers: int = 2, max_workers: int = 50,
max_hourly_cost_usd: float = 100.0):
self.min_workers = min_workers
self.max_workers = max_workers
self.max_hourly_cost = max_hourly_cost_usd
self.current_workers = min_workers
def evaluate(self, metrics: dict) -> dict:
"""
Returns recommended worker count and the reason for the recommendation.
metrics keys:
queue_depth: int — jobs waiting to be processed
error_rate: float — fraction of recent requests that failed
hourly_cost_usd: float — current spending rate
p95_latency_seconds: float — 95th percentile job latency
"""
queue_depth = metrics.get("queue_depth", 0)
error_rate = metrics.get("error_rate", 0.0)
hourly_cost = metrics.get("hourly_cost_usd", 0.0)
p95_latency = metrics.get("p95_latency_seconds", 0.0)
# Cost guard: never exceed budget regardless of demand
if hourly_cost > self.max_hourly_cost * 0.90:
desired = max(self.min_workers, self.current_workers - 3)
return {
"desired_workers": desired,
"reason": f"cost_guard: ${hourly_cost:.2f}/hr approaching limit",
"action": "scale_down",
}
# Rate limit pressure: high error rate usually means we're hitting provider limits
# Counterintuitively, scale DOWN to reduce pressure on the rate-limited API
if error_rate > 0.15:
desired = max(self.min_workers, self.current_workers - 2)
return {
"desired_workers": desired,
"reason": f"rate_limit_pressure: {error_rate:.0%} errors",
"action": "scale_down",
}
# Scale up: queue backing up
if queue_depth > 200:
desired = min(self.max_workers, self.current_workers + 10)
return {"desired_workers": desired, "reason": f"queue_depth={queue_depth}", "action": "scale_up"}
elif queue_depth > 50:
desired = min(self.max_workers, self.current_workers + 3)
return {"desired_workers": desired, "reason": f"queue_depth={queue_depth}", "action": "scale_up"}
# Scale down: queue empty and latency low
if queue_depth == 0 and p95_latency < 2.0:
desired = max(self.min_workers, self.current_workers - 1)
return {"desired_workers": desired, "reason": "queue_empty_low_latency", "action": "scale_down"}
return {
"desired_workers": self.current_workers,
"reason": "metrics_nominal",
"action": "hold",
}
Key Scaling Metrics to Monitor¶
| Metric | Normal | Alert Threshold | Action |
|---|---|---|---|
| Queue depth | 0–20 | > 100 for 5 min | Scale up workers |
| Provider error rate | < 1% | > 10% | Scale DOWN (rate limit pressure) |
| P95 job latency | < 5s | > 15s | Investigate bottleneck |
| Hourly cost | < budget/24 | > 90% of hourly budget | Throttle or scale down |
| Worker utilization | 60–80% | > 95% for 10 min | Add workers |
Scaling DOWN on High Error Rate
When your provider rate limit kicks in, you see a rising error rate. The wrong response is to scale UP your workers—more workers creating more requests accelerates rate limit violations. Scale DOWN your worker count to reduce request pressure until the rate limit window resets (usually 60 seconds).
Horizontal Scaling Limits and Workarounds¶
| Limitation | Root Cause | Workaround |
|---|---|---|
| Provider rate limits | External quota per API key | Multiple API keys (check ToS), provider tier upgrades, multi-provider routing |
| Cost scales linearly | Per-token billing | Caching, model routing, prompt compression (Lessons 4, 6) |
| 30s LLM latency | Model generation time | Streaming, async processing, smaller models for interactive paths |
| Single region provider | Provider network topology | Multi-region deployment with regional API keys |
| Context window limits | Model architecture | Chunking, summarization, RAG over long documents |
The fundamental insight: scaling LLM applications requires optimizing the inputs (fewer tokens, better caching) as much as scaling the infrastructure.
Edge Cases and Misconceptions¶
"Add more servers to handle more LLM requests." More servers increase your concurrent request capacity but cannot exceed your provider's rate limit. If your provider allows 3,500 RPM and you're already using 3,400, adding servers doesn't help—they'll all queue behind the same rate limit.
"Async processing solves all latency problems." Async queuing hides latency from the web server but doesn't reduce it for the user. If a job takes 15 seconds in the queue, the user waits 15 seconds. Use async for workloads where users can tolerate waiting (batch processing, report generation); use streaming for interactive workloads.
"Worker count should equal server CPU count." For LLM workloads, workers spend most of their time waiting for network responses (I/O-bound), not doing computation (CPU-bound). You can run 20–50 async workers on a single-core machine. CPU count is the wrong limit; provider rate limits and target latency are the right limits.
"Rate limiting only matters at high scale." Provider rate limits can bite you at any scale. Development and testing traffic counts against your production rate limit unless you use separate API keys. A team of 5 developers stress-testing a new feature can exhaust your production rate limit and cause user-facing failures.
Key Takeaways¶
- LLM scaling has three constraints that don't exist for traditional apps: provider rate limits (external cap), variable latency (5–30s per call), and linear cost growth—address all three in your architecture
- Calculate your scale requirements before building: daily requests, peak RPM needed, and peak TPM needed determine whether you need queueing at all
- Queue-based architecture decouples request intake (instant ACK) from LLM processing (async workers), enabling burst absorption and automatic retry
- Control worker concurrency with a semaphore bounded below provider rate limits; running more workers than the rate limit allows makes errors worse, not better
- Auto-scale on queue depth and error rate, not CPU/memory; when error rate is high due to rate limiting, scale DOWN workers to reduce pressure
- Use batch APIs for non-real-time workloads at 50% cost discount; streaming for interactive; async queuing for everything between
Further Reading¶
- Scalable and Reliable LLM Serving — Research on throughput optimization for LLM inference at scale
- vLLM: Efficient Memory Management for Large Language Model Serving — Key paper on LLM inference optimization (relevant if self-hosting)
- Celery documentation — Production Python distributed task queue for LLM worker architectures
- Modal documentation — Serverless GPU infrastructure designed for AI workloads with auto-scaling built in
Module Complete¶
You've completed Module 10: LLMOps & Production Systems. You now have the tools to take any LLM application from proof-of-concept to production: prompt versioning, observability, caching, A/B testing, cost optimization, deployment patterns, API design, security, and scaling. These practices compound—each lesson makes the next more effective.
Next module: Module 19: LLM Evaluation & Quality Engineering — Build systematic eval pipelines that catch regressions before users do.