Skip to main content
Lesson 20 of 20 · Production, Evals, and Optimization

Production architecture: monitoring, alerting, scaling

Lesson 20 of 20 Updated November 3, 2026 Advanced
On this page
  1. Your application in production: the three pillars
  2. Observe: what to measure
  3. React: graduated alerts
  4. Absorb: three resilience mechanisms
  5. Production deployment checklist
  6. Key takeaways
  7. Test your knowledge

Your application in production: the three pillars

Deploying an LLM application to production isn’t just about the code. Three pillars guarantee reliability: monitoring to observe, alerting to react, and scaling to absorb load. They form a chain, and a chain is only as strong as its weakest link: metrics no one looks at are useless, alerts without a response procedure are noise, and scaling that kicks in too late is just an outage. This final lesson brings together every concept from the course into a complete production architecture.

Observe: what to measure

Four families of metrics cover the essentials: call volume, latency, errors, and cost, broken down by model. Breaking down by model is what makes the dashboard actionable—a global error rate of 3% tells you nothing, while that same figure concentrated on a single model immediately points to the culprit.

The class below accumulates these metrics in memory, using a sliding window of 10,000 latencies to prevent the process from growing indefinitely. Note the use of percentiles rather than averages: the average latency of an LLM service is almost always misleading because a handful of very slow calls disappear into the average, yet those are the ones your users complain about.

import time
from dataclasses import dataclass, field
from collections import defaultdict

@dataclass
class ProductionMetrics:
    """Collects essential production metrics."""

    counters: dict = field(default_factory=lambda: defaultdict(int))
    latencies: dict = field(default_factory=lambda: defaultdict(list))
    errors: dict = field(default_factory=lambda: defaultdict(int))
    costs: dict = field(default_factory=lambda: defaultdict(float))

    def record_call(
        self,
        model: str,
        latency: float,
        input_tokens: int,
        output_tokens: int,
        cost: float,
        success: bool,
    ):
        self.counters[model] += 1
        self.latencies[model].append(latency)
        self.costs[model] += cost

        if not success:
            self.errors[model] += 1

        # Keep only the last 10,000 latencies
        if len(self.latencies[model]) > 10_000:
            self.latencies[model] = self.latencies[model][-10_000:]

    def percentile(self, model: str, p: float) -> float:
        """Calculates a latency percentile."""
        values = sorted(self.latencies.get(model, []))
        if not values:
            return 0.0
        idx = int(len(values) * p / 100)
        return values[min(idx, len(values) - 1)]

    def error_rate(self, model: str) -> float:
        total = self.counters.get(model, 0)
        if total == 0:
            return 0.0
        return self.errors.get(model, 0) / total

    def dashboard(self) -> dict:
        """Generates a complete dashboard."""
        dashboard = {}
        for model in self.counters:
            dashboard[model] = {
                "calls": self.counters[model],
                "latency_p50": f"{self.percentile(model, 50):.2f}s",
                "latency_p99": f"{self.percentile(model, 99):.2f}s",
                "error_rate": f"{self.error_rate(model):.2%}",
                "total_cost": f"${self.costs[model]:.2f}",
            }
        return dashboard

Of course, collection must be systematic. Manually instrumenting every call guarantees you’ll forget one—and probably the one that causes trouble. The decorator below moves measurement to the call function level: the finally block records latency and success no matter what, even when an exception propagates.

import openai
import functools

metrics = ProductionMetrics()

def monitor(func):
    """Decorator that monitors every API call."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        model = kwargs.get("model", args[0] if args else "unknown")
        start = time.perf_counter()
        success = True

        try:
            result = func(*args, **kwargs)
            return result
        except Exception as e:
            success = False
            raise
        finally:
            latency = time.perf_counter() - start
            metrics.record_call(
                model=model,
                latency=latency,
                input_tokens=0,
                output_tokens=0,
                cost=0,
                success=success,
            )

    return wrapper

Token counters and cost remain zero in this version because the response object isn’t available at recording time. Complete them by reading the usage fields returned by the API: without this information, your dashboard will faithfully display a column of zero costs.

React: graduated alerts

An alert is only valuable if it calls for action. That’s why the system distinguishes two thresholds per metric: a warning threshold, signaling a drift to examine during the day, and a critical threshold, justifying waking someone up. Confusing the two produces the worst possible outcome—a team that has learned to ignore notifications.

from enum import Enum

class AlertLevel(Enum):
    INFO = "info"
    WARNING = "warning"
    CRITICAL = "critical"

class AlertSystem:
    """Triggers alerts based on metrics."""

    def __init__(self):
        self.thresholds = {
            "error_rate": {"warning": 0.05, "critical": 0.15},
            "latency_p99": {"warning": 10.0, "critical": 30.0},
            "hourly_cost": {"warning": 50.0, "critical": 200.0},
        }
        self.sent_alerts: list[dict] = []

    def check(self, metrics: ProductionMetrics) -> list[dict]:
        """Checks metrics and generates alerts."""
        alerts = []

        for model in metrics.counters:
            # Error rate
            rate = metrics.error_rate(model)
            if rate >= self.thresholds["error_rate"]["critical"]:
                alerts.append({
                    "level": AlertLevel.CRITICAL,
                    "message": (
                        f"CRITICAL error rate for {model}: "
                        f"{rate:.1%}"
                    ),
                    "model": model,
                })
            elif rate >= self.thresholds["error_rate"]["warning"]:
                alerts.append({
                    "level": AlertLevel.WARNING,
                    "message": (
                        f"High error rate for {model}: "
                        f"{rate:.1%}"
                    ),
                    "model": model,
                })

            # Latency
            p99 = metrics.percentile(model, 99)
            if p99 >= self.thresholds["latency_p99"]["critical"]:
                alerts.append({
                    "level": AlertLevel.CRITICAL,
                    "message": (
                        f"CRITICAL p99 latency for {model}: "
                        f"{p99:.1f}s"
                    ),
                    "model": model,
                })

        self.sent_alerts.extend(alerts)
        return alerts

    async def notify(self, alerts: list[dict]):
        """Sends notifications (webhook, email, Slack)."""
        import httpx

        for alert in alerts:
            if alert["level"] == AlertLevel.CRITICAL:
                # Immediate notification
                async with httpx.AsyncClient() as http:
                    await http.post(
                        "https://your-webhook.example.com/alerts",
                        json={
                            "level": alert["level"].value,
                            "message": alert["message"],
                        },
                    )

Hourly cost alerts are the ones most often forgotten, and that’s a shame: an unbounded retry loop or a batch job launched twice results in a bill, not an error. It’s the only safety net that detects this type of incident while it’s happening.

Absorb: three resilience mechanisms

When the API returns an error, retrying makes things worse: you add load to a service already in trouble and make your own users wait for nothing. The circuit breaker reverses this logic. After a defined number of consecutive failures, it opens the circuit and immediately rejects subsequent calls, then retries after a reset delay.

class CircuitBreaker:
    """Cuts off calls when too many errors occur."""

    def __init__(
        self,
        open_threshold: int = 5,
        reset_delay: float = 60.0,
    ):
        self.threshold = open_threshold
        self.reset_delay = reset_delay
        self.consecutive_failures = 0
        self.opened_at: float | None = None

    @property
    def is_open(self) -> bool:
        if self.opened_at is None:
            return False
        if time.time() - self.opened_at > self.reset_delay:
            # Attempt to close the circuit
            self.opened_at = None
            self.consecutive_failures = 0
            return False
        return True

    def record_success(self):
        self.consecutive_failures = 0
        self.opened_at = None

    def record_failure(self):
        self.consecutive_failures += 1
        if self.consecutive_failures >= self.threshold:
            self.opened_at = time.time()

    def call(self, func, *args, **kwargs):
        """Executes the function if the circuit is closed."""
        if self.is_open:
            raise RuntimeError(
                "Circuit open — API unavailable. "
                f"Retry in {self.reset_delay}s."
            )
        try:
            result = func(*args, **kwargs)
            self.record_success()
            return result
        except Exception as e:
            self.record_failure()
            raise

The circuit breaker protects your application; it doesn’t make it work, though. That’s the role of model fallback, which tries alternatives in order, skipping those whose circuits are already open. One design point to remember: the order of the list is a product decision, not a technical one. Put first the model that delivers the expected service, followed by those that deliver a degraded but acceptable service—a slightly less sharp response is better than an unavailable message.

class ModelFallback:
    """Switches to a backup model in case of failure."""

    def __init__(self):
        self.models = ["gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.6-luna"]
        self.circuits = {
            m: CircuitBreaker() for m in self.models
        }

    def call(self, prompt: str) -> str:
        """Tries each model until a response is obtained."""
        client = openai.OpenAI()

        for model in self.models:
            circuit = self.circuits[model]
            if circuit.is_open:
                continue

            try:
                response = circuit.call(
                    client.responses.create,
                    model=model,
                    input=prompt,
                )
                return response.output_text
            except Exception:
                continue

        raise RuntimeError("All models are unavailable.")

The third mechanism concerns sizing. Driving the number of workers based on queue size rather than CPU load is the right reflex for a service that spends most of its time waiting for network responses: the CPU sits idle while the queue grows. Note the asymmetry in the applied rule—we double on scale-up and halve on scale-down, with widely separated thresholds (100 vs. 10). This gap prevents oscillation: without it, the system would spend its time creating and destroying workers around the tipping point.

class AutoScaler:
    """Adjusts the number of workers based on load."""

    def __init__(
        self,
        min_workers: int = 2,
        max_workers: int = 50,
        scale_up_threshold: int = 100,
        scale_down_threshold: int = 10,
    ):
        self.min_workers = min_workers
        self.max_workers = max_workers
        self.up_threshold = scale_up_threshold
        self.down_threshold = scale_down_threshold
        self.active_workers = min_workers

    def adjust(self, queue_size: int) -> int:
        """Returns the recommended number of workers."""
        if queue_size > self.up_threshold:
            self.active_workers = min(
                self.active_workers * 2,
                self.max_workers,
            )
        elif queue_size < self.down_threshold:
            self.active_workers = max(
                self.active_workers // 2,
                self.min_workers,
            )

        return self.active_workers

Production deployment checklist

This course ends with the checklist to run through before opening your application to the public. Each item refers to a lesson you’ve covered; if any seems unclear, reread that lesson before deploying.

  • Latency monitoring (p50, p95, p99) and error rates
  • Alerts configured on critical thresholds
  • Circuit breaker to protect against API failures
  • Model fallback configured
  • Input and output moderation enabled
  • Client-side rate limiting implemented
  • Structured logs for debugging
  • Automated evals in CI/CD
  • Cost budget with alerts
  • Versioned prompt documentation

Key takeaways

  • Monitoring covers latency, errors, costs, and response quality
  • Track latency percentiles, never just the average
  • Alerts must be graduated: warning then critical
  • Circuit breaker protects against failure cascades; fallback ensures availability
  • A production checklist prevents critical oversights

Test your knowledge

Latency, costs, evals: production reflexes in five questions.

1. What are the main levers for reducing latency?

Answer: Streaming (perceived latency), prompt caching (stable prefixes), Predicted Outputs when the response is largely predictable (Chat Completions, on models retired January 14, 2027), and optimizing the prompt itself—measured end to end.

2. When should you use the Batch API?

Answer: For massive processing without real-time constraints: asynchronous batches at reduced cost—ranking, enrichment, or volume generation have no place on the synchronous path.

3. What does conversation compaction do?

Answer: It compresses long history (summaries, pruning) to fit within context and control costs—essential for agents and long sessions.

4. What is the purpose of the Evals framework in CI/CD?

Answer: To automatically run your evaluations on every prompt or model change: regression is detected at commit, not in production.

5. What does a complete production architecture include?

Answer: Monitoring (latency, costs, quality), alerting, asynchronous queues, moderation at scale, and a scaling plan—AI in production is a system, not an API call.

Measure, cache, batch, evaluate continuously: four habits that make applications fast, lean, and reliable.