Designing AI systems
Latency, cost, routing, fallbacks and guardrails for features built on models.
Create a free account Try part of it here
Why it matters
Adding a model call to a product looks like adding any other API call. It isn't. A model call can take 20 seconds, cost more than the rest of the request combined, return something different each time, be confidently wrong, or fail because the provider is overloaded. Systems that treat models like a fast, free, reliable function fall over in production. This lesson is about designing for what models really are.
What you'll be able to do
- Name what makes model calls different from other dependencies: slow, costly, variable and sometimes down
- Estimate the monthly cost of an AI feature, and cut it with caching, routing and batching
- Build a fallback chain with a circuit breaker and output validation
- Plan guardrails, evals and privacy as parts of the design, not afterthoughts
What it covers
- What's different
- When the model fails
- A fallback chain
- Guardrails and quality
- Check the output before you use it
- Routing by difficulty
- A model call isn't a function call
What's different
A calculator is instant, free and always right. A consultant is slow, bills you for each question, sometimes has a day off, and can be confidently wrong. A model call is much more like the consultant, and that shapes the design:
| property | ordinary API | model call | design response |
|---|---|---|---|
| latency | milliseconds | seconds | stream, parallelize, cache, precompute |
| cost per call | tiny | can be cents | estimate, cache, route, trim context |
| output | exact | varies, can be wrong | validate, eval, human review when stakes are high |
| availability | high | rate limits, overloads | retries, fallbacks, circuit breakers |
| change | versioned API | new models, prompt edits | version prompts, rerun evals before switching |
Latency
A restaurant brings bread right away, so the wait for dinner feels shorter.
- Stream the reply, so users see words in under a second even if the whole answer takes ten. Time to first token is what people feel.
- Run independent calls in parallel, not one after another.
- Use smaller models for simple steps like classifying, extracting or routing.
- Precompute what you can: summaries of documents at upload, not at question time. The Message Batches API runs large offline jobs at about half the price, with results within a day.
Cost
Estimate before you build: requests per day × (input tokens × input price + output tokens × output price) × 30. Then cut it:
- Prompt caching for long, stable prefixes (system prompts, documents, tool definitions).
- Response caching for repeated identical requests.
- Routing: send easy requests to a small, cheap model, and only hard ones to the largest, the way a firm sends simple questions to its junior staff.
- Trim context: retrieve the 5 best chunks, not 50.
- Cap
max_tokensto what the feature needs.
Requests × tokens × price, using the Opus and Haiku prices from Phase 1. Send easy requests to the small model and watch the bill drop.
Show the answer
Queue tickets and summarize them with the batch API
Work that can wait is a natural fit for batch processing at a lower price, with caching of the result.
When the model fails
If your usual restaurant is closed, you try the one next door, and if that's full you cook at home. A request to a model can fail in several ways: an error (429, 529, timeout), a refusal, or output that fails your checks (bad JSON, a missing field, an answer outside the allowed set). Plan a chain:
- Retry temporary errors with backoff, a couple of times.
- Fall back to another model, or a smaller one, if the first keeps failing.
- Degrade gracefully: a cached answer, a rule-based answer, or a clear "try again soon".
A circuit breaker stops you hammering a provider that's down. After several failures in a row, it opens: calls are skipped at once for a cooldown period, and the fallback is used. Then it lets one trial call through (half-open). If that succeeds it closes again; if not, it reopens. This keeps users from waiting on timeouts, and gives the provider room to recover.
In plain words: decide now what users will see when the model is down, so they never just get a spinner.
A fallback chain
Try the main model, retry a temporary error, fall back to another model, and finally degrade to a simple answer. Each scenario here takes a different path.
class Temporary(Exception): pass def chain(call_main, call_backup, default): path = [] for attempt in range(2): try: path.append("main") return call_main(), path except Temporary: path.append("retry") try: path.append("backup") return call_backup(), path except Temporary: path.append("default") return default, path def fails(): raise Temporary() print(chain(lambda: "great answer", lambda: "ok answer", "Try again soon")) print(chain(fails, lambda: "ok answer", "Try again soon")) print(chain(fails, fails, "Try again soon"))
Every path ends with something to show the user. In real code, add backoff between the retries and a time limit on the whole chain, so that being thorough doesn't turn into a 90-second wait.
Guardrails and quality
A new hire's work gets checked before it goes to a customer, especially when mistakes are costly. Do the same with a model:
- Validate inputs: size limits, file types, and treat user content as data, not instructions.
- Validate outputs: use structured outputs or JSON schemas, check values against allowed sets, and handle refusals.
- Humans for high stakes: a person approves refunds, medical or legal advice, or anything irreversible.
- Evals as a gate: an eval set runs before any prompt or model change ships, and production samples are scored to catch drift.
- Privacy: know what data goes to the provider, how long it's kept, and in which region. Strip what the model doesn't need.
Check the output before you use it
Model output is untrusted input. Parse it and check it against what's allowed, because valid JSON can still hold a wrong answer.
import json ALLOWED = {"billing", "shipping", "returns"} def route(reply): try: data = json.loads(reply) except ValueError: return "human", "not JSON" if data.get("team") not in ALLOWED: return "human", f"unknown team {data.get('team')!r}" if not 0 <= data.get("confidence", -1) <= 1: return "human", "bad confidence" return data["team"], "ok" print(route('{"team": "billing", "confidence": 0.9}')) print(route('Sure! The team is billing.')) print(route('{"team": "legal", "confidence": 0.9}'))
Each failure goes to a person, not to an error page. That's the safe default for a feature like this, and counting how often it happens gives you a quality measure for free.
Show the answer
It skips that provider at once and uses the fallback
That's the point of opening: users get a fast fallback, and the provider gets room to recover.
Routing by difficulty
A cheap first pass decides where each request goes. Here it's a simple rule; often it's a small model.
def route(question): hard_words = ("why", "compare", "design", "explain", "trade-off") if len(question.split()) > 40 or any(w in question.lower() for w in hard_words): return ["large", "small"] return ["small", "large"] for q in ["What are your opening hours?", "Compare the two plans and explain which suits a team of five."]: print(route(q), "<-", q)
Each list is a fallback chain: try the first model, and fall back to the second if it fails.
def route(question): hard = ("why", "compare", "design") return "large" if any(w in question.lower() for w in hard) else "small" print(route("What time do you open?"), route("Why did my order fail?"))
Show the answer
small large
Only the second question contains a hard word, "why". Routing easy requests to a small model saves most of the cost.
A model call isn't a function call
Ordinary code gives the same answer each time, in microseconds, for free, and it fails in ways you can list. A model call is slow, costs money, can be refused, and might return something plausible and wrong. Design with all of that in mind, and treat the model like a helpful remote service that sometimes has a bad day.
Use the smallest model that passes your evals
It's natural to pick the most capable model and leave it there. But most requests in a real product are easy. You don't send a surgeon to put on a bandage. Classifying a ticket or pulling out a date rarely needs your largest model, and a smaller one may cost 40 times less. Routing easy work to it is usually the biggest saving available.
Don't retry a failing provider forever
If a provider has an outage, retrying only adds to the pile, and your users wait. Set a limit, and then fall back to another model or a degraded answer. Track how often that happens, since a quiet rise in fallbacks may be your first sign of trouble.
Switching models is a code change
A new model version looks like a one-line edit. It can change tone, length, formatting and what it refuses, and any of those can break your product. Rerun your evals first, and roll it out in stages, as you would with any other risky change.
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
What does streaming improve for users?