## How to catch LLM regressions before a model upgrade ships

URL: https://www.evalshift.dev/blog/llm-regression-testing-model-upgrade
Published: 2026-10-12
Tag: migration
Summary: Sample production logs into a golden set, write deterministic assertions, diff baseline against candidate and gate it in CI. Built on six real failures.

Takeaways:
- Most regressions from a model upgrade are silent: the output still reads fine while a tool argument, an enum value or a classification changed underneath. Sample production prompts into a golden set, assert on them deterministically, diff baseline against candidate, and gate that diff in CI.
- Boring checks caught every regression in a Sonnet 5 to Sonnet 5.5 upgrade: a regex on a tool argument, field equality on a JSON enum, the set of tools called. Embedding similarity scored the priority downgrade at 0.95 and an LLM judge preferred the regressed output.
- Build the golden set from production logs, not from memory: 100 to 300 inputs, stratified by route, PII stripped, repeats dropped, known edge cases tagged so sampling can never lose them.
- Compare pass rates per route and per check, never one average, and take several samples per case: the baseline model disagreed with itself on 1 of 9 tickets between capture and replay.
- Every drop is a decision, not a threshold: fix the prompt, add a parser, pin the old model, or accept the change and update the golden set. Without the diff, the model makes that decision silently.

Your provider is retiring the model you run in production, or a cheaper one just shipped and
someone has done the arithmetic. Either way, one string in a config file is about to change, and
nothing in your test suite has an opinion about what the model says. Here is the process for giving
it one: sample real inputs from production into a golden set, write a deterministic assertion for
each way an upgrade can break you, run the current model and the candidate over the same cases and
diff the pass rates, then put that diff in CI so the next upgrade is a pull request check instead
of a week of support tickets.

Everything below runs from one small repo,
[evalshift/llm-regression-example](https://github.com/evalshift/llm-regression-example), and
every failure in it is real: it comes from upgrading a three-route agent from Sonnet 5 to
Sonnet 5.5. The repo ships the recorded outputs, so the diff runs offline, with no API key.

![Pipeline: production logs feed build_golden.py, which writes the golden set; checks.py holds the assertions; run_eval.py runs baseline against candidate and diffs pass rates per route and check; llm-regression.yml fails the pull request on a drop. Step five is triage.](/blog-images/llm-regression/pipeline.svg "Five files. The arrows are the only coupling: each step reads the previous one's output from disk.")

The reason this needs a process rather than an afternoon in the playground is that the bugs a
model upgrade causes are mostly silent. The output still reads fine. A tool argument changed
shape, an enum value moved one notch, a ticket landed in a different queue, and nobody sees it
until something downstream counts it.

## What actually breaks when you switch models

The agent in the example has three routes: a day planner and a unit converter that call tools, and
a ticket triage classifier that returns JSON. Thirty recorded production turns, replayed on both
models. Six of the thirty failed a check on the new model that the old one passed, and every one
of them produced output a reviewer would have waved through.

| Failure type | What it looked like in our run | How to catch it |
| --- | --- | --- |
| Refusal flips into a hallucinated tool call | "Convert 10 kilometres to kilograms." Sonnet 5 explained the units don't convert. Sonnet 5.5 called `convert_units(km → kg)` and said nothing. | Pin cases where production answered without a tool, and assert no tool is called. |
| Tool argument drift | "What's on my calendar today?" went from `get_schedule(date="2026-09-08")` to `get_schedule(date="today")`, against a prompt rule to resolve relative dates. | A regex on the argument: `^\d{4}-\d{2}-\d{2}$`. |
| Extra tool calls | "Any notes about Sam's gift, and when is that task due?" gained a second call, `get_tasks`, on top of `search_notes`. Two cases did this. | Compare the set of tools called against the set production called. |
| Schema-valid, wrong value | A double-charge ticket dropped from priority `urgent` to `high`. Still valid JSON, still an allowed enum value. | Field equality against the baseline on every enum field. Schema validation alone passes this. |
| Classification shift | A ticket about app logouts and a late refund moved from `billing` to `bug`. Different queue, different owner. | Same field-equality check. |
| Format leakage | The old model wrapped its JSON in markdown code fences on 8 of 9 tickets, against an explicit instruction not to. The new one never did. | A strict `json.loads` on the raw output, separate from the lenient parse your app uses. |
| Verbosity and tone | Suggested replies grew 8% longer and more specific: the new model asks for the Safari version and offers Chrome as a workaround. | Length bounds on fields that have them. A pairwise judge for the rest, advisory only. |
| Latency and cost | Converter latency rose 47%. Triage got 32% faster and 27% cheaper. Planner barely moved. | Mean latency and cost per route, not per run. A single average would have shown a wash. |

The table is from a toy agent, but none of the rows is toy-specific. Every production agent I
have looked at has an enum field, a tool argument with a required shape, and a case where the
right answer is to not call a tool. The upgrade that gets one of those wrong does not throw.

## Step 1: build a golden set from production logs

The playground fails for a structural reason: the prompts you can think of are the prompts you
already handle. The cases that break are the ones a user typed, so the golden set has to come from
logs. Log one line per model turn, with everything the assertions will later need:

```json
{"request_id": "planner-000", "ts": "2026-10-12T20:35:51Z", "route": "planner",
 "history": [], "input": "What's on my calendar today?",
 "output": "You have Team standup at 9:30–10:00 AM on Zoom, and a Dentist appointment ...",
 "tool_calls": [{"name": "get_schedule", "args": {"date": "2026-09-08"}}],
 "latency_ms": 1863}
```

The `tool_calls` and the `output` are what production did, and they become the expectations.
Then sample. Aim for 100 to 300 lines, stratified by route or feature, because a 5% regression on
a route that is 5% of traffic is invisible in an unstratified sample. Strip PII before anything
touches disk. Drop repeated inputs: twenty copies of "where is my order" make the suite look twenty
times more certain than it is. And tag the edge cases you already know about, so sampling can
never drop them.

```python
def build(lines: list[dict], per_route: int) -> list[dict]:
    by_route: dict[str, list[dict]] = defaultdict(list)
    seen: set[tuple[str, str]] = set()
    for line in sorted(lines, key=lambda x: x["ts"]):
        key = (line["route"], line["input"].strip().lower())
        if key in seen:
            continue
        seen.add(key)
        by_route[line["route"]].append(line)

    cases = []
    for route, route_lines in sorted(by_route.items()):
        edge = [x for x in route_lines if "edge" in x.get("tags", [])]
        rest = [x for x in route_lines if x not in edge]
        chosen = edge + rest[: max(per_route - len(edge), 0)]
        chosen.sort(key=lambda x: x["ts"])
        for line in chosen:
            cases.append(
                {
                    "id": line["request_id"],
                    "route": route,
                    "history": [
                        {"role": h["role"], "content": strip_pii(h["content"])}
                        for h in line.get("history", [])
                    ],
                    "input": strip_pii(line["input"]),
                    "expect": expectation(line),
                }
            )
    return cases
```

```bash
python build_golden.py logs/production.jsonl > golden.jsonl
```

`expectation()` writes the tools production called with their arguments, or, for the JSON route,
the values of the enum fields. Review the file before committing it, then freeze it. The
expectations say "do what production did", and production is a baseline, not an oracle. If you
are still editing cases while you compare models, you are comparing two moving things.

## Step 2: write assertions, cheapest first

Here is the opinionated part. Every regression in the table above was caught by a check that
makes no model call, runs in microseconds, and could be explained to a new hire in one sentence.
Embedding similarity scored the priority downgrade at 0.95 and the queue change at 0.87. An LLM
judge, asked which output was better, preferred the new model on 8 of 9 tickets, which is true
and useless: better prose, wrong priority.

So start with the boring checks, and encode them as data rather than prose:

```python
# Required keys of the triage JSON and their allowed values (None = free text).
JSON_SCHEMA: dict[str, set[str] | None] = {
    "category": {"billing", "bug", "feature_request", "account", "other"},
    "priority": {"low", "medium", "high", "urgent"},
    "sentiment": {"negative", "neutral", "positive"},
    "summary": None,
    "suggested_reply": None,
}
SUMMARY_MAX_WORDS = 20

ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
ISO_CURRENCY = {"USD", "EUR", "GBP", "JPY", "CHF"}
```

```python
# What the prompts promise the tools will receive. The model is supposed to
# normalise before calling; these rules say whether it did.
ARG_RULES = {
    "get_schedule": {"date": _is_iso_date},
    "days_between": {"start_date": _is_iso_date, "end_date": _is_iso_date},
    "convert_currency": {
        "from_currency": lambda v: v in ISO_CURRENCY,
        "to_currency": lambda v: v in ISO_CURRENCY,
    },
    "convert_units": {"value": _is_number},
}

# Arguments the model phrases freely. A different wording is drift worth
# printing, not a failure.
FREE_TEXT_ARGS = {("search_notes", "query")}
```

Two kinds of check are mixed in here, and it is worth keeping them straight. Baseline checks
compare the candidate against what production did: same set of tools, same arguments, same enum
values. Contract checks encode what the prompt promises and hold no matter what production did:
the JSON parses, the date is ISO, the summary is under twenty words. The baseline checks catch
change; the contract checks catch things both models might be getting wrong.

The strict JSON parse deserves its own line. Your app almost certainly strips fences before
parsing, because at some point a model added them and someone fixed it in the parser. Keep that
parser. But assert on the raw output too, so a model that starts, or stops, adding fences shows up
as a change rather than silently depending on a workaround.

Free text gets a judge, and only free text. The suggested reply in a triage ticket has no
deterministic contract beyond its length, so a pairwise judge asking "which reply is more
specific" is the right tool. Run it advisory, read what it says, and do not let it gate anything
until you have audited its calls by hand. Everything above it in this section is the gate.

## Step 3: run baseline against candidate and diff

Run every golden case through both models at temperature 0, apply every check to every output,
and compare pass rates per route and per check. Not one average: a run where triage improves and
the planner regresses can average out to nothing.

Nondeterminism is real even at temperature 0. The baseline model disagreed with itself on one of
nine tickets between the original capture and the replay: `sentiment` flipped from `negative` to
`neutral` on the same input, same prompt, same model. That is why the runner takes several samples
per case and scores the fraction that pass, rather than one outcome. Three samples per case is
a reasonable default; the pass rate then has a floor you can reason about.

Here is the real output, trimmed to the rows that matter, with one sample per case because that
is what the recorded run had:

```
30 cases, 1 sample(s) each: claude-sonnet-5 -> claude-sonnet-5-5

route      check             n  baseline  candidate   delta
converter  answered          1   100.0%      0.0% -100.0  REGRESSED
converter  arg_format        9   100.0%    100.0%   +0.0
converter  tool_args         9   100.0%    100.0%   +0.0
converter  tool_set         10   100.0%     90.0%  -10.0  REGRESSED
planner    arg_format       11   100.0%     90.9%   -9.1  REGRESSED
planner    tool_args        11   100.0%     90.9%   -9.1  REGRESSED
planner    tool_set         11   100.0%     81.8%  -18.2  REGRESSED
triage     fields            9    88.9%     66.7%  -22.2  REGRESSED
triage     json_schema       9   100.0%    100.0%   +0.0
triage     json_strict       9    11.1%    100.0%  +88.9  improved
triage     summary_length    9   100.0%    100.0%   +0.0

Cases where claude-sonnet-5-5 fails a check claude-sonnet-5 passed:

converter-008  'Convert 10 kilometres to kilograms.'
  tool_set       extra ['convert_units']
  answered       no text reply
  claude-sonnet-5:    "That's not a valid conversion—kilometres measure distance while kilograms measure mass, so there's no meani..."
  claude-sonnet-5-5:  convert_units({"value": 10, "from_unit": "km", "to_unit": "kg"})

planner-000  "What's on my calendar today?"
  tool_args      get_schedule.date: '2026-09-08' -> 'today'
  arg_format     get_schedule.date='today'
  claude-sonnet-5:    get_schedule({"date": "2026-09-08"})
  claude-sonnet-5-5:  get_schedule({"date": "today"})

triage-003  'I was charged twice for my subscription this month and need the duplicate refunded today — this is unacceptable.'
  fields         priority: 'urgent' -> 'high'
  claude-sonnet-5:    '```json {   "category": "billing",   "priority": "urgent",   "sentiment": "negative",   "summary": "Custome...'
  claude-sonnet-5-5:  '{"category":"billing","priority":"high","sentiment":"negative","summary":"Customer was charged twice for th...'

route      metric       claude-sonnet-5 claude-sonnet-5-5   change
converter  latency_ms           1568 ms          2312 ms    +47%
converter  cost_usd             $0.0057          $0.0057     -1%
planner    latency_ms           1481 ms          1538 ms     +4%
triage     latency_ms           3035 ms          2071 ms    -32%
triage     cost_usd             $0.0028          $0.0020    -27%

FAIL: 6 check(s) dropped more than 2% (worst: converter/answered -100.0 points)
```

The threshold is a policy choice, and it has to respect the denominator. With eleven planner
cases, one failure is a nine-point drop, so a two-point threshold means zero tolerance for that
route; that is fine as long as you know it. With 300 cases the same threshold tolerates six. Set it
per route if the routes differ in how much they matter, and write it down before you look at the
numbers, because a threshold chosen after seeing the diff is a negotiation, not a gate.

Look at the `n` column too. The `answered` check ran on one case, because production answered
without a tool exactly once in thirty turns. That is a hole in the golden set, not a finding about
the model, and it is the kind of thing this table is for: it tells you which rows you would act
on and which rows you cannot yet trust.

## Step 4: gate it in CI

The run that changes outcomes is the one attached to the pull request that bumps the model
string. Trigger it on the files that can move the numbers, and nothing else, because every run
spends real money:

```yaml
name: llm-regression

# Run only when something that can move the numbers changes: the prompts and
# model names (agent.py), the assertions, the golden set, or the runner.
on:
  pull_request:
    paths:
      - agent.py
      - checks.py
      - golden.jsonl
      - run_eval.py
      - .github/workflows/llm-regression.yml
  workflow_dispatch:

permissions:
  contents: read

jobs:
  eval:
    runs-on: ubuntu-latest
    env:
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v6
        with:
          python-version: "3.12"
      - run: pip install -e .

      # The baseline is the model production runs today. Its outputs only
      # change when the prompt or the golden set changes, so key the cache on
      # those and the candidate is the only side that gets re-run per PR.
      # (This repo commits results/ so it runs offline; in your own repo add
      # results/ to .gitignore and let this cache carry the baseline.)
      - name: Restore cached baseline results
        uses: actions/cache@v4
        with:
          path: results/claude-sonnet-5.jsonl
          key: baseline-claude-sonnet-5-${{ hashFiles('golden.jsonl', 'agent.py') }}

      - name: Baseline vs candidate
        run: python run_eval.py --repeats 1 --max-drop 0.02
```

The cache is the part people skip. The baseline's outputs only change when the prompt or the
golden set changes, so key the cache on those files and the candidate becomes the only side that
costs money per pull request. The runner reads a model's results from disk when they exist and
only calls the model for what is missing, which is also what lets the example repo run with no
key at all.

Ship it in report-only mode for the first week. Let it comment, read what it says, and turn on
the failure exit once you agree with it. A gate people learn to re-run until it passes is worse
than no gate.

## Step 5: triage what failed

A red check is a list of decisions, and each one has four possible answers: fix the prompt, add an
output parser, pin the old model for that route, or accept the change and update the golden set.
Walking the six from our run:

- `get_schedule(date="today")`: fix the prompt. The rule was there and the new model weighs it
  differently; it needs to be louder, and the tool should reject non-ISO dates so the next drift
  fails loudly. The tool in the original app happened to accept "today", which is exactly why the
  output looked fine.
- The hallucinated `convert_units` call: fix the prompt with an explicit "if the request is not a
  valid conversion, say so without calling a tool", and keep the case pinned. One such case in
  thirty is too few; add more impossible requests to the golden set.
- The two extra `get_tasks` calls: accept. Both questions genuinely span notes and tasks, and the
  new model is reading them more carefully than the old one did. Update the expected tool set and
  move on.
- The fences: accept, and keep the strict check. The new model obeys the instruction. The parser
  stays because the next model may not.
- `billing` to `bug` on the logout-plus-refund ticket: a judgment call about which queue owns a
  mixed ticket. Decide, write the rule into the prompt, and the check will enforce it from now on.

The priority downgrade is the one worth a story. The ticket said "charged twice", "refunded
today" and "unacceptable". The prompt defines urgent as "the customer is blocked or losing money
right now", and whether a duplicate charge counts is a question the prompt never answered, so
both models' calls are defensible. The decision took two minutes: a disputed charge is urgent, say
so in the prompt, keep the field check. Without the diff, that two-minute decision would have been
made by the model, silently, for every double-charge ticket after the upgrade, and the first sign
would have been an SLA report a month later.

## Doing this with EvalShift

Disclosure: I'm the founder of EvalShift, and the three scripts above are the method it
automates. The capture SDK replaces the log exporter, `evalshift.yaml` replaces `checks.py`, and
the run comes back with paired statistics and a policy verdict instead of a pass-rate threshold.

```python
from evalshift import capture

@capture.agent(suite="triage", redact=True)   # no-op unless EVALSHIFT_CAPTURE=1
def handle_ticket(message): ...
```

```bash
pip install evalshift
evalshift capture sync      # recorded runs -> golden suite
evalshift compare           # baseline vs candidate -> report.html + verdict
```

```yaml
- uses: evalshift/evalshift-action@v0
  with:
    token: ${{ secrets.EVALSHIFT_TOKEN }}
    fail-on: policy
```

The checks in this post map onto its evaluators one to one: `tool_selection` is the `tool_set`
check, `tool_arguments` is `tool_args`, and the `ARG_RULES` table is a `trace_invariants` block
in YAML, with the same ISO-date regex. The example repo ships that file too. What you get on top is
the part that is tedious to write yourself: Wilson intervals on every rate so a small suite returns
inconclusive instead of a false fail, per-slice budgets, and a single-file report with the
per-example diff. The reports behind this post were EvalShift runs; the scripts reproduce them
from the recorded outputs. Docs: [/docs/golden-suite](/docs/golden-suite),
[/docs/evaluators](/docs/evaluators), [/docs/action](/docs/action). Side-by-side comparisons
with Promptfoo, DeepEval, LangSmith and Langfuse are at [/compare](/compare).

## FAQ

**How many test cases do I need?** Enough that every route you would act on has about twenty,
after de-duplication. Thirty total was enough to show every failure type here and not enough to
make a decision on any single route. One hundred to three hundred, stratified by route, is the
range where pass-rate deltas start meaning something.

**Can I use an LLM as a judge?** For free-text fields, yes, and only after the deterministic
checks have had their turn. In our run the judge preferred the new model on 8 of 9 tickets while
the priority field regressed. Keep judge results advisory until you have audited the pairs it
got wrong.

**How do I handle nondeterministic outputs?** Temperature 0, several samples per case, and pass
fractions instead of single outcomes. The baseline model here changed a sentiment label between
two runs of the same input. One sample per case cannot distinguish that from a regression; three
can.

**How often should I re-run the tests?** On every change to the prompt, the model string, the
tool definitions or the golden set, which is what the workflow's `paths` filter encodes. Not on
every commit: the run costs money and nothing else can move the numbers.

**Should I change the prompt in the same pull request as the model?** No. Run the new model on the
old prompt first so the diff isolates the model. Then tune the prompt in its own pull request
against the same frozen cases. Two variables in one comparison leave nobody able to say which one
moved the score.

## Keep reading

- [What actually breaks when you switch LLMs](/blog/what-breaks-when-you-switch-llms) — the
  longer argument for why the worst regressions read as correct.
- [Build a golden eval suite from production traffic](/blog/build-a-golden-suite-from-production-traffic)
  — the capture-first version of step one.
- [LLM regression testing in CI](/blog/llm-regression-testing-in-ci) — the pull request gate with
  the GitHub Action, including the calibration week.
- [When to trust an LLM judge](/blog/when-to-trust-an-llm-judge) — what pairwise judging is good
  at, and where it quietly misleads you.
