AI Agent Architecture — Patterns for Production Systems
Use Case: Design and implement production-ready AI agents with proper patterns, safety controls, and scalability.
1. Agent Architecture Patterns
Pattern 1: ReAct (Reasoning + Acting)
class ReActAgent:
"""
ReAct loop: Thought → Action → Observation → Thought → ...
"""
def __init__(self, llm_client, tools: list[Tool]):
self.llm = llm_client
self.tools = {t.name: t for t in tools}
self.max_steps = 10
async def run(self, query: str) -> str:
"""Execute ReAct loop."""
thoughts = []
for step in range(self.max_steps):
# Thought: LLM decides what to do
thought = await self.llm.generate(
self._build_thought_prompt(query, thoughts)
)
thoughts.append(thought)
# Parse action
action = self._parse_action(thought)
# Check if done
if action.type == "finish":
return action.result
# Execute action
observation = await self._execute_action(action)
thoughts.append(f"Observation: {observation}")
return "Max steps reached"
def _build_thought_prompt(self, query: str, thoughts: list[str]) -> str:
return f"""
Query: {query}
Previous thoughts:
{chr(10).join(thoughts)}
What should I do next? Choose one:
1. Think more (provide reasoning)
2. Act (call a tool)
3. Finish (provide answer)
"""
def _execute_action(self, action: dict) -> str:
"""Execute tool with safety checks."""
tool = self.tools.get(action.tool_name)
if not tool:
return f"Error: Tool '{action.tool_name}' not found"
# Validate inputs
if not self._validate_inputs(tool, action.arguments):
return "Error: Invalid arguments"
# Execute with timeout
try:
return asyncio.wait_for(
tool.execute(**action.arguments),
timeout=30
)
except asyncio.TimeoutError:
return "Error: Tool execution timed out"
Pattern 2: Plan-and-Execute
class PlanExecuteAgent:
"""
Two-phase: Plan → Execute each step
"""
def __init__(self, llm_client, tools: list[Tool]):
self.llm = llm_client
self.tools = {t.name: t for t in tools}
async def run(self, goal: str) -> dict:
"""Plan and execute."""
# Phase 1: Planning
plan = await self._create_plan(goal)
# Phase 2: Execution
results = []
for step in plan.steps:
result = await self._execute_step(step)
results.append(result)
# Check for failures
if result.status == "failed":
return await self._recover(plan, results)
return {
"plan": plan,
"results": results,
"success": True
}
async def _create_plan(self, goal: str) -> Plan:
"""Generate execution plan."""
prompt = f"""
Goal: {goal}
Break this into numbered steps. Each step should:
1. Be atomic and executable
2. Specify which tool to use
3. Have clear success criteria
Return JSON:
{{
"steps": [
{{"id": 1, "description": "...", "tool": "...",
"arguments": {{}}, "success_criteria": "..."}}
]
}}
"""
return Plan.from_json(await self.llm.generate(prompt))
async def _execute_step(self, step: dict) -> StepResult:
"""Execute a single plan step."""
tool = self.tools.get(step["tool"])
if not tool:
return StepResult(status="failed", error="Tool not found")
try:
output = await tool.execute(**step["arguments"])
success = self._check_success(output, step["success_criteria"])
return StepResult(status="success" if success else "partial",
output=output)
except Exception as e:
return StepResult(status="failed", error=str(e))
Pattern 3: Multi-Agent System
class MultiAgentSystem:
"""
Coordinator + Specialist agents
"""
def __init__(self):
self.agents = {
"coordinator": CoordinatorAgent(),
"researcher": ResearchAgent(),
"analyst": AnalystAgent(),
"writer": WriterAgent()
}
self.message_queue = asyncio.Queue()
async def run(self, request: str) -> str:
"""Orchestrate multi-agent workflow."""
# Coordinator decomposes request
subtasks = await self.agents["coordinator"].decompose(request)
# Execute subtasks (parallel where possible)
results = await asyncio.gather(*[
self._execute_subtask(task)
for task in subtasks
])
# Coordinator synthesizes final response
return await self.agents["coordinator"].synthesize(
request, results
)
async def _execute_subtask(self, subtask: dict) -> dict:
"""Route subtask to appropriate agent."""
agent_name = subtask["assigned_agent"]
agent = self.agents.get(agent_name)
if not agent:
return {"error": f"Agent {agent_name} not found"}
return await agent.execute(subtask)
2. Tool Integration
Tool Definition
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class Tool:
name: str
description: str
parameters: dict
execute: Callable
requires_auth: bool = False
rate_limit: int = 60 # Calls per minute
def validate_inputs(self, inputs: dict) -> bool:
"""Validate tool inputs against schema."""
for param, schema in self.parameters.items():
if param not in inputs:
if schema.get("required", False):
return False
elif not self._validate_type(inputs[param], schema.get("type")):
return False
return True
def _validate_type(self, value: Any, expected_type: str) -> bool:
"""Type validation."""
type_map = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict
}
return isinstance(value, type_map.get(expected_type, type))
Safe Tool Execution
class SafeToolExecutor:
"""Execute tools with safety constraints."""
def __init__(self):
self.audit_log = []
self.blocked_tools = {"dangerous_delete", "mass_email"}
async def execute(self, tool: Tool, inputs: dict) -> dict:
"""Execute tool with safety checks."""
# Check if tool is blocked
if tool.name in self.blocked_tools:
return {"error": "Tool blocked by policy"}
# Validate inputs
if not tool.validate_inputs(inputs):
return {"error": "Invalid inputs"}
# Rate limiting
if not self._check_rate_limit(tool.name):
return {"error": "Rate limit exceeded"}
# Audit logging
start_time = time.time()
self.audit_log.append({
"tool": tool.name,
"inputs": self._sanitize_inputs(inputs),
"timestamp": datetime.utcnow().isoformat()
})
# Execute with timeout
try:
result = await asyncio.wait_for(
tool.execute(**inputs),
timeout=30
)
# Log success
self.audit_log[-1]["duration_ms"] = (time.time() - start_time) * 1000
self.audit_log[-1]["status"] = "success"
return result
except Exception as e:
self.audit_log[-1]["status"] = "error"
self.audit_log[-1]["error"] = str(e)
return {"error": str(e)}
def _sanitize_inputs(self, inputs: dict) -> dict:
"""Remove sensitive data from audit log."""
sensitive_keys = {"password", "token", "secret", "api_key"}
return {
k: "***" if any(s in k.lower() for s in sensitive_keys) else v
for k, v in inputs.items()
}
3. Safety and Guardrails
Input Sanitization
class InputSanitizer:
"""Sanitize agent inputs for safety."""
DANGEROUS_PATTERNS = [
r"rm\s+-rf\s+/(",
r"sudo\s+",
r"cat\s+/etc/(passwd|shadow)",
r"\bor\b.*\band\b", # SQL injection
r"<script>", # XSS
]
def sanitize(self, text: str) -> str:
"""Remove dangerous patterns."""
sanitized = text
for pattern in self.DANGEROUS_PATTERNS:
sanitized = re.sub(pattern, "", sanitized, flags=re.I)
return sanitized
def validate(self, text: str) -> bool:
"""Check if input is safe."""
return len(self.sanitize(text)) == len(text)
Output Filtering
class OutputFilter:
"""Filter agent outputs for safety."""
SENSITIVE_PATTERNS = [
r"\b\d{3}-\d{2}-\d{4}\b", # SSN
r"\b\d{16}\b", # Credit card
r"password\s*[:=]\s*\S+", # Passwords
]
def filter(self, text: str) -> str:
"""Remove sensitive information."""
filtered = text
for pattern in self.SENSITIVE_PATTERNS:
filtered = re.sub(pattern, "[REDACTED]", filtered, flags=re.I)
return filtered
Human-in-the-Loop
class HumanApproval:
"""Require human approval for critical actions."""
CRITICAL_ACTIONS = {
"delete", "modify", "transfer", "publish", "send_email"
}
def should_approve(self, action: str, context: dict) -> bool:
"""Determine if human approval is needed."""
# Always approve critical actions
if action in self.CRITICAL_ACTIONS:
return True
# Approve based on risk score
risk_score = self._calculate_risk(action, context)
return risk_score > 0.7
async def request_approval(self, action: str, context: dict) -> bool:
"""Request and wait for human approval."""
approval_request = {
"action": action,
"context": context,
"timestamp": datetime.utcnow().isoformat()
}
# Send to approval queue
await self.approval_queue.put(approval_request)
# Wait for approval (with timeout)
try:
approval = await asyncio.wait_for(
self.approval_queue.get(),
timeout=300 # 5 minutes
)
return approval["approved"]
except asyncio.TimeoutError:
return False # Deny on timeout
4. State Management
Agent State
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class AgentState:
"""Track agent execution state."""
agent_id: str
query: str
steps_executed: int = 0
tools_called: list[str] = field(default_factory=list)
observations: list[str] = field(default_factory=list)
final_answer: Optional[str] = None
error: Optional[str] = None
started_at: Optional[float] = None
completed_at: Optional[float] = None
def to_dict(self) -> dict:
return {
"agent_id": self.agent_id,
"query": self.query,
"steps": self.steps_executed,
"tools": self.tools_called,
"observations_count": len(self.observations),
"has_answer": self.final_answer is not None,
"has_error": self.error is not None,
"duration_seconds": (
(self.completed_at or time.time()) - (self.started_at or time.time())
) if self.started_at else None
}
State Persistence
import redis
class StateStore:
"""Persist agent state for recovery."""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.ttl = 3600 # 1 hour
def save_state(self, state: AgentState):
"""Save agent state."""
key = f"agent:{state.agent_id}"
self.redis.setex(
key,
self.ttl,
json.dumps(state.to_dict())
)
def load_state(self, agent_id: str) -> Optional[AgentState]:
"""Load agent state for recovery."""
key = f"agent:{agent_id}"
data = self.redis.get(key)
if not data:
return None
state_dict = json.loads(data)
return AgentState(**state_dict)
def cleanup_expired(self):
"""Clean up expired states."""
# Redis keys auto-expire with TTL
pass
5. Concurrency and Scaling
Queue-Based Architecture
class AgentQueue:
"""Queue-based agent execution."""
def __init__(self, max_workers: int = 10):
self.queue = asyncio.Queue()
self.workers = []
self.max_workers = max_workers
async def start(self):
"""Start worker tasks."""
for i in range(self.max_workers):
worker = asyncio.create_task(self._worker(i))
self.workers.append(worker)
async def submit(self, request: dict):
"""Submit agent request."""
await self.queue.put(request)
async def _worker(self, worker_id: int):
"""Process requests from queue."""
while True:
request = await self.queue.get()
try:
result = await self._process(request)
await self._send_result(request["callback"], result)
except Exception as e:
await self._send_error(request["callback"], str(e))
finally:
self.queue.task_done()
async def _process(self, request: dict) -> dict:
"""Process single request."""
agent = self._create_agent(request)
return await agent.run(request["query"])
Load Testing
import asyncio
from locust import HttpUser, task, between
class AgentLoadTest(HttpUser):
wait_time = between(1, 3)
@task
def test_agent(self):
"""Test agent endpoint under load."""
payload = {
"query": "What is the status of service X?",
"session_id": self.user_id
}
with self.client.post(
"/api/agent/run",
json=payload,
catch_response=True
) as response:
if response.status_code == 200:
response.success()
else:
response.failure(f"Status {response.status_code}")
6. Monitoring and Debugging
Agent Metrics
from prometheus_client import Counter, Histogram, Gauge
# Agent execution metrics
AGENT_RUNS = Counter("agent_runs_total", "Total agent runs", ["agent_type"])
AGENT_STEPS = Histogram("agent_steps", "Steps per agent run", ["agent_type"])
AGENT_DURATION = Histogram("agent_duration_seconds", "Agent run duration")
AGENT_ERRORS = Counter("agent_errors_total", "Agent errors", ["error_type"])
AGENT_TOOL_CALLS = Counter("agent_tool_calls_total", "Tool calls", ["tool"])
# Active agents
ACTIVE_AGENTS = Gauge("agent_active", "Currently active agents")
Distributed Tracing
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
async def run_with_tracing(agent, query: str):
"""Run agent with distributed tracing."""
with tracer.start_as_current_span("agent.run") as span:
span.set_attribute("agent.query", query)
try:
result = await agent.run(query)
span.set_attribute("agent.success", True)
return result
except Exception as e:
span.set_status(Status(StatusCode.ERROR))
span.record_exception(e)
raise
7. Production Checklist
- Agent pattern selected (ReAct/Plan-Execute/Multi-Agent)
- Tools defined with schemas and validation
- Safety guardrails implemented
- State persistence configured
- Concurrency controls in place
- Monitoring and tracing enabled
- Rate limiting configured
- Human approval workflow for critical actions
- Load testing completed
- Rollback plan documented
FAQ
Q: When should I use ReAct vs Plan-and-Execute?
A: Use ReAct for interactive, uncertain environments. Use Plan-and-Execute for well-defined tasks with clear sub-goals.
Q: How to prevent agent loops and infinite execution?
A: Implement step limits, cycle detection, and timeout mechanisms. Validate each action before execution.
Q: What's the safest way to give agents tool access?
A: Use least-privilege permissions, sandbox execution, input validation, and audit logging for all tool calls.
Q: How to scale agents for high concurrency?
A: Queue-based architecture, stateless agents where possible, connection pooling, and horizontal scaling with load balancing.
Q: How to handle agent failures gracefully?
A: Implement circuit breakers, fallback strategies, state recovery, and comprehensive error logging.