Versions, traces, failures, cost, and rollouts: keeping an LLM application working after launch. Capstone for Part 4.
Dispatch now exists in four versions: a chat loop (Chapter 7), a tool user (Chapter 20), a retriever (Chapter 21), and an agent (Chapter 22), with an eval suite that can tell whether a change helped (Chapter 23). This chapter puts it in front of on-call engineers and keeps it working. The map ends Part 4 here: after this chapter you have a system you could run, not just a program you could demo.
The question this chapter answers: what changes when an LLM application becomes a service other people depend on, and what do you have to build so that you find out about problems before they do?
An airline does not learn that an engine is failing when a passenger complains. Every flight records hundreds of numbers: fuel flow, temperatures, vibration. Maintenance reads those records after every flight and notices a trend long before anything breaks. When a new part is installed, it goes on a few aircraft first and the records of those aircraft are compared with the rest. And every aircraft carries a precise logbook of which part, at which revision, is installed where, because "it worked last week" is useless unless you know what changed since.
An LLM application needs the same three things, and needs them more, because its failures are quiet. A crashed service throws errors that page someone. A Dispatch that has started confidently inventing phone numbers returns HTTP 200 on every request. Nothing breaks. The numbers just drift, and only records can show it.
| At the airline | In the machine | The word we will use |
|---|---|---|
| The logbook of parts and revisions | Pinned model ids, a prompt version, tool and corpus versions, on every response | versioning, reproducibility |
| The flight recorder | One structured record per request: tokens, cost, latency, steps, status | trace |
| Maintenance reading the records | Aggregates over traces, watched for change | monitoring, dashboard |
| Quiet wear that no alarm catches | Quality falling while every request succeeds | drift |
| A new part on a few aircraft first | A new version on a small share of traffic, compared with the rest | canary rollout |
| Diverting to another airport | Answering from a second model when the first is unavailable, and saying so | fallback, degraded mode |
| Incident reports that change the checklists | Production failures turned into new eval cases | data flywheel |
An LLM application's behaviour is the product of several artifacts, and each can change without you noticing. Write them down, on every response, so that "it got worse on Tuesday" can be answered with "Tuesday is when X changed".
| Artifact | How it changes behind your back | What to pin and record |
|---|---|---|
| The model | An alias that moves to a new snapshot; a model deprecated and retired on the provider's schedule | The exact model id you request, and the model id the response says served it |
| The system prompt | Someone edits a sentence and forgets to bump a version number | A version computed from the prompt text itself, not typed by hand |
| Tool definitions | A description reworded; a parameter added | Included in the same computed version: they are part of the prompt |
| Retrieved content | A runbook updated; the index rebuilt with a new embedding model | Corpus and index versions |
| Your code | The loop's step budget, retry policy, sampling settings | The service version |
Dispatch computes its prompt version as the first twelve hex digits of a hash over the system prompt and the tool definitions. Change a comma in a tool description and the version changes by itself. Every response carries it in a header and every trace records it.
# code/ch24/dispatch_service.py
PROMPT_VERSION = hashlib.sha256((agent.SYSTEM + json.dumps(agent.TOOLS, sort_keys=True)).encode()).hexdigest()[:12]
What you cannot pin is the sampling. The same request to the same model returns different text (Chapter 7 §7.3), and on current frontier models the sampling settings are set by the provider, not you public. So reproducibility for an LLM application does not mean "the same bytes out". It means "the same measured behaviour": the eval suite of Chapter 23, run against the pinned artifacts, gives the same numbers within its noise. That is the real regression test, and it is why the eval suite and the version fields are two halves of one idea.
Plan for model retirement. Providers deprecate models on a published schedule with notice public, and the successor behaves differently: different verbosity, different tool-calling habits, sometimes different API parameters. A model migration is a release like any other: run the suite on the new model, read the per-tag table, adjust the prompt, gate it, canary it.
A trace is one structured record per request, written when the request finishes. It is the raw material for every number in this chapter, so decide its fields carefully and keep them stable. Here is the row Dispatch's service wrote for one triage:
{"ts": 1790464652, "request_id": "req_a099065236", "version": "v3.0", "prompt_version": "a7937c88f16f",
"model": "claude-opus-5", "message_sha": "ce413675b321c289", "message_chars": 41, "status": "ok",
"steps": 5, "tools": ["get_queue_depth", "get_worker_status", "get_inbound_rate", "search_runbooks",
"propose_action"], "proposals": 1, "input_tokens": 1880, "output_tokens": 298,
"cache_read_input_tokens": 4040, "cache_creation_input_tokens": 1010, "served_models": ["claude-opus-5"],
"degraded": false, "refusal_fallback": false, "cost_usd": 0.02518, "latency_s": 0.0}
This row came from running the service against the scripted client of code/ch24/scripted_client.py, which is why its latency is zero; against the real model the same triage takes several seconds.
Every field answers a question someone will ask during an incident. status separates success from refusal, budget exhaustion, rate limiting, and errors, the same distinctions the eval harness made in Chapter 23 §23.4. served_models and degraded say whether the answer came from the model you asked for. The four token counts, priced per model, give the cost, exactly: 1,880 uncached input tokens at $5 per million, 1,010 written to the cache at 1.25 times that, 4,040 read from the cache at a tenth of it, and 298 output tokens at $25 per million come to $0.02518.
Metadata always, content rarely. Notice what the row does not contain: the engineer's message and Dispatch's answer. Those can hold customer names, device tokens, credentials pasted by mistake. The trace keeps a hash and a length, which is enough to group repeated questions and spot abuse, and is safe to keep for months and show on a dashboard. Content goes to a separate store, for a small sample of requests (Dispatch writes 5%), with restricted access and a short retention period. You need that sample: it is the only way to review what the application actually said. You do not need it for every request, and you do not want it in the same place as the metrics.
For an agent, one row per request hides where the time went. The widget below expands Dispatch's triage into its spans: five model calls, each a prefill of the growing context followed by a decode, with the tool calls between them.
Two things stand out, and both follow from Chapter 7. Decode dominates, because decode is one token per forward pass (Chapter 7 §7.2) and five calls each generate their own output. And every call re-sends the whole conversation so far: call 5 prefills everything calls 1 to 4 saw, plus their results. An agent's cost and latency grow faster than its step count, which is why the step budget of Chapter 22 is a cost control as much as a safety control.
From the traces you compute a handful of rates and distributions, per version, over a sliding window. The ones worth a dashboard for an application like Dispatch:
stop_reason: refusal. It never shows up as an error. A prompt change that makes refusals triple is invisible unless you count them.The monitor computes all of this from a trace log. The log in the repository is synthetic, six hours of generated traffic, seeded so the numbers reproduce: a stable version v3.0 on 90% of requests and a canary v3.1, a prompt change, on 10%.
$ python code/ch24/make_sample_log.py && python code/ch24/monitor.py
wrote 1200 synthetic traces to code/ch24/traces.jsonl (110 canary)
metric v3.0 (stable) v3.1 (canary)
requests 1090 110
error rate 0.7% 0.0%
refusal rate 1.2% 6.4%
budget exhausted 0.7% 0.9%
degraded (fallback model) 0.6% 0.9%
latency p50 (s) 8.19 6.76
latency p95 (s) 14.82 13.98
cost per request ($) 0.0140 0.0126
input tokens from cache 72.6% 73.1%
tool calls per request 2.97 2.91
errors: {'APIConnectionError': 6, 'RateLimitError': 2}
CANARY GATE · refusals: canary 7/110, stable rate 1.2% → P(≥7 by chance) = 0.0004
cost: pass · latency p95: pass · refusals: HOLD: do not widen the canary
Read the canary column as an operator would. v3.1 is faster and 10% cheaper, because its prompt makes answers shorter. It would look like a win on a cost dashboard. But it refuses 6.4% of requests against 1.2% for the stable version. Is that noise? If the canary refused at the stable rate, seeing 7 or more refusals in 110 requests would happen with probability 0.0004. It is not noise. The shorter prompt dropped a sentence that told Dispatch it may answer from runbooks, and now it declines runbook questions. The gate holds the canary at 10%, and the next step is to read the sampled transcripts of the refused requests.
None of the dashboard numbers measure whether Dispatch's answers are right. For that you need the same thing Chapter 23 used: a judge applying a rubric. In production you run it on the sampled transcripts. Every day, take the 5% sample, have a judge grade each answer against the general rules (cites a runbook when it uses one; does not invent numbers, names, or procedures), and plot the pass rate over time. A falling line with no deploy on the calendar means something outside your code moved: the runbooks were edited, users started asking about a new service, or the provider changed something. Sampled judge grading on live traffic is the only monitor that sees this.
An LLM API fails in a small number of distinct ways, and each deserves a different response. The mistake to avoid is treating them all as "retry, then give up", or worse, "fall back to another model".
| What happened | What it means | What to do |
|---|---|---|
400 bad request | Your request is wrong: a malformed tool schema, too many tokens, a parameter this model does not accept | Fail loudly. A retry fails identically, and a fallback model hides the bug. |
429 rate limited | You exceeded your account's limit | Back off with jitter and try again. The limit is on your account, so a different model does not help, and doubling traffic to it makes things worse. |
5xx, 529 overloaded | The provider is struggling | Retry with backoff. If it persists, answer from a second model and mark the response as degraded. |
| Connection error, timeout | The network, or a very long request | Retry with backoff. Set a hard wall-clock limit per request so a hung connection frees its slot. |
200 with stop_reason: refusal | The model, or a safety classifier, declined | Not an error, but check for it before reading the content. Providers can re-run a declined request on another model server-side; opt in. |
200 with stop_reason: max_tokens | The answer was cut off | Raise the limit or ask for shorter output. Never show a truncated answer as if it were complete. |
The SDK already retries 408, 409, 429, and 5xx responses with exponential backoff and jitter, twice by default; Dispatch's service raises that to three. The widget shows why the jitter matters: twelve clients rate-limited at the same instant, retrying on the same schedule, all collide again.
Dispatch's service puts all of this in one small wrapper that the Chapter 22 agent loop uses as its client. The loop is unchanged; only the client is different.
# code/ch24/dispatch_service.py (excerpt)
class ProductionClient:
def create(self, **kw):
try: # refusals are re-run server-side on the model recommended for the refusal category
r = self.base.beta.messages.create(**kw, betas=["server-side-fallback-2026-07-01"], fallbacks="default")
except anthropic.RateLimitError:
raise # the limit is per account; another model does not help
except anthropic.APIStatusError as e:
if e.status_code < 500: # our request is wrong: fail loudly
raise
self.degraded = True # the SDK already retried; the provider is struggling
r = self.base.messages.create(**{**kw, "model": DEGRADED})
self.served.add(r.model)
self.cost += call_cost(r.model, r.usage)
return r
The fallbacks="default" parameter, behind its beta header, asks the provider to re-run a request that a safety classifier declined on another model chosen for that kind of refusal public (the Claude API documentation). The outage fallback is your own decision, and it deserves thought. For Dispatch, a slightly weaker answer during a provider outage is better than no answer during an incident, so the service falls back to claude-sonnet-5 and says so: the response carries degraded: true, and the trace counts it. For an application where a weaker answer is dangerous, the right fallback may be a clear error message instead.
These paths only happen during bad hours, which makes them the least tested code in most services. Test them deliberately, with a fake client that raises the error on demand:
$ python code/ch24/test_service.py
ok test_bad_request_is_not_hidden_by_a_fallback ok test_happy_path_queues_but_never_executes ok test_outage_degrades_to_second_model_and_says_so ok test_rate_limit_is_passed_back_not_retried_elsewhere ok test_trace_never_contains_the_message 5 passed
From Chapter 19 you know why providers price per token and why output costs several times input. From the trace you know what each request costs. What remains is knowing which levers to pull, and in which order. The first ones are free.
What does Dispatch cost to run for a month? Postbox has a few hundred incidents a month, and each on-call engineer asks Dispatch several questions per incident.
requests per month 300 incidents × 8 questions ≈ 2,400 cost per request monitor.py, stable version ≈ $0.014 model cost per month ≈ $34 same traffic, cache cold cost per request ≈ $0.026 ≈ $62 same traffic, 3× context a tool returning full JSON logs ≈ $90–100
For an internal tool like Dispatch, the model bill is small next to one engineer-hour saved per incident. The numbers change character at consumer scale: two million requests a month at $0.014 is $28,000, and then the cache hit rate and the size of every tool result become line items worth an engineer's attention.
Every change goes through the eval suite first (Chapter 23). Passing it earns the change a place in production traffic, carefully, in stages, because forty cases cannot contain everything real users ask.
A canary is a statistical test, and the arithmetic of Chapter 23 §23.5 applies to it: small canaries see only large regressions, and only after enough traffic. The refusal regression above was detectable after 110 canary requests because it was large, 1.2% to 6.4%. A regression from 1.2% to 2% would need thousands.
That trade-off has no free resolution. A bigger canary exposes more users to a bad version but finds the problem sooner. Two practical answers: canary during busy hours, when traffic accumulates fastest, and pair the canary with shadow grading, so the judge sees the new version's answers on real traffic before anyone relies on them.
Everything in Part 4 now fits in one small service. code/ch24/dispatch_service.py wraps the Chapter 22 agent loop, unchanged, in an HTTP server with the production pieces of this chapter: computed versions, a trace per request, a sampled transcript store, typed error handling with refusal and outage fallbacks, and an approval queue. It runs against the real model with an API key, or against the scripted client with --stub, which replays one fixed triage so everything else can be exercised for free.
The approval gate changes shape in a service. In Chapter 22 the gate was an input() prompt: the loop stopped and waited for a person at the keyboard. A service cannot block an HTTP request for as long as an operator takes to decide, so propose_action now puts the proposal in a queue and tells the model, in the tool result, that it has not run and must not be described as done. The operator reads the queue and approves, and then carries out the change with their own tools. Dispatch can suggest; it cannot act.
$ python code/ch24/dispatch_service.py --stub &
$ curl -s -X POST localhost:8024/triage -d '{"message": "Pager: push-queue depth > 50k. Triage it."}'
$ curl -s localhost:8024/proposals
$ curl -s -X POST localhost:8024/proposals/P-0001/approve
Dispatch v3.0 · prompt a7937c88f16f · stub · 127.0.0.1:8024 · traces → traces.test.jsonl
{"request_id": "req_a099065236", "status": "ok",
"answer": "Cause: an inbound surge from tenant acme (3.7x baseline); workers are healthy. I proposed scaling
12 → 24 replicas per runbook 01; it takes about 90 s to apply. If depth is still growing 10 minutes
after scaling, contact acme about pacing via the batch endpoint.",
"proposals": [{"action": "Scale push workers from 12 to 24 replicas",
"reason": "Depth 12k→58k in 5 min; workers 12/12 healthy; inbound 3.7x, 80% from tenant acme.
Runbook 01: healthy workers + growing queue → check inbound, scale 12→24. Runbook 07: max 48."}],
"degraded": false, "version": "v3.0"}
[{"id": "P-0001", "action": "Scale push workers from 12 to 24 replicas", …, "status": "pending"}]
{"id": "P-0001", "action": "Scale push workers from 12 to 24 replicas", …, "status": "approved"}
The answer comes from the scripted client, so its wording is fixed. The tool calls behind it are real: the Chapter 22 loop ran them, search_runbooks found runbook 01 in Chapter 21's corpus by BM25, and the proposal went through the queue. Against the real model, the wording varies from run to run and the structure stays the same, which is exactly what the eval suite checks.
Production is where you learn what your eval suite is missing. The practice that turns that into improvement is simple and needs discipline:
After six months, Dispatch's forty cases are four hundred, most of them things that actually went wrong. That is the data flywheel: usage produces failures, failures become cases, cases gate changes, better changes produce more usage. The labs run the same loop at scale when they turn user feedback into post-training data and evals (Chapter 14 and Chapter 18) inferred from their published descriptions of feedback-driven data collection. The difference for an application builder is that your loop feeds prompts, tools, and retrieval rather than weights.
Security is an operating concern too. A deployed Dispatch reads runbooks and tool results that other people can edit. Chapter 17 showed prompt injection: text in retrieved content that tries to redirect the model. In production the defences are the ones already built: tool results are marked as data, not instructions; the model's only write action is a proposal a human approves; and traces let you find the request where something strange happened. Add one monitor: the rate of proposals that operators decline. A spike means Dispatch is proposing things people reject, whether because of drift or because someone is steering it.
| Quantity | Value | Evidence |
|---|---|---|
| Model deprecation | Published schedules with advance notice; retired models stop serving | Provider deprecation pages public |
| Cached input price | ≈ 0.1× fresh input; cache writes ≈ 1.25× | Claude API pricing public |
| Batch discount | ≈ 50% | Claude API pricing public |
| SDK retry defaults | 2 retries with backoff on 408, 409, 429, 5xx | SDK documentation public |
| Server-side refusal fallbacks | Opt-in, beta, routed by refusal category | Claude API documentation public |
| Providers' internal serving SLOs and incident rates | Status pages report incidents; internal targets are not published | unknown |
| Dispatch, synthetic log | $0.014 per request · 73% of input cached · p95 14.8 s | code/ch24/monitor.py |
Use a model alias instead of a pinned id, and do not record the served model. The alias moves to a new snapshot one Tuesday. Refusals rise, answers get longer, cost goes up 20%. Nothing in your traces says why, because every row says the same model name. Pin the id, record what served.
Log full prompts and answers in the trace. The dashboard is now a store of customer data, readable by everyone who can see a dashboard, retained for as long as metrics are. The first time an engineer pastes a credential into Dispatch, it lives in your metrics system. Metadata in traces; content sampled, restricted, and short-lived.
Fall back to another model on every error. A malformed tool schema, a 400, now silently goes to the fallback model, which returns the same 400, or worse, accepts a slightly different schema and answers. The bug hides for weeks. Rate limits send double the traffic to the second model, which is on the same account limit. Fall back only on provider-side failures, and flag it.
Read response.content[0].text without checking the stop reason. A refusal can arrive with empty content; the code crashes, or shows a half-streamed answer as if complete. Branch on stop_reason first.
Watch only error rate and latency. The canary that refuses five times as often shows 0% errors and better latency. It ships to everyone. The quiet failures of an LLM application are all 200s. Count refusals, budget exhaustion, and declined proposals, and grade a daily sample.
Ship a prompt change that passed the eval suite straight to 100%. Forty cases see 20-point differences (Chapter 23 §23.5). A regression on a question type the suite does not cover reaches every user at once. The canary is where the suite's blind spots show up.
Say it back. An LLM application in production fails quietly: bad answers return HTTP 200. So it needs records. Pin every artifact that shapes behaviour, the model id, a prompt version computed from the prompt and tool definitions, corpus and service versions, and record them on every response, because sampling cannot be pinned and reproducibility means the eval suite gives the same numbers. Write one trace per request with status, steps, tools, the four token counts, cost by served model, and latency, and keep content out of it: a small sample of transcripts goes to a separate, restricted store. From traces, watch errors by class, refusals, degraded answers, budget exhaustion, latency p50 and p95, cost, and cache share, per version; grade the sampled transcripts daily to catch drift that no counter sees. Handle each failure by its meaning: fail loudly on 400s, back off on 429s, retry and then degrade to a second model on provider failures, and check the stop reason for refusals and truncation before reading content. Control cost with the free levers first: cache the prefix, keep tool results lean, cap output, lower effort where the evals allow, batch what can wait. Roll out through the eval gate, then a canary compared statistically with the stable version, then wider. Turn every production failure into an eval case. Dispatch is now that system: the Chapter 22 loop in a service with versions, traces, fallbacks, an approval queue, and a monitor that held a canary with five times the refusal rate.
claude-opus-5 at $5 and $25 per million. Compute its cost. Then compute the cost if the cache had been cold (the 3,030 tokens written at 1.25×) and if the request had been served by claude-sonnet-5 at $2 and $10 with a warm cache.test_service.py: a refusal. Extend the scripted client so a call can return stop_reason: "refusal" with empty content, and assert that the service responds with status refused, does not crash, and records it in the trace. Then add a declined-proposals rate to monitor.py, which needs a new trace field; decide where the service learns that a proposal was declined.