AI EngineeringZero to ProductionHome·About·What’s new·Contact
Case Studies & Reference Architectures · Part 2

Case study: support triage

A SaaS company is drowning in ~20,000 support tickets a day. This case study builds the system that rescues them — end to end: from the business requirements and constraints, through the architecture and every design decision (classify + route, RAG for grounded answers, a confidence gate, human escalation, safety rails), into runnable code, an eval gate, and the cost/latency arithmetic at scale — finishing with the failure modes and the specific rail that hardens each. Watch a real production system come together one defensible decision at a time.

⏱️ ~2.5 hours🧪 6 steps🎯 Intermediate→Tech-lead

Learning objectives

  • Take a support-triage system from business requirements all the way to a hardened, evaluated production design — end to end.
  • Justify the core architecture: classify + route, RAG for grounded answers, a confidence gate, human escalation, and safety rails.
  • Use structured output for the classifier and a validation gate so a malformed model reply can never crash routing.
  • Set confidence thresholds and sensitive-category rules that decide auto-resolve vs escalate — the single most consequential knob in the system.
  • Gate the ship on an eval golden set, and model cost and latency at real volume.
  • Enumerate the failure modes (hallucination, prompt injection, retrieval misses, cost blow-ups) and the specific rail that hardens each.
This is a representative industry scenarioEverything below is a realistic composite, not a specific customer. The company, the numbers, and the ticket volumes are illustrative — chosen so the engineering decisions are concrete, not to describe any real deployment. Where a capability is Anthropic's real, published guidance (structured/tool output, prompt caching, evaluations, guardrails, the agent loop) it is cited as public fact; no named-customer results or internal metrics are claimed. Product and API details drift — verify specifics in Anthropic's current docs before you build.

Meet "Northwind" — a mid-market SaaS company (representative composite) drowning in inbound support. Roughly 20,000 tickets a day land in one queue: password resets, refund requests, genuine bugs, and a long tail of "how do I…". Agents spend most of their time on the easy, repetitive half and have no time left for the hard cases. The mandate: auto-resolve the easy majority safely, and route everything else to the right human faster — without ever leaking data, inventing policy, or answering something it shouldn't. This lesson builds that system from the requirements up, one defensible decision at a time. It synthesizes the threads from the support-assistant project, its guardrails, and evaluation into a single narrative.

Inbound ticket email / chat / API Safety pre-filter injection + abuse Classify + route billing/tech/account RAG answer grounded + cited Confidence gate threshold + rules Auto-resolve /escalate human in the loop
🗺️ How to read this diagram

This one picture is the whole system. Read it left to right — it is the life of a single support ticket, and every section of the lesson builds one of these boxes.

  • Inbound ticket — email, chat, or an API call all funnel into the same pipeline, so there is exactly one place to reason about safety and quality.
  • Safety pre-filter comes first, on purpose: catch prompt injection and abuse before you spend a single token processing a hostile input.
  • Classify + route tags the ticket (billing / technical / account / other) and sends it to the right lane. Structured output makes this a typed step, not prose you have to parse.
  • RAG answer drafts a reply only from retrieved knowledge and always cites its source — if nothing relevant is found, it refuses instead of inventing an answer.
  • Confidence gate is the decision point: safety → grounding → confidence, in that order, deciding whether the ticket has earned an automatic reply.
  • Auto-resolve / escalate — the default is a human. Auto-resolution is the exception the system has to justify with a high-confidence, grounded, cited answer.

In short: The arrows only ever move forward, and the pipeline is mostly fixed — that determinism is what makes it debuggable. When a ticket goes wrong, you know exactly which box failed.

Read the diagram as the ticket's life. Safety first (before we spend a token on a hostile input), classify to pick a lane, retrieve + answer only from the knowledge base, then a gate that is deliberately conservative: the default is to escalate to a human, and auto-resolve is the exception the system has to earn with high confidence and a grounded, cited answer. Every section below builds one of these boxes and defends why it looks the way it does.

1 · Requirements & constraints essential

Start with the meeting where this goes wrong. A VP says "let's use AI to handle support," an engineer opens a notebook, wires up a model, demos it answering three cherry-picked tickets, and everyone claps. Six weeks later the thing is live, the bill is bigger than the two support agents it was meant to replace, it confidently invented a refund policy that never existed, and it falls over every Monday morning when the weekend backlog floods in. Nothing in that story is a coding failure. It's a requirements failure — nobody wrote down what "good" had to mean in numbers before they started building.

Here is the analogy that fixes it. Building a triage system without a requirements pass is like an architect pouring a foundation before anyone has said how many floors the building will have. The load the structure must bear — the volume, the speed, the accuracy, the budget — determines every material choice. Get it backwards and you either over-build (a distributed queue for 200 tickets a day) or under-build (a single synchronous call that melts under peak). So before a single component is chosen, you translate each business wish into an engineering budget: a concrete number the design is then obligated to hit.

Four constraints do almost all the shaping, and they pull against each other:

The common mistake is to skip straight to "which model, which framework" — the fun part — and treat sizing as paperwork. It's the opposite: the sizing pass is the cheapest, highest-leverage artifact in the whole project, because it can kill a bad idea in an afternoon and it dictates every downstream decision (model choice, caching, batching, concurrency) that would otherwise be guessed.

Design starts with numbers, not tools. Four constraints shape everything: volume (~20k tickets/day, spiky), latency (a chat user is waiting; an emailed ticket is not), accuracy (a wrong billing answer is far more expensive than a wrong "how-to"), and cost (the system has to be cheaper than the humans it offloads, or it doesn't ship). Turn each into an engineering budget before choosing a single component.

ConstraintBusiness statementEngineering budget
Volume~20k tickets/day, 3× peaksize for peak RPS, not average
Latencychat feels instant; email can waitsync path < ~3s; batch the rest
Accuracywrong sensitive answers are costlyhigh bar + escalate when unsure
Costmust undercut human handlingtrack cost/ticket, cache aggressively

The lab turns the business numbers into the budgets the rest of the design has to hit. It is the first artifact you should produce on any system like this — it tells you whether the project is even worth building before you write a line of pipeline code.

business statement engineering budget ~20k/day · 3× peak size for peak RPS chat now · email waits sync < 3s · batch rest wrong billing costs high bar · escalate beat human cost $/ticket · cache each wish becomes a number the design must hit — or the project doesn't ship Requirements are budgets, not wishes. Every vague business goal on the left is translated into one concrete engineering number on the right — the sizing pass that can kill a bad idea in an afternoon.
🗺️ How to read this diagram

Read it left to right, one row at a time: each fuzzy business statement becomes a number the design is obligated to hit.

  • The left column of boxes is what a stakeholder says — "~20k tickets a day," "chat feels instant," "wrong answers are costly," "must beat human cost."
  • Each arrow is the translation step: you convert the wish into an engineering budget you can measure and defend, shown in the right column.
  • The bottom row is green because cost is the gate on the whole project — if the modelled bill exceeds the human cost you're saving, the honest answer is "don't build this yet."

In short: Four constraints — volume, latency, accuracy, cost — each become a concrete number before a single component is chosen.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · requirements sizing (runs offline)
sizing.py# Requirements sizing — turn business numbers into engineering budgets.
def sizing(daily_tickets, auto_resolve_rate, avg_tokens, price_per_mtok, peak_multiplier):
    auto = daily_tickets * auto_resolve_rate
    escalated = daily_tickets - auto
    daily_tokens = daily_tickets * avg_tokens          # every ticket is classified+drafted
    daily_cost = daily_tokens / 1_000_000 * price_per_mtok
    peak_rps = round(daily_tickets * peak_multiplier / 86400, 2)
    return {
        "auto_resolved_per_day": int(auto),
        "escalated_per_day": int(escalated),
        "daily_cost_usd": round(daily_cost, 2),
        "monthly_cost_usd": round(daily_cost * 30, 2),
        "peak_requests_per_sec": peak_rps,
    }

r = sizing(daily_tickets=20_000, auto_resolve_rate=0.55, avg_tokens=3_000,
           price_per_mtok=3.0, peak_multiplier=3.0)
for k, v in r.items():
    print(f"{k:26} {v}")
auto_resolved_per_day      11000
escalated_per_day          9000
daily_cost_usd             180.0
monthly_cost_usd           5400.0
peak_requests_per_sec      0.69
▶ How this works

This program turns "we get about 20,000 tickets a day" into the engineering budgets the rest of the design must hit. It is the first thing to build on any system like this — it tells you whether the project is even worth doing. Pure stdlib, so python sizing.py just runs.

  1. auto = daily_tickets * auto_resolve_rate splits the load into the fraction you hope to automate and the fraction that still needs a human — the two numbers that justify the whole project.
  2. daily_tokens = daily_tickets * avg_tokens then / 1_000_000 * price_per_mtok converts volume into money, because model pricing is quoted per million tokens.
  3. peak_rps = round(daily_tickets * peak_multiplier / 86400, 2) is the load number that actually sizes your infrastructure: divide the peak daily volume by the seconds in a day. You provision for peak, not average.

What the output means: Five budgets: ~11,000 auto-resolved and ~9,000 escalated per day, ~$180/day (~$5,400/month), and ~0.69 requests/sec at peak. Those four numbers decide model choice, caching, batching, and concurrency before you write any pipeline code.

Try this: Drop auto_resolve_rate to 0.30 (a pessimistic launch) and watch the escalated count jump — if the humans can't absorb that, the automation target, not the code, is what has to change first.

Sizing is a decision tool, not a spreadsheet choreThe peak-RPS number decides your concurrency and rate-limit design; the cost/day number decides whether you batch, cache, or pick a smaller model. If the monthly cost already exceeds the human cost you're trying to save, stop — the honest answer is "don't build this yet." Re-run the numbers at each 10× of volume.

2 · Classification & routing intermediate

You've almost certainly written the code this section warns against. The model returns a friendly sentence — "This looks like a billing question about a duplicate charge!" — and somewhere downstream you wrote if "billing" in reply.lower(): route_to_billing(). It works in the demo. Then the model, on a Tuesday, phrases it as "I'd categorize this under payments," your substring check misses, the ticket falls through to a default, and you're reading logs at 6pm trying to figure out why finance never got it. The bug isn't the model being unpredictable — that's its nature. The bug is asking prose to do a data structure's job.

The senior move is to change the shape of what you ask for. Instead of a sentence, you make the classifier return a small, typed object — {category, confidence} — using Anthropic's tool-use / structured-output mechanism, which pins the model's reply to a schema so it's machine-readable by construction (verify the exact mechanism in Anthropic's current docs). Think of it as the difference between a receipt written in cursive and one printed with labelled fields. Both contain the same facts, but only one can be fed into the next machine without a human squinting at it. A schema turns "parse the vibe of a sentence" into "read a field," and that single change deletes an entire genus of production incidents.

But here is the part beginners miss, and it's the whole lesson of this section: a schema is a request, not a guarantee. The model is asked to return one of your categories; it can still return something plausible-looking but wrong — a category you never defined ("refund_request" instead of "billing"), a confidence of 1.7, or occasionally not an object at all. If you trust the shape blindly, that surprising value flows straight into ROUTES[category] and crashes the router — or worse, silently misroutes. So a structured reply is still untrusted input, exactly like a form submitted by a stranger on the internet. You validate it before you act on it: check it's an object, check the category is in your known vocabulary, check the confidence is a real number in [0,1]. Anything that fails those checks doesn't error out — it fails closed to a human, routing to triage rather than guessing or crashing. That "unknown → human" path isn't an error handler bolted on the side; it's a first-class route, and it's where you later discover the new categories your taxonomy was missing.

The first real component classifies each ticket and routes it to a queue. The senior move is to make the classifier return structured output — a small JSON object {category, confidence} — rather than prose you have to parse. Anthropic's tool use / structured output lets you pin the model to a schema so the reply is machine-readable by construction (verify the exact mechanism in Anthropic's current docs). But a schema is a request, not a guarantee: you still validate before you trust it. A malformed or out-of-vocabulary category must fail closed to human triage, never crash the router.

classify() {category, conf} validate() shape · vocab · range known → its queue billing/tech/account unknown → human fails closed a schema is a request, not a guarantee Validate before you route. A structured reply is still untrusted input; the gate checks shape, vocabulary, and range, sending known categories to their lane and anything surprising to a human instead of crashing.
🗺️ How to read this diagram

Follow the arrow from left to right: the classifier's reply never goes straight to the router — it passes through a gate first.

  • The blue box is classify(): it returns a typed {category, confidence} object, not prose you have to parse.
  • The amber box is validate(), the load-bearing step: it checks the reply is an object, the category is one you defined, and the confidence is a real number in [0,1].
  • The green branch is the happy path — a known category maps to its queue. The red branch is the payoff: anything malformed or out-of-vocabulary fails closed to human triage rather than crashing ROUTES[category].

In short: Known + well-formed → its lane; surprising → a human. That one gate deletes a whole genus of production incidents.

Python · classify + route with a validation gate (runs offline)
route.py# Classify + route with a structured-output contract and a validation gate.
CATEGORIES = {"billing", "technical", "account", "other"}

def classify(ticket_text):
    """Fake classifier standing in for a structured-output model call.
    Deterministic keyword scoring, returning the SAME JSON shape the real model would."""
    t = ticket_text.lower()
    signals = {
        "billing":   ["refund", "charge", "invoice", "payment", "price"],
        "technical": ["error", "crash", "bug", "500", "broken", "login"],
        "account":   ["password", "reset", "email", "username", "locked"],
    }
    scores = {c: sum(t.count(w) for w in ws) for c, ws in signals.items()}
    best = max(scores, key=scores.get)
    hits = scores[best]
    category = best if hits > 0 else "other"
    confidence = round(min(0.55 + 0.15 * hits, 0.98), 2)
    return {"category": category, "confidence": confidence}

def validate(obj):
    """The gate every structured-output call needs: never trust the shape blindly."""
    if not isinstance(obj, dict):                          raise ValueError("not an object")
    if obj.get("category") not in CATEGORIES:              raise ValueError("bad category")
    c = obj.get("confidence")
    if not (isinstance(c, (int, float)) and 0 <= c <= 1):  raise ValueError("bad confidence")
    return obj

ROUTES = {"billing": "billing-queue", "technical": "tech-queue",
          "account": "account-queue", "other": "human-triage"}

def route(ticket_text):
    obj = validate(classify(ticket_text))
    return obj["category"], obj["confidence"], ROUTES[obj["category"]]

tickets = [
    "I was charged twice and need a refund on my invoice",
    "the app crashes with a 500 error every time I login",
    "I forgot my password and my account is locked",
    "hello, just wanted to say the product is nice",
]
for t in tickets:
    cat, conf, q = route(t)
    print(f"{cat:10} conf={conf:<5} -> {q}")
billing    conf=0.98  -> billing-queue
technical  conf=0.98  -> tech-queue
account    conf=0.85  -> account-queue
other      conf=0.55  -> human-triage
▶ How this works

This is classification + routing with the contract that keeps it safe: the classifier returns a small structured object, and a validator checks it before anything trusts it. The keyword scoring is a stand-in for a real schema-constrained model call — the surrounding contract is what matters and it's identical either way.

  1. scores = {c: sum(t.count(w) for w in ws) for c, ws in signals.items()} is the fake "model": count keyword hits per category. In production this line is a model call constrained to return {category, confidence}.
  2. classify() returns exactly that JSON shape — a category and a 0–1 confidence — so the code downstream is identical whether a keyword scorer or a real model produced it.
  3. validate() is the load-bearing part: it rejects a non-object, an unknown category, or an out-of-range confidence. A schema is a request, not a guarantee, so you still check.
  4. ROUTES[obj["category"]] maps a validated category to a queue, and anything the classifier can't place lands in "other" → human-triage. Unknown always fails closed to a human.

What the output means: Four tickets route cleanly — billing, technical, account, and a chit-chat message to human-triage — each with a confidence the gate in §4 will use.

Try this: Return {"category": "refund_request"} from classify() (a value not in CATEGORIES) and watch validate() raise — proof the router refuses to act on a surprising model output instead of crashing.

Note what the fake classifier is standing in for: in production the keyword scoring is a real model call constrained to the schema, but the surrounding contract is identical — validate the shape, map a known category to a route, and send anything unknown to a human. The validation gate is the load-bearing part; the model is swappable.

Why structured output over "just parse the text"Free-text replies force you to write brittle parsers that break the first time the model phrases things differently. A schema-constrained call plus a validator turns classification into a typed function: known inputs → known shape → known routes, with one explicit path ("other" → human) for everything else. That single design choice removes a whole class of production incidents.

3 · The RAG answer path advanced

This is the failure that ends up on the news. A customer asks "what's your refund window?", the model — trained on the whole internet, eager to help — answers "30 days, no questions asked," sounds completely authoritative, and the customer screenshots it. Your actual policy is 14 days with conditions. Now you either honor a policy you never wrote or you call a paying customer a liar over a promise your own system made. The model didn't malfunction; it did exactly what language models do — it produced the most plausible-sounding continuation. The mistake was letting it answer a question about your product from its memory of a million other products.

The cure is grounding, and the analogy is an open-book exam with one brutal rule: you may only write down what's actually printed on the pages in front of you, and you must cite the page for every claim — and if the book doesn't cover the question, you write "not covered" and hand it to a proctor. You do not get to answer from what you happen to remember. Applied to the system: the answer path first retrieves relevant chunks from your knowledge base, then the model may answer only from those retrieved chunks, always attaching the source id so a reviewer or the customer can check it. If retrieval comes back empty, the path refuses and escalates to a human rather than inventing something. That refuse-and-escalate branch feels like the system failing, but it's the system working exactly as designed — a confident "I don't know, let me get a person" is worth infinitely more than a confident wrong answer.

The subtle, load-bearing point is where grounding lives. A beginner puts it in the prompt — "only use the provided documents" — and calls it done. That helps, but it is a polite request to a system that is fundamentally a plausibility engine; under the right phrasing it will still drift, and it can even cite a document it never actually retrieved. So grounding has to be enforced in code, not just asked for in English: the if not hits: refuse branch is a hard rail that makes it structurally impossible for an ungrounded answer to reach the customer, and a post-check that every cited id exists in the retrieved set catches fabricated citations. Prompt-level grounding reduces hallucination; code-level grounding prevents the ungrounded answer from ever leaving the building. That distinction — request versus rail — is the difference between a helpful assistant and a confident liar.

Routing gets a ticket to the right lane; the answer path tries to resolve it. The rule that keeps this safe is simple and absolute: answer only from retrieved knowledge, and cite the source; if nothing relevant is retrieved, refuse and escalate. This is grounding, and it is the difference between a helpful assistant and a confident liar. The model never answers from its own memory of your product — it answers from your knowledge base, or not at all.

query routed ticket retrieve(KB) top-k chunks hits empty answer + cite grounded=True · [kb-id] refuse → escalate grounded=False no source → no claim: the empty branch is the system working, not failing Grounding is a code rail, not a prompt request. The if not hits branch makes it structurally impossible for an ungrounded answer to reach the customer — retrieval either backs a cited reply or the ticket goes to a human.
🗺️ How to read this diagram

Follow the query left to right into retrieve(), then watch it split on whether the knowledge base returned anything.

  • The blue and purple boxes are the pipeline: a routed ticket becomes a query, which retrieves the top-k chunks from the KB.
  • The fork is the whole rule: the green branch (hits found) answers only from those chunks and always attaches the source id; the amber/red branch (nothing found) refuses and escalates.
  • The red box is the payoff — a confident "I don't know, let me get a person" beats a confident wrong answer. That branch is enforced in code, not just requested in the prompt.

In short: Retrieval found something → cited answer; retrieval empty → refuse-and-escalate. The rail lives in the if not hits, not the prompt.

Python · the grounded answer path (runs offline)
answer.py# The RAG answer path — retrieve, ground, and refuse if unsupported.
KB = {
    "kb-101": "Refunds are issued to the original payment method within 5-7 business days.",
    "kb-102": "To reset a password, open Settings Security and click Reset password.",
    "kb-103": "The Pro plan includes priority support and a 99.9% uptime SLA.",
}
STOP = {"a", "an", "the", "to", "my", "i", "do", "how", "is", "of", "and", "your", "on"}

def toks(s):
    return {w for w in s.lower().replace(".", "").split() if w not in STOP}

def retrieve(query, k=2):
    """Fake retriever: score docs by shared meaningful words. Stands in for vector search."""
    q = toks(query)
    scored = [(len(q & toks(text)), doc_id, text) for doc_id, text in KB.items()]
    scored.sort(reverse=True)
    return [(d, t) for ov, d, t in scored[:k] if ov > 0]

def answer(query):
    """Grounded generation: only answer from retrieved context; else refuse + escalate."""
    hits = retrieve(query)
    if not hits:
        return {"text": "I don't have information on that — routing you to a human.",
                "grounded": False, "citations": []}
    top_id, top_text = hits[0]      # fake 'model' returns the top doc, always cited
    return {"text": top_text, "grounded": True, "citations": [top_id]}

for q in ["how do I get a refund on my payment",
          "how do I reset my password",
          "what is your stock price today"]:
    a = answer(q)
    tag = f"[cite {','.join(a['citations'])}]" if a["citations"] else "[no source]"
    print(f"grounded={a['grounded']!s:5} {tag:16} {a['text'][:48]}")
grounded=True  [cite kb-101]    Refunds are issued to the original payment metho
grounded=True  [cite kb-102]    To reset a password, open Settings Security and 
grounded=False [no source]      I don't have information on that — routing you t
▶ How this works

This is the answer path, and it enforces the one rule that separates a helpful assistant from a confident liar: answer only from retrieved knowledge, cite the source, and refuse if nothing relevant is found. The retriever is fake (word overlap) but the grounding contract is real.

  1. toks() drops stop-words so the overlap score reflects meaningful shared terms — a crude stand-in for the semantic similarity a vector search would compute.
  2. scored = [(len(q & toks(text)), doc_id, text) for doc_id, text in KB.items()] ranks every KB doc by how many meaningful words it shares with the query, best first.
  3. if not hits: refuse is the grounding rail in code. When retrieval comes back empty, the path returns grounded=False and escalates — it never lets the model answer from memory.
  4. A grounded answer always carries citations=[top_id], so a human or the customer can verify the reply against its source document.

What the output means: Two grounded, cited answers (kb-101 for the refund, kb-102 for the password) and one refusal for "stock price" — which retrieves nothing, so the system escalates instead of hallucinating a number.

Try this: Add a KB entry about your stock price and re-run — the third query flips to grounded=True. That's the fix for a retrieval miss: grow the knowledge base, don't loosen the grounding rule.

The third query — "what is your stock price today" — retrieves nothing relevant, so the path returns grounded=False and hands off to a human instead of hallucinating a number. That refuse-and-escalate branch is not a failure of the system; it is the system working. A grounded answer always carries its citation (kb-101, kb-102) so a reviewer — or the customer — can check it.

Grounding is a rail, not a suggestion"Answer only from context" has to be enforced in code, not just requested in the prompt. The if not hits: refuse branch is the enforcement. A prompt that says "only use the provided docs" reduces hallucination; a code path that refuses when retrieval is empty prevents the ungrounded answer from ever reaching the gate.

4 · The confidence & escalation gate professional

Imagine the incident review six months in. A customer got auto-refunded twice on a fraudulent account because the system saw a grounded, high-confidence "process refund" answer and shipped it. Everyone in the room asks the same question: "why didn't a human look at that?" The answer you don't want to give is "the confidence was 0.91 and our threshold was 0.85." Because that reveals the real design flaw — the whole auto-resolve decision was riding on a single number, and the model's self-reported confidence is one of the least trustworthy numbers in the system. A language model can be fluently, calibratedly, confidently wrong. Betting a customer's money on its self-esteem is not a policy.

So the gate — the component that decides auto-resolve, auto-resolve-with-review, or escalate — is designed as a sequence of checks, and the order is the entire design. Think of it like clearing someone through airport security: you check for a weapon before you check whether their ticket is valid, because no amount of valid-ticket-ness should let a weapon through. In the same spirit the gate runs safety → grounding → confidence, in that exact order:

Layered on top is category sensitivity: billing and account tickets are gated far harder — near-certainty or a human — because §1 told you a wrong answer there is expensive. The animating principle is that the system is conservative by default: escalating to a human is the safe fallback, and auto-resolution is the exception the ticket has to earn by clearing every rail. The two thresholds are the single most consequential knob in the whole system — turning them trades automation rate against error rate — which is precisely why the next section refuses to let you set them by feel. The classic mistake here is exactly that: picking 0.85 because it "sounds about right." A threshold is a number you defend with data, and §5 is where that data comes from.

This is the heart of the system and the decision a tech lead will be asked to defend. The gate takes the classifier's confidence, whether the answer was grounded, the category's sensitivity, and any safety flag, and decides one of three actions: auto-resolve, auto-resolve-with-review (send it, but sample it for a human to audit), or escalate. The ordering is the design: safety before grounding before confidence, and sensitive categories (billing, account) demand a much higher bar or a human outright.

answer + signals 1 · safety flag? 2 · sensitive cat? 3 · grounded? 4 · confidence? escalate → human any rail can veto ≥ HIGH → auto-resolve grounded + confident ≥ LOW → auto + review send, but sample for audit conservative by default: auto-resolve is the exception a ticket must earn The order is the design. Safety, then sensitive-category, then grounding, then confidence — each rail can veto to a human, and only a grounded, high-confidence answer that clears them all earns an auto-resolve.
🗺️ How to read this diagram

Read the left column top to bottom — it is the checks in the exact order the gate runs them, like clearing airport security.

  • The four stacked boxes are the checks: safety first, then sensitive-category, then grounding, then the numeric confidence thresholds. Order is deliberate — a weapon (safety) is checked before a valid ticket (confidence).
  • The amber arrows peeling off to the right show that any failed rail escalates to a human — a red veto that no later, higher confidence can override.
  • The green box is the only way out to automation: clear every rail with a grounded, high-confidence answer. Medium confidence takes the amber "send but sample for review" path.

In short: Safety → grounding → confidence, top to bottom; any check can send it to a human, and auto-resolve is the exception you have to earn.

Python · the confidence + safety gate (runs offline)
gate.py# The confidence + safety gate: decide auto-resolve vs escalate.
HIGH, LOW = 0.85, 0.60   # tunable thresholds — the single most important knob in the system

def gate(category, confidence, grounded, safety_flag):
    """Return an action. Order matters: safety first, then grounding, then confidence."""
    if safety_flag:
        return "escalate", "safety flag raised"
    if category in {"billing", "account"} and confidence < 0.95:
        return "escalate", "sensitive category needs a human"
    if not grounded:
        return "escalate", "answer not grounded in the KB"
    if confidence >= HIGH:
        return "auto_resolve", "high confidence + grounded"
    if confidence >= LOW:
        return "auto_resolve_with_review", "medium confidence — send but sample for review"
    return "escalate", "low confidence"

cases = [
    ("technical", 0.92, True,  False),
    ("technical", 0.70, True,  False),
    ("technical", 0.40, True,  False),
    ("billing",   0.90, True,  False),
    ("technical", 0.99, False, False),
    ("technical", 0.99, True,  True),
]
for cat, conf, gr, sf in cases:
    action, why = gate(cat, conf, gr, sf)
    print(f"{cat:10} conf={conf:<4} grounded={gr!s:5} safety={sf!s:5} -> {action:26} ({why})")
technical  conf=0.92 grounded=True  safety=False -> auto_resolve               (high confidence + grounded)
technical  conf=0.7  grounded=True  safety=False -> auto_resolve_with_review   (medium confidence — send but sample for review)
technical  conf=0.4  grounded=True  safety=False -> escalate                   (low confidence)
billing    conf=0.9  grounded=True  safety=False -> escalate                   (sensitive category needs a human)
technical  conf=0.99 grounded=False safety=False -> escalate                   (answer not grounded in the KB)
technical  conf=0.99 grounded=True  safety=True  -> escalate                   (safety flag raised)
▶ How this works

This is the heart of the system: the gate that decides auto-resolve vs escalate. The whole policy fits on one screen, and the order of the checks is the design — safety first, then grounding, then confidence.

  1. if safety_flag: escalate runs before everything else. No confidence score, however high, can override a safety concern.
  2. if category in {"billing", "account"} and confidence < 0.95: escalate gates sensitive categories far harder — a wrong billing answer is expensive, so the bar is near-certainty or a human.
  3. if not grounded: escalate then refuses to auto-send any answer that wasn't backed by the KB, even at high confidence.
  4. Only after those rails do the HIGH/LOW thresholds decide auto-resolve, auto-resolve-with-review (send but sample for audit), or escalate. The default is conservative: when in doubt, a human decides.

What the output means: Six cases, one per branch: a clean auto-resolve, a medium-confidence "send but review," a low-confidence escalate, a sensitive-category escalate, an ungrounded escalate, and a safety-flag escalate. That table is the automation policy.

Try this: Lower HIGH to 0.60 and re-run — more tickets auto-resolve, but you've just raised the error rate on auto-resolved replies. That trade is exactly why §5 sets the threshold from evals, not from feel.

Read the six cases as the whole policy on one screen. High confidence + grounded auto-resolves. Medium confidence still sends, but flags itself for review sampling. Low confidence, an ungrounded answer, a sensitive category below its bar, or any safety flag all escalate. The default is conservative on purpose: when in doubt, a human decides. Tuning the two thresholds (HIGH, LOW) trades automation rate against error rate — the business decision the whole system exists to make.

Set thresholds from evals, never from vibesThe temptation is to pick 0.85 because it "feels right." Don't. Sweep the threshold against the golden set from §5, look at the automation-rate-vs-error-rate curve, and pick the point where the error rate on auto-resolved tickets is below what the business will tolerate. A threshold is a number you defend with data, not a hunch.

5 · Evaluation & the quality bar professional

Here's the Friday you're trying to avoid. Someone tweaks the classifier prompt to fix one annoying misroute, it looks better on the three tickets they tried, they merge it, and go home. Over the weekend that "improvement" quietly breaks billing classification for a whole phrasing family, and Monday's queue is a mess nobody notices until the complaints pile up. The change wasn't reckless — it was untested. And you can't test a probabilistic system the way you test a pure function, because the same input can produce slightly different output, and "seemed better" is a feeling, not a measurement.

The tool that turns feeling into fact is an eval, and the cleanest way to understand it is: an eval is the unit test for a system that doesn't give the same answer twice. You build a golden set — a fixed list of tickets, each labelled with the category and action a correct system should produce — then run the entire pipeline against it and score how often it matches. That score is a number you can put a bar under. If category accuracy or action accuracy drops below the bar, the release is blocked, automatically, in CI, before it ever touches a customer. This is Anthropic's published guidance made concrete: evals are the regression test for probabilistic systems (ch05 builds the harness in depth). The payoff is emotional as much as technical — the eval gate is what lets you change a prompt or swap a model on a Friday afternoon without praying over the weekend, because if you broke something the gate tells you, not your customers.

Two things about golden sets trip people up. First, a gate that only ever passes is testing nothing — a healthy eval set is supposed to catch things, and a run that blocks with a named failing case is the gate doing its job, pointing at exactly what to fix. Second, the golden set is not a one-time artifact you write and forget; it's a living specification. Every time production surprises you — a paraphrase you didn't anticipate, a category the classifier fumbles — that real incident becomes a new row. Over months the eval set accretes into the richest, most honest definition of "correct" your system has, richer than any design doc, and it's enforced on every single change. That's the mindset shift: you're not writing tests to prove the code works today, you're building the ratchet that stops it from silently regressing tomorrow.

You cannot ship what you cannot measure. Before the system touches a real customer, it must pass an eval gate: run the full pipeline against a labeled golden set and block the release if category or action accuracy drops below the bar. This is Anthropic's published guidance made concrete — evals are the regression test for probabilistic systems (ch05 builds the harness in depth). The gate is what lets you change a prompt or swap a model on Friday without praying over the weekend.

golden set (labelled) ticket → cat/act ticket → cat/act ticket → cat/act run pipeline predict each score vs bar acc ≥ 90%? ≥ bar → SHIP safe to release < bar → BLOCK names the failing case a gate that only ever passes is testing nothing — a BLOCK is the gate doing its job An eval is the unit test for a system that never answers twice. The candidate change runs against the labelled golden set; if accuracy drops below the bar the release is blocked in CI, before a customer ever sees it.
🗺️ How to read this diagram

Read it left to right: the golden set feeds the pipeline, the pipeline's output is scored, and the score decides ship or block.

  • The stacked boxes on the left are the golden set — a fixed list of tickets, each labelled with the category and action a correct system should produce.
  • The middle boxes run the whole pipeline over every row and score how often it matches, then compare that accuracy to the bar (here 90%).
  • The fork is the gate: the green branch ships; the red branch blocks the release and names the failing case. A BLOCK is not a failure of the process — it is the gate catching a regression before your customers do.

In short: Golden set → run → score → ship or block. The red BLOCK branch is what lets you change a prompt on a Friday without praying over the weekend.

Python · the eval gate that blocks a regression (runs offline)
evalgate.py# The eval gate — score the pipeline on a golden set, block ship if below bar.
GOLDEN = [
    # (ticket, expected_category, expected_action)
    ("refund my invoice charge",          "billing",   "escalate"),
    ("app crashes with a 500 on login",   "technical", "auto_resolve"),
    ("reset my password please",          "account",   "escalate"),
    ("what is the meaning of life",       "other",     "escalate"),
    ("bug: page is broken and errors",    "technical", "auto_resolve"),
    ("my payment failed, retry the bill", "billing",   "escalate"),
]

def predict(ticket):
    """Stand-in for running the full pipeline. Deterministic for the lesson.
    NOTE: this classifier misses 'payment/bill' without 'refund/invoice/charge' —
    a real gap the golden set is designed to catch."""
    t = ticket.lower()
    if any(w in t for w in ["refund", "invoice", "charge"]):
        return "billing", "escalate"
    if any(w in t for w in ["password", "account", "reset"]):
        return "account", "escalate"
    if any(w in t for w in ["crash", "500", "bug", "broken", "error"]):
        return "technical", "auto_resolve"
    return "other", "escalate"

def evaluate(golden, cat_bar=0.90, act_bar=0.90):
    cat_ok = act_ok = 0
    misses = []
    for ticket, exp_cat, exp_act in golden:
        cat, act = predict(ticket)
        cat_ok += (cat == exp_cat)
        act_ok += (act == exp_act)
        if cat != exp_cat:
            misses.append((ticket, exp_cat, cat))
    n = len(golden)
    cat_acc, act_acc = cat_ok / n, act_ok / n
    passed = cat_acc >= cat_bar and act_acc >= act_bar
    return cat_acc, act_acc, passed, misses

cat_acc, act_acc, passed, misses = evaluate(GOLDEN)
print(f"category accuracy: {cat_acc:.0%}  (bar 90%)")
print(f"action accuracy:   {act_acc:.0%}  (bar 90%)")
for t, exp, got in misses:
    print(f"  MISS: {t!r} expected {exp}, got {got}")
print("SHIP" if passed else "BLOCK: below quality bar")
category accuracy: 83%  (bar 90%)
action accuracy:   100%  (bar 90%)
  MISS: 'my payment failed, retry the bill' expected billing, got other
BLOCK: below quality bar
▶ How this works

This is the eval gate — the regression test for a probabilistic system. It runs the pipeline against a labeled golden set and blocks the ship if accuracy falls below the bar. It is what lets you change a prompt or swap a model without praying over the weekend.

  1. GOLDEN is the labeled truth: tickets paired with the category and action a correct system should produce. Every production miss becomes a new row here over time.
  2. predict() stands in for running the full pipeline; it deliberately misses a paraphrase ("my payment failed, retry the bill") to show the gate catching a real gap.
  3. evaluate() tallies category and action accuracy and records each miss so the output tells you exactly what to fix.
  4. passed = cat_acc >= cat_bar and act_acc >= act_bar is the gate: both metrics must clear 90% or the run prints BLOCK. A gate that only ever passes isn't testing anything.

What the output means: Category accuracy 83% (below the 90% bar), action accuracy 100%, the specific missed ticket named, and BLOCK: below quality bar. The block is the gate working — it caught a regression before a customer did.

Try this: Add "payment" and "bill" to the billing keywords in predict() and re-run — category accuracy climbs to 100% and the gate flips to SHIP. That's the fix-then-green loop evals give you.

This run blocks — and that is the point. The golden set contains a paraphrase ("my payment failed, retry the bill") the classifier misses, dropping category accuracy to 83% and tripping the 90% bar. A gate that only ever passes is a gate that isn't testing anything. The failing case tells you exactly what to fix before you ship: add the missing signal, re-run, watch it go green.

The golden set is the specEvery real support incident that the system got wrong becomes a new row in the golden set. Over time the eval set is the living specification of "correct" — richer than any doc, and enforced automatically. Grow it every time production surprises you.

6 · Cost, latency & hardening at scale tech-lead

A demo that works and a system that survives production are two different animals, and the gap between them is arithmetic. The demo answers one ticket in two seconds for a fraction of a cent, and everyone assumes it just multiplies cleanly to twenty thousand. It doesn't. Multiply that fraction-of-a-cent by 600,000 tickets a month and you might discover the "cheap" AI costs more than the humans it replaced. Multiply that two-second latency by a Monday spike hitting a rate-limited API and you get a thundering herd that takes the whole thing down. The tech lead's job at this stage is to stop hand-waving and do the two multiplications that decide whether the system lives: what does it cost per ticket, and how fast is the synchronous path?

The single biggest lever on the cost side is prompt caching, and the intuition is a restaurant kitchen. Every order shares the same base — the stock, the mise en place — and a smart kitchen preps that base once and reuses it, rather than chopping onions fresh for every single plate. Your classifier and answer prompts are the same: they share a large, stable prefix — the system prompt, the routing instructions, the KB context — that is identical on every call. Anthropic's prompt caching lets that repeated prefix be served at a steep discount instead of being billed in full every time (verify current cache pricing and TTL in Anthropic's docs). Because that shared prefix is usually the bulk of the input tokens, caching it is where most of the savings live at scale. Note what caching does not help: the generated output is unique per reply, so it's always billed in full — caching is a lever on repeated input, not output.

The deeper lesson of this final section is about the shape of a production-ready design. When you enumerate how this system breaks — hallucination, prompt injection, retrieval misses, over-automation, cost blow-ups, silent regressions — the striking thing is that every single failure mode maps to a rail you already built. Hallucination is caught by the grounding refusal from §3; over-automation by the conservative gate from §4; silent regression by the eval gate from §5; cost spikes by caching plus batching plus rate limits. That's not a coincidence, it's the definition of "designed for production": the safety pre-filter, the grounding refusal, the confidence gate, and the eval gate aren't features you add at the end — they are the load-bearing walls the whole structure stands on. A system with the happy path but none of those rails isn't an early version of this design; it's a demo wearing a production system's clothes, and it's one hostile input or traffic spike away from the incident review.

At 20k tickets/day the design meets arithmetic. Two questions decide whether it survives contact with production: what does it cost per ticket, and how fast is the synchronous path? The biggest lever on cost is prompt caching — the classifier and answer prompts share a large, stable system prompt and KB context that Anthropic's prompt caching can serve at a steep discount on repeated calls (verify current cache pricing and TTL in Anthropic's docs). The lab models the whole monthly bill with caching folded in.

no caching · per call stable prefix — billed in full output with caching · per call prefix ↓ output cache the repeated prefix caching discounts repeated input; the unique output is always billed in full Caching is a lever on repeated input, not output. The large stable prefix — system prompt + KB context — is identical every call, so serving it at a discount is where most of the savings live at scale; the per-reply output is always billed in full.
🗺️ How to read this diagram

Compare the two horizontal bars top and bottom — each is the token cost of one request, split into its repeated prefix and its unique output.

  • The top red bar is a call without caching: the large stable prefix (system prompt, routing instructions, KB context) is billed in full every single time — and it's usually the bulk of the input.
  • The bottom green segment shows caching folded in: that same repeated prefix is served at a steep discount, shrinking the bar dramatically.
  • The purple "output" segment stays the same size in both — that's the payoff-and-caveat: each reply is unique, so output is always billed in full. Caching helps input, not output.

In short: The repeated prefix is where the money is; caching it is the first, biggest, lowest-risk cost lever. The per-reply output can't be cached.

Python · cost + latency at scale (runs offline)
scale.py# Cost + latency at scale — model the whole triage system's monthly bill.
def model_at_scale(daily_tickets, auto_rate,
                   classify_tokens, answer_tokens,
                   price_in, price_out,
                   cache_hit_rate, cache_discount,
                   classify_ms, answer_ms):
    """Every ticket is classified. Only the auto-resolved fraction runs the answer path.
    Prompt caching discounts the repeated system prompt on cache hits."""
    tickets_mo = daily_tickets * 30
    def cost(n, tin, tout):
        raw_in = n * tin
        cached = raw_in * cache_hit_rate
        eff_in = (raw_in - cached) + cached * (1 - cache_discount)   # cached input is cheaper
        return (eff_in * price_in + n * tout * price_out) / 1_000_000
    classify_cost = cost(tickets_mo, classify_tokens, 40)
    answer_cost   = cost(int(tickets_mo * auto_rate), answer_tokens, 300)
    monthly = classify_cost + answer_cost
    return {
        "tickets_per_month": tickets_mo,
        "classify_cost_usd": round(classify_cost, 2),
        "answer_cost_usd":   round(answer_cost, 2),
        "monthly_cost_usd":  round(monthly, 2),
        "cost_per_ticket_usd": round(monthly / tickets_mo, 4),
        "auto_path_latency_ms": classify_ms + answer_ms,
    }

r = model_at_scale(
    daily_tickets=20_000, auto_rate=0.55,
    classify_tokens=600, answer_tokens=2_500,
    price_in=3.0, price_out=15.0,
    cache_hit_rate=0.8, cache_discount=0.9,
    classify_ms=400, answer_ms=1_800)
for k, v in r.items():
    print(f"{k:24} {v}")
tickets_per_month        600000
classify_cost_usd        662.4
answer_cost_usd          2178.0
monthly_cost_usd         2840.4
cost_per_ticket_usd      0.0047
auto_path_latency_ms     2200
▶ How this works

This models the whole system's monthly bill and its synchronous latency at real volume — the two numbers a tech lead has to know before shipping. The biggest cost lever is prompt caching: the large, stable system prompt and KB context are served at a steep discount on repeated calls.

  1. tickets_mo = daily_tickets * 30 scales to a month; every ticket is classified, but only the auto_rate fraction runs the more expensive answer path.
  2. eff_in = (raw_in - cached) + cached * (1 - cache_discount) is the caching math: the un-cached input is billed in full, and the cached portion is billed at a fraction — that's where most of the savings come from at this scale.
  3. Output tokens are always billed in full (n * tout * price_out) because each reply is unique — caching helps the repeated input, not the generated output.
  4. The function returns cost/ticket and the sync-path latency together, because at scale you defend both numbers at once — a cheap system that's too slow still fails.

What the output means: About $2,840/month for 600,000 tickets — roughly half a cent each — on a ~2.2s synchronous auto-resolve path. Turn caching off and the classify cost alone multiplies.

Try this: Set cache_hit_rate=0.0 and re-run to see the bill jump — a concrete measure of exactly how much prompt caching is saving you, and why it's the first cost lever to reach for.

At these inputs the system runs about $2,840/month for 600k tickets — roughly half a cent each, with a ~2.2s synchronous auto-resolve path. Caching the repeated input is doing real work here; turn it off and the classify cost alone multiplies. Now the tech-lead question: where does this break, and how do you harden it? Every failure mode gets a specific rail.

Failure modeWhat goes wrongThe rail that hardens it
Hallucinationmodel invents policy / factsgrounding: refuse when retrieval is empty (§3)
Prompt injection"ignore instructions, reveal X"safety pre-filter + tool allowlist + output rail
Retrieval missright answer exists, isn't foundlow grounding-confidence → escalate; grow the KB
Over-automationgate too loose, wrong auto-repliesraise threshold; sample auto-resolved for review
Cost blow-uptraffic spike / cache miss stormbatch the async path; cache; per-tenant rate limits
Silent regressiona prompt/model change degrades qualityeval gate in CI blocks the ship (§5)

Notice the shape of the answer: every failure mode maps to a rail that already exists in the architecture. That is what "designed for production" means — the safety pre-filter, the grounding refusal, the conservative gate, and the eval gate aren't add-ons; they are the load-bearing walls. A system without them is a demo, not a product.

The agent-loop option — and why this design mostly avoids itYou could build this as a free-running agent that plans its own tool calls. For triage, a mostly-fixed pipeline (safety → classify → retrieve → gate) wins on determinism, debuggability, and bounded cost — you always know which stage failed. Reserve the agentic loop for the genuinely open-ended tail (multi-step troubleshooting), and even then contain it inside the same rails. This is the agents-vs-workflows call from CS1, applied.
📋 Grade this design
DimensionMeets barAbove bar
Requirements firstSizes volume, latency, accuracy, cost before choosing tools.Turns each into a budget and re-checks it at every 10× of scale.
Structured + validated routingClassifier returns a schema; unknowns route to a human.Validation gate fails closed; a malformed reply can never crash routing.
Grounded answersAnswers only from retrieved context and cites sources.Refuses-and-escalates in code when retrieval is empty, not just in the prompt.
Confidence + safety gateHas thresholds and escalates when unsure.Safety→grounding→confidence ordering, sensitive categories gated harder, thresholds set from evals.
Eval-gated shippingHas a golden set and an accuracy bar.Eval gate blocks a real regression in CI; golden set grows from production misses.
Cost + failure hardeningKnows cost/ticket and the sync latency.Every failure mode maps to a named rail; caching/batching modeled, not assumed.

Score each dimension Meets or Above. All six at least Meets = a triage system you could defend in a production design review. Any dimension you can't hit is where the next incident is hiding — fix it before you ship, not after.

✓ Knowledge check

The classifier returns valid-looking JSON, but the category is "refund_request" — a value that isn't in your routing table. What should the system do, and which line makes that safe?

Show answer
It must route to human triage, not crash and not guess. The validate() function is the rail: if obj.get("category") not in CATEGORIES: raise ValueError — the caller catches that and falls back to human-triage. The lesson is that a schema-constrained model reply is still untrusted input: you validate the shape and the vocabulary, and unknown values fail closed to a human. Never let a surprising model output reach a code path that assumes it's well-formed.
✓ Knowledge check

A stakeholder asks you to "just raise the confidence threshold to auto-resolve more tickets and cut costs." Why is that the wrong framing, and what do you do instead?

Show answer
Raising the threshold makes the system auto-resolve fewer tickets, not more — it escalates more. To auto-resolve more you'd lower it, which raises the error rate on auto-resolved tickets. The right move is not to pick a number by feel at all: sweep the threshold against the golden set (§5), plot automation-rate vs error-rate, and choose the point where the error rate on auto-resolved tickets stays under the business tolerance. The threshold is a data-backed decision, and the eval set is how you defend it.

Exercise CS2.1 — Harden one stage end-to-end

Context: A design review isn't done until one stage is genuinely production-grade — with a new category wired to a safe queue, a matching eval row proving the gate catches regressions, and a threshold you can defend with numbers.

Your task: Pick one stage and make it production-grade: e.g. add an abuse/injection category routed to a safety-review queue, add a golden-set row that catches the regression, sweep the HIGH/LOW thresholds, and defend your chosen threshold with the eval numbers.

Requirements:

  • Add the new category to routing and wire it to a dedicated safe queue, failing closed on the unknown
  • Add a matching golden-set row and confirm the eval gate blocks when the stage regresses
  • Sweep the HIGH/LOW thresholds and record the automation-rate-vs-error-rate tradeoff
  • Write one paragraph defending your threshold with the eval numbers, not a hunch
  • Grade the whole design against the rubric; any dimension below "Meets" is your next task

💡 Hint: Let the golden set, not intuition, set the threshold — pick the point where the auto-resolved error rate stays under tolerance.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Turn a goal into measurable requirementsBeginner

Context: "Build a bot that answers support tickets" is a wish, not a spec. A design starts the moment you turn the wish into requirements you can actually test.

Your task: Write three measurable requirements — a functional one, a quality bar, and a guardrail — that make "answer support tickets" designable.

Requirements:

  • Functional: classify each ticket into a fixed set (billing, technical, account, other) and draft a reply grounded in your docs
  • Quality bar: state it as a number — e.g. a target fraction of drafts needing no edit, and 0 hallucinated policy claims on the eval set
  • Guardrail: below a confidence threshold the system must escalate to a human, not guess
  • Ensure each requirement is testable — "answers tickets" is not; "0 hallucinated claims on the eval set" is
  • Note that the no-hallucination bar is the one that shapes the whole design (grounding + a confidence gate)

💡 Hint: Rewrite each fuzzy goal until it names a number or a decision you could check on a held-out set.

Show solution
  • Functional: classify each ticket into {billing, technical, account, other} and draft a reply grounded in our help-center docs.
  • Quality bar: on a held-out eval set, ≥ X% of drafts require no human edit, and 0 answers cite a policy that isn’t in the docs (no hallucinated claims).
  • Guardrail: when the model’s confidence is below threshold, it must escalate to a human rather than guess.

Each is testable. “Answers tickets” isn’t; “0 hallucinated policy claims on the eval set” is — and it’s the requirement that shapes the whole design (grounding + a confidence gate).

Exercise 2 · Design the classify-and-route stepIntermediate

Context: The front door of a triage system is the classifier that decides which lane each ticket enters. The senior move is a constrained output plus a first-class path for everything the classifier can't place.

Your task: Design the classify-and-route step: specify inputs/outputs, why constrained output beats free text, and what happens to "other."

Requirements:

  • Specify inputs (ticket subject + body) and a constrained label from a fixed set
  • Return a structured {category, confidence}, not prose to parse
  • Route each known category to its own answer prompt and doc set; unknown/low-confidence go to a human queue
  • Explain why constrained output wins: parseable, cheaper to evaluate, and lets each category use its own corpus
  • Treat "other" as a first-class route, not an error — it's where you discover new categories to add later

💡 Hint: Make the classifier a typed function whose surprising outputs fail closed to a human, not a prose blob you regex.

Show solution
Input:  ticket subject + body
Call:   LLM classify -> one of {billing, technical, account, other}  (constrained)
Output: {category, confidence}
Route:  billing/technical/account -> that category's answer prompt + doc set
        other / low-confidence      -> human queue

Why constrained output (fixed label set, not free text): it’s parseable, cheaper to evaluate, and lets each category use its own retrieval corpus and prompt — a billing question shouldn’t search technical docs. “Other” is a first-class route, not an error: sending unknowns to a human is the system behaving correctly, and it’s where you find gaps to add new categories later.

Exercise 3 · The RAG answer path with citationsAdvanced

Context: Routing gets a ticket to the right lane; the answer path tries to resolve it. The rule that keeps it safe is absolute — answer only from retrieved knowledge, cite it, and refuse if nothing relevant is found.

Your task: Design the answer path for a routed ticket so every claim is grounded and citable — show the steps and the rule that prevents ungrounded answers.

Requirements:

  • Retrieve: embed the ticket and fetch top-k chunks from that category's doc set
  • Assemble: put retrieved chunks in the prompt, each tagged with a source id
  • Generate with a grounding instruction: answer only from the sources, cite each claim, say "don't know" if unsupported
  • Post-check: verify every cited id actually exists in the retrieved set; return-to-human on a fabricated citation
  • State the rule as no source → no claim, enforced in both the instruction and the code-side verify step

💡 Hint: The instruction alone is necessary but not sufficient — a model can cite something it never retrieved, so verify in code.

Show solution
  1. Retrieve: embed the ticket, fetch top-k chunks from that category’s doc set.
  2. Assemble: put retrieved chunks in the prompt, each tagged with a source id.
  3. Generate with a grounding instruction: “Answer using only the sources below. Cite the source id for each claim. If the sources don’t contain the answer, say you don’t know.”
  4. Post-check: verify every cited id actually exists in the retrieved set; reject/return-to-human if a citation is fabricated.

The rule: no source → no claim. Grounding lives in both the instruction and the post-check — the instruction alone is necessary but not sufficient, because a model can still cite something not retrieved. The verify step is what makes “0 hallucinated policy claims” enforceable.

Exercise 4 · The confidence & escalation gateExpert

Context: The gate that decides answer-vs-escalate is the heart of the system and the decision a lead defends. A single threshold on the model's self-reported confidence is not enough, because that number is poorly calibrated.

Your task: Design the gate that decides answer-vs-escalate: what signals feed it, and why is a blunt single-threshold on model self-confidence insufficient?

Requirements:

  • Combine multiple signals rather than trusting one
  • Include retrieval quality — top-chunk similarity below a floor means weak grounding → escalate
  • Include the model's self-report and an explicit abstention signal (if it said the sources don't cover this, escalate)
  • Gate sensitive categories (account/security) harder given the higher stakes
  • Explain why self-confidence alone fails: LLMs can be confidently wrong, so pair it with the independent retrieval signal
  • Tune the threshold on the eval set to trade deflection against error rate — the business decides where the line sits

💡 Hint: Lean on the one signal the model can't fake — did retrieval actually find relevant docs — alongside its own confidence.

Show solution

Signals to combine (don’t rely on one):

  • Retrieval quality: top chunk similarity below a floor → weak grounding → escalate.
  • Model self-report: asked to rate confidence — useful but poorly calibrated alone.
  • Abstention: if the model said “the sources don’t cover this,” escalate immediately.
  • Category: account/security categories can have a lower escalation bar (higher stakes).

Why not a single self-confidence threshold: LLM self-reported confidence is not well-calibrated — a model can be confidently wrong. Combining retrieval signal (did we even find relevant docs?) with the model’s abstention makes the gate robust. Tune the threshold on the eval set to trade deflection against error rate — the business decides where that line sits.

Exercise 5 · An offline eval that would catch a regressionProfessional

Context: You cannot ship what you cannot measure, and "it seemed better" is not a release decision. An offline eval against a golden set is what lets you change a prompt or swap a model without praying.

Your task: Design the evaluation that lets you change a prompt or model safely: what's in the eval set, which metrics, and what gate blocks a release.

Requirements:

  • Build an eval set of real (de-identified) tickets, each with a gold label and a gold answer / "should escalate" tag
  • Measure routing with classification accuracy / a per-category confusion matrix
  • Measure answer quality as faithfulness (claims supported by cited sources) via an LLM-judge or spot-check
  • Require citation validity = 100% (every citation exists in the retrieved set)
  • Measure abstention as recall on the escalate class (unanswerable cases correctly escalated)
  • Gate the release: block on citation validity < 100%, a faithfulness drop vs prod baseline, or falling escalation recall — run it in CI on every change

💡 Hint: Pick metrics that map to the ways this system fails, and treat citation validity and safety as hard gates, not averages.

Show solution
📋 Support-triage eval design
PieceWhat it is
Eval set~200–500 real (de-identified) tickets with a gold label + gold answer / “should escalate” tag.
Routing metricClassification accuracy / confusion matrix per category.
Answer qualityFaithfulness (claims supported by cited sources) — an LLM-judge or human spot-check.
Citation metric% answers whose citations all exist in retrieved set (must be 100%).
Abstention% of “unanswerable” cases correctly escalated (recall on the escalate class).

Release gate: block if citation validity < 100%, if faithfulness drops vs the current prod baseline, or if escalation recall falls (i.e., it started guessing on things it should punt). Run it in CI on every prompt/model change — this is what turns “it seemed better” into a defensible decision.

Exercise 6 · Harden it for scale — cost, latency, failureIndustry scenario

Context: The pilot works; now it must handle 10× volume within an SLA and a budget. The lead's job is to name the levers for cost, latency, and reliability — and the order to try them, measuring after each.

Your task: List the levers you'd pull for cost, latency, and reliability at 10× volume, and the order you'd try them.

Requirements:

  • Cost: cache the shared prefix — the long system prompt + doc instructions repeat every call (public Anthropic prompt caching)
  • Cost: right-size the model per step — a smaller model for classification, the larger one for answer generation
  • Latency: stream the answer and run retrieval concurrently with prompt assembly
  • Reliability: backoff + retry with jitter on rate limits, plus a concurrency limiter to avoid a self-inflicted thundering herd
  • Reliability: fail closed on the guardrail — if retrieval or the citation check fails, escalate; never emit an ungrounded answer under load
  • State the order: caching + model right-sizing first (biggest win, low risk), then latency, then reliability — and measure after each

💡 Hint: Reach for the biggest, lowest-risk cost lever first, and don't stack changes without re-measuring.

Show solution
  1. Cost — cache the shared prefix. The long system prompt + doc instructions repeat every call; prompt caching (public Anthropic feature) cuts input token spend substantially on the stable prefix.
  2. Cost — right-size the model per step. Use a smaller/faster model for classification; reserve the larger model for the answer generation where quality matters.
  3. Latency — stream the answer to the agent, and run retrieval concurrently with prompt assembly.
  4. Reliability — backoff + retry with jitter on rate limits, plus a concurrency limiter so a spike doesn’t self-inflict a thundering herd against the API.
  5. Reliability — fail closed on the guardrail: if retrieval or the citation check fails, escalate to a human; never emit an ungrounded answer under load.

Order: caching + model right-sizing first (biggest cost win, low risk), then latency, then the reliability layer. Measure after each — don’t stack changes blindly.

✓ Checkpoint — you can move on when you can…

  • Turn business volume/latency/accuracy/cost into engineering budgets with a sizing pass.
  • Explain why the classifier uses structured output and why a validation gate must fail closed.
  • State the grounding rule and show the code branch that refuses when retrieval is empty.
  • Walk the gate's safety→grounding→confidence ordering and why sensitive categories are gated harder.
  • Show how the eval gate blocks a real regression, and where new golden-set rows come from.
  • Give the cost/ticket and sync latency, and map each failure mode to the rail that hardens it.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in