Building Scalable GenAI Applications: From Prototype to Production
Complete guide to architecting production-grade generative AI applications. Covers infrastructure, monitoring, cost optimization, and lessons from scaling to millions of users.

We scaled an AI chatbot from 0 to 50K concurrent users in 8 months. This is everything we learned about building production-grade GenAI systems.
The Gap Between POC and Production
Prototype (Fast, broken):
- 50ms to 20 second response times
- Crashes with 10 concurrent users
- Hallucinations accepted
- Single point of failure
- $0 infrastructure cost
Production (Reliable, expensive):
- Consistent sub-1 second response times
- Handles 50K concurrent users
- Hallucination rate < 1%
- Multi-region failover
- $50K/month infrastructure
The gap is larger than most teams expect.
Architecture: The Full Stack
┌─────────────────────────────────────────────────┐
│ User Interface Layer │
│ Web (React) | Mobile (React Native) | API │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ API Gateway & Load Balancing │
│ CloudFront | Kong | Rate Limiting │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ Request Queueing & Orchestration │
│ RabbitMQ | Kafka | Redis | Task Queue │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ LLM Inference Layer │
│ vLLM | TensorRT-LLM | Together | Bedrock │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ Context & Memory Layer │
│ Vector DB | Cache | Database │
└──────────────┬──────────────────────────────────┘
│
┌──────────────▼──────────────────────────────────┐
│ Monitoring, Logging & Observability │
│ Prometheus | Datadog | Jaeger | ELK │
└─────────────────────────────────────────────────┘
Layer 1: API Gateway
Rate Limiting Strategy
from fastapi import FastAPI, HTTPException
from slowapi import Limiter
from slowapi.util import get_remote_address
app = FastAPI()
limiter = Limiter(key_func=get_remote_address)
# Tiered rate limiting
@app.post("/chat")
@limiter.limit("10/minute") # Free tier
async def chat_free(request: ChatRequest):
return process_chat(request)
@app.post("/chat-pro")
@limiter.limit("100/minute") # Pro tier
async def chat_pro(request: ChatRequest):
return process_chat(request)
# Token bucket algorithm for smooth distribution
class TokenBucket:
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity
self.tokens = capacity
self.refill_rate = refill_rate
self.last_refill = time.time()
def consume(self, tokens: int = 1) -> bool:
self.refill()
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
def refill(self):
now = time.time()
elapsed = now - self.last_refill
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_rate
)
self.last_refill = now
Layer 2: Request Queue Architecture
Why Queuing Matters
Without queuing:
User Request → LLM Inference (5-30s) → Response
└─ If inference crashes, user waits/loses request
With queuing:
User Request → Queue → Worker Pool → LLM Inference → Response
└─ Queue persists requests
└─ Multiple workers scale independently
└─ Failures handled gracefully
Implementation with Celery
from celery import Celery, Task
from celery.result import AsyncResult
import redis
app = Celery('genai')
app.conf.update(
broker='redis://redis:6379/0',
result_backend='redis://redis:6379/1',
task_serializer='json',
accept_content=['json'],
result_expires=3600,
task_track_started=True,
task_time_limit=300, # 5 min hard limit
task_soft_time_limit=280, # 4:40 soft limit
)
@app.task(bind=True, max_retries=3)
def generate_response(self, user_id: str, query: str):
try:
# Get context from vector DB
context = retrieve_context(query)
# Call LLM
response = llm_client.generate(
messages=[
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": f"{context}\n{query}"}
],
temperature=0.7,
max_tokens=500,
timeout=30
)
# Store result
save_response(user_id, query, response)
return {"status": "success", "response": response}
except LLMTimeoutError as e:
# Retry with exponential backoff
raise self.retry(exc=e, countdown=2 ** self.request.retries)
except Exception as e:
logging.error(f"Error: {e}")
raise
# FastAPI integration
from fastapi import FastAPI
from celery.result import AsyncResult
api = FastAPI()
@api.post("/chat")
async def chat(request: ChatRequest):
# Submit task to queue
task = generate_response.delay(
user_id=request.user_id,
query=request.query
)
# Return immediately with task ID
return {
"task_id": task.id,
"status": "queued",
"estimated_wait": 5 # seconds
}
@api.get("/chat/{task_id}")
async def get_result(task_id: str):
result = AsyncResult(task_id, app=app)
if result.state == 'PENDING':
return {"status": "processing", "progress": 50}
elif result.state == 'SUCCESS':
return {"status": "complete", "response": result.result}
elif result.state == 'FAILURE':
return {"status": "failed", "error": str(result.info)}
Layer 3: LLM Inference Optimization
Model Serving with vLLM
from vllm import AsyncLLMEngine
from vllm.sampling_params import SamplingParams
# High-performance inference engine
engine = AsyncLLMEngine.from_engine_args({
"model": "meta-llama/Llama-2-7b-hf",
"tensor_parallel_size": 2, # 2 GPUs
"gpu_memory_utilization": 0.9, # Use 90% VRAM
"max_num_batched_tokens": 8192, # Batch size
"max_model_len": 4096, # Context length
"enable_prefix_caching": True, # Key optimization
"trust_remote_code": True,
"dtype": "float16", # Mixed precision
})
async def inference(prompt: str, user_id: str):
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.95,
max_tokens=500
)
# vLLM handles batching automatically
results = await engine.generate(
prompt,
sampling_params,
request_id=user_id
)
return results[0].outputs[0].text
# Prefix caching magic
# If 100 users ask about "Python best practices"
# The "Python best practices" prefix is cached
# Only compute unique suffixes
# 80-90% speed improvement on repeated prefixes
Load Balancing Across Models
class ModelSelector:
def __init__(self):
self.models = {
"fast": "meta-llama/Llama-2-7b", # Speed
"accurate": "meta-llama/Llama-2-70b", # Quality
"balanced": "mistral-medium" # Balance
}
def select(self, query: str, latency_budget: float):
# User has 1 second budget?
if latency_budget < 1.0:
return "fast"
# 2 second budget?
elif latency_budget < 2.0:
return "balanced"
# No limit?
else:
return "accurate"
selector = ModelSelector()
@app.post("/chat")
async def chat(request: ChatRequest):
model = selector.select(
query=request.query,
latency_budget=request.latency_budget_seconds
)
response = await inference(request.query, model)
return response
Layer 4: Intelligent Caching
Multi-Layer Cache Strategy
import hashlib
from cachetools import TTLCache
import redis
class IntelligentCache:
def __init__(self):
# Layer 1: In-memory (fast, per-instance)
self.memory_cache = TTLCache(
maxsize=10000,
ttl=3600 # 1 hour
)
# Layer 2: Redis (fast, distributed)
self.redis_client = redis.Redis(
host='redis',
port=6379,
db=0,
decode_responses=True
)
def get_response(self, query: str):
# Generate deterministic key
key = hashlib.sha256(query.encode()).hexdigest()
# Try L1: Memory cache
if key in self.memory_cache:
return self.memory_cache[key]
# Try L2: Redis cache
cached = self.redis_client.get(key)
if cached:
self.memory_cache[key] = cached
return cached
return None
def set_response(self, query: str, response: str):
key = hashlib.sha256(query.encode()).hexdigest()
self.memory_cache[key] = response
self.redis_client.setex(key, 86400, response) # 24 hours
cache = IntelligentCache()
@app.post("/chat")
async def chat(request: ChatRequest):
# Check cache first
cached = cache.get_response(request.query)
if cached:
return {"response": cached, "source": "cache"}
# Generate new response
response = await generate_response(request.query)
# Store in cache
cache.set_response(request.query, response)
return {"response": response, "source": "model"}
Layer 5: Context Retrieval (RAG)
Efficient Vector Search
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer
class ContextRetrieval:
def __init__(self):
self.qdrant = QdrantClient("localhost", port=6333)
self.encoder = SentenceTransformer('all-MiniLM-L6-v2')
def retrieve(self, query: str, top_k: int = 5):
# Encode query
query_vector = self.encoder.encode(query)
# Search with filtering
results = self.qdrant.search(
collection_name="documents",
query_vector=query_vector,
query_filter=Filter(
must=[
HasIdCondition(has_id=[1, 2, 3]) # Only specific docs
]
),
limit=top_k,
with_payload=True
)
# Format context
context = "\n".join([
f"[{i+1}] {hit.payload['content']}"
for i, hit in enumerate(results)
])
return context
retrieval = ContextRetrieval()
@app.post("/chat")
async def chat(request: ChatRequest):
# Step 1: Get context
context = retrieval.retrieve(request.query)
# Step 2: Generate response with context
response = await generate_response(context, request.query)
return response
Monitoring: Critical for Production
Key Metrics to Track
from prometheus_client import Counter, Histogram, Gauge
import time
# Latency tracking
latency_histogram = Histogram(
'chat_latency_seconds',
'Response latency',
buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0)
)
# Error tracking
error_counter = Counter(
'chat_errors_total',
'Total errors',
['error_type']
)
# Queue depth
queue_gauge = Gauge(
'chat_queue_depth',
'Number of pending requests'
)
# Hallucination detection
hallucination_counter = Counter(
'hallucinations_detected',
'Detected hallucinations'
)
@app.post("/chat")
async def chat(request: ChatRequest):
start_time = time.time()
try:
# Get response
response = await generate_response(request.query)
# Check for hallucination
if is_hallucination(response, request.query):
hallucination_counter.inc()
# Track latency
latency_histogram.observe(time.time() - start_time)
return response
except Exception as e:
error_counter.labels(error_type=type(e).__name__).inc()
raise
# Alerting rules
alert_rules = {
"P99 Latency > 5s": {
"query": "histogram_quantile(0.99, chat_latency_seconds)",
"threshold": 5.0,
"severity": "warning"
},
"Error Rate > 1%": {
"query": "rate(chat_errors_total[5m])",
"threshold": 0.01,
"severity": "critical"
},
"Queue Depth > 1000": {
"query": "chat_queue_depth",
"threshold": 1000,
"severity": "warning"
}
}
Cost Optimization at Scale
Cost Breakdown (1M requests/day)
Component Cost/Month Optimization
──────────────────────────────────────────────────────────
LLM API (Claude/GPT) $20,000 Use open-source models
Vector DB (Qdrant) $2,000 S3 Vectors tier
Redis Cache $1,000 Use in-memory/tiered
Load Balancer $500 Kubernetes ingress
Monitoring $1,000 Open-source stack
Data Transfer $2,000 Optimize routing
─────────────────────────────────────────────────────
TOTAL $26,500/mo
Optimization strategies:
1. Use Llama 2 (free) instead of GPT-4 ($20K → $5K)
2. Implement aggressive caching (10-30% cache hits)
3. Use precomputed embeddings
4. Batch requests where possible
Scaling Patterns
Horizontal Scaling
# Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: genai-api
spec:
replicas: 10 # Start with 10 pods
selector:
matchLabels:
app: genai-api
template:
metadata:
labels:
app: genai-api
spec:
containers:
- name: api
image: genai-api:v1
resources:
requests:
memory: "4Gi"
cpu: "2"
limits:
memory: "8Gi"
cpu: "4"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
---
# Autoscaling
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: genai-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: genai-api
minReplicas: 5
maxReplicas: 100
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
Common Pitfalls
❌ Pitfall 1: Direct API Calls Without Queue
Problem: If inference fails, user loses request Solution: Always use message queue
❌ Pitfall 2: No Caching
Problem: Regenerate responses for identical queries Solution: Implement multi-layer cache (memory + Redis)
❌ Pitfall 3: Single LLM Model
Problem: Can't tradeoff speed vs accuracy Solution: Use model selector based on latency budget
❌ Pitfall 4: No Rate Limiting
Problem: Costs explode with malicious traffic Solution: Implement tiered rate limiting
❌ Pitfall 5: Ignoring Hallucinations
Problem: AI returns incorrect information Solution: Implement hallucination detection and filtering
Full Request Flow
User Request (150ms total latency budget)
↓
1. API Gateway (5ms)
- Rate limiting check
- Auth validation
↓
2. Check Cache (10ms)
- Memory cache hit → Return immediately (15ms total)
- Redis cache hit → Return (25ms total)
- Cache miss → Continue
↓
3. Enqueue Task (5ms)
- Add to Celery queue
- Return task ID to user
↓
4. Worker Processing (parallel, 100-120ms)
- Retrieve context from vector DB (30ms)
- Select appropriate model (5ms)
- Call LLM inference (60-80ms)
- Validate response (5ms)
↓
5. Store & Cache Result (10ms)
- Save to database
- Cache in Redis
- Update memory cache
↓
6. User Polls Status (every 1s)
- Returns result when ready
- Or "still processing"
Conclusion
Scaling GenAI applications requires:
- ✅ Robust queueing: Handle load spikes
- ✅ Intelligent caching: Reduce inference calls by 70%
- ✅ Model selection: Match latency requirements
- ✅ Monitoring: Track all critical metrics
- ✅ Cost awareness: LLM inference dominates costs
- ✅ Graceful degradation: Fallbacks when inference fails
The gap between POC and production is real, but systematic architecture closes it.