Capstone: ship a production AI service
An AI-backed service with validation, caching, rate limits, fallbacks, logging and evals.
Create a free account Try part of it here
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
- The service
- Bump the version, miss the cache
- Logs that help without hurting
- Redact before you log
- Checking model output before trusting it
- 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:
| step | on failure | from |
|---|---|---|
| 1. authenticate the API key | 401 invalid_api_key | APIs: auth |
| 2. rate limit: each key's limit per 60-second window | 429 rate_limited with retry_after | shipping an LLM app |
| 3. validate the body | 400 invalid_request with a detail | APIs: validation |
| 4. cache by prompt version + text | (a hit skips the models) | caches and queues |
| 5. classify with each model in turn | try the next model | designing AI systems |
| 6. fall back to keyword rules | (always answers) | designing AI systems |
| 7. log one structured entry | tracing, 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.loadsinside atry; - accept only values from
CATEGORIESandPRIORITIES; - 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.
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)
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.
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"))
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.
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.
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))
Prose fails to parse, and "complaint" isn't an allowed category, so both return None and the service would try the next model.
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.
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.
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"))
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
Try a quiz question
Why aren't rule-based fallback answers cached?