Part 4 · Chapter 24

Operating in production

Versions, traces, failures, cost, and rollouts: keeping an LLM application working after launch. Capstone for Part 4.

Where we are

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?

Picture this

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.

Map it
At the airlineIn the machineThe word we will use
The logbook of parts and revisionsPinned model ids, a prompt version, tool and corpus versions, on every responseversioning, reproducibility
The flight recorderOne structured record per request: tokens, cost, latency, steps, statustrace
Maintenance reading the recordsAggregates over traces, watched for changemonitoring, dashboard
Quiet wear that no alarm catchesQuality falling while every request succeedsdrift
A new part on a few aircraft firstA new version on a small share of traffic, compared with the restcanary rollout
Diverting to another airportAnswering from a second model when the first is unavailable, and saying sofallback, degraded mode
Incident reports that change the checklistsProduction failures turned into new eval casesdata flywheel

24.1Pin everything you can

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".

ArtifactHow it changes behind your backWhat to pin and record
The modelAn alias that moves to a new snapshot; a model deprecated and retired on the provider's scheduleThe exact model id you request, and the model id the response says served it
The system promptSomeone edits a sentence and forgets to bump a version numberA version computed from the prompt text itself, not typed by hand
Tool definitionsA description reworded; a parameter addedIncluded in the same computed version: they are part of the prompt
Retrieved contentA runbook updated; the index rebuilt with a new embedding modelCorpus and index versions
Your codeThe loop's step budget, retry policy, sampling settingsThe 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.

24.2Trace every request

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.

Static view of the widget. One triage as fourteen spans over about 9.3 seconds: five prefills (0.2 to 0.3 s each, reading a 1,010-token cached prefix plus the new results), five decodes (1 to 2.4 s each, about 82% of the total), and four short tool steps. With a cold cache each prefill costs about 0.35 s more.

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.

24.3Watch the right numbers

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:

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.

Static view of the widget. The dashboard at the monitor's values: 1,090 stable and 110 canary requests, refusals 1.2% against 6.4% (HOLD), p95 latency 14.8 s against 14.0 s, 73% of input tokens read from cache, cost per request about $0.0138 against $0.0124. With the cache hit rate at zero, cost per request nearly doubles.

Drift: quality falls while everything succeeds

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.

24.4When things fail

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 happenedWhat it meansWhat to do
400 bad requestYour request is wrong: a malformed tool schema, too many tokens, a parameter this model does not acceptFail loudly. A retry fails identically, and a fallback model hides the bug.
429 rate limitedYou exceeded your account's limitBack 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 overloadedThe provider is strugglingRetry with backoff. If it persists, answer from a second model and mark the response as degraded.
Connection error, timeoutThe network, or a very long requestRetry with backoff. Set a hard wall-clock limit per request so a hung connection frees its slot.
200 with stop_reason: refusalThe model, or a safety classifier, declinedNot 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_tokensThe answer was cut offRaise 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.

Static view of the widget. Twelve clients hit a 429 at time zero. Without jitter, their retries land at the same instants (0.5 s, 1.5 s, 3.5 s) and collide each time. With jitter, each delay is multiplied by a random factor between 0.5 and 1.5, and the retries spread out.

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

24.5Cost is a design parameter

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.

  1. Cache the stable prefix. System prompt, tool definitions, reference documents: put them first, keep them byte-identical, mark a cache breakpoint (Chapter 20). Cached input costs a tenth of fresh input. In the synthetic log, 73% of input tokens come from the cache.
  2. Keep the context lean. An agent re-sends everything on every call. A tool that returns a 5,000-token JSON blob when the model needs three numbers is paid for on every later call in the loop. Return what the model needs.
  3. Cap the output. Output tokens cost five times input on current frontier pricing public (Appendix B). "Answer in at most three sentences" is a cost control.
  4. Use lower effort where quality holds. Current models accept an effort setting that trades depth of reasoning for tokens. Measure with the eval suite; many routes lose nothing at lower effort.
  5. Batch what is not urgent. Nightly summaries, eval runs, back-filling labels: batch APIs run asynchronously at a discount, about half price on the Claude API public.
  6. Route to a smaller model, last, and only with eval evidence per route. Two models also mean two caches, so routing can cost more than it saves on cache-heavy traffic.
Back of the envelope

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.

24.6Rolling out a change

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.

Static view of the widget. At 200 requests an hour, a 10% canary, a 2% baseline defect rate, and a true canary rate of 6%, the regression becomes statistically visible after about 5.5 hours, roughly 110 canary requests. Halving the canary share roughly doubles the wait. A 1-point regression is not visible within 48 hours at 10%.

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.

24.7Capstone 4: Dispatch in production

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.

on-call engineerPOST /triage dispatch_service.py run_agent()Chapter 22's loop,≤ 8 steps ProductionClientrefusal + outage fallback,cost per served model toolsqueue, workers, inboundrunbooks (Ch 21)propose_action → queue version fieldsservice v3.0prompt a7937c88f16f(hash of prompt + tools) model APIopus-5 · sonnet-5 if degraded traces.jsonlevery request, metadata only transcripts5% sample, restricted GET /proposalsPOST …/approve(the operator acts) monitor.pydashboard + canary gate
What is inside the deployed Dispatch? The Chapter 22 loop, unchanged, using a production client that handles fallbacks and counts cost. Proposals go to a queue that an operator approves; Dispatch never executes anything itself. Every request writes a metadata-only trace that the monitor reads; a small sample writes a full transcript to a separate, restricted store.

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.

24.8The loop that improves the system

Production is where you learn what your eval suite is missing. The practice that turns that into improvement is simple and needs discipline:

  1. Every complaint, every refused request that should have been answered, every judge failure in the daily sample becomes a candidate eval case, with its fixture recorded at the time.
  2. A person writes the expectation, as in Chapter 23 §23.2, and the case joins the suite.
  3. The next change must pass the suite, including the new case.

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.

24.9Beacon's numbers

QuantityValueEvidence
Model deprecationPublished schedules with advance notice; retired models stop servingProvider 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 defaults2 retries with backoff on 408, 409, 429, 5xxSDK documentation public
Server-side refusal fallbacksOpt-in, beta, routed by refusal categoryClaude API documentation public
Providers' internal serving SLOs and incident ratesStatus pages report incidents; internal targets are not publishedunknown
Dispatch, synthetic log$0.014 per request · 73% of input cached · p95 14.8 scode/ch24/monitor.py
Break it

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.

Rebuild the model

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.

pinned versionsmodel, prompt hash servicetyped failures, gate tracesmetadata, always monitor+ sampled judging canary gatewiden or hold new eval casesCh 23 suite the flywheel: failures become cases, cases gate the next change
What is the whole chapter in one line? Pin what you can, trace every request, watch the quiet numbers, gate every rollout, and feed every failure back into the eval suite.
Exercises
  1. By hand. A trace row shows 2,400 uncached input tokens, 3,030 cache-read tokens, no cache writes, and 410 output tokens, served by 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.
  2. Calculation. The stable refusal rate is 1.5%. A canary gets 40 requests an hour. How many hours until a canary refusal rate of 4.5% is detectable with the formula of Chapter 23 §23.5 (use the two-proportion version in the canary widget, stable traffic nine times the canary's)? How long for a canary at 2.5%? Which of the two would you rather detect, and what does that say about canary share?
  3. Code. Add a sixth test to 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.
Further reading