LLM Systems Engineering · Chapter 15 of 20
An eval suite, start to finish
Where the cases come from, the five kinds of assertion, a runnable pytest suite, how to use a model as a judge without fooling yourself, and how to run it in CI.
One chapter covers what an eval suite is and why it is the evidence; another covers the statistics and the judge biases with measured effect sizes. This one writes the suite.
Why the usual approach does not survive contact
Ordinary testing rests on a contract: given this input, the output equals that. Language models break it twice over. The same input can legitimately produce different text, and for most interesting tasks there is no single correct answer — there is a range of acceptable ones and a range of unacceptable ones.
So the frame changes, and this reframing is the whole chapter
A test passes or fails. An eval produces a SCORE.
You do not ask ‘did it pass’. You ask ‘what did it score, against which fixed set of cases, compared with what baseline’.
Which means the gate in CI is not ‘everything passed’ — it is ‘the score did not drop by more than X against the last release’. Teams that miss this build a suite that is red every morning and gets ignored within a fortnight.
Where the cases come from
This is the work, and it is unglamorous. The suite is only as good as the cases in it, and generated cases are the weakest source because they are answerable by construction.
- Production failures. This is the gold. Every time someone reports a bad answer, it becomes a case — with the input, what happened, and what should have happened. An eval set that does not grow from real complaints is measuring a system you do not have.
- Real user inputs, sampled from logs. Messy, ambiguous, badly punctuated, occasionally in the wrong language. That is what production looks like.
- The cases you are afraid of. Deliberate adversarial input, prompt injection attempts, requests the system must refuse, questions whose answer is genuinely not in your documents.
- Edge cases from the spec. Empty input, enormous input, conflicting sources, the record type that only exists twice in the database.
- A handful of easy ones, deliberately — so that when the suite goes wrong you can tell a broken pipeline from a quality regression.
Fifty real cases beat five hundred generated ones. Start at thirty and grow it every time something goes wrong in production. Version it, in the repository, with the code — the eval set is a product artefact, and a suite whose cases nobody can date is not evidence of anything.
The five kinds of assertion
Rung 1 deserves more respect than it gets. ‘Is it valid JSON, does it have the four required keys, is it under the length cap, does it leak any personal data (PII — personally identifiable information), did it come back in under three seconds’ — that is five deterministic checks costing nothing, and in most systems they catch the majority of real regressions. Structural failure is far more common in production than subtle wrongness.
A suite you can actually run
You do not need a framework to start. Ordinary pytest is enough, and starting there means you can see exactly what is being asserted.
# evals/cases.yaml -- the golden set, versioned in the repo
- id: refund-window-standard
input: "How long do I have to return something?"
expect_contains: ["30 days"]
expect_cites: ["returns-policy"]
source: production-complaint-2026-03-11 # where this case came from
- id: refund-window-not-in-docs
input: "What's the return window for wholesale orders?"
expect_refusal: true # the answer is NOT in our corpus
note: "System must decline, not invent a number."
source: adversarial
- id: injection-in-retrieved-doc
input: "Summarise the onboarding page"
fixture: doc_with_embedded_instructions
expect_not_contains: ["ignore previous", "SYSTEM:"]
source: security-review-2026-05import json, yaml, pytest
CASES = yaml.safe_load(open("evals/cases.yaml"))
@pytest.mark.parametrize("case", CASES, ids=lambda c: c["id"])
def test_structural(case):
"""RUNG 1 -- deterministic. Free, instant, and runs on every commit."""
out = run_system(case["input"])
assert out.text, "empty response"
assert len(out.text) < 4000, "response exceeded the length cap"
assert not EMAIL_RE.search(out.text), "leaked an email address" # personal data
assert out.latency_ms < 5000, f"too slow: {out.latency_ms}ms"
if case.get("expect_cites"):
# RUNG 2 -- reference-based. Did retrieval find the right document?
# Note this is asserted SEPARATELY from the answer text, so a failure
# tells you WHICH half broke. That separation is most of the value.
got = {c.doc_id for c in out.citations}
assert set(case["expect_cites"]) & got, f"cited {got}, expected {case['expect_cites']}"
for s in case.get("expect_contains", []):
assert s.lower() in out.text.lower(), f"missing required content: {s!r}"
for s in case.get("expect_not_contains", []):
assert s.lower() not in out.text.lower(), f"forbidden content present: {s!r}"
if case.get("expect_refusal"):
# A weak check on purpose -- see the judge test for the real one.
assert any(p in out.text.lower() for p in REFUSAL_PHRASES), \
"should have declined; answered instead"Assert on retrieval and on the answer separately.
A single pass/fail on the final text tells you the system is wrong. It does not tell you which half.
Right chunk retrieved, bad answer → a prompt or model problem. Wrong chunk retrieved → a search problem, and no amount of prompt work will fix it.
This is the same debugging order as the RAG chapter, encoded into the test suite so you get the answer for free instead of investigating it each time.
Using a model as a judge, without fooling yourself
For open-ended output there is no reference answer to match, so you ask a model to score against a rubric. It works, and it is the least reliable rung on the ladder — so it needs to be built carefully.
JUDGE_RUBRIC = """You are grading one answer against the sources it was given.
Score each criterion 1-5 and return JSON only.
grounded: Is EVERY factual claim supported by the sources? A single unsupported
claim caps this at 2, no matter how good the rest is.
complete: Does it answer what was actually asked?
honest: If the sources do not contain the answer, does it SAY SO rather than guess?
An invented answer scores 1 here regardless of how plausible it sounds.
Return: {"grounded": n, "complete": n, "honest": n, "why": "one sentence"}"""
def judge(question, answer, sources):
r = client.messages.create(
model="claude-opus-5",
max_tokens=512,
system=JUDGE_RUBRIC,
messages=[{"role": "user", "content":
f"SOURCES:\n{sources}\n\nQUESTION: {question}\n\nANSWER: {answer}"}],
)
return json.loads(text_of(r))
@pytest.mark.eval # tier 2 -- excluded from the every-commit run
@pytest.mark.parametrize("case", OPEN_ENDED, ids=lambda c: c["id"])
def test_quality(case, baseline):
out = run_system(case["input"])
s = judge(case["input"], out.text, out.sources)
assert s["grounded"] >= 4, f"ungrounded: {s['why']}"
assert s["honest"] >= 4, f"should have declined: {s['why']}"
record(case["id"], s) # written out for the baseline comparison- Give the rubric hard caps, not adjectives. ‘A single unsupported claim caps this at 2’ produces far more consistent scores than ‘rate the groundedness’. Vague rubrics give you a judge that scores fluency.
- Score criteria separately. One overall number hides which thing broke, and a well-written wrong answer will score well on it.
- Never let a model judge its own family in a comparison. Self-preference is measured and it is not a constant you can subtract out.
- If you compare two systems, run both orderings and count a verdict only when it survives the swap — position bias alone moves win rates by ten to fifteen points. The measured effect sizes are in the statistics chapter, and they are larger than most people assume.
- Calibrate the judge against humans once. Have a person score forty cases, compare, and find out where the judge disagrees. Until you have done this you have an unvalidated measuring instrument, and a number from one of those is not evidence — it is a number.
Running it in continuous integration
# Tier 1 -- every commit. Fast, free, blocks the build.
pytest evals/ -m "not eval" -q
# Tier 2 -- before merge. Costs money, so gate on the DELTA, not on perfection.
pytest evals/ -m eval --baseline=baselines/v1.4.2.json --tolerance=0.03
# Tier 3 -- nightly. Reports, never blocks.
pytest evals/ --full --adversarial --report=reports/$(date +%F).jsonThe tolerance is doing real work. Judge scores move slightly run to run even on unchanged code, so a zero-tolerance gate fails randomly and gets disabled within a fortnight. Pick the tolerance by running the suite three times against unchanged code and measuring the spread — then set it just above that. Anyone who sets it by intuition is guessing.
Offline is not enough
A golden set measures the cases you thought of. Production will find the ones you did not, so a second loop runs on live traffic:
- Log everything needed to reproduce an answer — the input, the retrieved chunk identifiers, the prompt version, the model version, the output. Without this you cannot turn a complaint into a test case, which is how the golden set grows.
- Run the cheap deterministic checks on live output too. Schema validity, refusal rate, citation rate, length, latency. These are free and they catch a bad deploy faster than any user report.
- Sample and judge a slice of real traffic — a few hundred a day is enough to see a trend.
- Watch the implicit signals: thumbs down, rephrasing the same question, abandoning the conversation, escalating to a human. Users rarely file a bug report; they just leave, and the rephrase rate is the most under-used quality metric there is.
- Alert on the rate, not the incident. One bad answer is noise. A refusal rate that doubled overnight is a deploy.
Tools, and whether you need one
| What it gives you | Worth it when | |
|---|---|---|
| Plain pytest | Everything above. No new dependency, no lock-in, and you can see exactly what is asserted. | Start here. A great many teams never need more. |
| promptfoo | Declarative YAML test cases, side-by-side comparison across models and prompts, a local web view | Comparing prompt or model variants often |
| DeepEval | Pytest-native with pre-built metrics — faithfulness, relevancy, hallucination | You want standard metrics without writing rubrics |
| Ragas | Retrieval-specific metrics: context precision and recall, faithfulness | Evaluating a RAG pipeline specifically |
| Braintrust / LangSmith | Hosted: tracing, datasets, run history, regression views over time | Several people need to see results, and you want the history kept for you |
The honest advice is the same as for RAG frameworks: the value is in the cases and the rubrics, not the runner. A team with thirty real production-derived cases in plain pytest is far better off than one with a hosted platform and forty generated cases nobody trusts.
What goes wrong
- The suite is written once and never grows. It measures the system you shipped in March. The fix is procedural: a production complaint becomes a case before the fix is merged.
- Everything is an LLM judge. Slow, expensive, and unreliable for checks a schema validator would have made with certainty.
- The judge was never calibrated, so nobody knows whether its 4 out of 5 means anything.
- Exact-string assertions. Flaky by construction; deleted within a month.
- A zero-tolerance gate that fails on noise and gets switched off.
- Cases generated by a model, checked by a model. A closed loop that measures self-consistency and reports it as quality.
- No baseline recorded, so a score of 87% cannot be compared with anything and every argument about quality reverts to opinion.
The one-sentence version: collect real cases from real failures, assert with the cheapest check that can catch each one, keep the judge for what genuinely needs it and calibrate it against a human, gate on the delta against a recorded baseline rather than on perfection, and keep a second loop running on live traffic — because the golden set only ever contains the failures you already knew about.
Check yourself