Production Hardening
Everything so far works on your laptop. This chapter is what stands between that and a service real users depend on: caching to cut cost, observability to debug, guardrails to stay safe, cost controls to stay solvent, and deployment discipline to ship changes without breaking things. We assemble it all into one hardened service.
Learning objectives
- Cut cost dramatically with prompt caching — and verify it's actually working.
- Instrument every request with structured logs, metrics, and traces.
- Add input and output guardrails, including prompt-injection defense.
- Control cost with model routing and semantic caching.
- Deploy behind flags with versioning and instant rollback.
From prototype to product advanced
A prototype answers correctly when you're watching. A product answers correctly at 3am, under load, when a user pastes something weird, without bankrupting you, and lets you fix a regression in minutes. The gap is entirely the concerns in this chapter — and they're reusable across every LLM system you'll ever build.
Lab 6.1 · Prompt caching — your biggest cost lever advanced
If you send a large stable prefix (system prompt, tool defs, shared context) on every request, caching lets the provider reuse it at ~10% of the input price. The rule: caching is a prefix match — any byte change anywhere in the prefix invalidates everything after it. Render order is tools → system → messages.
This diagram explains prompt caching — the single biggest cost lever in the chapter. The idea: the big unchanging start of every request (your rules, tools, shared context) can be stored once and reused for ~10% of the price, instead of being re-charged in full each time.
- Each row is one request. The left, wide box is the prefix — the stable content (tools + system + shared context) that's identical on every call. The narrow right box is the tail — the new user message that changes each time.
- req 1: the first time, the provider stores ("writes") the prefix into the cache. You pay full price this once.
- req 2 (green): the prefix is byte-for-byte identical, so it's a cache hit — reused at ~10% cost. Only the new tail is charged at full price. This is the win.
- req 3 (red): a single byte in the prefix changed, so it's a cache miss — everything after the change is re-priced at full cost. That's why the prefix must stay exactly the same and volatile bits (timestamps, IDs, the question) go last.
In short: keep the expensive, unchanging stuff first and frozen; put anything that varies last. Same byte in front → cheap; one byte changed → back to full price.
- Freeze the prefix, mark a breakpoint on the last stable block.
caching.py
resp = client.messages.create( model="claude-opus-4-8", max_tokens=600, system=[{ "type": "text", "text": LARGE_STABLE_INSTRUCTIONS, # same bytes every call "cache_control": {"type": "ephemeral"}, # breakpoint here }], messages=[{"role":"user","content": user_question}], # volatile — after cache ) - Verify it's working. This step separates people who "added caching" from people who have caching.
Illustrative fragment — defines demo values / files are needed before this runs standalone.
caching.py
print("cache write:", resp.usage.cache_creation_input_tokens) print("cache read: ", resp.usage.cache_read_input_tokens) # >0 on 2nd+ call = win print("uncached: ", resp.usage.input_tokens)Run the same request twice. On the second call,
cache_read_input_tokensshould be large andinput_tokenssmall. If read is always 0, you have a silent invalidator.
Adding caching is easy; proving it works is what matters. These three prints read the token counts the API returns so you can confirm the cache is actually being hit — otherwise you might be paying full price and never know (there's no error when caching silently fails).
resp.usage.cache_creation_input_tokens— how many tokens were written into the cache. This is large on the first call (you're storing the prefix).resp.usage.cache_read_input_tokens— how many tokens were read from the cache. The comment says it all:>0 on 2nd+ call = win. This is the number you watch.resp.usage.input_tokens— the uncached tokens you paid full price for. On a good cache hit this should be small (just the new question).
What the output means: Run the same request twice. On the second run, cache read should be large and uncached small. If cache read is always 0, something in your prefix is changing between calls (a "silent invalidator").
Try this: Add a datetime.now() into the system text and re-run — cache read drops to 0. That's exactly the kind of bug this check catches.
This is how you turn caching on. You send your large stable instructions as a system block and mark it with cache_control — a "cache up to here" flag (a breakpoint). The provider then stores everything up to that mark and reuses it on the next identical request.
system=[{...}]— instead of a plain string, the system prompt is a block with a"text"field holdingLARGE_STABLE_INSTRUCTIONS. The comment# same bytes every callis the whole trick: this text must be identical every time or the cache won't match."cache_control": {"type": "ephemeral"}is the breakpoint: "cache everything from the start up to this point." Ephemeral just means the cache is short-lived (a few minutes), which is fine for repeated calls.messages=[...]carries theuser_question— the volatile part. It comes after the cached block on purpose, so changing the question never disturbs the cached prefix.
What the output means: Nothing visible yet — this just sends one request. The proof that caching worked comes from the token counts in the next block.
Try this: Remember the render order is tools → system → messages. Anything you want cached has to sit before the volatile content in that order.
datetime.now()or a UUID in the system prompt- Unsorted
json.dumps()(addsort_keys=True) - A per-user tool set, or reordered tools
- Conditional system sections that vary per request
Lab 6.2 · Observability advanced
You can't reproduce a stochastic bug locally. You debug from traces. Capture enough on every request to reconstruct exactly what happened.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
observability.pyimport logging, time, json, uuid
log = logging.getLogger("llm")
def traced_call(messages, *, prompt_version, **kw):
trace_id = str(uuid.uuid4())
t0 = time.monotonic()
resp = client.messages.create(model="claude-opus-4-8",
max_tokens=1024, messages=messages, **kw)
log.info(json.dumps({
"trace_id": trace_id,
"request_id": resp._request_id, # for provider support
"prompt_version": prompt_version, # attribute quality shifts
"model": resp.model,
"stop_reason": resp.stop_reason,
"in_tokens": resp.usage.input_tokens,
"out_tokens": resp.usage.output_tokens,
"cache_read": resp.usage.cache_read_input_tokens,
"latency_ms": round((time.monotonic()-t0)*1000),
}))
return resp
This wraps every model call so that one line of JSON gets logged for each request, capturing everything you'd need to reconstruct what happened later. You can't step through a bug that only appears in production at 3am — you debug from these logs instead.
trace_id = str(uuid.uuid4())mints a unique ID for this request so you can tie together everything about it (logs, feedback, follow-ups).t0 = time.monotonic()starts a stopwatch.- The call to
client.messages.create(...)runs in the middle — this function is a transparent wrapper: it does the real work and records it. log.info(json.dumps({...}))writes one structured JSON line. Logging JSON (not free text) means tools can search and chart it. Noteresp._request_id(# for provider support) — quote this to the provider if a request misbehaves — andprompt_version, so a quality dip can be blamed on the exact prompt that caused it.- It logs token counts (
in_tokens/out_tokensfor cost),cache_read(is caching working?), andlatency_ms = round((time.monotonic()-t0)*1000)— the stopwatch, so you can alert when requests get slow.
What the output means: Each call emits a JSON log line like {"trace_id": "...", "model": ..., "latency_ms": 812, ...}. Collected over time these become your latency, cost, and error dashboards.
Try this: The four signal types in the table below — logs, metrics, traces, feedback — all hang off this same trace_id. Capturing it once is what makes debugging possible at all.
The four signal types
| Type | Capture | Alert when |
|---|---|---|
| Logs | Per-request: IDs, versions, tokens, stop reason (PII redacted) | Error/refusal rate spikes |
| Metrics | p50/p95/p99 latency, cost/req, cache-hit rate | p95 breaches SLO |
| Traces | Span tree: retrieval → rerank → LLM → tools | A stage's latency balloons |
| Feedback | 👍/👎, edits, escalations — wired to trace_id | Negative-feedback rate rises |
Lab 6.3 · Guardrails expert
Validate on the way in and on the way out. Cheap insurance against expensive incidents.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def generate(*a, **k): # demo stub
return _Any()
def judge(*a, **k): # demo stub
return _Any()guardrails.pyimport re
# ---- INPUT guardrails (before the model) ----
def redact_pii(text):
text = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", text)
text = re.sub(r"\b[\w.]+@[\w.]+\b", "[EMAIL]", text)
return text
def within_scope(text):
# cheap classifier or keyword gate for out-of-scope/abuse
return True
# ---- OUTPUT guardrails (before returning to user) ----
def check_grounded(answer, context):
"""For RAG: flag claims not supported by context (LLM judge)."""
j = judge("Is every claim supported by the context?", answer, context)
return j.passed
def safe_pipeline(user_input, context):
clean = redact_pii(user_input)
if not within_scope(clean):
return "I can only help with product questions."
answer = generate(clean, context)
if not check_grounded(answer, context):
return "I'm not fully certain — routing you to a human."
return answer
Guardrails are cheap checks that run before the model (on the input) and after it (on the output), so a bad request or a wrong answer never reaches a user. This block defines the checks, then safe_pipeline wires them around the model call.
- Input side.
redact_pii(text)usesre.sub(regular-expression find-and-replace) to swap out sensitive data — an SSN pattern becomes[SSN], an email becomes[EMAIL]— so private data never even reaches the model.within_scope(text)is a cheap gate that returnsTrue/Falsefor whether the request is something you handle. - Output side.
check_grounded(answer, context)asks an LLM judge whether every claim in the answer is actually supported by the retrieved context — the defense against a confident-but-made-up answer (a "hallucination") in RAG. safe_pipeline(user_input, context)is the assembly line: redact → scope-check → generate → groundedness-check. At each gate, if the check fails itreturns a safe canned message instead of continuing.- Notice it fails gracefully: out-of-scope gets
"I can only help with product questions."; an ungrounded answer gets"routing you to a human."— never a crash, never a leaked or unsupported answer.
Try this: These functions are stubs here (within_scope just returns True). In production, within_scope would be a small cheap classifier and judge a real model call — but the shape (gate in, gate out) is what matters.
- Keep untrusted content in clearly-delimited user-role blocks, never merged into the system prompt.
- Use the system/operator channel for real instructions — don't let retrieved text override it.
- Apply least-privilege to tools and gate any action injected text could trigger.
Lab 6.4 · Cost control expert
- Model routing — send easy requests to a cheap model, hard ones to the frontier model. A tiny classifier decides.
Setup to run this snippet
class _Any: '''stands in for any undefined demo value; supports call/attr/index/ iteration and basic arithmetic (as 0.7) so demo snippets run.''' def __call__(self, *a, **k): return _Any() def __getattr__(self, k): return _Any() def __getitem__(self, k): return _Any() def __iter__(self): return iter([]) def __len__(self): return 0 def __contains__(self, o): return True def __enter__(self, *a): return _Any() def __exit__(self, *a): return False def __float__(self): return 0.7 def __int__(self): return 1 def __lt__(self, o): return True def __gt__(self, o): return False def __le__(self, o): return True def __ge__(self, o): return False def __add__(self, o): return o def __radd__(self, o): return o def __bool__(self): return True def __repr__(self): return 'demo' def __str__(self): return 'demo' def is_simple(*a, **k): # demo stub return _Any() user_query = _Any()router.py
def route(query): # cheap heuristic or a haiku-class classifier if is_simple(query): return "claude-haiku-4-5", "low" # cheap + fast return "claude-opus-4-8", "high" # capable + costly model, effort = route(user_query) - Semantic cache — cache full answers keyed by query embedding; serve near-duplicate questions with no model call at all.
- Right-size
max_tokensandeffortper route — the two most direct spend dials. - Batch API for non-urgent jobs (evals, backfills) at roughly half cost.
Not every request needs your most expensive model. Model routing sends easy questions to a small cheap model and only hard ones to the big one — often the single largest cost saving after caching.
route(query)decides which model to use.is_simple(query)is a quick check — a keyword rule or a tiny fast classifier — for whether the question is easy.- If it's simple, it returns
"claude-haiku-4-5", "low"— a small, fast, cheap model with low reasoning effort. The comment marks it# cheap + fast. - Otherwise it returns
"claude-opus-4-8", "high"— the capable, costly model with high effort (# capable + costly). The function hands back two values: a model name and an effort level. model, effort = route(user_query)unpacks those two returned values into two variables in one line — ready to pass straight into a model call.
What the output means: For an easy query you get ("claude-haiku-4-5", "low"); for a hard one ("claude-opus-4-8", "high"). Same code path, very different cost.
Try this: Routing pairs with the other dials in this lab — semantic caching, right-sized max_tokens/effort, and the Batch API for non-urgent jobs. Each trims spend without hurting quality.
429s. For fan-out, warm the cache with one request before firing the rest — parallel requests with an identical prefix all miss the cache because there's nothing to read yet.Lab 6.5 · Deployment & rollback expert
Prompts, model IDs, embedding versions, and index snapshots are all versioned artifacts in your release process.
- Version everything, log it per request. Prompt version, model ID, embedding-model version, index snapshot ID — so any regression is attributable.
- Gate deploys on evals. Chapter 5's
run_evals.pyruns in CI; a metric regression fails the PR. - Canary. Roll a new prompt/model to a small % of traffic; compare quality, latency, and cost against control before full rollout.
- Feature-flag model swaps. A model change invalidates prompt caches and shifts behavior — roll it like any risky change, with an instant flip back.
config.py
ACTIVE = { "model": flag("llm.model", default="claude-opus-4-8"), "prompt_version": flag("llm.prompt", default="v7"), } # flip either back in seconds if metrics/feedback dip - Re-baseline on model upgrades. When you change models, re-measure token counts + cost, re-tune prompts, and re-run evals — behavior shifts even when the API is compatible.
This makes your model and prompt choices feature flags — values read at runtime — instead of hard-coded constants. Why: if a new model or prompt version misbehaves in production, you flip the flag back instantly, with no code change or redeploy.
ACTIVEis a dictionary holding the two riskiest settings: which"model"and which"prompt_version"are live right now.flag("llm.model", default="claude-opus-4-8")reads the current value from a flag system, falling back to thedefaultif nothing is set. Same for"prompt_version"defaulting to"v7".- The comment is the payoff:
# flip either back in seconds if metrics/feedback dip. Because these are flags, rollback is instant — you don't ship code to undo a bad change.
Try this: Pair this with the rest of Lab 6.5: version everything and log it per request, gate deploys on evals, and canary (roll to a small % of traffic first). Flags are the instant-undo button for all of it.
Capstone · Assemble the hardened service expert
Wire the pieces from all six chapters into one request path. This is the shape of a real production LLM service.
Setup to run this snippet
ACTIVE = 5 # demo constant (a hard cap)
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def build_messages(*a, **k): # demo stub
return _Any()
def check_grounded(*a, **k): # demo stub
return _Any()
def escalate_to_human(*a, **k): # demo stub
return _Any()
def hybrid_retrieve(*a, **k): # demo stub
return _Any()
def new_trace(*a, **k): # demo stub
return _Any()
def redact_pii(*a, **k): # demo stub
return _Any()
def refuse(*a, **k): # demo stub
return _Any()
def rerank(*a, **k): # demo stub
return _Any()
def route(*a, **k): # demo stub
return _Any()
class _semantic_cache_t:
get = 'demo'
put = 'demo'
def get(self, *a, **k): return 'demo'
def put(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
semantic_cache = _semantic_cache_t()
def traced_call(*a, **k): # demo stub
return _Any()
def within_scope(*a, **k): # demo stub
return _Any()service.pydef handle_request(user_input, user_ctx):
trace_id = new_trace()
# 1. INPUT guardrails (Ch 6.3)
clean = redact_pii(user_input)
if not within_scope(clean):
return refuse(trace_id, "out_of_scope")
# 2. Semantic cache check (Ch 6.4)
if (cached := semantic_cache.get(clean)):
return cached
# 3. Route to a model (Ch 6.4)
model, effort = route(clean)
# 4. Retrieve, ACL-filtered (Ch 3) — retrieval as a tool if agentic (Ch 4)
ctx = hybrid_retrieve(clean, tenant=user_ctx.tenant)
ctx = rerank(clean, ctx, keep=4)
# 5. Generate with cached stable prefix (Ch 6.1) + structured/grounded (Ch 2-3)
answer = traced_call(build_messages(clean, ctx),
prompt_version=ACTIVE["prompt_version"],
model=model,
output_config={"effort": effort})
# 6. OUTPUT guardrails (Ch 6.3)
if not check_grounded(answer, ctx):
return escalate_to_human(trace_id)
semantic_cache.put(clean, answer)
return answer # every step traced; feedback wired to trace_id
This is the whole production request path laid out left to right — every user request walks these seven boxes in order. It's the handle_request code above, drawn as a pipeline.
- Follow the arrows left→right. A request enters at Guard IN (yellow border) — input screening: redact PII, reject out-of-scope/abusive input before spending any money on the model.
- Sem. cache — the semantic cache. If we've already answered a near-identical question, we return that stored answer and skip everything to the right (no model call at all — the cheapest possible request).
- Route then Retrieve + rerank (teal border) — pick a cheap-vs-capable model for this query, then fetch and re-order the most relevant context (the RAG step from Ch 3).
- LLM +cache (the purple gradient box, the star) is the actual model call, sending the cached stable prefix from Lab 6.1. Guard OUT (yellow again) checks the answer is grounded/safe before it reaches the user, then Answer (green) is returned.
- The monospace line underneath —
tracing · metrics · versioning · cost accounting wrap every box— is the point: observability isn't one step, it surrounds all of them so you can debug and cost any request end to end.
In short: colour tells the story — yellow = guardrails (in and out), teal = retrieval, purple = the model, green = success. The two yellow boxes bracketing the model are your cheap insurance.
Production go-live checklist expert
| Area | Must-haves |
|---|---|
| Reliability | Timeouts + retries · circuit breaker/fallback · handles refusal/max_tokens · streaming for long output |
| Quality | Golden eval set in CI · regression gate · online feedback captured · groundedness checked (RAG) |
| Guardrails | Input PII + injection screening · output schema + safety validation · human gate on risky actions · rate limiting |
| Cost | Prompt caching verified (>0 reads) · model routing · cost-per-request dashboard · right-sized max_tokens/effort |
| Security | Secrets in a manager · least-privilege tool creds · tenant isolation at retrieval · code/shell sandboxed |
| Ops | Full request tracing · SLO alerts · prompt + model versioned · one-flip rollback ready |
Where to go next expert
- Scale retrieval — move from your in-memory store to a real vector DB (pgvector, Weaviate, Pinecone, FAISS) with persistence and ANN speed.
- Advanced RAG — parent-document retrieval, contextual chunk headers, graph RAG for multi-hop questions.
- Managed agents — server-hosted stateful agents with sandboxed workspaces for long-horizon tasks.
- Deeper evals — adversarial test sets, red-teaming, automated failure mining from production traces.
- Fine-tuning — when you need to teach style/behavior (not facts) that prompting can't reach.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Prompt caching only saves money if you can prove the cache is actually hitting. The API hands back cache_read_input_tokens and input_tokens on every response, and one aggregate number tells you whether caching is real or theatre.
Your task: Write a function that takes a list of per-request usage dicts and returns the fraction of input tokens that were served from cache across the whole batch.
Requirements:
- Sum
cache_read_input_tokensand total input tokens across every request - Total input =
cache_read_input_tokens + input_tokensper request - Return
0.0(don't divide by zero) when there are no input tokens - Show that the first (cache-write) request contributes ~0% while later reads pull the rate up
- A rate stuck near zero should read as a red flag, not a pass
💡 Hint: Accumulate the two totals in a single pass, then divide once at the end — a per-request average would weight a tiny request the same as a huge one.
Show solution
The win metric is cached tokens over total input tokens; 0 on the second identical call means a silent invalidator.
def cache_hit_rate(usages):
cached = sum(u["cache_read_input_tokens"] for u in usages)
total = sum(u["cache_read_input_tokens"] + u["input_tokens"] for u in usages)
return cached / total if total else 0.0
usages = [
{"cache_read_input_tokens": 0, "input_tokens": 2000}, # req1: cache write, full price
{"cache_read_input_tokens": 1950, "input_tokens": 50}, # req2: hit
{"cache_read_input_tokens": 1950, "input_tokens": 50}, # req3: hit
]
print(round(cache_hit_rate(usages), 3)) # 0.929
Context: A cache hit requires the cached prefix to be byte-identical across calls. A stray timestamp or reordered field silently breaks the match and quietly triples your bill.
Your task: Given the stable prefix string used on two separate calls, return whether the cache can hit and, if not, the first character index where the two prefixes diverge.
Requirements:
- Return a signal that the cache can hit (and no divergence index) when the strings are identical
- Otherwise return the first index at which the two strings differ
- Handle the case where one string is a prefix of the other (divergence at the shorter length)
- Comparison is exact and byte-level — no normalisation or trimming
- Demonstrate a real killer such as an injected
datetime.now()shifting the divergence point
💡 Hint: Walk both strings position by position until they disagree; the first mismatched index is the mechanical cause of the dropped hit rate.
Show solution
Caching is a prefix match: any differing byte invalidates everything after it. A datetime.now() or unsorted JSON in the prefix shows up as an early divergence.
def cache_can_hit(prefix_a, prefix_b):
if prefix_a == prefix_b:
return True, -1
n = min(len(prefix_a), len(prefix_b))
for i in range(n):
if prefix_a[i] != prefix_b[i]:
return False, i
return False, n # one is a prefix of the other (length differs)
stable = "You are a support agent. Tools: [search, escalate]. "
good = stable + "Answer the user."
killer = stable + "Time: 2026-09-07T10:00. Answer the user." # injected timestamp
print(cache_can_hit(good, good)) # (True, -1)
print(cache_can_hit(good, killer)) # (False, 52) -> prefix diverges, cache misses
Context: A per-user tool set or an unsorted json.dumps() reorders bytes and kills the cache even though nothing meaningful changed. A canonical fingerprint makes semantically-identical prefixes hash the same.
Your task: Write prefix_fingerprint that canonicalises the tools list plus the system prompt before hashing, so two orderings of the same tools produce one stable key.
Requirements:
- Sort the tools deterministically (e.g. by
name) before serialising - Serialise with
json.dumps(..., sort_keys=True, separators=(",", ":"))so whitespace and key order can't drift - Hash the canonical bytes with
hashlib.sha256and keep a short hex slice as the key - Two different tool orderings must yield the same fingerprint
- A one-character change to the system prompt must change the fingerprint
💡 Hint: The fingerprint's whole job is to throw away everything that doesn't matter (order, spacing) before hashing what does.
Show solution
sort_keys=True and sorting tools by name make the serialization deterministic, so the fingerprint is stable across equivalent-but-reordered inputs.
import json, hashlib
def prefix_fingerprint(system, tools):
canonical = {
"system": system,
# sort tools by name so ordering can't change the bytes
"tools": sorted(tools, key=lambda t: t["name"]),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode()).hexdigest()[:16]
sys_prompt = "You are a support agent."
tools_a = [{"name": "search"}, {"name": "escalate"}]
tools_b = [{"name": "escalate"}, {"name": "search"}] # reordered per-user
print(prefix_fingerprint(sys_prompt, tools_a) == prefix_fingerprint(sys_prompt, tools_b)) # True
print(prefix_fingerprint(sys_prompt + " ", tools_a) == prefix_fingerprint(sys_prompt, tools_a)) # False
Context: In production a guardrail that crashes is worse than no guardrail — a stack trace can leak the very input you were trying to contain. Lab 6.3's pipeline is an ordered chain of gates that must fail closed.
Your task: Reimplement safe_pipeline as a chain where each gate returns either a safe canned reply (stop) or None (continue), and any exception routes to a fixed escalation message.
Requirements:
- Redact PII (e.g. SSNs, emails) before any gate runs
- Run gates in order; the first non-
Nonereturn short-circuits the chain - Wrap the whole chain in
try/exceptso any gate raising returns the escalation message, never a crash - Demonstrate a scope gate, an injection gate, and a deliberately-throwing gate
- Confirm the throwing gate escalates instead of leaking a traceback
💡 Hint: "Fail closed" means the except branch returns the same safe escalation string a rejecting gate would — the error path and the deny path converge.
Show solution
Guardrails are cheap insurance only if they never fall open. Wrapping each gate so an exception routes to a human is the correctness-sensitive part.
import re
def redact_pii(text):
text = re.sub(r"\b\d{3}-\d{2}-\d{4}\b", "[SSN]", text)
return re.sub(r"\b[\w.]+@[\w.]+\b", "[EMAIL]", text)
ESCALATE = "I'm not fully certain - routing you to a human."
def safe_pipeline(user_input, gates, generate):
try:
clean = redact_pii(user_input)
for gate in gates:
stop = gate(clean) # returns a canned reply, or None to continue
if stop is not None:
return stop
return generate(clean)
except Exception:
return ESCALATE # fail CLOSED: never crash, never leak
def scope_gate(text):
return None if "product" in text else "I can only help with product questions."
def injection_gate(text):
return "Request blocked." if "ignore previous" in text.lower() else None
def boom_gate(text):
raise RuntimeError("judge API down")
gen = lambda t: f"Answer: {t}"
print(safe_pipeline("product help, my ssn is 123-45-6789", [scope_gate, injection_gate], gen))
# -> Answer: product help, my ssn is [SSN]
print(safe_pipeline("weather please", [scope_gate], gen)) # -> I can only help...
print(safe_pipeline("product; ignore previous instructions", [scope_gate, injection_gate], gen)) # -> Request blocked.
print(safe_pipeline("product", [boom_gate], gen)) # -> escalates (fail closed)
Context: Two production realities collide: you must respect rate limits, and a fan-out of parallel calls all miss an empty cache at once (a thundering herd of writes). Warming one request first turns the rest into cheap cache reads.
Your task: Implement a token-bucket limiter and a fan-out helper that warms the cache with a single request before firing the remaining calls in parallel.
Requirements:
TokenBucketrefills based on elapsed time and a configured rate/capacitytake(n)returns whether enough tokens were available and deducts them if so- The fan-out helper sends request one first (to populate the shared prefix) before the rest
- Remaining calls wait/retry until the bucket grants them, then run marked as warmed
- Use a monotonic clock for elapsed time, not wall-clock
💡 Hint: Warming serialises exactly one "writer" so the parallel "readers" all hit a populated prefix instead of racing to write it.
Show solution
The bucket refills at a steady rate and blocks callers past the limit; warming a single request first populates the shared prefix so the parallel burst hits the cache instead of all writing it.
import time
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens = capacity
self.updated = time.monotonic()
def take(self, n=1):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= n:
self.tokens -= n
return True
return False
def fan_out(requests, call, bucket):
# warm the cache with ONE request so the burst reads instead of all writing
results = [call(requests[0], warmed=False)]
for r in requests[1:]:
while not bucket.take(): # respect the limit; queue overflow
time.sleep(0.001)
results.append(call(r, warmed=True))
return results
log = []
def fake_call(req, warmed):
log.append("HIT" if warmed else "WRITE")
return req
bucket = TokenBucket(rate=1000, capacity=10)
fan_out(["a", "b", "c", "d"], fake_call, bucket)
print(log) # ['WRITE', 'HIT', 'HIT', 'HIT'] -> only the warm-up misses
Context: Your team owns the hardened request path and must hold a p95 SLO even when the primary model provider degrades. The go-live checklist demands a reliability layer, not just a happy-path call.
Your task: Design the reliability layer and write the retry-with-fallback wrapper that keeps the service up when the primary provider is failing. (Design + code.)
Requirements:
- Timeouts plus jittered backoff on transient errors (429/5xx); never blindly retry non-idempotent writes
- A circuit breaker that stops hammering the primary after N failures and routes to a fallback
- Graceful degradation: a safe canned reply + escalation when everything fails, never an exception to the caller
- Trace provider, latency and outcome for every attempt
- Show the breaker opening after repeated failures and traffic switching to the fallback
💡 Hint: Separate the two concerns: the breaker decides which provider to try; the retry loop with jitter decides how hard to try before giving up to the safe reply.
Show solution
Design (maps to the go-live checklist).
- Timeouts + bounded retries with jittered backoff on transient errors (
429, 5xx) — never retry a non-idempotent write blindly. - Circuit breaker: after N consecutive failures, stop hammering the primary and route to a fallback model/provider so latency doesn't pile up against the SLO.
- Graceful degradation: if all providers fail, return a safe canned reply and escalate — the guardrail principle, extended to availability.
- Everything traced (Ch 6.2): each attempt logs provider, latency, and outcome so the breaker's behavior is observable.
import random
class Circuit:
def __init__(self, threshold=3):
self.fails, self.threshold, self.open = 0, threshold, False
def record(self, ok):
self.fails = 0 if ok else self.fails + 1
self.open = self.fails >= self.threshold
def call_with_fallback(prompt, primary, fallback, breaker, max_retries=2, rng=random.Random(0)):
provider = fallback if breaker.open else primary
for attempt in range(max_retries + 1):
try:
out = provider(prompt)
breaker.record(ok=True)
return {"provider": provider.__name__, "out": out}
except Exception:
breaker.record(ok=False)
if breaker.open and provider is not fallback:
provider = fallback # trip over to the fallback
time.sleep(rng.uniform(0, 0.01) * (attempt + 1)) # jittered backoff
return {"provider": "none", "out": "Service busy - escalating to a human."}
import time
def primary(p): raise TimeoutError("primary degraded")
def fallback(p): return f"[fallback] {p}"
brk = Circuit(threshold=3)
for _ in range(4):
r = call_with_fallback("scale the api deployment", primary, fallback, brk)
print(r["provider"], "|", r["out"]) # after the breaker opens -> fallback | [fallback] scale the api deployment
The reliability logic runs offline as shown. Wiring primary/fallback to real providers needs an API key.
✓ Capstone checkpoint — you're production-ready when you can…
- Add prompt caching and prove it works via
cache_read_input_tokens. - Trace a request end-to-end and name the four signal types.
- Implement input + output guardrails and explain prompt-injection defense.
- Cut cost with routing + semantic caching without hurting quality.
- Deploy behind flags with versioning, eval gates, and instant rollback.
- Walk the full hardened request path from memory and pass the go-live checklist.
This is the capstone: every technique in the chapter wired into one request path. Read it top to bottom — it's the exact order a real production LLM service processes a request, with each numbered comment pointing back to the lab it came from.
- 1 · Input guardrails —
redact_pii(user_input)cleans the input, thenif not within_scope(clean): return refuse(...)stops junk early. Cheap checks first, before any spend. - 2 · Semantic cache —
if (cached := semantic_cache.get(clean))uses the:=walrus operator to fetch and test in one line; a hitreturns the stored answer with no model call at all. - 3–4 · Route & retrieve —
route(clean)picks the cheap-or-capable model, thenhybrid_retrieve(...)(ACL-filtered bytenantfor isolation) andrerank(..., keep=4)gather the best context. - 5 · Generate —
traced_call(...)runs the model (so it's logged), passing theprompt_version, chosenmodel, andeffort. This is Lab 6.2's traced wrapper doing the real work. - 6 · Output guardrails —
if not check_grounded(answer, ctx): return escalate_to_human(...). Only if the answer passes do wesemantic_cache.putit (so the next identical question is free) andreturn answer.
What the output means: A fully hardened answer, or a safe fallback (refuse / escalate) at whichever gate fails — and every step tagged with the same trace_id so you can replay exactly what happened.
Try this: Trace one request down the numbered comments and match each to its lab: guardrails (6.3), cache + routing (6.4), retrieval (Ch 3), caching + tracing (6.1/6.2). The diagram right below is this same flow drawn as boxes.
kubectl delete. You now have every building block. Next: the FDE method (Ch 7) teaches how to deploy it into a real company, then Chapter 8 assembles all of it into the AI DevOps Engineer — including a hire-as-a-service model.Knowledge check check yourself
Prompt caching reuses a stable prefix at ~10% of input price, but the lesson stresses that caching is a prefix match. What is a 'silent cache killer,' and how do you detect one?
Show answer
Why does the chapter say prompt-injection defenses and safety guardrails must treat retrieved documents, tool outputs, and web pages as untrusted input rather than trusting the system prompt to hold?