← back to writing

Oct 25, 2026  ·  8 min read

How to know your LLM app works: building an eval harness

evalstestingllm

You change one line in a prompt. You try three questions, the answers look better, you ship. A week later a customer gets told their non-refundable order will be refunded. The change helped the three questions you tried and broke a kind of question you didn't.

An eval (evaluation) is a test suite for behaviour that isn't exact. Ordinary unit tests check add(2, 2) == 4. An LLM's answer can be worded a hundred correct ways, can be right 90% of the time, and changes when you touch a prompt, a model version or the retrieval step. An eval harness is the small program that runs your system over a fixed set of cases, scores every answer, and tells you, with honest uncertainty, whether a change made things better or worse.

WHY THIS MATTERS

Without evals, every prompt change, model upgrade or retrieval tweak is a guess. With them, you can say "version B fixes 12 refund cases and breaks 1 policy case" before shipping. That's the difference between an AI demo and an AI product, and it's what reviewers ask for first.

Part I · The pieces

Chapter 1: Cases are frozen questions with known answers

The problem

To measure anything you need a fixed set of inputs and some notion of a right answer. Two terms:

MENTAL MODEL

An exam paper with a marking scheme. The eval set is an exam you give to every version of your system. Each question has a marking scheme, not one exact answer: "must say refund", "must mention 7 days", "must not promise a refund for perishables". Same paper for every candidate, so scores are comparable.

Details, built on the model

Cases are just data. Tags let us later see which kind of question got better or worse.

cases = [
  {"id": "r1", "tag": "refund", "q": "My blender arrived broken",
   "must": ["refund"], "must_not": []},
  {"id": "r2", "tag": "refund", "q": "Item damaged, what now?",
   "must": ["refund", "7 days"], "must_not": []},
  {"id": "p1", "tag": "policy", "q": "Can I return spoiled milk?",
   "must": ["cannot"], "must_not": ["refund"]},
  {"id": "d1", "tag": "delivery", "q": "Parcel never came",
   "must": ["investigat"], "must_not": []},
  {"id": "d2", "tag": "delivery", "q": "Order is late",
   "must": ["voucher"], "must_not": []},
]
print(len(cases), "cases;", sorted({c["tag"] for c in cases}))
5 cases; ['delivery', 'policy', 'refund']

Good cases come from real traffic and real failures: every bug report becomes a case, so the same bug can't come back silently. Real eval sets have hundreds of cases; we use 5 here, and then 200 synthetic ones, to keep the output readable.

MODEL RECAP

Recap: an eval set is a frozen exam paper whose questions have marking schemes.

Chapter 2: The runner and the scorers

The problem

We need to run a system over every case and mark each answer.

MENTAL MODEL

An exam hall and a marker. The runner is the exam hall: it gives each question to the candidate system and collects the answer sheets, recording failures (timeouts, errors) as answers too. The scorer is the marker: it reads one answer against that question's marking scheme and returns a score from 0 to 1. Keeping them separate means you can re-mark old answer sheets with a better marker without re-running the expensive system.

cases
runner
calls the system
answer sheets
(saved)
scorer
per case 0..1
report
overall + per tag

Details, built on the model

Two fake "systems" stand in for two prompt versions, so the article runs offline:

def system_a(q):
    q = q.lower()
    if "milk" in q:
        return "Sorry, perishables cannot be returned."
    if "broken" in q or "damaged" in q:
        return "We will refund you."
    if "never came" in q:
        return "We will investigate with the courier."
    return "Please wait a little longer."

def system_b(q):
    q = q.lower()
    if "milk" in q:
        return "Perishables cannot be returned, but a refund is ok."
    if "broken" in q or "damaged" in q:
        return "We will refund you within 7 days."
    if "never came" in q:
        return "We will investigate with the courier."
    return "Sorry it's late, here is a voucher."

def run(system, cases):
    sheets = {}
    for c in cases:
        try:
            sheets[c["id"]] = system(c["q"])
        except Exception as e:           # a crash is an answer too
            sheets[c["id"]] = f"ERROR: {e}"
    return sheets

def score(case, answer):
    a = answer.lower()
    hits = [m in a for m in case["must"]]
    if any(bad in a for bad in case["must_not"]):
        return 0.0                       # a forbidden claim fails it
    return sum(hits) / len(hits)

for name, sysf in [("A", system_a), ("B", system_b)]:
    sheets = run(sysf, cases)
    marks = {c["id"]: score(c, sheets[c["id"]]) for c in cases}
    print(name, marks, "mean", round(sum(marks.values()) / 5, 2))
A {'r1': 1.0, 'r2': 0.5, 'p1': 1.0, 'd1': 1.0, 'd2': 0.0} mean 0.7
B {'r1': 1.0, 'r2': 1.0, 'p1': 0.0, 'd1': 1.0, 'd2': 1.0} mean 0.8

The scorer here is a rule-based check: fast, free, deterministic. For open-ended answers you'd use an LLM-as-judge (another model given the question, answer and a rubric, asked for a score). The model still holds: it's just a different marker reading the same saved sheets. Judges need their own checking against a few human-marked answers, because a marker that is wrong 20% of the time makes every score fuzzy.

MODEL RECAP

Recap: the runner collects answer sheets, the scorer marks each against its scheme, and they stay separate.

Part II · Trusting the numbers

Chapter 3: Averages hide regressions

The problem

B's mean is higher. Ship it? Look at case p1.

MENTAL MODEL

A class average can rise while one student fails. A mean is one number for the whole exam. A change can fix many easy questions and break one critical one, and the mean still goes up. So always compare per case (which ones flipped?) and per tag (which kind of question moved?), not just overall.

Details, built on the model

from collections import defaultdict

def marks_for(system):
    sheets = run(system, cases)
    return {c["id"]: score(c, sheets[c["id"]]) for c in cases}

ma, mb = marks_for(system_a), marks_for(system_b)
for c in cases:
    a, b = ma[c["id"]], mb[c["id"]]
    if a != b:
        word = "FIXED" if b > a else "BROKE"
        print(f"{word} {c['id']} ({c['tag']}): {a} -> {b}")

by_tag = defaultdict(list)
for c in cases:
    by_tag[c["tag"]].append(mb[c["id"]] - ma[c["id"]])
for tag, diffs in sorted(by_tag.items()):
    print(f"{tag:9} change {sum(diffs) / len(diffs):+.2f}")
FIXED r2 (refund): 0.5 -> 1.0
BROKE p1 (policy): 1.0 -> 0.0
FIXED d2 (delivery): 0.0 -> 1.0
delivery  change +0.50
policy    change -1.00
refund    change +0.25

The mean went up, and B now tells customers spoiled milk can be refunded. That's the exact story from the introduction, caught before shipping.

MODEL RECAP

Recap: compare case by case and tag by tag; a better average can hide a broken case.

Chapter 4: Is the difference real? The paired bootstrap

The problem

On a real eval set, B scores 0.81 and A scores 0.78. Is that improvement, or luck in which cases happened to be in the set? With few cases, small differences are often noise.

Two terms:

MENTAL MODEL

Re-sit the exam with a reshuffled paper, thousands of times. If B's advantage survives almost every reshuffled paper, it's real. If in many reshuffles A wins, your "improvement" depends on which questions happened to be on the paper. The middle 95% of the reshuffled differences is the 95% confidence interval; if it doesn't include 0, the change is very likely a real effect.

Details, built on the model

We simulate 200 cases where B is genuinely better, then bootstrap:

import numpy as np

rng = np.random.default_rng(0)
n = 200
a = (rng.random(n) < 0.78).astype(float)       # pass/fail per case
flip = rng.random(n)
b = a.copy()
b[(a == 0) & (flip < 0.45)] = 1                # B fixes some
b[(a == 1) & (flip > 0.97)] = 0                # and breaks some

def paired_bootstrap(a, b, reps=10_000, seed=1):
    r = np.random.default_rng(seed)
    d = b - a                                  # per-case difference
    idx = r.integers(0, len(d), size=(reps, len(d)))
    means = d[idx].mean(axis=1)
    return d.mean(), np.percentile(means, [2.5, 97.5])

diff, (lo, hi) = paired_bootstrap(a, b)
print(f"A {a.mean():.3f}  B {b.mean():.3f}  diff {diff:+.3f}")
print(f"95% interval [{lo:+.3f}, {hi:+.3f}]")

small = slice(0, 20)
diff, (lo, hi) = paired_bootstrap(a[small], b[small])
print(f"first 20 only: diff {diff:+.3f}, "
      f"interval [{lo:+.3f}, {hi:+.3f}]")
A 0.710  B 0.805  diff +0.095
95% interval [+0.045, +0.145]
first 20 only: diff +0.100, interval [+0.000, +0.250]

The model explains both lines. With 200 cases, the interval [+0.045, +0.145] stays above 0: almost every reshuffled paper still says B is better. With only the first 20 cases, the observed gain is about the same (+0.10), but the interval stretches from +0.25 all the way down to 0, so we can't rule out "no real change". That's why "it looked better on a few examples" isn't evidence, and why eval sets need size.

MODEL RECAP

Recap: re-sample the paired per-case differences; if the 95% interval excludes 0, the change is real.

Chapter 5: Gate releases on it

The problem

An eval nobody runs protects nothing. The final step is making it run automatically, in CI (continuous integration: the checks that run on every pull request), and fail the build when it should.

MENTAL MODEL

A bouncer with two rules. The gate lets a change through only if (1) no critical case broke (cases tagged as must-never-fail, such as refund policy) and (2) the overall change isn't significantly negative. Rule 1 catches the single disaster that averages hide; rule 2 catches slow, broad decay.

Details, built on the model

CRITICAL = {"policy"}

def gate(cases, old, new):
    broke = [c["id"] for c in cases
             if c["tag"] in CRITICAL and new[c["id"]] < old[c["id"]]]
    d = np.array([new[c["id"]] - old[c["id"]] for c in cases])
    if broke:
        return False, f"critical cases broke: {broke}"
    if d.mean() < 0:
        return False, f"mean dropped by {d.mean():.2f}"
    return True, f"ok, mean change {d.mean():+.2f}"

print(gate(cases, ma, mb))
print(gate(cases, ma, ma))
(False, "critical cases broke: ['p1']")
(True, 'ok, mean change +0.00')

In CI, a False becomes a non-zero exit code, and the pull request shows exactly which case broke. For a real set you'd swap the plain mean check for the bootstrap interval from Chapter 4, cache answer sheets so unchanged parts don't cost API calls again, and pin the model version so the exam isn't changing under you.

WHY THIS MATTERS

This is what makes it safe to upgrade a model, shorten a prompt to save tokens, or change chunk sizes from the previous article: each change gets a verdict in minutes, with the broken cases named.

MODEL RECAP

Recap: the gate blocks any broken critical case and any significant overall drop.

All the mental models, in one place

MODEL RECAP

  1. An exam paper with a marking scheme. A frozen set of cases, each with checks a good answer must pass.
  2. An exam hall and a marker. The runner saves answer sheets; scorers (rules or an LLM judge) mark them separately.
  3. The average can rise while one student fails. Always diff per case and per tag.
  4. Re-sit with a reshuffled paper. A paired bootstrap shows whether a difference survives resampling; small sets give wide intervals.
  5. A bouncer with two rules. CI fails on any broken critical case or a significant overall drop.

← back to writing