Index Ai Academy
Phase 11 · Professional practice · Career

Capstone: ship a production AI service

An AI-backed service with validation, caching, rate limits, fallbacks, logging and evals.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

This capstone brings the whole academy together in the shape of real work. You'll build a service a support team could use: it reads a customer ticket and returns a category and a priority. It's small, but it has everything a production AI feature needs: it checks who's calling, limits abuse, validates input, saves money with a cache, survives model failures, logs what happened without leaking private data, and comes with an eval to prove it works. This is the kind of project that belongs at the top of your portfolio.

What you'll be able to do

  • Combine authentication, rate limiting, validation, caching, fallbacks and logging in one service
  • Treat model output as untrusted: parse it, check it against allowed values, and fall back
  • Log every request in a structured way, without keys or customer text
  • Measure the service with an eval set before and after each change

What it covers

  1. The service
  2. Bump the version, miss the cache
  3. Logs that help without hurting
  4. Redact before you log
  5. Checking model output before trusting it
  6. Why the fallback must not be cached

The service

Think of a bank teller. They check your ID, make sure you aren't over a limit, read your form, and only then do the slow part. If the computer system is down, they still give you some answer.

handle(request) works like an HTTP handler. A request has an api_key, a request_id and a JSON body with ticket_id and text. It returns (status, body). The steps, in order:

stepon failurefrom
1. authenticate the API key401 invalid_api_keyAPIs: auth
2. rate limit: each key's limit per 60-second window429 rate_limited with retry_aftershipping an LLM app
3. validate the body400 invalid_request with a detailAPIs: validation
4. cache by prompt version + text(a hit skips the models)caches and queues
5. classify with each model in turntry the next modeldesigning AI systems
6. fall back to keyword rules(always answers)designing AI systems
7. log one structured entrytracing, logging

The order matters. Authentication comes first, so strangers can't use up anyone's limit. The rate limit comes before validation, so even bad requests count against the caller. Cheap checks run before expensive ones, and the model, the most expensive step, runs last.

Model output is untrusted

You'd read a new temp's draft before it went out to a customer. A model is asked for JSON like {"category": "bug", "priority": "high"}. It may return prose, broken JSON, or a category that doesn't exist. So:

  • parse with json.loads inside a try;
  • accept only values from CATEGORIES and PRIORITIES;
  • anything else counts as a failure, and the next model is tried.

If every model fails, rules() gives a basic keyword answer, so the support team always gets something. Rule answers are not cached: next time, the models get another chance.

Cache keys and versions

The cache key is a hash of the prompt version plus the text. When you change the prompt, bump PROMPT_VERSION, and old answers stop being used automatically, like a new edition replacing the old one on the shelf. Only the result is cached; the response still uses each request's own ticket_id.

Worked example

Bump the version, miss the cache

The cache key is built from the prompt version and the text. So changing the prompt retires every old answer without you having to delete anything.

import hashlib

def cache_key(version, text):
    return hashlib.sha256(f"{version}|{text}".encode()).hexdigest()[:10]

cache = {cache_key("v1", "Where's my order?"): "shipping"}

print(cache_key("v1", "Where's my order?") in cache)
print(cache_key("v1", "Where is my order?") in cache)
print(cache_key("v2", "Where's my order?") in cache)
It runs right here in your browser, with nothing to install.

The second is a miss because the text differs, even by a letter. The third misses because the prompt changed, so the answer may be different now. Without the version in the key, your improved prompt would keep returning old answers.

Logs that help without hurting

A doctor's appointment book says who came and when. It doesn't say what they talked about. Each request gets exactly one log entry: request_id, the first 6 characters of the key plus "...", the status, the source (which model, or "rules") and whether the cache was used. Never the full key, which would be a leaked credential, and never the ticket text, which is customer data.

In plain words: log enough to find out what happened, and nothing you'd hate to see leaked.

Worked example

Redact before you log

Logs get copied to many places and kept for a long time, so personal data shouldn't go in. Replace it before the line is written, and keep what you need for debugging.

import re, hashlib

def redact(text):
    text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "[email]", text)
    text = re.sub(r"\b\d(?:[ -]?\d){12,15}\b", "[card]", text)
    return text

def log_line(request_id, ticket_text):
    return {"request_id": request_id, "chars": len(ticket_text),
            "text_hash": hashlib.sha256(ticket_text.encode()).hexdigest()[:8],
            "preview": redact(ticket_text)[:60]}

print(log_line("req-41", "Refund to ann@shop.example, card 4242 4242 4242 4242 please"))
In the lesson you can change this and run it.

You can still find the request, see its size and spot repeats through the hash. The email and card are gone from the log. Regex redaction misses things like names, so the safest approach is to log less to begin with.

Quick check
Why does the service apply the rate limit before validating the request body?
  1. It makes validation faster
  2. Validation needs the rate limit's result
  3. Rate limits only apply to valid requests
  4. So even malformed requests count against the caller's limit
Show the answer

So even malformed requests count against the caller's limit

Otherwise a caller could flood the service with bad requests for free.

Worked example

Checking model output before trusting it

Three replies a model might give. Only one is usable.

import json

CATEGORIES = {"billing", "bug", "account", "feature", "other"}
PRIORITIES = {"low", "normal", "high", "urgent"}

def parse(reply):
    try:
        out = json.loads(reply)
    except json.JSONDecodeError:
        return None
    if isinstance(out, dict) and out.get("category") in CATEGORIES and out.get("priority") in PRIORITIES:
        return {"category": out["category"], "priority": out["priority"]}
    return None

for reply in ['{"category": "bug", "priority": "high"}',
              "Sure! This looks like a bug.",
              '{"category": "complaint", "priority": "high"}']:
    print(parse(reply))
In the lesson you can change this and run it.

Prose fails to parse, and "complaint" isn't an allowed category, so both return None and the service would try the next model.

Try it now

The key is known and under its limit, so the request reaches validation, where the empty text stops it with 400. The model is never called.

Quick check
Why does the service check the API key before validating the request body?
  1. The order doesn't matter
  2. So unknown callers are turned away cheaply, before they can use any resources or limits
  3. Validation needs the key
  4. Keys are slower to check
Show the answer

So unknown callers are turned away cheaply, before they can use any resources or limits

Put cheap, protective checks first and expensive work last.

Quick check
The cache key includes the prompt version. What does this print?
import hashlib

key = lambda version, text: hashlib.sha256((version + "\n" + text).encode()).hexdigest()[:8]
print(key("triage-v3", "Refund please") == key("triage-v4", "Refund please"))
  1. None
  2. False
  3. It raises an error
  4. True
Show the answer

False

The two versions are hashed along with the text, so the keys differ and == prints False. A new prompt version gives new keys, so old answers stop being served without hunting them down.

Why the fallback must not be cached

A subtle bug is waiting in the cache. When the model fails, the service returns a safe fallback answer, and that's correct. But if you cache it as you would a model answer, then that fallback keeps being served for that text long after the model has recovered. A short outage becomes a lasting wrong answer. So cache only real model results, and mark fallback responses so that you can count them.

A model won't always return valid JSON

It's easy to believe that a good prompt guarantees the format. It doesn't. A model may wrap the JSON in a sentence, cut it off at the token limit, invent a category you never listed, or refuse. Every one of those will reach your parser at some point in production. So parse it, check it against the allowed values, and have a plan for each failure. The check isn't there because the model is bad. It's there because the model's output is input from outside your system, and you've learned how to treat that.

Who's calling comes first

Check the API key before you validate the body. Otherwise a stranger can send junk and read your validation messages, or use up your rate limits and your compute with no key at all. Authentication first is also cheaper: rejecting a bad key costs almost nothing, while reading and validating a large body doesn't.

Logging for the person on call

Whoever's on call at 3 a.m. needs to know what happened to a request, not what the customer wrote. A request id, the outcome, the latency and whether it came from the cache will answer almost every question. Full API keys and customer messages won't help, and they turn your logs into a liability. If you do need a key in the logs, keep only the last four characters, enough to tell which one it was.

Practice what you just read

  • 3 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

Why aren't rule-based fallback answers cached?

  1. So the models get another chance next time, instead of a weak answer sticking
  2. Rules are faster than the cache, so caching them would only slow things down
  3. The cache can't store dicts, and the rule-based answers are returned as dicts
  4. Rules change every call, so a cached answer would be out of date right away

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson