Shipping LLM features safely
Eval gates on repeated runs with confidence intervals, versioned configs, canaries sized by arithmetic rather than habit, and a rollback you have rehearsed.
Here is a worked incident — hypothetical, but assembled from failure modes that recur — on an e-commerce catalog assistant that answers product questions and returns structured size recommendations. Nothing about it is unusual, which is the point.
| Time | Event |
|---|---|
| Mon 10:00 | Config v42 merges: cheaper model, shorter prompt. Offline gate: overall 86% → 88%, passed |
| Mon 11:00 | Canary at 5% of traffic behind a feature flag |
| Mon 17:40 | Malformed-JSON rate on canary 1.9% vs 0.4% control; alert threshold 1.0% |
| Mon 17:42 | Auto-rollback flips flag to v41; on-call paged |
| Mon 18:30 | Logs keyed by config version: ~1,700 canary requests, 32 bad responses |
| Tue 10:00 | Root cause: new model wraps JSON in a code fence for sizing questions only |
| Tue 15:00 | ‘sizing’ slice added to golden set: 12 rows, all fail on v42 |
| Wed 11:00 | v43 (strict JSON schema decoding) passes the gate; canary resumes |
The release worked as designed: a regression the offline gate missed was caught on 5% of traffic and reversed in two minutes with no deploy. But the gate should have caught it, and it did not for two reasons that recur everywhere. The overall score rose while one slice collapsed, and the golden set had no rows for that slice. This post builds the path that makes this incident boring and then makes it rarer.
Version everything that changes behaviour
Bundle into one versioned config, logged on every request: prompt template and id, exact model version (never a floating alias), sampling settings, retrieval config, tool schemas, and the judge prompt used to evaluate it. Prompt versioning is what made “1,700 canary requests, 32 bad responses” a 15-minute query instead of a forensic project, and it is what makes rollback a one-field change.
Gate 1: offline evals on repeated runs
Model output is not deterministic, and setting temperature to 0 does not make it so: providers and engines such as vLLM batch requests together, and floating-point reductions that depend on batch composition change outputs between identical calls (He / Thinking Machines, 2025). A gate that compares one run of the baseline with one run of the candidate is comparing two samples of noise.
So the eval gate runs each example 5 times per config, computes per-slice change with a paired bootstrap interval, and blocks only when a slice is worse by more than its tolerance with 95% confidence. Safety is a hard floor: every safety example must pass every repeat. The rules are fixed before anyone sees numbers. The script below is deliberately constructed to fail: it exits with code 1, which is what fails the CI job.
import random
import sys
REPEATS = 5
# Passes out of 5 repeated runs per example, grouped by slice: (baseline, candidate).
RESULTS = {
"sizing": ([5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4], [3, 5, 2, 4, 3, 5, 1, 5, 4, 3, 5, 2]),
"orders": ([3, 4, 2, 5, 3, 4, 2, 5, 3, 4, 5, 2, 3, 4, 5, 3], [5, 5, 4, 5, 5, 5, 4, 5, 5, 5, 5, 4, 5, 5, 5, 5]),
"format": ([5, 5, 4, 5, 5, 5, 5, 4], [5, 5, 5, 5, 5, 5, 5, 4]),
"safety": ([5, 5, 5, 5, 5, 5], [5, 5, 5, 5, 5, 5]),
}
# Agreed before anyone saw the numbers: block if we are 95% sure a slice fell by more than this.
TOLERANCE = {"sizing": 0.03, "orders": 0.03, "format": 0.05, "overall": 0.02}
HARD_FLOOR = {"safety"} # every example must pass every repeat
def change_ci(base: list[int], cand: list[int], iters: int = 5000, seed: int = 3) -> tuple[float, float, float]:
diffs = [(c - b) / REPEATS for b, c in zip(base, cand)]
rng = random.Random(seed)
means = sorted(sum(rng.choices(diffs, k=len(diffs))) / len(diffs) for _ in range(iters))
return sum(diffs) / len(diffs), means[int(0.025 * iters)], means[int(0.975 * iters)]
def gate() -> list[str]:
problems = []
slices = dict(RESULTS)
slices["overall"] = tuple(sum((RESULTS[s][i] for s in RESULTS), []) for i in (0, 1))
for name, (base, cand) in slices.items():
if name in HARD_FLOOR:
if min(cand) < REPEATS:
problems.append("%s: an example failed at least one repeat" % name)
continue
delta, lo, hi = change_ci(base, cand)
print("%-8s %+6.1f pts CI [%+.1f, %+.1f]" % (name, 100 * delta, 100 * lo, 100 * hi))
if hi < -TOLERANCE[name]:
problems.append("%s: worse by more than %.0f pts with 95%% confidence" % (name, 100 * TOLERANCE[name]))
return problems
if __name__ == "__main__":
problems = gate()
for p in problems:
print("FAIL:", p)
print("gate:", "blocked" if problems else "passed")
sys.exit(1 if problems else 0) # non-zero exit fails the CI jobsizing -26.7 pts CI [-41.7, -11.7]
orders +25.0 pts CI [+16.2, +32.5]
format +2.5 pts CI [+0.0, +7.5]
overall +2.4 pts CI [-6.2, +10.5]
FAIL: sizing: worse by more than 3 pts with 95% confidence
gate: blockedThis is v42 with the sizing slice in place. Overall is up 2.4 points (and the interval says even that is not established); orders genuinely improved; sizing is down 27 points with an interval that excludes anything better than −11.7. The gate blocks, correctly. Without per-slice rules the average would have shipped it. Without repeats and intervals, the same rule would fire on noise once a week and people would learn to override it.
Budget it: 42 examples × 5 repeats × 2 configs = 420 calls. At roughly $0.01 a call that is under $5 and, at 20 concurrent requests, about a minute. Run the baseline once per baseline change and cache it, which halves the cost. Keep the gate under 10 minutes or people will route around it.
Gate 2: shadow traffic
Shadow traffic sends a copy of real requests to the candidate and records its outputs while users only see the current version. It catches what offline sets miss — the incident above would have shown up as a 1.9% malformed-JSON rate in shadow before any user saw it. It doubles model spend on the mirrored share, and must never trigger side effects: for agents, shadow only read-only tools or point tools at stubs.
Gate 3: canary, sized by arithmetic
A canary exposes a small share of users to the candidate behind a feature flag and compares it with the rest. The question is how long to hold each step, and the answer is a sample-size calculation, not a habit. For comparing two rates with a two-sided α = 0.05 and 80% power:
import math
def n_per_arm(p_control: float, p_canary: float, z_alpha: float = 1.96, z_power: float = 0.8416) -> int:
"""Requests needed in each arm to detect p_control -> p_canary (two-sided 5%, 80% power)."""
variance = p_control * (1 - p_control) + p_canary * (1 - p_canary)
return math.ceil((z_alpha + z_power) ** 2 * variance / (p_control - p_canary) ** 2)
if __name__ == "__main__":
daily_requests, canary_share = 120_000, 0.05
per_hour = daily_requests * canary_share / 24
for metric, a, b in [("error rate", 0.005, 0.010), ("thumbs-down", 0.04, 0.05), ("escalation", 0.02, 0.03)]:
n = n_per_arm(a, b)
print("%-12s %.1f%% -> %.1f%%: %5d requests, %4.1f h at 5%%" % (metric, 100 * a, 100 * b, n, n / per_hour))error rate 0.5% -> 1.0%: 4671 requests, 18.7 h at 5%
thumbs-down 4.0% -> 5.0%: 6743 requests, 27.0 h at 5%
escalation 2.0% -> 3.0%: 3823 requests, 15.3 h at 5%At 120k requests a day, a 5% canary needs about 27 hours to detect a one-point rise in thumbs-down rate. So the rule is: hold the first canary step for max(time to reach n on your slowest guardrail metric, one full daily cycle), because traffic at 3 a.m. is not traffic at 3 p.m.; later steps carry more traffic and need only reach n. Large regressions show up far sooner — the 4.75× malformed-JSON jump above needed a few hundred requests — so pair the fixed dwell with a fast-trip threshold that rolls back immediately on gross failures.
Guardrails, each with a threshold written down before the ramp starts: error and timeout rate including malformed structured output; p95 and p99 latency (longer outputs show up in the tail first); cost per request; refusal and guardrail-trigger rates; thumbs-down, retries and escalations to a human.
A/B tests answer a different question
A canary asks “is it safe?”; an A/B test asks “is it better?”. Fix the primary metric and sample size before starting and do not stop early on a good-looking day: peeking at a 5% test daily for two weeks and stopping at the first significant result pushes the false-positive rate well above 5%. Use a sequential method if you must look. Expect offline and online results to disagree sometimes; an offline gain that does not appear online usually means the golden set no longer matches traffic.
Rollback you have actually practised
Rollback is a flag flip to the previous config version, never a code deploy. Rehearse it quarterly in production with a no-op config change, and time it; the target is under 5 minutes from decision to 0% exposure. Unrehearsed rollbacks fail on the details: a response cache still serving new-version answers, a schema the old prompt cannot read, a downstream consumer that already migrated. Keep prompt and schema changes backward compatible for at least one version so the previous config can always run against current data.
Close the loop
Every incident becomes golden-set rows before the fix merges — the incident above added 12 sizing rows. Write the postmortem around one question: which gate should have caught this, and what does it need so it will next time? Over a year, that feedback loop turns the eval set into a record of everything that has gone wrong, which is exactly what a gate should check.
Key takeaways
- Version the whole config — prompt, exact model version, sampling, retrieval, tools — and log it on every request; it turns impact analysis into a query.
- Temperature 0 is not determinism. Gate on 5 repeats per example with a paired-bootstrap interval per slice, block only on confident regressions beyond a pre-agreed tolerance, and keep safety as a hard floor.
- An overall score can rise while a slice collapses; per-slice rules and incident-driven slices catch what averages hide.
- Size canary dwell with arithmetic: at 120k requests/day, a 5% canary needs ~27 hours to see a 4%→5% thumbs-down rise. Hold the first step for max(n, one daily cycle) and add a fast-trip threshold for gross failures.
- Rollback is a rehearsed flag flip under 5 minutes, and every incident adds golden-set rows before its fix ships.
Sources
- Canarying Releases (The Site Reliability Workbook, ch. 16) — Google SRE, 2018book
- Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations — Evan Miller, 2024paper
- Defeating Nondeterminism in LLM Inference — Horace He / Thinking Machines Lab, 2025blog