Building AI Applications

Architecture patterns, APIs, and integrating AI into products

From Experiments to Production

Building AI-powered applications is different from traditional software development. You're integrating systems that are probabilistic (not deterministic), working with API rate limits and costs, handling longer latencies, and managing models that can fail in unexpected ways. This lesson shows you how to architect, build, and deploy AI applications that are reliable, scalable, and maintainable in production.

AI Application Architecture Patterns

AI applications typically follow one of several architectural patterns. Choose based on your requirements for latency, cost, control, and complexity:

1. Direct API Integration (Simplest)

Your application calls AI APIs (OpenAI, Anthropic, etc.) directly for each request.

┌──────────┐      ┌──────────────┐      ┌─────────────┐
│  Client  │─────>│ Your Backend │─────>│  AI API     │
│  (User)  │<─────│  (Flask/     │<─────│ (OpenAI/    │
└──────────┘      │   FastAPI)   │      │  Anthropic) │
                  └──────────────┘      └─────────────┘

Flow:
1. User sends request to your API
2. Your backend calls AI API
3. AI API returns response
4. Your backend processes and returns to user

Pros:
✓ Simple to implement
✓ No infrastructure to manage
✓ Always latest model versions
✓ Minimal code

Cons:
✗ Dependent on external API availability
✗ Latency (network + model inference)
✗ Cost per request
✗ Limited customization

Best for:

  • Prototypes and MVPs
  • Low to medium volume applications
  • When you need the most capable models
  • When development speed matters more than cost

2. Cached API Integration (Optimized)

Add caching layer to reduce API calls for similar requests.

┌──────────┐      ┌──────────────┐      ┌─────────┐      ┌─────────┐
│  Client  │─────>│ Your Backend │─────>│  Cache  │─────>│ AI API  │
└──────────┘      └──────────────┘      │ (Redis) │      └─────────┘
                                        └─────────┘

Flow:
1. Check cache for similar prompt (semantic similarity)
2. If cache hit: return cached response (fast, free)
3. If cache miss: call AI API, cache result
4. Return response to user

Example:
- "What is Python?" and "What's Python?" → same cache entry
- Saves ~50-80% of API costs for repeated queries

Best for:

  • Applications with repeated similar queries
  • Cost optimization (reduce API calls by 50-80%)
  • Improving response times
  • Customer support chatbots, FAQ systems

3. Self-Hosted Models (Full Control)

Run open-source models (Llama, Mistral, etc.) on your own infrastructure.

┌──────────┐      ┌──────────────┐      ┌──────────────────┐
│  Client  │─────>│ Your Backend │─────>│  Your GPU Server │
└──────────┘      └──────────────┘      │  (Llama 3, etc.) │
                                        └──────────────────┘

Pros:
✓ No per-request costs (only infrastructure)
✓ Full data privacy
✓ Low latency (no network to external API)
✓ Can fine-tune models for your domain
✓ No rate limits

Cons:
✗ Complex infrastructure (GPUs, orchestration)
✗ Model quality usually lower than commercial APIs
✗ You manage updates, scaling, monitoring
✗ High upfront and maintenance costs

Best for:

  • High-volume applications (where API costs exceed infrastructure costs)
  • Sensitive data that can't leave your infrastructure
  • When you need custom fine-tuned models
  • When you have GPU infrastructure expertise

4. Hybrid Architecture (Best of Both)

Use different models for different tasks based on requirements.

Strategy:
- Simple tasks → Fast, cheap local model (classification, extraction)
- Complex tasks → Powerful API model (reasoning, generation)

Example routing:
┌────────────────────────┐
│ Is this spam?          │ → Local model (fast, cheap)
├────────────────────────┤
│ Classify sentiment     │ → Local model (good enough)
├────────────────────────┤
│ Write marketing copy   │ → API model (quality matters)
├────────────────────────┤
│ Complex code review    │ → API model (needs reasoning)
└────────────────────────┘

Result: Optimize cost, speed, and quality per use case

Best for:

  • Large-scale applications with varied AI needs
  • When you need to optimize cost/quality trade-offs
  • Applications with both simple and complex AI tasks
Recommendation: Start with Pattern 1 (Direct API) to validate your idea quickly. Add Pattern 2 (Caching) when you have users and want to reduce costs. Consider Pattern 3 (Self-hosted) or 4 (Hybrid) only when you have significant scale and resources.

Working with AI APIs

Let's build a production-ready AI integration with proper error handling, retries, and cost management.

# Complete AI API Integration with Best Practices

import anthropic
from typing import Optional, Dict, Any
import time
import logging
from functools import wraps
from datetime import datetime

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class AIClient:
    """Production-ready AI API client with error handling and monitoring."""

    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model
        self.request_count = 0
        self.total_tokens = 0
        self.total_cost = 0.0

    def _calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
        """Calculate API cost based on token usage."""
        # Claude Sonnet pricing (example, check current pricing)
        INPUT_COST_PER_1M = 3.00   # $3 per 1M input tokens
        OUTPUT_COST_PER_1M = 15.00  # $15 per 1M output tokens

        input_cost = (input_tokens / 1_000_000) * INPUT_COST_PER_1M
        output_cost = (output_tokens / 1_000_000) * OUTPUT_COST_PER_1M

        return input_cost + output_cost

    @staticmethod
    def _retry_with_backoff(max_retries: int = 3, base_delay: float = 1.0):
        """Decorator for exponential backoff retry logic."""
        def decorator(func):
            @wraps(func)
            def wrapper(*args, **kwargs):
                for attempt in range(max_retries):
                    try:
                        return func(*args, **kwargs)
                    except anthropic.RateLimitError as e:
                        if attempt == max_retries - 1:
                            raise
                        delay = base_delay * (2 ** attempt)
                        logger.warning(f"Rate limited. Retry {attempt + 1}/{max_retries} after {delay}s")
                        time.sleep(delay)
                    except anthropic.APIError as e:
                        logger.error(f"API error: {e}")
                        if attempt == max_retries - 1:
                            raise
                        time.sleep(base_delay)
            return wrapper
        return decorator

    @_retry_with_backoff(max_retries=3)
    def generate(
        self,
        prompt: str,
        system_prompt: Optional[str] = None,
        max_tokens: int = 1000,
        temperature: float = 0.7,
        timeout: int = 30
    ) -> Dict[str, Any]:
        """
        Generate response from AI with full error handling and monitoring.

        Args:
            prompt: User prompt
            system_prompt: System instructions
            max_tokens: Max response length
            temperature: Creativity (0-1)
            timeout: Request timeout in seconds

        Returns:
            Dict with response, tokens, cost, and metadata
        """
        start_time = time.time()

        try:
            # Build messages
            messages = [{"role": "user", "content": prompt}]

            # Make API call
            response = self.client.messages.create(
                model=self.model,
                max_tokens=max_tokens,
                temperature=temperature,
                system=system_prompt if system_prompt else anthropic.NOT_GIVEN,
                messages=messages,
                timeout=timeout
            )

            # Extract metrics
            input_tokens = response.usage.input_tokens
            output_tokens = response.usage.output_tokens
            cost = self._calculate_cost(input_tokens, output_tokens)

            # Update counters
            self.request_count += 1
            self.total_tokens += input_tokens + output_tokens
            self.total_cost += cost

            # Calculate latency
            latency = time.time() - start_time

            # Log metrics
            logger.info(
                f"AI Request #{self.request_count}: "
                f"tokens={input_tokens + output_tokens}, "
                f"cost={cost:.4f}, "
                f"latency={latency:.2f}s"
            )

            return {
                "response": response.content[0].text,
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "total_tokens": input_tokens + output_tokens,
                "cost": cost,
                "latency": latency,
                "model": self.model,
                "timestamp": datetime.now().isoformat()
            }

        except anthropic.APITimeoutError:
            logger.error(f"Request timeout after {timeout}s")
            raise

        except anthropic.AuthenticationError:
            logger.error("Invalid API key")
            raise

        except anthropic.BadRequestError as e:
            logger.error(f"Bad request: {e}")
            raise

        except Exception as e:
            logger.error(f"Unexpected error: {e}")
            raise

    def get_stats(self) -> Dict[str, Any]:
        """Get usage statistics."""
        return {
            "total_requests": self.request_count,
            "total_tokens": self.total_tokens,
            "total_cost": round(self.total_cost, 4),
            "avg_cost_per_request": round(self.total_cost / max(self.request_count, 1), 4)
        }


# Example usage
if __name__ == "__main__":
    import os

    # Initialize client
    client = AIClient(api_key=os.getenv("ANTHROPIC_API_KEY"))

    # Example 1: Simple generation
    result = client.generate(
        prompt="Explain machine learning in one sentence.",
        temperature=0.3
    )
    print(f"Response: {result['response']}")
    print(f"Cost: {result['cost']:.4f}")
    print(f"Tokens: {result['total_tokens']}")

    # Example 2: With system prompt
    result = client.generate(
        prompt="Review this code for security issues: [code here]",
        system_prompt="You are a security expert. Be thorough and specific.",
        max_tokens=1500
    )

    # Check statistics
    stats = client.get_stats()
    print(f"\nTotal API usage:")
    print(f"  Requests: {stats['total_requests']}")
    print(f"  Cost: {stats['total_cost']}")
    print(f"  Avg cost/request: {stats['avg_cost_per_request']}")
Key features of this implementation:
  • Automatic retry with exponential backoff for rate limits
  • Comprehensive error handling for all API error types
  • Cost tracking per request and total
  • Latency monitoring
  • Configurable timeouts
  • Detailed logging for debugging and monitoring

Adding a Caching Layer

Caching can reduce your AI API costs by 50-80% for applications with repeated or similar queries.

import redis
import hashlib
import json
from typing import Optional, Dict, Any


class CachedAIClient(AIClient):
    """AI client with Redis caching to reduce API calls and costs."""

    def __init__(
        self,
        api_key: str,
        redis_url: str = "redis://localhost:6379",
        cache_ttl: int = 3600,  # 1 hour
        model: str = "claude-sonnet-4-20250514"
    ):
        super().__init__(api_key, model)
        self.redis_client = redis.from_url(redis_url)
        self.cache_ttl = cache_ttl
        self.cache_hits = 0
        self.cache_misses = 0

    def _generate_cache_key(
        self,
        prompt: str,
        system_prompt: Optional[str],
        temperature: float,
        max_tokens: int
    ) -> str:
        """Generate cache key from request parameters."""
        # Combine all parameters that affect the response
        cache_input = f"{prompt}|{system_prompt}|{temperature}|{max_tokens}|{self.model}"
        return f"ai_cache:{hashlib.sha256(cache_input.encode()).hexdigest()}"

    def generate(
        self,
        prompt: str,
        system_prompt: Optional[str] = None,
        max_tokens: int = 1000,
        temperature: float = 0.7,
        use_cache: bool = True,
        timeout: int = 30
    ) -> Dict[str, Any]:
        """
        Generate with caching support.

        Args:
            use_cache: If True, check cache before calling API
            (other args same as parent class)
        """
        # Generate cache key
        cache_key = self._generate_cache_key(prompt, system_prompt, temperature, max_tokens)

        # Check cache if enabled
        if use_cache:
            try:
                cached_result = self.redis_client.get(cache_key)
                if cached_result:
                    self.cache_hits += 1
                    result = json.loads(cached_result)
                    result['from_cache'] = True
                    logger.info(f"Cache HIT. Saved {result['cost']:.4f}")
                    return result
            except redis.RedisError as e:
                logger.warning(f"Cache check failed: {e}. Falling back to API.")

        # Cache miss - call API
        self.cache_misses += 1
        result = super().generate(
            prompt=prompt,
            system_prompt=system_prompt,
            max_tokens=max_tokens,
            temperature=temperature,
            timeout=timeout
        )

        # Store in cache
        if use_cache:
            try:
                result_copy = result.copy()
                result_copy['from_cache'] = False
                self.redis_client.setex(
                    cache_key,
                    self.cache_ttl,
                    json.dumps(result_copy)
                )
            except redis.RedisError as e:
                logger.warning(f"Failed to cache result: {e}")

        result['from_cache'] = False
        return result

    def get_stats(self) -> Dict[str, Any]:
        """Get statistics including cache performance."""
        stats = super().get_stats()

        total_requests = self.cache_hits + self.cache_misses
        cache_hit_rate = (self.cache_hits / max(total_requests, 1)) * 100

        # Estimate savings
        avg_cost = stats['avg_cost_per_request']
        estimated_savings = self.cache_hits * avg_cost

        stats.update({
            'cache_hits': self.cache_hits,
            'cache_misses': self.cache_misses,
            'cache_hit_rate': f"{cache_hit_rate:.1f}%",
            'estimated_savings': f"{estimated_savings:.4f}"
        })

        return stats

    def clear_cache(self, pattern: str = "ai_cache:*"):
        """Clear cached responses matching pattern."""
        keys = self.redis_client.keys(pattern)
        if keys:
            self.redis_client.delete(*keys)
            logger.info(f"Cleared {len(keys)} cache entries")


# Example usage
if __name__ == "__main__":
    import os

    # Initialize cached client
    client = CachedAIClient(
        api_key=os.getenv("ANTHROPIC_API_KEY"),
        cache_ttl=3600  # Cache for 1 hour
    )

    # First request - cache miss, calls API
    result1 = client.generate("What is Python?")
    print(f"First request - Cost: {result1['cost']:.4f}, Cached: {result1['from_cache']}")

    # Second identical request - cache hit, no API call
    result2 = client.generate("What is Python?")
    print(f"Second request - Cost: {result2['cost']:.4f}, Cached: {result2['from_cache']}")

    # Check savings
    stats = client.get_stats()
    print(f"\nCache Statistics:")
    print(f"  Hit rate: {stats['cache_hit_rate']}")
    print(f"  Savings: {stats['estimated_savings']}")
    print(f"  Total cost: {stats['total_cost']}")
Cache invalidation considerations:
  • Set appropriate TTL based on how often data changes
  • For FAQ/support: long TTL (hours to days)
  • For dynamic content: short TTL (minutes)
  • Implement cache versioning for model updates
  • Monitor cache hit rates - low rates suggest cache isn't helping

Complete Example: AI-Powered Code Review Service

Let's build a production-ready REST API that reviews code for security issues, bugs, and best practices.

Architecture Overview
Components:
1. FastAPI REST API (Python web framework)
2. CachedAIClient (our AI client with Redis caching)
3. Background task queue (for async processing)
4. Rate limiting (protect from abuse)
5. Authentication (API keys)
6. Monitoring and logging
# app.py - Complete AI-powered code review service

from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Header
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
import os
from datetime import datetime
import uuid

# Import our cached AI client
from ai_client import CachedAIClient

# Initialize FastAPI app
app = FastAPI(
    title="AI Code Review Service",
    description="AI-powered code review for security and best practices",
    version="1.0.0"
)

# Initialize AI client (singleton)
ai_client = CachedAIClient(
    api_key=os.getenv("ANTHROPIC_API_KEY"),
    redis_url=os.getenv("REDIS_URL", "redis://localhost:6379"),
    cache_ttl=3600
)


# Request/Response models
class CodeReviewRequest(BaseModel):
    """Request body for code review."""
    code: str = Field(..., description="Code to review", min_length=1)
    language: str = Field("python", description="Programming language")
    focus: str = Field(
        "all",
        description="Review focus: security, performance, quality, or all"
    )

    model_config = ConfigDict(json_schema_extra={
        "examples": [{
            "code": "def login(username, password):\n    query = f\"SELECT * FROM users WHERE name='{username}'\"",
            "language": "python",
            "focus": "security"
        }]
    })


class Finding(BaseModel):
    """Individual review finding."""
    severity: str  # critical, high, medium, low
    category: str  # security, performance, quality
    line: Optional[int]
    issue: str
    recommendation: str


class CodeReviewResponse(BaseModel):
    """Response from code review."""
    review_id: str
    summary: str
    score: int  # 1-10
    findings: List[Finding]
    language: str
    from_cache: bool
    processing_time: float
    cost: float


# Authentication
async def verify_api_key(x_api_key: str = Header(...)):
    """Verify API key from header."""
    valid_keys = os.getenv("API_KEYS", "").split(",")
    if x_api_key not in valid_keys:
        raise HTTPException(status_code=401, detail="Invalid API key")
    return x_api_key


# Build review prompt
def build_review_prompt(code: str, language: str, focus: str) -> tuple[str, str]:
    """Build system and user prompts for code review."""

    system_prompt = f"""You are an expert {language} code security and quality reviewer.
Provide thorough, actionable feedback. Always cite specific line numbers.
Be direct about issues but constructive in recommendations."""

    focus_instructions = {
        "security": """Focus on security vulnerabilities:
- Injection attacks (SQL, command, XSS)
- Authentication/authorization issues
- Data exposure risks
- Input validation problems
- Cryptographic weaknesses""",

        "performance": """Focus on performance issues:
- Time complexity problems
- Memory usage issues
- Inefficient algorithms
- Unnecessary operations
- Scalability concerns""",

        "quality": """Focus on code quality:
- Readability and maintainability
- Error handling
- Code organization
- Best practices
- Documentation""",

        "all": """Perform comprehensive review covering:
- Security vulnerabilities
- Performance issues
- Code quality and readability
- Best practices
- Error handling"""
    }

    user_prompt = f"""{focus_instructions.get(focus, focus_instructions['all'])}

Return a JSON object with this structure:
{{
    "summary": "Brief overall assessment",
    "score": <1-10 score>,
    "findings": [
        {{
            "severity": "critical|high|medium|low",
            "category": "security|performance|quality",
            "line": <line number or null>,
            "issue": "Clear description of the issue",
            "recommendation": "Specific fix or improvement"
        }}
    ]
}}

Code to review:
```{language}
{code}
```

Return only valid JSON, no additional text."""

    return system_prompt, user_prompt


# Main endpoint
@app.post("/review", response_model=CodeReviewResponse, dependencies=[Depends(verify_api_key)])
async def review_code(request: CodeReviewRequest):
    """
    Review code for security issues, bugs, and best practices.

    Returns detailed findings with severity ratings and recommendations.
    """
    import time
    import json

    start_time = time.time()
    review_id = str(uuid.uuid4())

    try:
        # Build prompts
        system_prompt, user_prompt = build_review_prompt(
            request.code,
            request.language,
            request.focus
        )

        # Call AI (with caching)
        result = ai_client.generate(
            prompt=user_prompt,
            system_prompt=system_prompt,
            temperature=0.3,  # Low temp for consistent analysis
            max_tokens=2000
        )

        # Parse JSON response
        try:
            # Extract JSON from response
            response_text = result['response']
            start_idx = response_text.find('{')
            end_idx = response_text.rfind('}') + 1
            json_text = response_text[start_idx:end_idx]
            review_data = json.loads(json_text)
        except (json.JSONDecodeError, ValueError) as e:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to parse AI response: {str(e)}"
            )

        # Build response
        processing_time = time.time() - start_time

        return CodeReviewResponse(
            review_id=review_id,
            summary=review_data.get('summary', 'No summary provided'),
            score=review_data.get('score', 5),
            findings=[Finding(**f) for f in review_data.get('findings', [])],
            language=request.language,
            from_cache=result['from_cache'],
            processing_time=round(processing_time, 2),
            cost=result['cost']
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Review failed: {str(e)}")


# Health check endpoint
@app.get("/health")
async def health_check():
    """Check service health and AI API connectivity."""
    try:
        # Test AI API with minimal request
        result = ai_client.generate(
            prompt="Say 'OK'",
            max_tokens=10,
            temperature=0
        )
        return {
            "status": "healthy",
            "ai_api": "connected",
            "timestamp": datetime.now().isoformat()
        }
    except Exception as e:
        return {
            "status": "unhealthy",
            "ai_api": "disconnected",
            "error": str(e),
            "timestamp": datetime.now().isoformat()
        }


# Statistics endpoint
@app.get("/stats", dependencies=[Depends(verify_api_key)])
async def get_stats():
    """Get API usage statistics."""
    return ai_client.get_stats()


# Clear cache endpoint (admin only)
@app.delete("/cache", dependencies=[Depends(verify_api_key)])
async def clear_cache():
    """Clear the response cache."""
    ai_client.clear_cache()
    return {"message": "Cache cleared successfully"}


# Run with: uvicorn app:app --reload
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)
Production-ready features:
  • ✓ Authentication with API keys
  • ✓ Comprehensive error handling
  • ✓ Request/response validation with Pydantic
  • ✓ Redis caching to reduce costs
  • ✓ Health check endpoint for monitoring
  • ✓ Usage statistics endpoint
  • ✓ Structured JSON responses
  • ✓ Proper HTTP status codes
  • ✓ API documentation (FastAPI auto-generates)

Testing the API:

# Install dependencies
pip install fastapi uvicorn redis anthropic

# Set environment variables
export ANTHROPIC_API_KEY="your-key-here"
export REDIS_URL="redis://localhost:6379"
export API_KEYS="test-key-123,prod-key-456"

# Run the server
uvicorn app:app --reload

# Test with curl
curl -X POST "http://localhost:8000/review" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: test-key-123" \
  -d '{
    "code": "def get_user(id): return db.execute(f\"SELECT * FROM users WHERE id={id}\").fetchone()",
    "language": "python",
    "focus": "security"
  }'

# Response:
{
  "review_id": "a1b2c3d4-...",
  "summary": "Critical SQL injection vulnerability detected",
  "score": 2,
  "findings": [
    {
      "severity": "critical",
      "category": "security",
      "line": 1,
      "issue": "SQL injection: user input directly in query",
      "recommendation": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE id=?', (id,))"
    }
  ],
  "language": "python",
  "from_cache": false,
  "processing_time": 1.23,
  "cost": 0.0045
}

# Second identical request uses cache (from_cache: true, cost: 0.0)

Best Practices for Production AI Applications

1. Always Have Fallbacks

AI APIs can fail. Have graceful degradation: fallback to cached responses, simpler models, or default behavior. Never let an AI API failure break your entire application.

2. Implement Rate Limiting

Protect your application from abuse and runaway costs. Limit requests per user, per API key, and globally. Set budget alerts on your AI API provider.

3. Monitor Everything

Track: costs per request, latency, error rates, cache hit rates, token usage, and user satisfaction. Set up alerts for anomalies (sudden cost spikes, high error rates).

4. Validate AI Outputs

Never trust AI output blindly. Validate JSON structure, check for required fields, sanitize text output, and have fallbacks for malformed responses. AI can generate invalid or unexpected outputs.

5. Optimize for Cost

Use caching aggressively. Choose appropriate models (don't use GPT-4 for simple tasks). Limit max_tokens. Use streaming for long responses. Monitor and optimize prompts to reduce token usage.

6. Handle Sensitive Data Carefully

Don't send sensitive data (passwords, credit cards, PII) to AI APIs unless absolutely necessary. If you must, use providers with zero data retention policies and enterprise agreements. Consider anonymizing data first.

7. Test AI Responses Thoroughly

Build test suites with diverse inputs: normal cases, edge cases, adversarial inputs, malformed data. AI models can behave unpredictably. Regression test after model updates.

Handling Non-Deterministic AI Systems

Unlike traditional software, AI systems are non-deterministic, the same input can produce different outputs. This creates unique challenges for reliability, testing, and idempotency. Here are proven techniques to make non-deterministic systems behave predictably.

1. Enforce Output Schemas with Validation

Never trust that AI will return valid JSON or include all required fields. Always validate and retry on schema violations.

from pydantic import BaseModel, Field, ValidationError
from typing import List, Optional
import json


class ArticleOutput(BaseModel):
    """Strict schema for AI-generated article."""
    title: str = Field(..., min_length=10, max_length=200)
    author: str = Field(..., min_length=2)
    summary: str = Field(..., min_length=50)
    content: str = Field(..., min_length=500)
    tags: List[str] = Field(..., min_items=3, max_items=10)
    category: str = Field(..., pattern="^(tech|business|science)$")

    # Template requirements
    has_introduction: bool = Field(default=False)
    has_conclusion: bool = Field(default=False)
    word_count: int = Field(..., ge=500)


def generate_article_with_validation(
    ai_client,
    topic: str,
    max_retries: int = 3
) -> ArticleOutput:
    """
    Generate article with automatic validation and retry.

    Returns validated article or raises after max retries.
    """

    system_prompt = """You are a professional article writer.

CRITICAL: You must return ONLY valid JSON with this exact structure:
{
    "title": "string (10-200 chars)",
    "author": "string (2+ chars)",
    "summary": "string (50+ chars)",
    "content": "string (500+ chars)",
    "tags": ["string", "string", "string"],  // 3-10 tags
    "category": "tech|business|science",
    "has_introduction": true,
    "has_conclusion": true,
    "word_count": 500
}

The article content MUST have:
- An introduction section (start with "Introduction:")
- A conclusion section (end with "Conclusion:")
- At least 500 words"""

    user_prompt = f"""Write a complete article about: {topic}

Return only the JSON object, no additional text."""

    for attempt in range(max_retries):
        try:
            # Call AI
            result = ai_client.generate(
                prompt=user_prompt,
                system_prompt=system_prompt,
                temperature=0.7,
                max_tokens=2000
            )

            # Extract JSON (AI might wrap it in markdown or text)
            response_text = result['response']

            # Try to find JSON in response
            start_idx = response_text.find('{')
            end_idx = response_text.rfind('}') + 1

            if start_idx == -1 or end_idx == 0:
                raise ValueError("No JSON found in response")

            json_text = response_text[start_idx:end_idx]
            data = json.loads(json_text)

            # Validate structure with Pydantic
            article = ArticleOutput(**data)

            # Additional content validation
            if "Introduction:" not in article.content:
                raise ValueError("Missing introduction section in content")

            if "Conclusion:" not in article.content:
                raise ValueError("Missing conclusion section in content")

            # Success!
            logger.info(f"Valid article generated on attempt {attempt + 1}")
            return article

        except (json.JSONDecodeError, ValidationError, ValueError) as e:
            logger.warning(
                f"Attempt {attempt + 1}/{max_retries} failed: {type(e).__name__}: {str(e)}"
            )

            if attempt == max_retries - 1:
                # Final attempt failed
                raise ValueError(
                    f"Failed to generate valid article after {max_retries} attempts. "
                    f"Last error: {str(e)}"
                )

            # Add feedback to next prompt for better results
            user_prompt += f"\n\nPREVIOUS ATTEMPT FAILED: {str(e)}. Please fix and try again."

    # Should never reach here due to raise above
    raise RuntimeError("Unexpected end of retry loop")


# Example usage
if __name__ == "__main__":
    client = AIClient(api_key=os.getenv("ANTHROPIC_API_KEY"))

    try:
        article = generate_article_with_validation(
            ai_client=client,
            topic="The Impact of AI on Software Engineering",
            max_retries=3
        )

        print(f"✓ Valid article generated!")
        print(f"  Title: {article.title}")
        print(f"  Category: {article.category}")
        print(f"  Tags: {', '.join(article.tags)}")
        print(f"  Word count: {article.word_count}")
        print(f"  Has intro: {article.has_introduction}")
        print(f"  Has conclusion: {article.has_conclusion}")

    except ValueError as e:
        print(f"✗ Failed to generate valid article: {e}")
Why this works:
  • Pydantic validates types, ranges, patterns automatically
  • Retry loop gives AI multiple chances to get it right
  • Error feedback helps AI fix mistakes in next attempt
  • Explicit template checks ensure all required sections present

2. Ensure Idempotency with Request IDs & Caching

When your service depends on AI, identical requests should return identical results. This is critical for reliability and avoiding duplicate charges.

import hashlib
from datetime import datetime, timedelta
from typing import Optional


class IdempotentAIService:
    """
    AI service that guarantees idempotency using request IDs and result caching.

    Ensures:
    1. Same request_id always returns same result
    2. Duplicate requests don't call AI API again
    3. Results are stable for configured TTL
    """

    def __init__(self, ai_client, redis_client, result_ttl: int = 86400):
        self.ai_client = ai_client
        self.redis = redis_client
        self.result_ttl = result_ttl  # 24 hours default

    def _generate_request_id(
        self,
        operation: str,
        user_id: str,
        input_data: dict
    ) -> str:
        """
        Generate deterministic request ID from inputs.

        Same inputs → same request_id → same cached result
        """
        # Canonicalize input (sorted JSON for consistency)
        canonical_input = json.dumps(input_data, sort_keys=True)

        # Hash to create stable ID
        hash_input = f"{operation}:{user_id}:{canonical_input}"
        request_id = hashlib.sha256(hash_input.encode()).hexdigest()

        return request_id

    def generate_summary(
        self,
        user_id: str,
        document: str,
        max_length: int = 200,
        request_id: Optional[str] = None
    ) -> dict:
        """
        Generate document summary with idempotency guarantee.

        Args:
            user_id: User making request
            document: Document to summarize
            max_length: Max summary length
            request_id: Optional explicit request ID (for client retry)

        Returns:
            Dict with summary and metadata, guaranteed identical for same request_id
        """

        # Generate or use provided request_id
        if request_id is None:
            request_id = self._generate_request_id(
                operation="summarize",
                user_id=user_id,
                input_data={
                    "document_hash": hashlib.sha256(document.encode()).hexdigest(),
                    "max_length": max_length
                }
            )

        # Check if we already processed this exact request
        cache_key = f"idempotent_result:{request_id}"
        cached_result = self.redis.get(cache_key)

        if cached_result:
            result = json.loads(cached_result)
            result['from_cache'] = True
            result['request_id'] = request_id
            logger.info(f"Idempotent cache hit for request {request_id[:8]}")
            return result

        # Check if request is currently processing (prevent duplicate processing)
        processing_key = f"processing:{request_id}"
        if self.redis.exists(processing_key):
            # Wait for ongoing processing to complete
            for _ in range(30):  # Wait up to 30 seconds
                time.sleep(1)
                cached_result = self.redis.get(cache_key)
                if cached_result:
                    result = json.loads(cached_result)
                    result['from_cache'] = True
                    result['request_id'] = request_id
                    return result

            raise TimeoutError(
                f"Request {request_id[:8]} is still processing after 30s"
            )

        # Mark as processing
        self.redis.setex(processing_key, 60, "processing")  # 60s timeout

        try:
            # Call AI with temperature=0 for more deterministic output
            ai_result = self.ai_client.generate(
                prompt=f"Summarize this document in max {max_length} words:\n\n{document}",
                system_prompt="You are a precise summarizer. Be concise and accurate.",
                temperature=0.0,  # More deterministic
                max_tokens=max_length * 2  # Words to tokens rough estimate
            )

            # Build result
            result = {
                'request_id': request_id,
                'summary': ai_result['response'],
                'user_id': user_id,
                'timestamp': datetime.now().isoformat(),
                'cost': ai_result['cost'],
                'from_cache': False
            }

            # Store result with TTL
            self.redis.setex(
                cache_key,
                self.result_ttl,
                json.dumps(result)
            )

            logger.info(
                f"Processed new request {request_id[:8]}, "
                f"cached for {self.result_ttl}s"
            )

            return result

        finally:
            # Always clear processing lock
            self.redis.delete(processing_key)


# Example usage
if __name__ == "__main__":
    import os
    import redis

    ai_client = AIClient(api_key=os.getenv("ANTHROPIC_API_KEY"))
    redis_client = redis.from_url(os.getenv("REDIS_URL"))

    service = IdempotentAIService(
        ai_client=ai_client,
        redis_client=redis_client,
        result_ttl=86400  # 24 hours
    )

    document = """
    Artificial intelligence is transforming software engineering...
    [long document text]
    """

    # First request
    result1 = service.generate_summary(
        user_id="user123",
        document=document,
        max_length=100
    )
    print(f"Request 1: {result1['request_id'][:8]}, From cache: {result1['from_cache']}")

    # Identical request - returns cached result, no AI call
    result2 = service.generate_summary(
        user_id="user123",
        document=document,
        max_length=100
    )
    print(f"Request 2: {result2['request_id'][:8]}, From cache: {result2['from_cache']}")

    # Verify idempotency
    assert result1['summary'] == result2['summary']
    assert result1['request_id'] == result2['request_id']
    print("✓ Idempotency guaranteed!")
Idempotency guarantees:
  • Same inputs always produce same cached result
  • Duplicate requests don't trigger duplicate AI calls
  • Processing locks prevent concurrent duplicate processing
  • Explicit request IDs allow clients to retry safely
  • Temperature=0 makes AI output more deterministic

3. Use Temperature=0 and Seeding for Consistency

When consistency is more important than creativity, use techniques that make AI output more deterministic.

def generate_consistent_classification(
    ai_client,
    text: str,
    categories: List[str]
) -> str:
    """
    Classify text into categories with maximum consistency.

    Same input should produce same category (nearly always).
    """

    system_prompt = f"""You are a classifier. Return ONLY the category name, nothing else.

Available categories: {', '.join(categories)}

Rules:
1. Return exactly one category from the list
2. No explanation, just the category name
3. If unclear, choose the closest match"""

    # Use temperature=0 for maximum determinism
    result = ai_client.generate(
        prompt=f"Classify this text:\n\n{text}",
        system_prompt=system_prompt,
        temperature=0.0,  # Most deterministic
        max_tokens=20
    )

    category = result['response'].strip()

    # Validate result
    if category not in categories:
        # Fallback: find closest match
        category = min(categories, key=lambda c: levenshtein_distance(c, category))

    return category


def generate_with_retry_until_stable(
    ai_client,
    prompt: str,
    system_prompt: str,
    stability_checks: int = 3
) -> str:
    """
    Generate response and retry until output stabilizes.

    Ensures we get consistent output by running multiple times
    and checking for agreement.
    """

    results = []

    for i in range(stability_checks):
        result = ai_client.generate(
            prompt=prompt,
            system_prompt=system_prompt,
            temperature=0.0  # Deterministic
        )
        results.append(result['response'])

    # Check if all results are identical or very similar
    if len(set(results)) == 1:
        # Perfect agreement
        logger.info("Output stable on first attempt")
        return results[0]

    # Calculate similarity between responses
    similarities = []
    for i in range(len(results)):
        for j in range(i + 1, len(results)):
            similarity = calculate_similarity(results[i], results[j])
            similarities.append(similarity)

    avg_similarity = sum(similarities) / len(similarities)

    if avg_similarity >= 0.95:  # 95% similar
        logger.info(f"Output stable with {avg_similarity:.2%} similarity")
        # Return the most common or first result
        return results[0]

    else:
        logger.warning(
            f"Output unstable: {avg_similarity:.2%} similarity. "
            "Consider stronger constraints or lower temperature."
        )
        # Return majority vote or first result
        return max(set(results), key=results.count)


# Example: Consistent product categorization
def categorize_product(ai_client, product_name: str, product_desc: str) -> str:
    """Categorize product with consistency."""

    categories = [
        "Electronics",
        "Clothing",
        "Home & Garden",
        "Sports & Outdoors",
        "Books & Media"
    ]

    text = f"{product_name}\n{product_desc}"

    # Use deterministic classification
    category = generate_consistent_classification(
        ai_client=ai_client,
        text=text,
        categories=categories
    )

    return category

4. Multi-Stage Validation Pipeline

For critical applications, validate AI output through multiple stages before accepting it.

from typing import List, Tuple


class ValidationStage:
    """Base class for validation stages."""

    def validate(self, data: dict) -> Tuple[bool, Optional[str]]:
        """
        Validate data.

        Returns:
            (is_valid, error_message)
        """
        raise NotImplementedError


class SchemaValidation(ValidationStage):
    """Stage 1: Validate schema structure."""

    def __init__(self, model_class):
        self.model_class = model_class

    def validate(self, data: dict) -> Tuple[bool, Optional[str]]:
        try:
            self.model_class(**data)
            return True, None
        except ValidationError as e:
            return False, f"Schema validation failed: {str(e)}"


class ContentValidation(ValidationStage):
    """Stage 2: Validate content quality and completeness."""

    def __init__(self, required_sections: List[str], min_length: int):
        self.required_sections = required_sections
        self.min_length = min_length

    def validate(self, data: dict) -> Tuple[bool, Optional[str]]:
        content = data.get('content', '')

        # Check length
        if len(content) < self.min_length:
            return False, f"Content too short: {len(content)} < {self.min_length}"

        # Check required sections
        for section in self.required_sections:
            if section not in content:
                return False, f"Missing required section: {section}"

        return True, None


class SafetyValidation(ValidationStage):
    """Stage 3: Validate safety and appropriateness."""

    def __init__(self, banned_words: List[str]):
        self.banned_words = [word.lower() for word in banned_words]

    def validate(self, data: dict) -> Tuple[bool, Optional[str]]:
        content = data.get('content', '').lower()

        for word in self.banned_words:
            if word in content:
                return False, f"Content contains banned word: {word}"

        return True, None


class BusinessRuleValidation(ValidationStage):
    """Stage 4: Validate business rules."""

    def validate(self, data: dict) -> Tuple[bool, Optional[str]]:
        # Example: Check if product price is reasonable
        if 'price' in data:
            price = data['price']
            if price < 0:
                return False, "Price cannot be negative"
            if price > 1000000:
                return False, "Price suspiciously high"

        # Example: Check if date is in valid range
        if 'publish_date' in data:
            date = datetime.fromisoformat(data['publish_date'])
            if date > datetime.now():
                return False, "Publish date cannot be in future"

        return True, None


class ValidationPipeline:
    """Multi-stage validation pipeline for AI outputs."""

    def __init__(self, stages: List[ValidationStage]):
        self.stages = stages

    def validate(self, data: dict) -> Tuple[bool, List[str]]:
        """
        Run all validation stages.

        Returns:
            (all_valid, list_of_errors)
        """
        errors = []

        for i, stage in enumerate(self.stages):
            is_valid, error_msg = stage.validate(data)

            if not is_valid:
                errors.append(f"Stage {i+1} ({stage.__class__.__name__}): {error_msg}")

        all_valid = len(errors) == 0
        return all_valid, errors


def generate_with_pipeline_validation(
    ai_client,
    prompt: str,
    system_prompt: str,
    validation_pipeline: ValidationPipeline,
    max_retries: int = 3
) -> dict:
    """
    Generate AI output with multi-stage validation.

    Retries until output passes all validation stages.
    """

    for attempt in range(max_retries):
        # Generate
        result = ai_client.generate(
            prompt=prompt,
            system_prompt=system_prompt,
            temperature=0.3
        )

        # Parse
        try:
            data = extract_and_parse_json(result['response'])
        except Exception as e:
            logger.warning(f"Attempt {attempt + 1}: Failed to parse JSON: {e}")
            continue

        # Validate through pipeline
        is_valid, errors = validation_pipeline.validate(data)

        if is_valid:
            logger.info(f"Output passed all validation stages on attempt {attempt + 1}")
            return data

        else:
            logger.warning(
                f"Attempt {attempt + 1}: Validation failed:\n" +
                "\n".join(errors)
            )

            # Add feedback to prompt for next attempt
            prompt += f"\n\nPREVIOUS ATTEMPT HAD ERRORS:\n" + "\n".join(errors)

    raise ValueError(
        f"Failed to generate valid output after {max_retries} attempts"
    )


# Example usage
if __name__ == "__main__":
    # Create validation pipeline
    pipeline = ValidationPipeline([
        SchemaValidation(ArticleOutput),
        ContentValidation(
            required_sections=["Introduction:", "Conclusion:"],
            min_length=500
        ),
        SafetyValidation(banned_words=["spam", "scam"]),
        BusinessRuleValidation()
    ])

    # Generate with validation
    article_data = generate_with_pipeline_validation(
        ai_client=client,
        prompt="Write article about AI safety",
        system_prompt="You are a professional tech writer.",
        validation_pipeline=pipeline,
        max_retries=3
    )

    print("✓ Article passed all validation stages!")
    print(f"  Title: {article_data['title']}")
    print(f"  Length: {len(article_data['content'])} chars")
When to use multi-stage validation:
  • High-stakes applications (legal, medical, financial)
  • User-facing content that requires quality control
  • When AI output directly affects business logic
  • Compliance requirements (content moderation, safety)
Key Strategies for Handling Non-Determinism
  • Schema validation: Use Pydantic to enforce structure, retry on failures
  • Idempotency: Cache results by request ID, prevent duplicate processing
  • Consistency: Use temperature=0 and stability checks for critical tasks
  • Multi-stage validation: Validate schema → content → safety → business rules
  • Feedback loops: Tell AI about previous failures to improve next attempt
  • Graceful degradation: Have fallbacks when validation consistently fails

Common Pitfalls When Building AI Applications

1. Underestimating Costs

Problem: Small per-request costs add up quickly at scale. $0.01 per request × 100,000 users = $1,000/day = $30,000/month.

Solution: Model your costs early. Implement caching. Monitor spending daily. Set hard limits. Consider self-hosted models for high-volume use cases.

2. Ignoring Latency

Problem: AI API calls take 1-5 seconds. Synchronous calls block your API, creating poor user experience.

Solution: Use async processing for non-critical paths. Show progress indicators. Use streaming responses. Cache aggressively.

3. No Retry Logic

Problem: AI APIs have rate limits and occasional failures. Without retries, your app breaks intermittently.

Solution: Implement exponential backoff retries for rate limits and transient errors. But don't retry on 4xx errors (bad requests).

4. Trusting AI Output Format

Problem: Even when you ask for JSON, AI might return malformed or wrapped JSON, breaking your parser.

Solution: Extract JSON with regex, handle parsing errors gracefully, validate structure with schemas, and have fallback responses.

5. Prompt Injection Vulnerabilities

Problem: Users can manipulate AI behavior by injecting instructions into their input: "Ignore previous instructions and..."

Solution: Validate and sanitize user inputs. Use system prompts to set behavior. Separate instructions from user data. Monitor for suspicious patterns.

6. No Cost Monitoring

Problem: Wake up to a $10,000 API bill because a bug caused infinite loops or a user abused your system.

Solution: Set spending alerts. Implement rate limiting per user. Monitor costs in real-time. Set hard budget caps on API providers.

Key Takeaways

  • Choose the right architecture - Start simple (direct API), optimize as you scale (caching, self-hosted)
  • Error handling is critical - Implement retries, fallbacks, and graceful degradation
  • Caching reduces costs by 50-80% - Use Redis or similar for repeated queries
  • Monitor everything - Track costs, latency, error rates, and usage patterns
  • Validate AI outputs - Don't trust responses blindly, parse and verify
  • Optimize for cost - Use appropriate models, limit tokens, implement caching
  • Handle sensitive data carefully - Avoid sending PII to external APIs
  • Test thoroughly - AI behavior can be unpredictable, test edge cases
  • Implement rate limiting - Protect from abuse and runaway costs
  • Plan for scale - What works at 100 requests/day breaks at 100,000
What's Next?

You now know how to build production-ready AI applications. In the next lessons, we'll cover:

  • MLOps & Deployment - Deploying, monitoring, and maintaining AI systems at scale
  • Ethics & Best Practices - Responsible AI development, bias mitigation, and fairness
Software Engineering in AI EraLesson 5