What is Eval Harness?
Eval Harness is an AI design pattern for LLM apps and agents, in the Operations and quality group. Run test cases automatically on every prompt, model, or code change.
Joana's data team built a system that turns plain questions into database queries. And they did the hard part already. They have a dataset. One hundred twenty real questions, each with the correct query.
The problem is that running the eval is a half hour ritual. Open a notebook, point it at the right prompt version, wait, copy the results into a spreadsheet, and compare it with last week's spreadsheet.
So in practice, the eval runs when someone remembers. And the week three people changed the prompt, nobody ran it. Accuracy dropped. And they had to dig through the three changes by hand, to find which one caused it.
Having the cases isn't enough. They have to run on their own, the same way every time, and the result has to reach whoever made the change, before the merge. In this lesson, you'll see how to turn a dataset into an automatic check that runs on every change.
An eval harness has four fixed parts.
Load the cases.
Run the real system on each one.
Grade each output with the right rule.
And compare the result with a stored baseline.
Then you wire it into your pipeline. Every change to a prompt, a model, a tool, or the pipeline code triggers the eval, and attaches the report to the pull request. And one detail matters a lot. The eval harness runs the system as it runs in production. Same code, same versioned prompt. Not a simplified copy.
Here's the flow. Someone opens a pull request.
The eval harness loads the current version of the dataset.
Each case runs through the real pipeline, in parallel, with a few repeats when the output varies.
Each output gets graded. By exact match, a rule in code, or a judge.
The scores become rates per category, plus cost and latency.
The result is compared with the main branch's baseline.
And the report goes onto the pull request. A regression above the threshold blocks the merge.
On Monday, Joana's pull request changes only one example in the prompt, the one about joining tables. The pipeline starts the eval harness, runs the one hundred twenty cases through the real system, and posts this on the pull request.
Aggregation holds. Date filters hold. Joins drop almost seventeen points. Regression.
The merge is blocked. The report lists the eleven join cases that went from right to wrong. Joana opens the first one, sees the generated query, and notices the new example flipped which table was the main one. Without the eval harness, that drop would only show up in Friday's spreadsheet, mixed with two other changes. With it, the guilty change is obvious, because only one pull request ever merged.
And notice how these cases are graded. Not by comparing the query text. Two different queries can return the same table. The eval harness runs the generated query and the expected one on a test database, and compares the rows. A query written differently that returns the right rows passes. One with an extra join that duplicates rows fails. Comparing text would have rejected the first, and sometimes accepted the second.
Here's one for you. Remember the week three people changed the prompt, and accuracy dropped? The dataset already existed. What would the eval harness have done differently?
Each pull request would have run the one hundred twenty cases on the real system. And the first one that broke joins would have been blocked. Having cases in the repository doesn't protect you. The notebook ritual is what failed. The eval harness exists so the eval runs without anyone having to remember. And it slices the problem for you, so nobody has to dig through three changes by hand.
Which grader should you use? Start with what code can decide on its own. For the route a classifier picks, use exact match.
A database query: run it, and compare the rows.
An agent: check the path. It called the order lookup. It did not call the refund without approval.
And free text, only when there's no other way: a model as judge, with a rubric, checked against a person on a sample. The judge is the last resort. It costs one more call per case, and it can agree with a bad answer. If a code rule covers eighty percent of the cases, leave the judge for the other twenty. Not the other way around.
The eval harness usually runs at two speeds. On every commit, a subset of twenty canary cases runs in a few minutes. It catches the obvious miss.
Before the merge, and every night, the full set runs, with repeats. That one catches the rare category.
And the threshold isn't a matter of taste. Run the same version twice, with no change at all, and see how much the result moves. If the natural spread is one point, a threshold of zero rejects innocent pull requests. If it's four points, a threshold of two lets a real regression through. The threshold sits a little above the noise you measured.
Now the common mistakes. A simplified copy. The eval harness calls the model directly, without retrieval, without the router, without the tools. The eval passes, and production fails. The entry point has to be the same function production calls.
A notebook as the pipeline. It works once. The week of three pull requests, nobody opens it.
A frozen dataset. The eval harness is only as good as its cases. Every production bug becomes a new case. Otherwise you're measuring an old world.
And a zero threshold on a system with a lot of variation. The same case passes and fails. Add repeats and averages before you tighten the threshold.
The eval harness needs evaluating too. Does the judge agree with a person? How stable is the result between identical runs? And how many of last quarter's production bugs have a case in the dataset?
And keep an eye on one signal. If the nightly eval drops with no code change at all, suspect the provider, with an updated model, or your test data. That alert is as useful as the one on the pull request. The baseline ages too. Keep eval traces apart from production traces, tagged with the version tested.
On the page, the eval harness comes in Python and TypeScript, and both do the same thing. Load the cases from a file. Run each one a few times through the real system, with a concurrency limit, so you don't blow through the provider's rate limit. And turn the scores into rates per category.
Then compare each category with the baseline from the main branch. If any one drops more than the threshold, the process exits with an error, and the pipeline fails.
That's one command your pipeline can run. Grading with rules and judges, and a nicer report on the pull request, come in the next iterations. And off the shelf tools can save you the scaffolding. What you never outsource is the dataset, the grader, and the trigger on the pull request.
So here's the idea to take with you. A dataset protects you only when it runs on its own. Run the real system, grade with the simplest rule that works, compare per category against a baseline, and put the answer on the pull request, before the merge. Then three changes in one week stop being a mystery.
In the next lesson, we'll look at the fuel for all of this: the golden dataset, and how to keep it alive. See you there.
Bancada de Evals
Eval Harness is an AI design pattern for LLM apps and agents, in the Operations and quality group. Run test cases automatically on every prompt, model, or code change.
Run the test cases automatically on every prompt, model, or code change, and compare against the previous version. It is for any AI system that changes often and must prove it did not get worse.
A factory test line: every new part goes through the same gauge before it ships.
Joana’s data team has a natural-language-to-SQL system. They already have a dataset: 120 real questions with the correct query. The problem is that running the eval is a half-hour ritual: open a notebook, point it at the right prompt version, wait, copy the results table into a spreadsheet, and compare it with last week’s spreadsheet.
In practice, the eval runs when someone remembers. The week three people changed the prompt, nobody ran it. Accuracy dropped, and they had to bisect by hand to find which of the three changes caused it.
Having the cases is not enough. They must run on their own, the same way, every time, and the result must reach whoever made the change before the merge.
The question this pattern answers: how do you turn an eval dataset into an automatic check that runs on every change?
Build an eval harness with four fixed parts: load the cases, run the real system on each one, grade each output with the right rule, and compare the result with the stored baseline. Wire the eval harness into CI: every change to a prompt, model, tool, or pipeline code triggers the eval and attaches the report to the pull request.
The eval harness runs the system as it runs in production (same code, same versioned prompt), not a simplified copy. And it stores every run, so you can open a failing case and see exactly what the model returned.
On Monday Joana’s PR changes only the JOIN example. CI starts the eval harness, loads evals/sql@19, runs the 120 cases through the real pipeline, and pastes this on the PR:
category baseline current delta
aggregation 94.2% 94.0% -0.2%
join 88.1% 71.3% -16.8% REGRESSION
date_filter 91.0% 90.5% -0.5%
The merge blocks. The report lists the 11 join cases that went from right to wrong. Joana opens the first, sees the generated SQL, and notices the new example flipped the fact table. Without the eval harness, that drop would only show up in Friday’s spreadsheet, mixed with the other two changes of the week. Three prompt edits in one week is the usual failure mode: the drop has three parents and nobody wants to own the bisect. A gate on the PR makes the parent obvious, because only one of those PRs ever merged.
SQL cases are not graded by query text. Two different SQLs can return the same table. The eval harness runs the generated SQL and the expected SQL on the test database and compares the rows. Case sql-041:
{ "id": "sql-041", "input": "How many orders did we have in March by state?", "expected_rows": "fixtures/sql-041.csv", "category": "aggregation", "grader": "rule" }
If the model writes date_trunc instead of EXTRACT(MONTH ...) and the result matches, the case passes. If it writes an extra JOIN and duplicates rows, it fails. Comparing SQL strings would have rejected the first (good) and sometimes accepted the second (bad).
flowchart LR
PR(["1. Pull request"]):::io --> L["2. Load cases"]:::tool
DS[("Versioned dataset")]:::store --> L
L --> R(["3. Run the real system"]):::llm
R --> G{"4. Grade each output"}
G --> A["5. Aggregate per category"]:::tool
BL[("Baseline")]:::store --> C{"6. Compare"}
A --> C
C --> REP(["7. Report on the PR and block"]):::io
schema Case { id: string, input: any, expected: any, category: string, grader: "exact" | "rule" | "judge" }
schema Result { case_id: string, output: any, score: float, cost: float, latency: float }
function run_eval(system_version, dataset, repeats=3):
results = []
parallel { # 3. in parallel
for case in dataset, r in range(repeats):
out = system(system_version).run(case.input)
score = grade(case, out) # 4
results.append(Result(case.id, out, score, out.cost, out.latency))
}
return aggregate(results, by="category") # 5
function grade(case, out):
if case.grader == "exact": return 1.0 if out == case.expected else 0.0
if case.grader == "rule": return RULES[case.category](out, case.expected)
return llm(JUDGE_RUBRIC, case, out) -> float # judge, when there is no alternative
function check(pr):
current = run_eval(pr.version, load("evals/dataset@latest"))
baseline = load_baseline("main")
diff = compare(current, baseline) # 6
report(pr, diff) # 7
if any(cat.drop > THRESHOLD for cat in diff): fail(pr)
Start with what code can decide on its own. A classifier route: exact string. SQL: execute and compare rows. An agent: the tool trajectory (it called buscar_pedido, it did not call reembolsar without approval). Free text, when there is no other path: LLM-as-judge with a rubric, measured against a person on a sample.
The judge is the last resort. It costs an extra call per case and can agree with a bad answer. If a code rule covers 80% of cases, leave the judge on the remaining 20%, not the other way around.
A subset of 20 canary cases runs on every commit, in a few minutes. The full 120 run before merge and overnight. The canary catches the obvious miss; the full set catches the rare category.
The drop threshold (2 percentage points in the sample code) is not a taste. Run the same version twice, with no change, and see how much the result moves. If natural spread is 1 point, a threshold of 0 rejects innocent PRs. If it is 4 points, a threshold of 2 lets a regression through. The threshold sits a little above the measured noise.
classify or run as production.Off-the-shelf frameworks (Promptfoo, LangSmith, your provider’s eval runner) save the scaffolding. What you do not outsource: the dataset, the grader, and the trigger on the PR.
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | Cases times repeats, plus the judge when present | one trace per case, tagged eval |
| Cost | Proportional to the dataset; a judge can double it | total eval cost in the report |
| Time | Bounded by allowed parallelism | CI job duration |
| Failure: flaky eval | The same case passes and fails with no change | high spread across repeats |
| Failure: wrong grading | The rule or judge approves bad output | approved cases a person would reject |
| Failure: stale dataset | The eval harness approves a version that fails new cases | production bugs with no matching case |
The numbers below are illustrative. Suppose 120 cases, 3 repeats, no judge, concurrency 8.
| When | What runs | What for |
|---|---|---|
| Every commit | 20 canaries, 1 repeat | catch the obvious miss in minutes |
| Every PR / night | 120 cases, 3 repeats | block a category regression |
The cheap canary does not replace the full set. It only saves you from waiting on the long job to learn that the prompt no longer parses.
The eval harness needs evaluating too:
{ "id": "sql-041", "input": "How many orders did we have in March by state?", "expected_rows": "fixtures/sql-041.csv", "category": "aggregation", "grader": "rule" }
If the nightly eval drops with no code change, the suspects are the provider (an updated model) or the test database. That alert is as useful as the PR one: the baseline ages too.
| Pros | Cons |
|---|---|
| Every change comes with a number | Costs money on every run |
| Regressions show up before merge | The dataset needs maintenance |
| Switching models becomes a measured decision | Bad grading gives false confidence |
| Debates become comparisons | Slower CI |
Both tabs implement a minimal eval harness with JSONL cases, exact-match grading, parallel execution, and a baseline comparison.
Python. Use this when the pipeline is Python and you want to run the eval with pytest or a CI script.
import asyncio
import json
from collections import defaultdict
from statistics import mean
from my_app.pipeline import classify # 3. the real system, not a copy
THRESHOLD = 0.02 # maximum tolerated drop per category
async def run_case(case: dict, repeats: int, sem: asyncio.Semaphore) -> list[float]:
async with sem:
outs = await asyncio.gather(*(classify(case["input"]) for _ in range(repeats)))
return [1.0 if o == case["expected"] else 0.0 for o in outs] # 4. exact match
async def run_eval(path: str, repeats: int = 3) -> dict[str, float]:
cases = [json.loads(line) for line in open(path, encoding="utf-8")]
sem = asyncio.Semaphore(8) # respect the rate limit
scores = await asyncio.gather(*(run_case(c, repeats, sem) for c in cases))
by_cat = defaultdict(list)
for case, s in zip(cases, scores):
by_cat[case["category"]].extend(s)
return {cat: mean(v) for cat, v in by_cat.items()} # 5
def main() -> None:
current = asyncio.run(run_eval("evals/tickets.jsonl"))
baseline = json.load(open("evals/baseline.json")) # produced on the main branch
failed = False
for cat, base in sorted(baseline.items()):
now = current.get(cat, 0.0)
flag = "REGRESSION" if base - now > THRESHOLD else ""
failed |= bool(flag)
print(f"{cat:20} {base:6.1%} {now:6.1%} {now - base:+6.1%} {flag}") # 7
raise SystemExit(1 if failed else 0)
if __name__ == "__main__":
main()What you gained: one command CI can run that fails the build on regression. What you lost: rule and judge grading, and the PR report, are left for the next iteration.
TypeScript. Use this when the pipeline runs on Node and the eval belongs in the project's test runner.
import { readFileSync } from "node:fs";
import { classify } from "../src/pipeline"; // 3. the real system
type Case = { input: string; expected: string; category: string };
const THRESHOLD = 0.02;
async function pool<T, R>(items: T[], limit: number, fn: (t: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: limit }, async () => {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return out;
}
export async function runEval(path: string, repeats = 3): Promise<Record<string, number>> {
const cases: Case[] = readFileSync(path, "utf8").trim().split("\n").map((l) => JSON.parse(l));
const scores = await pool(cases, 8, async (c) => {
const outs = await Promise.all(Array.from({ length: repeats }, () => classify(c.input)));
return outs.map((o) => (o === c.expected ? 1 : 0)); // 4
});
const byCat: Record<string, number[]> = {};
cases.forEach((c, i) => (byCat[c.category] ??= []).push(...scores[i]));
return Object.fromEntries(
Object.entries(byCat).map(([k, v]) => [k, v.reduce((a, b) => a + b, 0) / v.length]),
); // 5
}
const current = await runEval("evals/tickets.jsonl");
const baseline: Record<string, number> = JSON.parse(readFileSync("evals/baseline.json", "utf8"));
const regressions = Object.entries(baseline).filter(([cat, base]) => base - (current[cat] ?? 0) > THRESHOLD);
console.table(Object.keys(baseline).map((cat) => ({ cat, base: baseline[cat], now: current[cat] })));
process.exit(regressions.length ? 1 : 0); // 6 and 7What you gained: the eval runs with the project’s own npm, with bounded concurrency. What you lost: same as Python; start simple and add graders as cases demand.
Answer the 6 questions to complete this lesson. Watching alone does not complete it: the check is what counts toward the trail and the certificate.
Eval Harness is an AI design pattern for LLM apps and agents, in the Operations and quality group. Run test cases automatically on every prompt, model, or code change.
In the real-world scenarios Natural language to SQL, Support bot with human escalation, and Automated PR reviewer.