A prompt is code with no compiler behind it. You can delete a sentence from a system prompt, watch three answers come back looking fine, merge it, and only find out five weeks later that a whole class of question started failing on the day of that commit.
Key takeaways
Snapshot the scores, never the text. Exact output snapshot testing is the obvious move and it is wrong: 9 of my 200 cases flipped between two identical runs with temperature pinned at zero.
Gate on aggregate pass rate with a tolerance band, plus a small locked subset that may never regress. A rule that any single case may not regress will be routed around within a fortnight.
Four things move under you and only one is in your git history: your prompt, your corpus, the model version, and provider side infrastructure you cannot see.
Model upgrades break on parameters before they break on quality. Setting temperature on Claude Opus 4.7 and later now returns a 400, so the migration fails at the SDK call and never reaches your eval.
Full suite: 200 cases, 4 minutes 19 seconds wall clock at 8 concurrent workers, 62 cents a run. Cheap enough to run on every pull request that touches a prompt file.
Cost of an untested prompt edit
Last part we calibrated the judge until it agreed with two human labellers at Cohen kappa 0.767, which is the point at which its verdicts are worth acting on. This part takes that judge, plus the 200 golden cases from Part 20, and turns them into a gate: a thing that runs on every pull request touching a prompt file, and on every model change, and tells you a number before your users do.
Here is the edit that made me build it. Our documentation assistant was verbose, staff complained, and I added seven words to the system prompt: be concise and answer in under three sentences. Reasonable. I checked it against four questions by hand and shipped it on a Tuesday. Suite pass rate went from 172 of 200 to 158 of 200, a drop from 0.860 to 0.790, and every one of the 14 newly failing cases was a multi step configuration question where the correct answer genuinely needs five sentences. Nobody filed a ticket, because staff who get a truncated answer just stop using the tool. I found it eleven days later while doing something else.
Four separate things can move your output quality, and only one of them appears in your git history:
- Your prompt. Visible, reviewable, and the only one most teams test.
- Your corpus. Someone republishes a docs page, chunk boundaries shift, retrieval returns different context for the same question.
- Your model version. A pinned snapshot gets deprecated, an alias silently points somewhere new, or you deliberately upgrade.
- Provider side infrastructure. Serving stack changes, quantisation changes, routing changes. You get no commit, no changelog and no notice.
That last category is why a regression suite for an LLM feature is not optional in the way a regression suite for a pure function is optional. Your dependency changes without telling you. A scheduled nightly run against a frozen eval set is the only instrument that catches it.
Pinning a baseline before you change anything
Reach for snapshot testing and you will find good tooling waiting: syrupy, pytest-snapshot, inline-snapshot. All of them store the exact output of a call and fail when it differs. For an LLM feature this is a trap, and it is the single most common wrong turn I see. Model output is not stable enough to diff, and it does not need to be. Two answers can be textually unrelated and both correct.
What you pin is the verdict, not the prose. Run the suite, record whether each case passed, and store that alongside the git SHA and the exact model identifier. Store one sample answer per case too, so a human can eyeball a diff during triage, but never assert on it.
Now run it twice with nothing changed. I did, on the same commit, same model snapshot, temperature pinned at zero, and got 172 then 169. Nine individual cases flipped verdict in one direction or the other; the net was three. Temperature zero selects the highest probability token, it does not make the arithmetic underneath deterministic, and floating point non-determinism in batched GPU inference is enough to change a token, which changes everything after it. The GenAI Series piece on hallucination and temperature covers why that dial does less than its name promises.
Assertion tiers that keep the suite affordable
Grading runs in three tiers, cheapest first, and a case that fails a cheap tier never reaches an expensive one. Deterministic string and structure checks are free and instant. Schema validation costs a millisecond. Only the residue goes to the judge, which costs money and 900 milliseconds a call. On our suite that split is 118 cases settled deterministically, 82 sent to the judge, which is what keeps a full run at 62 cents instead of about 1.50.
Two details in there earn their keep. Ordering matters, so the aggregate test must be collected last; pytest runs tests in file order, and putting the gate at the bottom of the file is the plainest way to get that without a plugin. And critical cases assert individually, because a 2 point tolerance on the whole suite would happily absorb the four questions about billing that legal wants answered correctly every single time.
That is the be-concise commit, caught in four minutes instead of eleven days. Note what the failure message carries: the model identifier and the git SHA of the baseline. Six months from now, when someone reopens this run, those two strings are the difference between a diagnosis and a shrug.
Wiring the gate into a pipeline
My first version of this gate failed the build if any case regressed. It felt principled and it lasted thirteen days. Given a noise floor of nine flipping cases, that rule blocked 6 of the 9 pull requests we opened in a fortnight, every one of them a false alarm. Two engineers started re-running the job until it went green, which is the same as having no gate but slower. Then a genuine 7 point regression went in behind a red X that everyone had learned to scroll past. Rewriting it to an aggregate tolerance with a locked critical subset took an afternoon and the gate has been believed ever since. A test people route around is worse than no test, because it also costs you their trust in the next one you build.
Rebaselining deserves its own warning. If a branch can write baseline.json, every 1 point drop becomes the new normal and six merges later you have lost 6 points with a green pipeline the whole way. Baseline updates happen on main only, in a commit whose message says why, reviewed by a human.
If you would rather not own the harness, promptfoo does the same job declaratively, exits non-zero when assertions fail, and mixes deterministic assertion types with model graded ones in the same file. It is a reasonable default for a team that does not already live in pytest:
Run it with an API key from the environment and a pass rate threshold in the pipeline step. The trade off against the pytest version is control: you get the assertion library for free, and you give up the ability to express a gate like ours, where 40 named cases behave differently from the other 160.
Model upgrades break on parameters first
Everyone plans for a model upgrade to change answer quality. In practice the first thing that breaks is the request itself. Anthropic now lists temperature, top_p and top_k as deprecated parameters that return a 400 error when set to a non default value on Claude Opus 4.7 and later, including Opus 4.8, and on Claude Sonnet 5. Every eval harness I have written pins temperature to zero as a matter of hygiene, which means every one of them fails on first contact with those models:
Two hundred identical errors in six seconds is actually the good outcome, because it is loud. Build the sampling parameters as a dict that a per-model capability map populates, rather than passing temperature unconditionally, and this class of failure disappears for good. It is worth internalising the wider point: the parameter surface is versioned too, not just the weights, and a provider can remove a knob you built a habit around.
What I would do
Never point production at a floating alias. Pin the dated snapshot, put the string in one environment variable, and let the nightly suite run against both the pinned snapshot and the current candidate. An alias saves you one migration and costs you the ability to attribute a quality change to anything at all. Notice windows are generous enough to make pinning cheap: Anthropic commits to at least 60 days before retirement, OpenAI to at least 6 months for generally available models.
Migration runbook for a deprecated model
Deprecation notices are routine now rather than exceptional. Anthropic retired Claude Sonnet 4 and Opus 4 on 15 June 2026 and has Opus 4.1 retiring on 5 August 2026; OpenAI notified developers on 11 June 2026 that older GPT-5 and o3 snapshots leave the API on 11 December 2026. Assume you will do this two or three times a year, and make it boring. This is the sequence I follow, and it is the artifact from this part worth saving:
- Confirm the blast radius. Export API usage by key and model from the provider console and find every service on the retiring snapshot, not just the one you remembered.
- Freeze a fresh baseline on the old model while it still works. After the retirement date you cannot reproduce it, and a migration without a before number is a guess.
- Run the suite on two or three candidates. Include a cheaper tier, because upgrades are the only moment anyone will let you re-examine cost.
- Fix parameter errors before reading any quality number. A 400 is not a quality signal and will contaminate the comparison.
- Compare per tier, not just headline. A candidate can gain 3 points overall while losing your 40 critical cases; the aggregate hides that and the critical assertions do not.
- Shadow run for 5 to 7 days. Send live traffic to both, log both answers, serve only the incumbent. Real questions surface failure modes your golden set does not contain.
- Cut over behind a flag, rebaseline on main, keep rollback for two weeks. Rollback means the old model string still resolves, which stops being true on the retirement date, so do not cut over in the final fortnight.
Step five is where our last migration nearly went wrong. Here is what the four candidates scored on the same 200 cases, run three times each and averaged:
Newer tier won on aggregate by 2.5 points and I very nearly shipped it on that basis. It failed 3 of the 40 critical cases, all three refund policy questions, because it had become markedly more hedging about anything resembling a commitment. Aggregate up, the questions legal cares about down. We shipped it anyway, with a retrieval fix and two extra sentences in the system prompt aimed squarely at those three cases, and rebaselined at 0.890. Without the critical subset asserting individually, that would have gone out unnoticed and been discovered by a customer.
Regression symptoms and their usual causes
When the gate goes red, the shape of the failure tells you where to look before you read a single answer. Keep this near the runbook:
| Symptom in the run | Most likely cause | First thing to check |
|---|---|---|
| Every case errors, suite finishes in seconds | Request level break: parameter removed, auth, model string | Read the first traceback, not the pass rate |
| Drop of 1 to 2 points, different cases each run | Noise floor, not a regression | Re run twice; compare the union of failing ids |
| Cases fail in one topic cluster only | Corpus or chunking change, not the prompt | Diff retrieved chunk ids against the baseline record |
| Long answer cases fail, short ones pass | Length or conciseness instruction, or max output tokens | Check finish reason on the failing calls |
| Judged cases drop, deterministic cases hold | Judge drift, or the judge model itself changed | Re run the kappa check from Part 21 before touching the app |
| Slow decline across weeks, always green | Rebaselining on branches | Plot pass rate history; restrict baseline writes to main |
Row five catches people out more than any other. Your judge is a model call, so it is subject to every failure mode in this article. When judged cases fall and deterministic ones hold, suspect the instrument before the thing being measured.
Gate on aggregate pass rate, not on individual cases
If you take one rule from this part: gate on aggregate pass rate with a tolerance wider than your measured noise floor, and carve out a small set of cases that may never regress. Avoid exact output snapshot testing entirely, however well the tooling markets itself, because you will spend your credibility on diffs that mean nothing. Avoid a per case zero tolerance rule for the same reason, and I say that as someone who shipped one and watched it get routed around inside two weeks.
On Monday, do the smallest version of this: run your eval set once, write the pass rate to a file with today’s git SHA and your exact model string next to it, and commit it. That single file converts every future argument about whether the assistant got worse from an opinion into a subtraction. Everything else here is refinement on top of having a number from before.
Part 23 turns to guardrails: input filtering, output validation and PII handling. A regression gate tells you the assistant got worse on average; a guardrail stops one specific answer reaching a user. Different jobs, different failure modes, and you need both before this thing faces customers.
References
- Anthropic, Model deprecations, for retirement dates, the 60 day notice commitment and the temperature, top_p and top_k parameter deprecation.
- OpenAI, Deprecations, for the 6 month notice period on generally available models and the current shutdown schedule.
- promptfoo, Assertions and metrics, for the deterministic and model graded assertion types used in the YAML config above.
- pytest, How to parametrize fixtures and test functions, for the parametrize and ids arguments used in the test module.


DrJha