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.

Pattern 1: Direct API Integration

ClientUserYour BackendFlask / FastAPIAI APIOpenAI / Anthropicrequestgenerate

Figure 1: The backend forwards each request straight to the AI API and returns the response

Pros
  • Simple to implement, minimal code
  • No infrastructure to manage
  • Always the latest model versions
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.

Pattern 2: Cached API Integration

ClientYour BackendCacheRedisAI APIcheckon miss

Figure 2: A semantic cache serves repeated queries directly; only cache misses reach the AI API

Example: "What is Python?" and "What's Python?" map to the same cache entry via semantic similarity, saving roughly 50-80% of API cost on 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.

Pattern 3: Self-Hosted Models

ClientYour BackendYour GPU ServerLlama 3, etc.inference

Figure 3: Open-source models run on your own GPU infrastructure, keeping data in-house

Pros
  • No per-request costs, only infrastructure
  • Full data privacy, no rate limits
  • Low latency, can fine-tune for your domain
Cons
  • Complex infrastructure (GPUs, orchestration)
  • Model quality usually below commercial APIs
  • You own updates, scaling, monitoring, and cost
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.

Pattern 4: Hybrid Architecture

Task RouterLocal Modelfast, cheapAPI Modelhigh qualitysimple taskscomplex tasks

Figure 4: A router sends each request to the cheapest model that can handle it

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)
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.
# basicConfig sets the ROOT level, which also switches on every library that
# logs at INFO. httpx logs one line per HTTP request, so quiet it explicitly
# or your own metrics get buried in transport noise.
logging.basicConfig(level=logging.INFO)
logging.getLogger("httpx").setLevel(logging.WARNING)
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-5"):
        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 5 list pricing as of August 2026. Hardcoding rates
        # is a known liability: they change, and they differ per model. In
        # production, key this off self.model and check platform.claude.com.
        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,
        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
            timeout: Request timeout in seconds

        Returns:
            Dict with response, tokens, cost, and metadata

        Note: no temperature parameter. Current Claude models reject it with a
        400 (see Lesson 3); reach for output_config.effort instead when you
        need to trade depth against cost.
        """
        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,
                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": next(b.text for b in response.content if b.type == "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."
    )
    print(f"Response: {result['response']}")
    print(f"Cost: {result['cost']:.4f}")
    print(f"Tokens: {result['total_tokens']}")

    # 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']}")
Expected Output:
INFO:__main__:AI Request #1: tokens=78, cost=0.0009, latency=2.47s
Response: Machine learning is a branch of artificial intelligence in which computer systems learn patterns from data and use them to make predictions or decisions without being explicitly programmed for each specific task.
Cost: 0.0009
Tokens: 78

Total API usage:
  Requests: 1
  Cost: 0.0009
  Avg cost/request: 0.0009

Your figures will differ. The wording, the token count, and the latency all change between runs, and the cost follows the token count. What should match is the shape: one INFO line per request from your own logger, then the response and the running totals.

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-5"
    ):
        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],
        max_tokens: int
    ) -> str:
        """Generate cache key from request parameters."""
        # Every parameter that can change the response belongs in the key.
        # Leave one out and you serve a cached answer for a different question.
        cache_input = f"{prompt}|{system_prompt}|{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,
        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, 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,
            timeout=timeout
        )

        # Store in cache
        if use_cache:
            try:
                result_copy = result.copy()
                result_copy['from_cache'] = False
                # set(..., ex=) rather than setex(): setex is deprecated in
                # redis-py and warns on every call.
                self.redis_client.set(
                    cache_key,
                    json.dumps(result_copy),
                    ex=self.cache_ttl
                )
            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."""
        # scan_iter, not keys(): KEYS walks the entire keyspace in one blocking
        # call, which is invisible on a laptop and an outage on a busy server.
        deleted = 0
        for key in self.redis_client.scan_iter(match=pattern, count=100):
            self.redis_client.delete(key)
            deleted += 1
        if deleted:
            logger.info(f"Cleared {deleted} 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
    )

    client.clear_cache()  # start empty so the demo is repeatable

    # 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']}")
Expected Output:
INFO:aiclient:AI Request #1: tokens=528, cost=0.0078, latency=5.76s
INFO:aiclient:Cache HIT. Saved 0.0078
First request  - Cost: 0.0078, Cached: False
Second request - Cost: 0.0078, Cached: True

Cache Statistics:
  Hit rate: 50.0%
  Savings: 0.0078
  Total cost: 0.0078

Note what the second line is telling you. Cost: 0.0078 on the cache hit is not money you spent, it is the cost recorded when that answer was first generated, which is exactly the figure you avoided paying again. Total cost stays at one request because only real API calls increment it. Requires the Redis container from earlier: docker run -d --name ai-cache -p 6379:6379 redis:8-alpine.

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,
            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
        )
        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:
# Redis for the cache layer
docker run -d --name review-redis -p 6379:6379 redis:8-alpine

# 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
Expected Output:
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Then send it the vulnerable function from the request example:

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"
  }'
Expected Output:
{
  "review_id": "6ec57eac-924a-477a-9852-53d4e6cc7529",
  "summary": "This single-line function contains a critical SQL injection ...",
  "score": 2,
  "findings": [6 items, first: critical/security],
  "language": "python",
  "from_cache": false,
  "processing_time": 13.34,
  "cost": 0.019491
}

Send the identical request again and the cache answers it:

# same curl, second time
Expected Output:
{
  "review_id": "93ec7e46-9670-4274-9af1-6cac921dcadf",
  "summary": "This single-line function contains a critical SQL injection ...",
  "score": 2,
  "findings": [6 items, first: critical/security],
  "language": "python",
  "from_cache": true,
  "processing_time": 0.0,
  "cost": 0.019491
}
Read those two responses carefully. The cached one reports processing_time: 0.0 against 13.34 seconds, which is the whole point of the cache layer. But cost is still 0.019491, because it is the cost recorded when the review was generated, not a fresh charge. And review_id differs between the two: it is minted per HTTP request rather than stored with the result, so it identifies the call, not the review. If you need a stable id for a given piece of code, that is the idempotency pattern from earlier in this lesson, not this one.

A bad key never reaches the model, since the dependency runs first:

curl -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8000/review" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: nope" \
  -d '{"code": "print(1)", "language": "python", "focus": "security"}'
Expected Output:
401

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 appropriately-sized models (don't use a frontier model 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.

import json
import os
from typing import List

from pydantic import (BaseModel, Field, ValidationError,
                      computed_field, field_validator)


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
    tags: List[str] = Field(..., min_length=3, max_length=10)
    category: str = Field(..., pattern="^(tech|business|science)$")

    @field_validator("content")
    @classmethod
    def must_be_500_words(cls, v: str) -> str:
        """The requirement is 500 WORDS.

        min_length on a str counts characters, so min_length=500 would accept a
        28-word paragraph. Check the thing you actually care about.
        """
        words = len(v.split())
        if words < 500:
            raise ValueError(f"content has {words} words, need at least 500")
        return v

    @computed_field
    @property
    def word_count(self) -> int:
        """Derived from the text, so it cannot disagree with it.

        Asking the model to report its own word_count invites a number that
        looks plausible and is wrong: in testing it claimed 612 for an article
        of 654 words, and nothing checked.
        """
        return len(self.content.split())


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, the full article, AT LEAST 500 WORDS",
    "tags": ["string", "string", "string"],  // 3-10 tags
    "category": "tech|business|science"
}

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
            # max_tokens covers thinking AND the response on current models.
            # A 500-word article as JSON lands near 2,000 output tokens on its
            # own, so max_tokens=2000 truncates intermittently and the JSON
            # arrives half-written. Leave real headroom.
            result = ai_client.generate(
                prompt=user_prompt,
                system_prompt=system_prompt,
                max_tokens=8000
            )

            # 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("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}  (derived from content)")

    except ValueError as e:
        print(f"Failed to generate valid article: {e}")

    # The article that used to slip through: 500+ characters, 28 words.
    try:
        ArticleOutput(
            title="A Perfectly Valid Title", author="Someone", summary="s" * 50,
            content="Introduction: " + ("word " * 26) + "Conclusion:",
            tags=["a", "b", "c"], category="tech",
        )
    except ValidationError as e:
        print(f"\nShort article now rejected: {e.errors()[0]['msg']}")
Expected Output:
INFO:__main__:Valid article generated on attempt 1
Valid article generated
  Title     : The Impact of AI on Software Engineering: A New Era of Development
  Category  : tech
  Tags      : artificial intelligence, software engineering, technology trends, automation, developer tools
  Word count: 820  (derived from content)

Short article now rejected: Value error, content has 28 words, need at least 500
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
  • Validate the property you actually care about. A schema that looks strict can be loose in the way that matters: measure the constraint in its real unit, and derive any figure the model would otherwise report about itself

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
import json
import time
from datetime import datetime
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 (set(..., ex=) rather than the deprecated setex)
        self.redis.set(processing_key, "processing", ex=60)  # 60s timeout

        try:
            # Note there is no temperature here. Idempotency comes from the
            # cache, not from sampling settings: the model is asked once and
            # every later caller is served that stored answer verbatim.
            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.",
                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.set(
                cache_key,
                json.dumps(result),
                ex=self.result_ttl
            )

            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", "redis://localhost:6379"))

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

    document = (
        "Artificial intelligence is transforming software engineering. Teams now "
        "delegate boilerplate, tests, and refactors to assistants, while keeping "
        "architecture and review in human hands. The bottleneck is shifting from "
        "typing speed to judgment about what should be built at all."
    )

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

    # 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]}, "
          f"From cache: {result2['from_cache']}, Cost: {result2['cost']:.4f}")

    # Verify idempotency
    print(f"\nSame request id: {result1['request_id'] == result2['request_id']}")
    print(f"Same summary:    {result1['summary'] == result2['summary']}")
Expected Output:
INFO:__main__:Processed new request 03b401ce, cached for 86400s
INFO:__main__:Idempotent cache hit for request 03b401ce
Request 1: 03b401ce, From cache: False, Cost: 0.0026
Request 2: 03b401ce, From cache: True, Cost: 0.0026

Same request id: True
Same summary:    True

The request id is derived from the inputs, so the second call computes the same id, finds the stored result and never reaches the API. Note the document is real prose rather than a [long document text] placeholder: the id is a hash of the document, so a placeholder would hash just as happily and hide the fact that changing one character produces a completely different id and a fresh API call. Requires Redis: docker run -d --name idem-redis -p 6379:6379 redis:8-alpine.

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
  • The guarantee comes from the cache, not from sampling settings: the model runs once and every repeat is served that stored answer

3. Constrain the Output Space, Then Verify

The old advice here was "set temperature to 0". That lever is gone on current Claude models, which reject the parameter outright, and it was never the strongest tool anyway: it reduced sampling randomness but did nothing to stop the model returning "Electronics" one time and "Consumer Electronics" the next. Both were valid samples; neither was what your database column accepts.

What actually buys consistency is making the wrong answer unrepresentable. Give the model a closed set to choose from, validate what comes back against that set, and measure agreement when the stakes justify the extra calls.

from enum import Enum

import anthropic
from pydantic import BaseModel

client = anthropic.Anthropic()


class Category(str, Enum):
    """The closed set. Anything outside it is not a possible answer."""
    ELECTRONICS = "Electronics"
    CLOTHING = "Clothing"
    HOME_GARDEN = "Home & Garden"
    SPORTS = "Sports & Outdoors"
    BOOKS_MEDIA = "Books & Media"


class Classification(BaseModel):
    category: Category
    confidence: float


def classify(text: str) -> Classification:
    """Return a category guaranteed to be one of the five above.

    The enum becomes an "enum" constraint in the JSON schema the API enforces,
    so there is no prompt asking nicely for one word, no .strip(), and no
    fuzzy-matching fallback to repair an off-list answer.
    """
    response = client.messages.parse(
        model="claude-haiku-4-5",
        max_tokens=200,
        messages=[{"role": "user", "content": f"Classify this product:\n\n{text}"}],
        output_format=Classification,
    )
    return response.parsed_output


PRODUCT = "Trail Runner GPS Watch\nHeart-rate strap, 40h battery, topo maps."

result = classify(PRODUCT)
print(f"category   : {result.category.value}")
print(f"is a member: {result.category in Category}")
print(f"confidence : {result.confidence}")
Expected Output:
category   : Sports & Outdoors
is a member: True
confidence : 0.95

Compare that to the version this replaces, which asked the model in prose to "return ONLY the category name", trimmed whitespace off the reply, and then ran a Levenshtein search to snap near-misses back onto the list. Every one of those steps existed to repair an answer the schema now makes impossible.

# When the stakes justify it, measure agreement instead of assuming it.
from collections import Counter

votes = Counter(classify(PRODUCT).category.value for _ in range(5))
print(f"5 runs   : {dict(votes)}")

top, n = votes.most_common(1)[0]
print(f"agreement: {n}/5 on {top!r}")
Expected Output:
5 runs   : {'Sports & Outdoors': 5}
agreement: 5/5 on 'Sports & Outdoors'

Five identical votes is a strong signal, but note what it costs: five API calls for one label. Reserve the agreement check for decisions where a wrong label is expensive, and let the schema alone carry the routine cases. A split vote is not a failure either, it is the classifier telling you the item genuinely sits between two categories, which is a far more useful thing to route to a human than a confident single answer would have been.

4. Multi-Stage Validation Pipeline

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

import json
import re
from datetime import datetime
from typing import List, Optional, Tuple

from pydantic import ValidationError


def extract_and_parse_json(text: str) -> dict:
    """Pull the JSON object out of a response that may wrap it in prose."""
    start, end = text.find("{"), text.rfind("}") + 1
    if start == -1 or end == 0:
        raise ValueError("No JSON object found in response")
    return json.loads(text[start:end])


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_words: int):
        self.required_sections = required_sections
        self.min_words = min_words

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

        # Count WORDS, not characters. len(content) would pass a 28-word
        # paragraph against a 500 "length" requirement.
        words = len(content.split())
        if words < self.min_words:
            return False, f"Content too short: {words} words < {self.min_words}"

        # 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()

        # Match on word boundaries. A plain substring test rejects "scampi"
        # for containing "scam", and this stage sits in front of an article
        # about AI safety, which has every reason to discuss scams.
        for word in self.banned_words:
            if re.search(rf"\b{re.escape(word)}\b", 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. max_tokens covers thinking plus the response, so the
        # 1000-token default truncates a 500-word article mid-JSON and every
        # attempt fails at the parse step.
        result = ai_client.generate(
            prompt=prompt,
            system_prompt=system_prompt,
            max_tokens=8000
        )

        # 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__":
    client = AIClient(api_key=os.getenv("ANTHROPIC_API_KEY"))

    # Create validation pipeline
    pipeline = ValidationPipeline([
        SchemaValidation(ArticleOutput),
        ContentValidation(
            required_sections=["Introduction:", "Conclusion:"],
            min_words=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. Return ONLY valid JSON with keys "
            "title, author, summary, content, tags, category. content must be at "
            "least 500 words, start with \"Introduction:\" and contain "
            "\"Conclusion:\". category is one of tech|business|science."
        ),
        validation_pipeline=pipeline,
        max_retries=3
    )

    print("Article passed all validation stages")
    print(f"  Title : {article_data['title']}")
    print(f"  Words : {len(article_data['content'].split())}")
Expected Output:
INFO:__main__:Output passed all validation stages on attempt 1
Article passed all validation stages
  Title : AI Safety: Building Trustworthy Intelligence for a Rapidly Changing World
  Words : 723
Two traps in the stages themselves. A naive substring test in SafetyValidation rejects "scampi" for containing "scam", and this pipeline sits in front of an article about AI safety, which has every reason to use that word legitimately. And measuring length with len(content) counts characters, so a 28-word paragraph satisfies a "minimum 500" rule. A validation stage that is subtly wrong is worse than none: it produces confident passes and inexplicable rejections, and nobody looks at it because it is the part that is supposed to be checking.
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: Constrain the output space with an enum or schema, then measure agreement across runs 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