AI Daddy › AI Design Patterns
AI Design Patterns
Catalogs common patterns for building AI systems, similar to design patterns in software engineering. Each pattern includes when to use it, implementation…
AI Design Patterns
This chapter catalogs common patterns for building AI systems, similar to design patterns in software engineering. Each pattern includes when to use it, implementation guidance, and tradeoffs.
Table of Contents
RAG Patterns
Pattern: Naive RAG
The simplest RAG implementation:
Query → Embed → Search → Top K → Stuff into prompt → Generate
When to use:
- MVP and prototyping
- Simple question-answering
- When retrieval quality is sufficient
Limitations:
- No reranking
- No query enhancement
- May retrieve irrelevant chunks
Pattern: Advanced RAG
Enhanced pipeline with multiple stages:
Query → Rewrite → Embed → Hybrid Search → Rerank → Filter → Generate
class AdvancedRAG:
async def query(self, user_query: str) -> str:
# Step 1: Query rewriting
enhanced_query = await self.rewrite_query(user_query)
# Step 2: Hybrid retrieval
semantic_results = await self.vector_search(enhanced_query, top_k=50)
keyword_results = await self.bm25_search(enhanced_query, top_k=50)
# Step 3: Fusion
combined = self.reciprocal_rank_fusion(semantic_results, keyword_results)
# Step 4: Reranking
reranked = await self.rerank(enhanced_query, combined[:20])
# Step 5: Generation with top results
context = self.format_context(reranked[:5])
return await self.generate(user_query, context)
When to use:
- Production systems
- When accuracy matters
- Complex document sets
Pattern: Parent-Child Retrieval
Retrieve small chunks, return larger parent chunks:
Document
└── Parent chunk (2000 tokens)
├── Child chunk (200 tokens) ← Retrieve on this
├── Child chunk (200 tokens)
└── Child chunk (200 tokens)
class ParentChildRetriever:
def __init__(self, vector_store):
self.vector_store = vector_store
async def retrieve(self, query: str, top_k: int = 5) -> list[str]:
# Search on child chunks (more precise)
child_results = await self.vector_store.search(
query,
collection="child_chunks",
top_k=top_k * 3
)
# Get unique parent chunks
parent_ids = set(r.metadata["parent_id"] for r in child_results)
# Return parent chunks (more context)
parents = await self.get_parents(list(parent_ids)[:top_k])
return parents
When to use:
- Need precision in retrieval
- Need context in generation
- Document structure is hierarchical
Pattern: Self-RAG
Model decides when and what to retrieve:
class SelfRAG:
async def generate(self, query: str) -> str:
# Step 1: Decide if retrieval is needed
needs_retrieval = await self.assess_retrieval_need(query)
if needs_retrieval:
# Step 2: Retrieve
context = await self.retrieve(query)
# Step 3: Assess relevance
relevant_context = await self.filter_relevant(query, context)
# Step 4: Generate with context
response = await self.generate_with_context(query, relevant_context)
# Step 5: Self-critique
is_supported = await self.check_support(response, relevant_context)
if not is_supported:
response = await self.regenerate(query, relevant_context)
else:
response = await self.generate_without_context(query)
return response
When to use:
- Mixed knowledge (parametric + retrieved)
- Want model to be selective
- Research and experimentation
Pattern: Corrective RAG (CRAG)
Evaluate and correct retrieval quality:
class CorrectiveRAG:
async def query(self, user_query: str) -> str:
# Initial retrieval
docs = await self.retrieve(user_query)
# Grade each document
graded = []
for doc in docs:
grade = await self.grade_relevance(user_query, doc)
graded.append((doc, grade))
# Categorize results
relevant = [d for d, g in graded if g == "relevant"]
ambiguous = [d for d, g in graded if g == "ambiguous"]
if len(relevant) >= 3:
# Enough relevant docs
context = relevant
elif len(relevant) + len(ambiguous) >= 2:
# Refine ambiguous docs
refined = await self.refine_search(user_query, ambiguous)
context = relevant + refined
else:
# Web search fallback
web_results = await self.web_search(user_query)
context = relevant + web_results
return await self.generate(user_query, context)
When to use:
- Unreliable document corpus
- Need high accuracy
- Can afford latency for quality checks
Agent Patterns
Pattern: ReAct
Interleaved reasoning and acting:
Thought → Action → Observation → Thought → Action → Observation → Answer
See Agent Architectures for implementation.
When to use:
- General-purpose agents
- Explainable decision making
- Moderate complexity tasks
Pattern: Plan-and-Execute
Create a plan first, then execute steps:
class PlanAndExecuteAgent:
async def run(self, task: str) -> str:
# Step 1: Create plan
plan = await self.create_plan(task)
# Step 2: Execute each step
results = []
for step in plan.steps:
result = await self.execute_step(step, results)
results.append(result)
# Re-plan if needed
if result.needs_replanning:
plan = await self.replan(task, results)
# Step 3: Synthesize final answer
return await self.synthesize(task, results)
async def create_plan(self, task: str) -> Plan:
prompt = f"""
Create a step-by-step plan to accomplish this task: {task}
Return as JSON:
{{
"steps": [
{{"id": 1, "description": "...", "tool": "..."}},
...
]
}}
"""
return await self.llm.generate(prompt)
When to use:
- Complex multi-step tasks
- Need visibility into plan
- Tasks benefit from decomposition
Pattern: Critic/Verifier
One agent generates, another critiques:
class CriticPattern:
async def generate_with_critique(self, task: str, max_iterations: int = 3) -> str:
response = await self.generator.generate(task)
for i in range(max_iterations):
# Critique the response
critique = await self.critic.evaluate(task, response)
if critique.is_acceptable:
break
# Regenerate with feedback
response = await self.generator.regenerate(
task,
previous=response,
feedback=critique.feedback
)
return response
When to use:
- Quality is critical
- Can afford extra latency
- Tasks have clear success criteria
Pattern: Hierarchical Agents
Manager delegates to specialist workers:
class ManagerAgent:
def __init__(self):
self.workers = {
"research": ResearchAgent(),
"coding": CodingAgent(),
"writing": WritingAgent()
}
async def run(self, task: str) -> str:
# Decompose task
subtasks = await self.decompose(task)
# Assign to workers
results = {}
for subtask in subtasks:
worker = self.workers[subtask.worker_type]
results[subtask.id] = await worker.execute(subtask)
# Synthesize results
return await self.synthesize(task, results)
When to use:
- Complex tasks spanning domains
- Different tools per subtask
- Parallelization opportunities
Optimization Patterns
Pattern: Cascading Models
Route to cheapest sufficient model:
class ModelCascade:
def __init__(self):
self.models = [
("gpt-4o-mini", 0.15), # Cheapest
("gpt-4o", 2.50), # Mid-tier
("claude-3.5-sonnet", 3.00) # Most capable
]
async def generate(self, query: str) -> str:
# Classify complexity
complexity = await self.classify_complexity(query)
if complexity == "simple":
return await self.call_model("gpt-4o-mini", query)
elif complexity == "medium":
return await self.call_model("gpt-4o", query)
else:
return await self.call_model("claude-3.5-sonnet", query)
When to use:
- High query volume
- Variable query complexity
- Cost optimization priority
Pattern: Speculative Execution
Draft with small model, verify with large:
class SpeculativeExecution:
async def generate(self, prompt: str, n_tokens: int = 5) -> str:
output = []
while len(output) < max_tokens:
# Draft with small model
draft = await self.draft_model.generate(
prompt + "".join(output),
n_tokens=n_tokens
)
# Verify with large model
verified = await self.target_model.verify(
prompt + "".join(output),
draft
)
# Accept verified tokens
output.extend(verified.accepted_tokens)
if verified.is_complete:
break
return "".join(output)
When to use:
- Latency-critical applications
- Have aligned draft model
- Predictable generation patterns
Pattern: Caching Layers
Multi-level caching strategy:
class CachingLLM:
def __init__(self):
self.exact_cache = ExactMatchCache()
self.semantic_cache = SemanticCache(threshold=0.95)
async def generate(self, query: str) -> str:
# Level 1: Exact match
cached = await self.exact_cache.get(query)
if cached:
return cached
# Level 2: Semantic similarity
similar = await self.semantic_cache.get_similar(query)
if similar:
return similar
# Cache miss: Generate
response = await self.llm.generate(query)
# Store in caches
await self.exact_cache.set(query, response)
await self.semantic_cache.set(query, response)
return response
When to use:
- Repeated similar queries
- Cost reduction priority
- Can tolerate some staleness
Reliability Patterns
Pattern: Retry with Fallback
class RetryWithFallback:
async def generate(self, query: str) -> str:
providers = [
("openai", "gpt-4o"),
("anthropic", "claude-3.5-sonnet"),
("google", "gemini-1.5-pro")
]
for provider, model in providers:
try:
return await self.call(provider, model, query)
except RateLimitError:
continue
except ServiceError:
continue
# All providers failed
raise AllProvidersUnavailable()
Pattern: Circuit Breaker
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, reset_timeout: int = 60):
self.failures = 0
self.state = "closed"
self.last_failure = None
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
async def call(self, func, *args):
if self.state == "open":
if time.time() - self.last_failure > self.reset_timeout:
self.state = "half-open"
else:
raise CircuitOpenError()
try:
result = await func(*args)
self.failures = 0
self.state = "closed"
return result
except Exception as e:
self.failures += 1
self.last_failure = time.time()
if self.failures >= self.failure_threshold:
self.state = "open"
raise
Pattern: Bulkhead
Isolate failures between components:
class BulkheadExecutor:
def __init__(self, max_concurrent: int = 10):
self.semaphore = asyncio.Semaphore(max_concurrent)
async def execute(self, func, *args):
async with self.semaphore:
return await func(*args)
# Separate bulkheads for different operations
rag_bulkhead = BulkheadExecutor(max_concurrent=20)
agent_bulkhead = BulkheadExecutor(max_concurrent=5)
Cost Patterns
Pattern: Token Budget
class TokenBudget:
def __init__(self, max_input: int, max_output: int):
self.max_input = max_input
self.max_output = max_output
def constrain_input(self, messages: list[dict]) -> list[dict]:
total_tokens = 0
constrained = []
for msg in reversed(messages):
tokens = count_tokens(msg["content"])
if total_tokens + tokens > self.max_input:
break
constrained.insert(0, msg)
total_tokens += tokens
return constrained
Pattern: Cost Tracking Decorator
def track_cost(model: str):
def decorator(func):
async def wrapper(*args, **kwargs):
start_tokens = get_token_count()
result = await func(*args, **kwargs)
end_tokens = get_token_count()
cost = calculate_cost(model, end_tokens - start_tokens)
metrics.record("llm_cost", cost, tags={"model": model})
return result
return wrapper
return decorator
@track_cost("gpt-4o")
async def generate_response(query: str):
return await llm.generate(query)
Interview Questions
Q: Describe three RAG patterns and when to use each.
Strong answer:
"I will describe Naive RAG, Advanced RAG, and Parent-Child Retrieval.
Naive RAG is the simplest: embed query, search vectors, stuff top K into prompt, generate. I use this for MVPs and when retrieval quality is already good. It is fast to implement but has no reranking or query enhancement.
Advanced RAG adds multiple stages: query rewriting, hybrid search (semantic + keyword), reranking, and filtering. I use this in production when accuracy matters. The additional latency (100-200ms for reranking) is worth it for the 10-15% precision improvement.
Parent-Child Retrieval embeds small chunks for precise matching but returns larger parent chunks for context. I use this when documents have structure and I need both precision in retrieval and sufficient context for generation.
The pattern I choose depends on the accuracy requirements, latency budget, and document characteristics. I often start with Naive RAG to establish a baseline, then iterate to Advanced RAG."
Q: What reliability patterns would you use for a production LLM system?
Strong answer:
"I implement multiple layers of reliability:
Retry with exponential backoff for transient failures. Rate limits and temporary errors are common with LLM APIs.
Multi-provider fallback so if OpenAI is having issues, I automatically route to Anthropic or Google. This requires abstracting the LLM interface.
Circuit breaker to stop hammering a failing service. After N failures, I open the circuit and route to fallback immediately, giving the primary time to recover.
Graceful degradation when all providers fail. Return cached responses, show fallback messages, or queue for later processing rather than erroring.
Bulkhead isolation to prevent one component's failures from cascading. Agent workloads get separate thread pools from RAG workloads.
Timeouts at every level. LLM calls can hang; I set aggressive timeouts and handle them gracefully.
The key is assuming failures will happen and designing for them rather than hoping they will not."
References
Next: Anti-Patterns to Avoid