Evals are unit tests for your prompts - wire them into CI
Treat evals like unit tests: fast, version-controlled, and blocking on regression. How to structure an eval suite and gate prompt changes in CI.
You wouldn’t merge a code change without a passing test. So why do prompt changes, which can break your product just as badly, ship on “looked good to me”? A prompt is code. It deserves the same gate. The mental model that makes this click: evals are unit tests for your prompts.
The analogy, precisely
A unit test pins down behavior so a change can’t silently break it. An eval does the same for a non-deterministic output, with two adjustments:
- You assert a score against a threshold, not an exact string (the output varies).
- You run each case a few times and look at the distribution, because the failure mode is “usually fine, occasionally terrible.”
Where the analogy holds: cases live in the repo, run in CI, and block merges. Where it breaks: the “judge” scoring the output is itself a model, so you have to validate it (more on that below).
Structure the suite like tests
Keep it boring and version-controlled next to the code it guards:
evals/
golden/
support_replies.jsonl # inputs + reference of a good answer
rubrics/
support_tone_v3.md # what "good" means, written down
baselines/
main.json # committed scores to compare against
A case carries both deterministic rules and the fuzzy reference:
{
"id": "refund-001",
"input": "I was charged twice",
"reference": "Acknowledge the double charge; promise a refund in 5-10 days; no exact amount.",
"must_not_contain": "guaranteed"
}
Two layers, same as good tests
Cheap exact checks first, scored quality second:
def test_reply(case):
out = run_feature(case["input"])
# Deterministic: fast, exact, no model needed.
assert case["must_not_contain"] not in out.lower()
# Scored: rubric + judge for the fuzzy part.
result = score(rubric="support_tone_v3", output=out, reference=case["reference"])
assert result.score >= 0.8, result.explanation
The deterministic layer catches the hard failures (leaks, bad format, missing refusal) for free. The scored layer handles “is this actually good.”
Baseline comparison: did it improve, or just change?
This is the part that turns evals into a real gate. Every prompt change is measured against the committed baseline, so an “improvement” that quietly regresses other cases gets caught:
$ eval run --baseline main
prompt v36 (main) ...... 0.86 avg
prompt v37 (this PR) ... 0.83 avg
FAIL quality regressed 0.86 -> 0.83 on 4 cases
You update the baseline deliberately, in a reviewed commit, never silently. “It looked better to me” becomes a falsifiable claim.
The blocking gate
Same shape as any CI test job. A regression exits non-zero and the PR can’t merge:
# .github/workflows/evals.yml
on: [pull_request]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: python eval_runner.py --threshold 0.8
env: { MODEL_API_KEY: '${{ secrets.MODEL_API_KEY }}' }
Two practical notes: cache or sample to keep CI cost sane (you don’t need all 500 cases on every PR, run a core set on PRs, the full set nightly), and pin the judge model version so the bar doesn’t drift under you.
Anti-patterns to avoid
- Judge drift. If the scoring model changes, your scores move for no real reason. Validate the judge against human labels on a sample, and pin its version.
- Threshold gaming. A 0.8 that everything passes isn’t a gate. Set it where it actually catches your known-bad cases.
- Snapshot brittleness. Don’t assert exact outputs. Score against intent.
Evals are the single highest-leverage habit for an AI team: they turn prompt changes from a gamble into a measured, reviewable step. The full method, golden sets, judges, injection and cost gates, is on the LLM testing page, and installing all of it into your CI is exactly what a QA Foundation Sprint delivers.