Skip to main content

Best Practices for Building AI Agents

A comprehensive guide to patterns, practices, and principles for building robust, efficient, and maintainable AI agents.

Design Principles

1. Start Simple, Iterate

# Phase 1: Simple single-step agent
class SimpleAgent:
def run(self, task: str) -> str:
return self.llm.generate(f"Task: {task}")

# Phase 2: Add tool use
class ToolAgent(SimpleAgent):
def run(self, task: str) -> str:
# Decide if tool needed
# Use tool
# Generate response
pass

# Phase 3: Add multi-step reasoning
class ReActAgent(ToolAgent):
# Full ReAct loop
pass

# Phase 4: Add memory, planning, etc.

Don't build everything at once. Validate each capability before adding complexity.

2. Single Responsibility Principle

Each agent should have a clear, focused purpose.

# Bad: Agent that does everything
class SuperAgent:
def run(self, task):
if "search" in task:
return self.search()
elif "analyze" in task:
return self.analyze()
elif "write" in task:
return self.write()
# ... many more

# Good: Specialized agents
class SearchAgent:
"""Specialized in information retrieval"""
def run(self, query: str):
return self.search(query)

class AnalysisAgent:
"""Specialized in data analysis"""
def run(self, data: str):
return self.analyze(data)

class WritingAgent:
"""Specialized in content creation"""
def run(self, outline: str):
return self.write(outline)

3. Clear Interfaces

Define clear contracts between components.

from abc import ABC, abstractmethod
from typing import Protocol

class Tool(Protocol):
"""Standard tool interface"""

name: str
description: str

def execute(self, **kwargs) -> dict:
"""Execute the tool with given parameters"""
...

class Memory(Protocol):
"""Standard memory interface"""

def store(self, content: str, metadata: dict) -> None:
"""Store information"""
...

def retrieve(self, query: str, top_k: int) -> list:
"""Retrieve relevant information"""
...

class Agent(ABC):
"""Standard agent interface"""

@abstractmethod
def run(self, task: str) -> dict:
"""Execute agent on task"""
pass

Prompt Engineering Best Practices

1. Structured Prompts

STRUCTURED_PROMPT = """
[SYSTEM INSTRUCTIONS]
{system_instructions}

[AVAILABLE TOOLS]
{tool_descriptions}

[CONVERSATION HISTORY]
{conversation_history}

[CURRENT TASK]
{current_task}

[OUTPUT FORMAT]
{expected_format}

[BEGIN]
"""

2. Few-Shot Examples

def build_few_shot_prompt(task: str, examples: List[dict]) -> str:
"""Include relevant examples"""

prompt = "Here are examples of similar tasks:\n\n"

for i, example in enumerate(examples, 1):
prompt += f"Example {i}:\n"
prompt += f"Task: {example['task']}\n"
prompt += f"Approach: {example['approach']}\n"
prompt += f"Result: {example['result']}\n\n"

prompt += f"Now solve this task:\n{task}"

return prompt

3. Clear Constraints

CONSTRAINED_PROMPT = """
Task: {task}

Constraints:
- You MUST use at least one of the available tools
- You MUST NOT make assumptions about data you don't have
- You MUST stop after 5 steps if task is not complete
- You MUST cite sources for factual claims

Available tools: {tools}

Begin:
"""

Error Handling Patterns

1. Graceful Degradation

class RobustAgent:
def run(self, task: str):
"""Agent with fallback strategies"""

try:
# Try primary approach
return self._primary_approach(task)

except ToolExecutionError as e:
# Fallback: Try alternative tool
logging.warning(f"Primary tool failed: {e}, trying alternative")
return self._alternative_approach(task)

except TimeoutError:
# Fallback: Return partial result
logging.warning("Task timeout, returning partial result")
return self._get_partial_result()

except Exception as e:
# Last resort: Graceful error message
logging.error(f"Agent failed: {e}")
return {
'success': False,
'error': str(e),
'partial_work': self._get_partial_work()
}

2. Retry with Backoff

def retry_with_backoff(
func: Callable,
max_retries: int = 3,
base_delay: float = 1.0
):
"""Retry with exponential backoff"""

for attempt in range(max_retries):
try:
return func()

except RetryableError as e:
if attempt == max_retries - 1:
raise

delay = base_delay * (2 ** attempt)
logging.warning(f"Attempt {attempt+1} failed, retrying in {delay}s")
time.sleep(delay)

except NonRetryableError:
# Don't retry on certain errors
raise

3. Validation at Boundaries

class ValidatingAgent:
"""Validate inputs and outputs"""

def run(self, task: str) -> dict:
# Validate input
if not self._validate_input(task):
raise ValueError("Invalid task input")

# Execute
result = self._execute(task)

# Validate output
if not self._validate_output(result):
# Try to fix or re-execute
result = self._fix_output(result)

return result

def _validate_input(self, task: str) -> bool:
"""Validate task input"""
return (
task and
len(task) > 0 and
len(task) < MAX_TASK_LENGTH and
not self._contains_malicious_content(task)
)

def _validate_output(self, result: dict) -> bool:
"""Validate result structure and content"""
required_keys = ['success', 'result']
return (
all(key in result for key in required_keys) and
self._is_reasonable_result(result)
)

Performance Optimization

1. Caching

from functools import lru_cache
import hashlib

class CachingAgent:
"""Agent with result caching"""

def __init__(self, llm, tools):
self.llm = llm
self.tools = tools
self.cache = {}

def run(self, task: str) -> dict:
"""Execute with caching"""

# Check cache
cache_key = self._get_cache_key(task)
if cache_key in self.cache:
logging.info("Cache hit")
return self.cache[cache_key]

# Execute
result = self._execute(task)

# Store in cache
self.cache[cache_key] = result

return result

def _get_cache_key(self, task: str) -> str:
"""Generate cache key"""
return hashlib.md5(task.encode()).hexdigest()

@lru_cache(maxsize=100)
def _call_llm(self, prompt: str) -> str:
"""Cached LLM calls for identical prompts"""
return self.llm.generate(prompt)

2. Parallel Execution

import asyncio
from concurrent.futures import ThreadPoolExecutor

class ParallelAgent:
"""Execute independent tasks in parallel"""

def __init__(self, max_workers: int = 5):
self.executor = ThreadPoolExecutor(max_workers=max_workers)

async def run_parallel(self, tasks: List[str]) -> List[dict]:
"""Execute multiple tasks in parallel"""

# Create tasks
coroutines = [self._run_async(task) for task in tasks]

# Execute in parallel
results = await asyncio.gather(*coroutines)

return results

async def _run_async(self, task: str) -> dict:
"""Async task execution"""
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
self.executor,
self._execute,
task
)
return result

3. Token Optimization

class TokenOptimizedAgent:
"""Minimize token usage"""

def optimize_prompt(self, prompt: str) -> str:
"""Reduce prompt tokens while preserving meaning"""

# Remove redundant whitespace
optimized = re.sub(r'\s+', ' ', prompt)

# Use concise formatting
optimized = self._use_compact_format(optimized)

# Remove unnecessary examples if context is too long
if self._count_tokens(optimized) > self.max_tokens:
optimized = self._remove_examples(optimized)

return optimized

def summarize_long_context(self, context: str) -> str:
"""Summarize to reduce tokens"""

if self._count_tokens(context) < self.max_context_tokens:
return context

# Use LLM to summarize
summary = self.llm.generate(
f"Summarize the key points in under 200 words:\n\n{context}"
)

return summary

Testing Best Practices

1. Unit Tests for Components

import pytest

class TestToolExecution:
def test_calculator_basic(self):
"""Test calculator with simple expression"""
tool = CalculatorTool()
result = tool.execute(expression="2 + 2")
assert result == 4

def test_calculator_error_handling(self):
"""Test calculator handles invalid input"""
tool = CalculatorTool()
with pytest.raises(ValueError):
tool.execute(expression="invalid")

@pytest.mark.parametrize("expression,expected", [
("2 + 2", 4),
("10 / 2", 5),
("3 * 4", 12),
])
def test_calculator_expressions(self, expression, expected):
"""Test various expressions"""
tool = CalculatorTool()
result = tool.execute(expression=expression)
assert result == expected

2. Integration Tests

class TestAgentIntegration:
@pytest.fixture
def agent(self):
"""Setup agent for testing"""
llm = MockLLM() # Use mock for deterministic tests
tools = [CalculatorTool(), SearchTool()]
return Agent(llm, tools)

def test_simple_calculation_task(self, agent):
"""Test agent solves calculation task"""
result = agent.run("What is 15 * 7?")

assert result['success'] is True
assert "105" in result['output']

def test_multi_step_task(self, agent):
"""Test agent completes multi-step task"""
task = "Search for population of Tokyo, then calculate 10% of it"
result = agent.run(task)

assert result['success'] is True
assert len(result['steps']) >= 3 # Search + Calculate + Respond

3. Property-Based Testing

from hypothesis import given, strategies as st

class TestAgentProperties:
@given(st.text(min_size=1, max_size=100))
def test_agent_handles_any_text_input(self, text):
"""Agent should handle any text input without crashing"""
agent = Agent(llm, tools)

try:
result = agent.run(text)
assert isinstance(result, dict)
assert 'success' in result
except Exception as e:
# Should only raise known exception types
assert isinstance(e, (ValueError, AgentError))

Monitoring and Observability

1. Structured Logging

import structlog

logger = structlog.get_logger()

class ObservableAgent:
def run(self, task: str) -> dict:
"""Agent with comprehensive logging"""

logger.info(
"agent_task_started",
task=task,
agent_id=self.agent_id
)

try:
result = self._execute(task)

logger.info(
"agent_task_completed",
task=task,
agent_id=self.agent_id,
success=result['success'],
steps=len(result['steps']),
execution_time=result['execution_time']
)

return result

except Exception as e:
logger.error(
"agent_task_failed",
task=task,
agent_id=self.agent_id,
error=str(e),
traceback=traceback.format_exc()
)
raise

2. Metrics Collection

from prometheus_client import Counter, Histogram, Gauge

# Define metrics
task_counter = Counter(
'agent_tasks_total',
'Total number of tasks executed',
['agent_type', 'status']
)

task_duration = Histogram(
'agent_task_duration_seconds',
'Task execution duration',
['agent_type']
)

active_agents = Gauge(
'agent_active_count',
'Number of currently active agents'
)

class MetricsAgent:
def run(self, task: str) -> dict:
"""Agent with metrics"""

active_agents.inc()
start_time = time.time()

try:
result = self._execute(task)

# Record success
task_counter.labels(
agent_type=self.type,
status='success'
).inc()

return result

except Exception as e:
# Record failure
task_counter.labels(
agent_type=self.type,
status='failure'
).inc()
raise

finally:
# Record duration
duration = time.time() - start_time
task_duration.labels(agent_type=self.type).observe(duration)
active_agents.dec()

3. Distributed Tracing

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

class TracedAgent:
def run(self, task: str) -> dict:
"""Agent with distributed tracing"""

with tracer.start_as_current_span("agent.run") as span:
span.set_attribute("task", task)
span.set_attribute("agent.id", self.agent_id)

# Planning phase
with tracer.start_as_current_span("agent.plan"):
plan = self._create_plan(task)
span.set_attribute("plan.steps", len(plan))

# Execution phase
with tracer.start_as_current_span("agent.execute"):
result = self._execute_plan(plan)

span.set_attribute("success", result['success'])
return result

Cost Optimization

1. Model Selection

class CostOptimizedAgent:
"""Use appropriate model for task complexity"""

def run(self, task: str) -> dict:
# Classify task complexity
complexity = self._assess_complexity(task)

# Select model based on complexity
if complexity == "simple":
model = "gpt-3.5-turbo" # Cheaper
elif complexity == "medium":
model = "gpt-4"
else:
model = "gpt-4" # or "claude-opus" for hardest tasks

result = self.llm.generate(
prompt=self._build_prompt(task),
model=model
)

return result

def _assess_complexity(self, task: str) -> str:
"""Assess task complexity"""
# Simple heuristics or classifier
if len(task.split()) < 20 and "?" in task:
return "simple"
elif any(word in task.lower() for word in ["analyze", "compare", "evaluate"]):
return "complex"
else:
return "medium"

2. Prompt Caching

# Use cached prompts when possible
class CachedPromptAgent:
def __init__(self):
self.system_prompt = """
[Long system instructions that don't change]
"""

def run(self, task: str):
# Only task changes, system prompt is cached by provider
response = self.llm.generate(
system=self.system_prompt, # Cached
user=task # New each time
)
return response

Documentation Best Practices

1. Clear Component Documentation

class SearchAgent:
"""
Agent specialized in information retrieval.

This agent uses multiple search strategies to find relevant
information and synthesizes results into coherent answers.

Capabilities:
- Web search using multiple search engines
- Document search in internal knowledge base
- Semantic search using vector embeddings

Limitations:
- Cannot access content behind paywalls
- Limited to English language queries
- May struggle with very recent information (< 24 hours)

Example:
>>> agent = SearchAgent(llm, search_tools)
>>> result = agent.run("Latest developments in quantum computing")
>>> print(result['answer'])

Args:
llm: Language model instance
search_tools: List of search tool instances
max_results: Maximum number of search results to process (default: 10)

Returns:
Dictionary with keys:
- answer: Synthesized answer to query
- sources: List of source URLs
- confidence: Confidence score (0-1)
"""

2. Decision Documentation

# Document why decisions were made
class MyAgent:
"""
Architecture Decisions:

1. Why ReAct pattern?
- Provides interpretability through reasoning traces
- Allows easy debugging of tool selection
- More reliable than Plan-and-Execute for our use case

2. Why vector memory instead of knowledge graph?
- Simpler to implement and maintain
- Sufficient for our current needs
- Can upgrade later if needed

3. Why this tool set?
- Covers 90% of use cases based on user research
- Each tool has been tested for safety
- Limited set reduces confusion in tool selection
"""

Configuration Management

# Use configuration files
from dataclasses import dataclass
import yaml

@dataclass
class AgentConfig:
"""Agent configuration"""
model: str = "gpt-4"
temperature: float = 0.7
max_steps: int = 10
timeout: int = 300
allowed_tools: List[str] = None
memory_type: str = "vector"
log_level: str = "INFO"

@classmethod
def from_file(cls, config_file: str):
"""Load from YAML file"""
with open(config_file) as f:
config_dict = yaml.safe_load(f)
return cls(**config_dict)

# Usage
config = AgentConfig.from_file("agent_config.yaml")
agent = Agent(config=config)

Version Control Best Practices

# Track agent versions
class VersionedAgent:
VERSION = "2.1.0"

def __init__(self):
self.version = self.VERSION

def run(self, task: str) -> dict:
result = self._execute(task)

# Include version in result for tracking
result['agent_version'] = self.version

return result

Summary Checklist

  • Start with simple implementation, add complexity iteratively
  • Use clear interfaces and protocols
  • Implement comprehensive error handling
  • Add logging and monitoring from day one
  • Write tests at multiple levels (unit, integration, e2e)
  • Optimize for cost and performance
  • Document decisions and trade-offs
  • Use configuration management
  • Version your agents
  • Monitor in production
  • Regular security audits
  • Gather user feedback
  • Iterate based on real usage

Next Steps

  • Study production deployment strategies
  • Learn about continuous improvement based on monitoring
  • Explore advanced optimization techniques
  • Understand team collaboration on agent projects