Education › AI Engineering › Stage 2: Building with LLMs

Evaluating LLM output

Golden datasets, LLM-as-judge, regression suites — stop shipping on vibes.

Intermediate ~30 min read Module 8 of 16

Ordinary software is deterministic, so a test passes or it fails. An LLM application is probabilistic, its outputs are free text, and a change to a prompt, a model or a retrieval setting can improve ten cases and quietly break five. Teams that ship on the strength of "I tried a few, and it looked good" find out in production. Evaluations, usually shortened to evals, are the tests of AI engineering: a fixed dataset, an automated grader, and a number that you watch. This module shows how to build them so that every change becomes a measurement instead of a guess.

After this module you can
  • Explain why LLM applications need evals, and what makes them different from unit tests
  • Build a golden dataset from real traffic, edge cases and past failures
  • Choose the right grader for a task: code-based checks, similarity, LLM-as-judge or human review
  • Design an LLM judge with a rubric, and control its known biases
  • Run evals as a regression suite in CI, and keep them alive with production feedback

Why vibes do not scale

Checking by eye fails for predictable reasons. You try the handful of inputs that you thought of, which are the ones the system already handles. You remember the impressive answers and forget the mediocre ones. You cannot tell whether version seven is better than version six, because you did not run the same inputs through both. And when the provider updates the model, you have no means of knowing what changed for you.

An eval has three parts: a dataset of inputs, a task that runs your system on each of them, and a grader that scores each output. Run it, and you get a number that you can compare across versions. That number turns arguments into experiments: should we use the cheaper model, a shorter prompt, more retrieved passages? Run the eval and look.

inputsoutputsverdictsship if it passesbad answerbecomes a permanent caseGolden datasetcases + held-outSystemprompt vN, model, RAGGradercode checks, LLM judgePass rateby tag, vs baselineCI gatefloor + no regressionProductionfeedback, judgesFailure foundclassify it
An eval is a dataset, a task and a grader; the score gates changes in CI, and every failure found in production becomes a new case in the dataset, so the loop tightens over time.
Unit testEval
OutputDeterministicVaries from run to run
ResultPass or fail for each testA score across many cases; some failures are expected
JudgingExact assertionOften fuzzy: is this summary faithful and useful?
GoalNo failuresAbove a threshold, and no worse than last time
CostFree and instantModel calls cost money and take minutes

Because the outputs vary, a single case tells you little, and even a whole run carries noise. With 50 cases, each case is worth two percentage points, so a move from 84% to 86% is one case, and could easily be chance. Use more cases where you can, run the noisy evals several times, and be suspicious of small differences.

Building the golden dataset

The dataset is the asset. Graders and prompts come and go, and a well-chosen set of cases keeps its value for years. It must resemble reality, which means most of it should come from reality.

  • Real traffic. Sample actual user inputs from logs, tickets and search queries, with personal data removed. They are messier, shorter and stranger than anything you would invent.
  • Every failure you have seen. Each bug report and each bad answer becomes a permanent case. This is the rule that stops regressions from coming back.
  • Edge cases. Empty input, very long input, another language, several questions in one, a question the system should decline, a question whose answer is not in your data.
  • Adversarial cases. Attempts to override the instructions, extract the system prompt, or provoke harmful output.
  • Coverage. Include every category and every kind of user in realistic proportions, so that an improvement for the common case does not conceal a collapse in a rare, important one.
One line of cases.jsonl
json
{
  "id": "rag-0042",
  "input": "how do I roll back the checkout service?",
  "expected": {
    "must_mention": ["kubectl rollout undo", "deployment/checkout"],
    "must_not_mention": ["kubectl delete"],
    "relevant_chunks": ["kb-12#3"],
    "reference_answer": "Run kubectl rollout undo deployment/checkout -n shop, then confirm with kubectl rollout status."
  },
  "tags": ["runbook", "kubernetes", "from-incident-2304"],
  "added": "2026-09-02"
}

Start small. Twenty to fifty good cases are enough to catch the large problems and to make the habit stick, and a small eval that you run is worth more than a large one that you never finish. Write the expected results down yourself, or with a domain expert. Doing so forces you to define what "good" means, which is often the most valuable result of the whole exercise. You can use a model to help draft variations, provided that a person reviews them, since purely synthetic cases tend to be tidier and easier than real ones.

Watch out

Keep a held-out portion of the dataset that you never look at while tuning prompts. If you adjust the prompt until every visible case passes, you have overfitted to those cases, exactly as the core ML module described, and only the held-out set will tell you whether the system actually improved.

Choosing a grader

Use the cheapest, most deterministic grader that can judge the property you care about. Code-based checks are fast, free and perfectly consistent, so use them wherever they apply, and keep model-based judging for what truly needs judgement.

GraderUse forNotes
Exact or normalised matchClassification, extraction, routing, yes/noNormalise case and whitespace first
Code-based checksValid JSON, matches a schema, contains a required string, correct length, the code compiles and its tests pass, the SQL runsFast, free, reliable. Surprisingly many quality properties can be checked this way.
SimilarityCloseness to a reference answerEmbedding similarity is lenient on wording, and blind to a wrong number or a missing "not"
LLM-as-judgeFaithfulness, helpfulness, tone, completeness: anything that needs readingFlexible and scalable; must itself be validated
Human reviewThe ground truth, and calibrating every other graderSlow and costly; use samples
python
import json


def grade_structured(output: str, expected: dict) -> dict:
    """Code-based grading: deterministic, free, and run on every case."""
    checks = {}
    try:
        data = json.loads(output)
        checks["valid_json"] = True
    except json.JSONDecodeError:
        return {"valid_json": False, "passed": False}

    checks["category_correct"] = data.get("category") == expected["category"]
    checks["urgency_in_range"] = isinstance(data.get("urgency"), int) and 1 <= data["urgency"] <= 5
    checks["reason_present"] = bool(data.get("reason", "").strip())
    checks["passed"] = all(checks.values())
    return checks


def grade_text(output: str, expected: dict) -> dict:
    text = output.lower()
    missing = [s for s in expected.get("must_mention", []) if s.lower() not in text]
    forbidden = [s for s in expected.get("must_not_mention", []) if s.lower() in text]
    return {"missing": missing, "forbidden": forbidden, "passed": not missing and not forbidden}

Break quality down into separate, specific properties, and grade each one by itself: is it valid JSON, is the category right, is every claim supported by the source, does it cite a real passage, is it under 100 words? A single "quality score from 1 to 10" conceals which of those went wrong, and is much noisier than several narrow checks.

LLM-as-judge, done properly

Using a model to grade another model's output sounds circular, and it works, because judging is easier than generating: checking whether a summary is supported by a document is a simpler task than writing the summary. But a judge is a model too, with failure modes of its own, so it has to be built with care and checked against people.

python
import json

JUDGE_SYSTEM = """You are grading the answer given by a documentation assistant.
You will receive the source passages, the question, and the answer.

Grade ONE property only: faithfulness.
An answer is faithful if every factual claim and every command in it is directly
supported by the passages. Information that is true, but is not in the passages,
counts as unfaithful.

First write your reasoning: list each claim in the answer, and say whether a passage
supports it, quoting the supporting text. Then give the verdict.

Respond with a JSON object: {"reasoning": "...", "unsupported_claims": [...], "verdict": "pass" or "fail"}"""


def judge_faithfulness(passages: str, question: str, answer: str) -> dict:
    """call_llm is a stand-in: wire it to whichever provider you use."""
    user = (f"<passages>\n{passages}\n</passages>\n\n"
            f"<question>\n{question}\n</question>\n\n<answer>\n{answer}\n</answer>")
    raw = call_llm(
        [{"role": "system", "content": JUDGE_SYSTEM}, {"role": "user", "content": user}],
        temperature=0, max_tokens=800,
    )
    return json.loads(raw)
  • One property per judge. Ask a precise question with a clear definition. "Is this good?" gives noise.
  • Binary or a small scale. Pass or fail, or three to five levels, each one described. Models do not use a 1 to 100 scale consistently.
  • Reasoning before the verdict. Having the judge write out its analysis first improves accuracy, and gives you an explanation to read when you disagree with it.
  • Give it what it needs. A faithfulness judge needs the source, and a correctness judge needs the reference answer. A judge that has neither is merely checking whether the answer sounds good.
  • Use a capable model for judging, and low temperature, so that the grades are consistent.
Known biasWhat happensMitigation
Position biasIn a comparison of two answers, the one shown first, or the one shown second, is favouredRun each comparison twice with the order swapped, and count a win only when both runs agree
Verbosity biasLonger answers score higher, whatever their qualityMake the rubric specific about what earns credit; penalise padding explicitly
Self-preferenceA model tends to rate outputs from its own family more highlyUse a different model as the judge when comparing models
LeniencyJudges are reluctant to fail a fluent, confident answerDemand quoted evidence for every claim; include known-bad examples when you validate

Most important of all, validate the judge against human labels. Have a person grade 50 to 100 outputs, run the judge on the same ones, and measure how often the two agree. Read the disagreements: sometimes the judge is wrong, and sometimes it has caught something the human missed. If agreement is poor, improve the rubric before you trust any number that the judge produces. Repeat the check from time to time, and whenever you change the judge's model or its prompt.

A harness, and a gate in CI

python
import json
import sys
from pathlib import Path

THRESHOLD = 0.90            # minimum pass rate
MAX_DROP = 0.03             # largest allowed fall against the stored baseline


def run_eval(cases_path: str, system_under_test, grade) -> dict:
    cases = [json.loads(line) for line in Path(cases_path).read_text().splitlines() if line.strip()]
    results = []
    for case in cases:
        output = system_under_test(case["input"])
        verdict = grade(output, case["expected"])
        results.append({"id": case["id"], "tags": case.get("tags", []),
                        "output": output, **verdict})
    passed = sum(r["passed"] for r in results)
    return {"pass_rate": passed / len(results), "n": len(results), "results": results}


if __name__ == "__main__":
    report = run_eval("cases.jsonl", system_under_test=my_app, grade=grade_text)
    Path("eval_report.json").write_text(json.dumps(report, indent=2))

    baseline = json.loads(Path("eval_baseline.json").read_text())["pass_rate"]
    print(f"pass rate {report['pass_rate']:.1%} (baseline {baseline:.1%}, n={report['n']})")
    for r in report["results"]:
        if not r["passed"]:
            print(f"  FAIL {r['id']} {r['tags']}")

    if report["pass_rate"] < THRESHOLD or report["pass_rate"] < baseline - MAX_DROP:
        sys.exit(1)             # non-zero exit fails the pipeline

This is the pipeline idea from the DevOps track, applied to AI. A change to a prompt, a model version, a retrieval parameter or a tool definition is a change to production, and it should pass through a gate before it ships. A few practical points follow.

  • Tier the suites. Run a small, fast, code-graded suite on every pull request, and the full suite with LLM judges every night and before each release, because judges cost money and time.
  • Report by tag, not only in total. An overall pass rate of 92% can conceal a collapse from 95% to 60% on Spanish inputs.
  • Store the full outputs, not only the scores, so that you can compare two versions side by side and read what changed.
  • Record the versions: the prompt, the model, the retrieval configuration and the dataset. A score without them cannot be reproduced.
  • Cache the unchanged parts. If only the generation prompt changed, there is no need to run retrieval again.
  • Track cost and latency alongside quality. A change that gains one point of quality for three times the cost is rarely a win.
  • Pin the model version and re-run the suite before adopting a new one. This is how you decide about an upgrade using evidence.

Evals never finish

An offline eval measures the cases that you thought of. Production supplies the ones you did not. The two form a loop.

  1. Log inputs, outputs, the context retrieved, versions, latency and cost, with sensitive data removed.
  2. Collect signals. Explicit ones, such as thumbs up or down, and implicit ones, such as the user rephrasing the question, abandoning the session, copying the answer, or escalating to a person.
  3. Sample and review. Read a random sample of real conversations every week. There is no substitute for it, and it is where you discover kinds of failure that no metric was watching for.
  4. Run judges online, on a sample of live traffic, to follow faithfulness or tone over time, and alert on a fall.
  5. Promote failures into the dataset. Every confirmed bad output becomes a permanent eval case, with a tag recording where it came from.
  6. Fix, re-run and ship, and start the loop again.

Beware of optimising the number instead of the product. If the judge rewards long answers, your prompts will drift towards padding. If the dataset grows stale, scores rise while users become less happy. Refresh the cases, re-validate the judges against people, and keep reading real outputs yourself. The LLM observability module in the final stage continues this loop in production.

Hands-on practice

Put an eval gate on a real prompt

  1. Take the RAG system or the classification prompt from an earlier module. Assemble 40 cases in a cases.jsonl file, using real inputs wherever you can, including five edge cases and five inputs that the system should decline or answer with "not found". Set 10 aside as held-out.
  2. Write the code-based graders first: valid output, required strings present, forbidden strings absent, citations that refer to real passages. Run them and record a baseline.
  3. Write an LLM judge for one property that needs judgement, such as faithfulness. It should check a single property, with a definition, reasoning before the verdict, and a pass or fail result.
  4. Grade 30 outputs by hand, without looking at the judge's verdicts. Then compare. Work out the agreement rate, read every disagreement, and refine the rubric once.
  5. Create two variants of your prompt. Compare them with a pairwise judge, running every comparison in both orders. Count how often the verdict flips when the order changes.
  6. Wrap everything in a script that writes a JSON report, prints the failures with their tags, and exits with a non-zero status below a threshold or on a fall against the baseline.
  7. Add it to your pipeline, so that a pull request that changes a prompt runs the fast suite. Break the prompt on purpose, and watch the gate stop it.
  8. Run the final prompt on the held-out cases, and compare that score with the one on the cases you tuned against.
Cheat sheet

Evaluating LLM output — at a glance

Main things to focus on

  • An eval is a dataset, a task and a grader, producing a number you can compare across versions.
  • The dataset is the lasting asset. Build it from real traffic, edge cases, adversarial inputs and every failure you have seen.
  • Keep a held-out set, or you will overfit your prompt to the cases you can see.
  • Use the cheapest grader that works: code-based checks first, a model judge only for what needs judgement.
  • Split quality into specific properties, and grade each one separately.
  • An LLM judge checks one property against a clear rubric, reasons before its verdict, and uses a binary or small scale.
  • Control position, verbosity and self-preference bias, and validate the judge against human labels.
  • Gate changes in CI on a threshold and on regression from a baseline, reported by tag. Feed production failures back into the dataset.

Dataset sources

real traffic (scrubbed)What users actually ask, in their own words
past failuresEvery bug becomes a permanent case
edge casesEmpty, huge, multilingual, multi-part, out of scope
adversarial casesInjection, prompt extraction, policy violations
unanswerable casesChecks that the system says "I don't know"
held-out splitNever looked at while tuning
tags per caseCategory, language, source, difficulty

Grader ladder

exact / normalised matchLabels, routes, extracted fields
schema validationStructure and types of JSON output
contains / does not containRequired commands, forbidden content
execute itCode compiles, tests pass, the SQL runs and returns the expected rows
embedding similarity to a referenceTolerant of wording; blind to small factual errors
LLM judge with a rubricFaithfulness, completeness, tone, helpfulness
human review of a sampleThe ground truth, and calibration for everything else

Judge prompt checklist

one property, defined preciselyWith examples of pass and fail, if helpful
all the evidence suppliedSource passages, reference answer, the question
reasoning first, verdict lastMore accurate, and explainable
pass/fail, or 3-5 described levelsNot 1 to 100
structured outputSo that code can read the verdict
temperature 0, capable modelConsistency
pairwise: run both ordersCount a win only if both runs agree
agreement with human labelsMeasure it before trusting the judge

Statistics to remember

1 case out of n = 100 / n percentage pointsWith n = 50, each case is 2 points
small differences are often noiseRe-run, or add cases, before concluding
pass rate by tagAverages hide the collapse of a subgroup
judge agreement = matching verdicts / totalAgainst human labels on the same outputs
quality, cost and latency togetherA change is judged on all three

CI gate

fast suite on every pull requestCode-graded, a few minutes
full suite nightly and before releaseIncludes LLM judges
fail if pass rate < thresholdAn absolute quality floor
fail if pass rate < baseline - toleranceNo silent regressions
sys.exit(1)A non-zero exit fails the pipeline
store outputs and versionsPrompt, model, retrieval config, dataset

Common pitfalls

  • Shipping on the strength of a few manual tries that happened to look good.
  • Building the dataset from invented inputs that are tidier and easier than real ones.
  • Tuning the prompt until every visible case passes, with no held-out set to reveal the overfitting.
  • Asking a judge for one overall quality score from 1 to 10, with no rubric.
  • Trusting an LLM judge that has never been compared with human labels.
  • Reading small score differences on a small dataset as real improvements.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →