Skip to content

3.2 · Eval-Driven Development

Much of an agent's work has no clean pass or fail. Edit a prompt and the model's replies shift in quality across hundreds of inputs; no single line goes red, so a test cannot grade the change. The problem is to verify a behavior that gets better or worse rather than right or wrong — a prompt change, a model swap — and the solution is the same shape one rung up from a test: a number on a held-out set, with a floor that refuses to drop.

Verification gave you tests, a loop, and a gate, machinery that works when output is deterministic. This lesson carries the same machinery up to output that is not.

Eval-driven development is tests-as-spec for non-deterministic output: a frozen set of inputs the change can't see, an automatic score, and a committed baseline that blocks any edit that lowers it. The point is the same as a test, to turn "looks better" into a fact the machine checks.

Concept

A test answers did this exact case pass? An eval answers did this whole category of behavior get better or worse? That shift is what lets you verify a prompt edit without reading every reply. An eval has three parts.

  1. The eval set is held-out. It is a fixed list of inputs with expected outputs that the change under test never gets to see, the same discipline as a test the agent is not allowed to edit. In Triage that is eval/cases.jsonl: ten labeled tickets across the five categories. Held-out means you cannot tune the prompt to the eval and call the result generalization.

  2. The score is a function the machine computes. Something runs every case and reduces the result to one number. Triage's eval/run.py scores plain accuracy, the fraction of tickets labeled correctly, and prints the misses so a drop tells you which category moved, not only that one did.

  3. The baseline is a committed floor. A score with no threshold is a vanity metric. The eval commits a baseline (0.80 in Triage) and exits non-zero below it. Wired into a gate, a prompt edit that quietly degrades one category cannot ship, the same way a red test cannot.

The loop is identical to the deterministic one: change, run the eval, and if the score dropped, read the misses and fix, repeating until it clears the floor. The object of grading moves from the replies to the eval run.

This is the same idea the public agent benchmarks run at industry scale: an eval set with an objective grader, frozen and shared — SWE-bench (resolve a real GitHub issue so the repo's tests pass), GAIA (multi-step reasoning with tools), and WebArena (complete a task in a live web app). You won't run them day to day, but they are the reference for what a credible eval looks like: a task the agent can't see the answer to, graded mechanically against a fixed key. The shape carries over to your own set, with held-out inputs, a scoring function, and a number you can defend.

Held-out eval — what a real one needs

The skill is in how the set is built. The same Triage eval appears in three versions.

Three tickets you happened to test by hand, no expected labels written down,
"score" = whether the replies looked fine when you ran them.

Why it fails: there is no number and nothing held out. You are eyeballing a sample you chose, which is the strategy 3.1 showed does not scale, now wearing the word "eval."

A jsonl set of tickets, each with an expected label. A runner that computes
accuracy and prints it. You run it after a prompt change and read the number.

Why it is solid: correctness is a number on a fixed set, and a drop is visible. The one thing missing is that nothing stops a regression; you still have to notice the number fell.

The same set and runner, plus a committed baseline the runner exits non-zero
below — wired into a pre-commit/CI gate. A prompt edit that drops accuracy is
refused at commit time and the misses name the category that moved.

Why it is great: the eval is now unskippable, like a test. A degrading change cannot ship even if no one was watching the number; the floor refuses it.

The through-line again: how long can the agent run before it needs you? An eval lets the agent grade a whole category of behavior on its own. A test catches the case you thought of, and the eval catches the ones you did not; together they extend how far the agent runs before it needs your judgment.

Guided Lab

You'll make a change that degrades the classifier, watch the eval catch it, and watch the baseline gate refuse it. This is the regression-blocking flow a deterministic test cannot give you.

On the Practice Repo, start from a Triage where the 3.1 billing fix is in place, so the eval is green (10/10 = 1.00).

  1. Confirm the clean baseline first:

    .venv/bin/python -m eval.run
    

    Expected: Triage eval: 10/10 correct = 1.00 (baseline 0.80) and PASS, exit 0.

  2. Introduce a realistic regression in the wording the model sees. Open app/llm/prompts.py and weaken the billing line of CATEGORY_GUIDE — strip it down so billing is under-described, e.g. change it to just - billing: invoices. (dropping refunds, charges, overcharging, subscriptions). This is the kind of edit that looks harmless in a diff.

  3. Re-run the eval against the real model, where prompt wording actually drives the verdict:

    export ANTHROPIC_API_KEY=sk-...
    export TRIAGE_USE_LLM=1
    .venv/bin/pip install -e '.[llm]'
    .venv/bin/python -m eval.run
    

    Expected: accuracy falls — the under-described billing cases (E-01, E-02, E-03) start landing in general. With all three billing cases lost the score reads 7/10 = 0.70, below the 0.80 baseline, and the runner exits 1. The misses name the billing tickets, so the eval tells you which category your prompt edit broke.

    No API key? Run the regression offline

    Offline, Triage classifies with a deterministic keyword stub that ignores the prompt, so editing prompts.py won't move the score. To see the same gate fire with no key, make the equivalent edit to the model's stand-in judgment: in app/llm/client.py, delete the billing keywords (refund, charge, charged, overcharg, subscription, …) from _STUB_RULES. Re-run python -m eval.run and the three billing cases fall to general — same 7/10 = 0.70, same exit 1.

  4. Gate the regression. With the pre-commit hook from 3.1 installed, stage the weakened prompt and try to commit:

    git commit -am "tighten billing prompt"
    

    Expected: the hook runs the eval, the score is below baseline, and git refuses the commit. A change that has no failing test, only a worse number, was still stopped automatically.

  5. Restore the original CATEGORY_GUIDE (or stub rules), re-run python -m eval.run to confirm 10/10 and PASS, and commit cleanly.

On your own codebase, pick one prompt or one flaky behavior in a repo you work in. Build a small held-out set (a dozen inputs with expected outputs), write a runner that prints one score, commit a baseline, and wire it into the same gate from 3.1. Then make a deliberately bad edit and confirm the gate refuses it.

Keep the artifact

This lesson leaves an eval set and runner with a committed baseline for one non-deterministic behavior, wired into the 3.1 gate. Like the test suite, it is not a worksheet; it ships in the repo and grades every future prompt or model change automatically. That accumulation is Module 6.

Self-check

You did it right if:

  • a change that has no failing test, only a lower score, is caught by your eval and named down to the category that moved; and
  • the baseline gate refuses the regression at commit time without you watching the number.

If you had to read the score and decide for yourself whether it was "bad enough," there is no committed floor yet; add one and wire it into the gate.

Recall

Before moving on: why can't a unit test verify a prompt change, and what is the eval-driven equivalent of a red test?

Answer

A prompt change makes output better or worse across many inputs, not pass/fail on one — there's no single line to go red. The equivalent is a held-out eval set scored to one number with a committed baseline the runner exits below; wired into a gate, a degrading change is refused the way a red test refuses a broken one.