Skip to content
fellowcoder
All articles

Write the eval first

Prompt engineering without an eval is just vibes with extra steps. Here's how to build a scoring harness in an afternoon, and why twenty examples beat two thousand.

fellowcoder6 min read1,252 words

There is a predictable arc to shipping an LLM feature. You write a prompt. It works on the three examples you tried. You ship it. Users find the fourth example. You add a sentence to the prompt. That fixes the fourth example and quietly breaks the second, which nobody notices for a week.

Six weeks later the prompt is 900 words of accumulated scar tissue, nobody remembers which sentence prevents which failure, and no one will touch it.

The fix is not better prompting. It is having a way to tell whether a change made things better — which is to say, an eval. And the reason people skip evals is that "build an evaluation framework" sounds like a quarter of work. It isn't. It is an afternoon, and the afternoon pays for itself the first time you avoid shipping a regression.

The minimum viable eval#

An eval is three things: a set of inputs, a way to score outputs, and a number at the end. That's it. Not a framework. Not a platform.

eval.py
import json
from anthropic import Anthropic
 
client = Anthropic()
 
# 1. Inputs, with what "correct" means for each.
CASES = json.load(open("cases.json"))
 
def run(case: dict) -> str:
    response = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        system=PROMPT,
        messages=[{"role": "user", "content": case["input"]}],
    )
    return "".join(b.text for b in response.content if b.type == "text")
 
# 2. Score. 3. Report.
passed = sum(check(run(c), c) for c in CASES)
print(f"{passed}/{len(CASES)} ({passed / len(CASES):.0%})")

Everything interesting is in check, and everything hard is in CASES.

Twenty good cases beat two thousand scraped ones#

The instinct is to gather a big dataset. Resist it. A large set of cases that all fail the same way tells you one thing, expensively. What you want is coverage of distinct failure modes, and twenty cases chosen deliberately will cover more of those than two thousand sampled from production traffic.

Build the set like this:

  • Five happy paths. The thing working as intended, in the variations you actually expect. These catch catastrophic regressions.
  • Ten adversarial cases. Every bug report becomes a case, permanently. This is where the real value accumulates — your eval becomes an institutional memory of every way the feature has been broken.
  • Five boundary cases. Empty input, enormous input, input in another language, input that looks like an instruction, input where the correct answer is "I can't do that."

Scoring: three tiers, in order of preference#

Tier 1: deterministic checks. If you can write assert, write assert. Does the output parse as JSON? Does it match the schema? Does it contain the account number? Is it under the length limit? Did it call the right tool with the right arguments?

This tier is free, instant, and never disagrees with itself. Push as much as you possibly can down into it. A lot of what looks like a subjective quality problem ("the summary should mention the refund policy") is a keyword check wearing a costume.

Tier 2: property checks. Not "is this the right answer" but "does this answer have the properties a right answer must have." For a summarizer: every proper noun in the summary appears in the source. For an extractor: no hallucinated fields. For a code generator: the code compiles and the tests pass.

Property checks are where the leverage is. They are still deterministic, they catch entire classes of failure rather than specific instances, and they don't require you to write a gold answer for every case.

Tier 3: model-graded. Sometimes the thing you care about really is subjective — tone, helpfulness, whether an explanation is actually clear. Use a model as the judge, but treat the judge as a component that itself needs validating.

judge.py
RUBRIC = """Score the response 1-5 on whether it answers the question
directly in the first sentence.
 
5 = first sentence is the answer.
3 = answer appears, but after preamble.
1 = no direct answer anywhere.
 
Reply with only the digit."""
 
def judge(question: str, answer: str) -> int:
    response = client.messages.create(
        model="claude-opus-5",
        max_tokens=8,
        output_config={"effort": "low"},
        system=RUBRIC,
        messages=[{
            "role": "user",
            "content": f"<question>{question}</question>\n<answer>{answer}</answer>",
        }],
    )
    return int(response.content[0].text.strip())

Three rules for model graders, learned the hard way:

  1. Grade one axis at a time. A judge asked to rate "overall quality" returns noise. A judge asked "does the first sentence answer the question" returns something you can act on.
  2. Validate the judge against yourself. Hand-label thirty outputs, run the judge on the same thirty, and check agreement. If the judge disagrees with you more than about 10% of the time, fix the rubric before trusting a single number it produces.
  3. Use a rubric with concrete anchors. "5 = excellent" is worthless. "5 = first sentence is the answer" is gradeable.

Report distributions, not averages#

A single pass rate hides the thing you most need to see. Two prompts that both score 80% can be completely different: one fails the same four cases every run, the other fails a random four out of twenty. The first has a bug you can find. The second has a stability problem, and shipping it means 20% of your users get a bad answer at random.

Run each case three times and report per-case pass rates. Cases that flip between runs are telling you something specific: the task is underspecified, or you are sitting right at a decision boundary, or your effort level is too low for the reasoning the task needs.

What changing one thing at a time actually buys you#

With an eval in place, prompt work stops being archaeology. You get to ask real questions and get real answers:

  • Does adding this instruction help, or just help on the case that motivated it?
  • Does dropping 400 words of accumulated instructions hurt? (Usually not. Most prompts are 30% dead weight written for a model that has since improved.)
  • Is effort: "medium" actually worse than "high" for this task, or are we paying for reasoning we don't need?
  • Did the model upgrade help? By how much? On which cases specifically?

That last one matters more than it used to. Model behavior shifts between versions — instruction-following gets more literal, verbosity recalibrates, tool-use eagerness moves. A prompt tuned against last year's model carries workarounds for failures that no longer exist, and some of those workarounds now actively hurt. Without an eval, migrating models is a leap of faith. With one, it is a diff.

The part nobody tells you#

The eval will be wrong at first. Your first pass rate will be 100% because your cases are too easy, or 20% because your checker is too strict about whitespace. Both are fine. The eval is a piece of software with bugs, like every other piece of software you have written, and you debug it the same way.

What matters is that you now have a place to put knowledge. Every failure you encounter has somewhere to live besides someone's memory. That is the actual product of an eval — not the number, but the fact that the number is attached to a growing record of everything you have learned about how this thing breaks.

Write it before the prompt. It will be shorter than the prompt, and it will outlive it.