- LLM evaluation is a repeatable test that runs your feature on fixed inputs, scores each output, and compares the result with the last version you shipped.
- Score each task with its own metric: exact match for classification, schema validity for extraction, recall at k for retrieval, and a validated judge for open-ended answers.
- An overall score can hide a regression. In this guide's offline run, the pass rate rose from 14 of 17 to 15 of 17 while a must-pass case broke, and the gate blocked the deploy.
- Detecting a 5-point drop from a 90 percent pass rate at 80 percent power takes about 686 cases per group unpaired, or about 280 paired under the assumptions in this guide.
- All code ran on Python 3.15.1 with a scripted fake model and judge on November 8, 2026. No hosted model, real judge, or CI run was exercised.
LLM evaluation is the practice of scoring a model-backed feature against fixed test cases so you can tell whether a change helped or hurt. A prompt edit, a model upgrade, or a retrieval tweak can change outputs without failing a single unit test. This guide builds the suite in the order you need it: failure modes, golden data, metrics, a runner, a baseline comparison, and a CI gate. It also covers agent evaluation and RAG evaluation, which need their own scoring logic.
#What LLM evaluation measures
LLM evaluation measures whether your feature meets a written standard on realistic inputs, and whether a change moved that result. Public benchmarks rank models on someone else's task, so they cannot tell you that your prompt edit broke refund handling. An eval suite answers that question for your code.
An eval here means a test you can rerun, with stored inputs, a grader, and a score. Rankings of general model quality help you shortlist models, and your own suite decides whether one of them works for you.
Keep the suite in your own repository, as data and code. As of November 8, 2026, OpenAI's evaluation guide says its hosted Evals platform would become read-only for existing users on October 31, 2026. The same notice schedules shutdown for November 30, 2026, so your golden data should live somewhere you control.
#Define the task and its failure modes
Write the task as one sentence, then read 30 to 50 real outputs and group what went wrong. Failure modes come from reading outputs. Shankar et al. (2024) call this criteria drift, because grading outputs is what teaches you the criteria.
This guide uses an illustrative billing support assistant. It routes tickets, extracts refund details, retrieves runbooks, and answers questions.
For each failure mode, record how bad it is. A wrong routing label costs a support agent a minute, and an invented refund policy costs money and trust. Severity decides which cases can never be allowed to fail.
| Failure mode | Example | Grader | Severity |
|---|---|---|---|
| Wrong queue | Refund ticket routed to how-to | Exact match | Medium |
| Malformed extraction | amount returned as a string | Schema check | High |
| Retrieval miss | Approval runbook not in the top 3 | Recall at k | High |
| Missing requirement | Answer omits the second-approver rule | Judged, must-pass | Blocks deploy |
| Invented policy | Answer cites a refund window that does not exist | Judged against sources | Blocks deploy |
#Assemble a golden dataset from real traffic
A golden dataset is a versioned set of real inputs paired with the expected behavior for each one. Sample it from production logs, because OpenAI's guide warns against eval data that does not reproduce production traffic, and hand-written cases skew toward failures you already imagined.
Build the first version in this order:
- Log inputs, outputs, retrieved context, and tool calls for every request.
- Sample a random slice to capture the real mix of requests.
- Add a failure slice from escalations, negative feedback, and bug reports.
- Remove secrets and personal data before anything enters the repository.
- Have someone who knows the domain write the expected behavior.
- Store each case with an ID, task type, input, expected result, tags, and source.
- Hold back a slice you never look at while tuning prompts.
Start smaller than you think. Anthropic says 20 to 50 tasks drawn from real failures is a good start for an agent suite, because early changes tend to have large effects.
Grow it by rule. When a bug reaches production, add a failing case before you fix the prompt. Anthropic's eval docs favor volume, saying more cases with slightly lower-signal automated grading beat fewer hand-graded ones.
#Choose LLM evaluation metrics per task type
Pick one primary metric per task type and prefer code over models. Exact match and schema checks are free and deterministic, so they can run on every commit. Use a judged metric only where the answer is open-ended, and validate that judge before it can block a release.
| Task type | Primary metric | How it is computed | Watch for |
|---|---|---|---|
| Classification, routing | Exact match | Compare the normalized output with the expected label | Rare labels vanish in the average, so report per-class results |
| Extraction | Schema validity | Parse JSON, then check types and allowed values | Valid JSON can still hold wrong values, so add field accuracy |
| Retrieval | Recall at k | Share of relevant documents found in the top k | Only as good as your relevance labels |
| Open-ended answers | Judged pass or fail, per criterion | A validated judge returns a verdict for one criterion | Position, verbosity, and self-preference bias |
| Agents | Outcome, plus rule checks on the path | Compare the final state with a goal state | Step-order checks break on harmless changes |
Retrieval recall is the fraction of relevant documents retrieved among all relevant documents, counted over the top k results. Structured outputs reduce schema failures and leave field accuracy untouched, so test both.
Prefer binary verdicts to 1 to 5 scores when you can. OpenAI's guide recommends pairwise or pass/fail formats for reliability. Track latency and cost next to these metrics as separate limits, so that a slower or pricier change cannot hide inside an accuracy gain.
#Run LLM evals offline
The runner below scores 17 cases across four task types, compares a candidate against a baseline, and exits non-zero when the gate blocks. It uses only the Python standard library and a scripted fake model, so you can run it without an API key and replace the fake with your own client later.
#!/usr/bin/env python3
"""Offline LLM eval runner. Standard library only.
Usage: python3 run_evals.py [candidate|fixed]
Compares a scripted candidate against a scripted baseline, prints the
per-task report, and exits 1 if the deploy gate blocks.
"""
import json
import math
import sys
from collections import defaultdict
# Golden dataset. In your repo, keep this as a versioned golden.jsonl.
# These cases are illustrative; yours should come from sampled traffic.
GOLDEN = [
{"id": "C1", "task": "classify", "input": "Charged twice in October", "expected": "billing"},
{"id": "C2", "task": "classify", "input": "App crashes on login", "expected": "bug"},
{"id": "C3", "task": "classify", "input": "How do I export invoices?", "expected": "how_to"},
{"id": "C4", "task": "classify", "input": "Refund my annual plan", "expected": "billing"},
{"id": "C5", "task": "classify", "input": "SSO setup fails with 403", "expected": "bug"},
{"id": "E1", "task": "extract", "input": "Refund $40 on order A-17"},
{"id": "E2", "task": "extract", "input": "Please return 25 EUR for B-09"},
{"id": "E3", "task": "extract", "input": "Refund 18.50 GBP, order C-33"},
{"id": "E4", "task": "extract", "input": "Refund $5 on order D-21"},
{"id": "R1", "task": "retrieve", "input": "refund approval limits", "relevant": ["approvals", "refund-policy"]},
{"id": "R2", "task": "retrieve", "input": "SSO error codes", "relevant": ["sso-errors"]},
{"id": "R3", "task": "retrieve", "input": "invoice export formats", "relevant": ["export", "invoice-api"]},
{"id": "R4", "task": "retrieve", "input": "dunning retry schedule", "relevant": ["dunning", "retries"]},
{"id": "A1", "task": "answer", "input": "Can I get a refund after 30 days?", "rubric": ["within 30 days"]},
{"id": "A2", "task": "answer", "input": "Why was my card charged twice?", "rubric": ["authorization"]},
{"id": "A3", "task": "answer", "input": "Who approves a $600 refund?", "rubric": ["second approver"], "must_pass": True},
{"id": "A4", "task": "answer", "input": "How long do dunning retries last?", "rubric": ["14 days"]},
]
REFUND_SCHEMA = {"order_id": str, "amount": (int, float), "currency": str}
CURRENCIES = {"USD", "EUR", "GBP"}
K = 3
# Scripted fake model outputs. Replace complete() with a real API call.
BASELINE = {
"C1": "billing", "C2": "bug", "C3": "how_to", "C4": "how_to", "C5": "bug",
"E1": '{"order_id": "A-17", "amount": 40, "currency": "USD"}',
"E2": '{"order_id": "B-09", "amount": 25, "currency": "EUR"}',
"E3": '{"order_id": "C-33", "amount": "18.50 GBP"}',
"E4": '{"order_id": "D-21", "amount": 5, "currency": "USD"}',
"R1": ["approvals", "refund-policy", "faq"],
"R2": ["sso-errors", "login", "faq"],
"R3": ["export", "faq", "billing-api"],
"R4": ["dunning", "retries", "faq"],
"A1": "You can request a refund within 30 days of purchase.",
"A2": "Two authorization holds posted; one drops off in 3 days.",
"A3": "Refunds over $500 need a second approver in finance.",
"A4": "Dunning retries run for 14 days, then the plan pauses.",
}
CANDIDATE = BASELINE | {
"C4": "billing",
"E3": '{"order_id": "C-33", "amount": 18.5, "currency": "GBP"}',
"R3": ["export", "invoice-api", "faq"],
"R4": ["dunning", "faq", "billing-api"],
"A3": "Refunds over $500 are approved by the finance team.",
}
FIXED = CANDIDATE | {
"R4": ["dunning", "retries", "faq"],
"A3": "Refunds over $500 need a second approver in finance.",
}
class ScriptedModel:
"""Stands in for a real client."""
def __init__(self, name, outputs):
self.name, self.outputs = name, outputs
def complete(self, case):
return self.outputs[case["id"]]
class ScriptedJudge:
"""Stands in for an LLM judge: passes if every rubric phrase appears."""
def grade(self, case, output):
missing = [p for p in case["rubric"] if p not in output.lower()]
return not missing, f"missing {missing}" if missing else "ok"
def exact_match(case, output, _judge):
ok = output.strip().lower() == case["expected"].lower()
return float(ok), f"got {output!r}"
def schema_valid(case, output, _judge):
try:
data = json.loads(output)
except json.JSONDecodeError as err:
return 0.0, f"invalid JSON: {err}"
for field, kind in REFUND_SCHEMA.items():
if not isinstance(data.get(field), kind):
return 0.0, f"bad field {field}: {data.get(field)!r}"
if data["currency"] not in CURRENCIES:
return 0.0, f"unknown currency {data['currency']}"
return 1.0, "ok"
def recall_at_k(case, output, _judge):
relevant = set(case["relevant"])
score = len(relevant & set(output[:K])) / len(relevant)
return score, f"recall@{K}={score:.2f}"
def judged(case, output, judge):
passed, why = judge.grade(case, output)
return float(passed), why
METRICS = {
"classify": ("exact_match", exact_match, 0.90),
"extract": ("schema_valid", schema_valid, 0.95),
"retrieve": (f"recall@{K}", recall_at_k, 0.85),
"answer": ("judged_pass", judged, 0.75),
}
def run(model, judge):
results = {}
for case in GOLDEN:
_name, grader, _floor = METRICS[case["task"]]
score, note = grader(case, model.complete(case), judge)
results[case["id"]] = {"score": score, "passed": score == 1.0, "note": note}
return results
def mcnemar_exact_p(new_fail, new_pass):
"""Two-sided exact McNemar: the discordant pairs are Binomial(n, 0.5)."""
n = new_fail + new_pass
if n == 0:
return 1.0
tail = sum(math.comb(n, i) for i in range(min(new_fail, new_pass) + 1)) / 2**n
return min(1.0, 2 * tail)
def compare(base, cand):
by_task = defaultdict(list)
for case in GOLDEN:
by_task[case["task"]].append(case)
blockers, rows = [], []
for task, cases in by_task.items():
metric, _grader, floor = METRICS[task]
ids = [c["id"] for c in cases]
b_mean = sum(base[i]["score"] for i in ids) / len(ids)
c_mean = sum(cand[i]["score"] for i in ids) / len(ids)
new_fail = [i for i in ids if base[i]["passed"] and not cand[i]["passed"]]
new_pass = [i for i in ids if not base[i]["passed"] and cand[i]["passed"]]
rows.append((task, metric, b_mean, c_mean, new_fail, new_pass))
if c_mean < floor:
blockers.append(f"{task}: {metric} {c_mean:.3f} is below the floor {floor:.2f}")
if len(new_fail) > len(new_pass):
blockers.append(f"{task}: net regression, new failures {new_fail}")
for case in GOLDEN:
if case.get("must_pass") and not cand[case["id"]]["passed"]:
blockers.append(f"must-pass case {case['id']} failed: {cand[case['id']]['note']}")
return rows, blockers
def main():
name = sys.argv[1] if len(sys.argv) > 1 else "candidate"
outputs = {"candidate": CANDIDATE, "fixed": FIXED}[name]
judge = ScriptedJudge()
base = run(ScriptedModel("baseline", BASELINE), judge)
cand = run(ScriptedModel(name, outputs), judge)
rows, blockers = compare(base, cand)
print(f"baseline vs {name}, {len(GOLDEN)} cases")
print(f"{'task':<9}{'metric':<14}{'baseline':>9}{'cand':>7} new_fail new_pass")
for task, metric, b_mean, c_mean, new_fail, new_pass in rows:
print(f"{task:<9}{metric:<14}{b_mean:>9.3f}{c_mean:>7.3f} "
f"{','.join(new_fail) or '-':<9} {','.join(new_pass) or '-'}")
b_pass = sum(r["passed"] for r in base.values())
c_pass = sum(r["passed"] for r in cand.values())
new_fail = sum(base[i]["passed"] and not cand[i]["passed"] for i in base)
new_pass = sum(cand[i]["passed"] and not base[i]["passed"] for i in base)
print(f"overall pass rate: {b_pass}/{len(base)} -> {c_pass}/{len(cand)}")
print(f"exact McNemar p = {mcnemar_exact_p(new_fail, new_pass):.3f} "
f"({new_fail} new failures, {new_pass} new passes)")
print("gate: BLOCK" if blockers else "gate: PASS")
for blocker in blockers:
print(f" - {blocker}")
sys.exit(1 if blockers else 0)
if __name__ == "__main__":
main()Run it twice, once for a candidate with a regression and once for a fixed version. Tested with Python 3.15.1 on November 8, 2026:
$ python3 run_evals.py candidate
baseline vs candidate, 17 cases
task metric baseline cand new_fail new_pass
classify exact_match 0.800 1.000 - C4
extract schema_valid 0.750 1.000 - E3
retrieve recall@3 0.875 0.875 R4 R3
answer judged_pass 1.000 0.750 A3 -
overall pass rate: 14/17 -> 15/17
exact McNemar p = 1.000 (2 new failures, 3 new passes)
gate: BLOCK
- answer: net regression, new failures ['A3']
- must-pass case A3 failed: missing ['second approver']
$ echo $?
1
$ python3 run_evals.py fixed
baseline vs fixed, 17 cases
task metric baseline cand new_fail new_pass
classify exact_match 0.800 1.000 - C4
extract schema_valid 0.750 1.000 - E3
retrieve recall@3 0.875 1.000 - R3
answer judged_pass 1.000 1.000 - -
overall pass rate: 14/17 -> 17/17
exact McNemar p = 0.250 (0 new failures, 3 new passes)
gate: PASS
$ echo $?
0This run exercised the dataset loop, all four graders, the paired comparison, the exact McNemar calculation, the gate rules, and both exit codes. It did not exercise a hosted model, a real LLM judge, retries, rate limits, concurrency, or real run-to-run variation.
The ScriptedJudge is a phrase matcher. Replace it with a judge you have validated, using the method in LLM as a judge, and replace ScriptedModel.complete with your client.
#Compare against a baseline
Compare the candidate with the last shipped run case by case, because the same overall score can hide a swap. In the first run, recall at 3 stayed at 0.875 while R3 recovered and R4 broke. Only the paired list of new failures and new passes shows that.
The overall pass rate rose from 14 of 17 to 15 of 17. A gate on the average would have shipped this candidate. The per-task view shows the answer task losing A3, the must-pass case about refund approval, and that is why the gate blocked.
In CI, produce the baseline by running the suite on your main branch and on the change, against the same golden file. If that costs too much, cache baseline scores keyed by a hash of the golden file, the model ID, and the prompt. Re-score whenever any of those change.
The printed p-value comes from an exact McNemar test, which uses only the cases that changed, under a binomial distribution. At p equal to 1.000 on 2 new failures and 3 new passes, a 17-case suite cannot separate this change from noise. Treat the p-value as a reminder to read the case list.
If you sample at a temperature above zero, the same case can pass and fail on different runs. Run each case several times and count a flaky case as a failure until you have fixed it.
#Sample size for LLM evals
You need far more cases to measure a small drop than to catch a large one. At a 90 percent pass rate, a paired design needs about 280 cases to detect a 5-point drop with 80 percent power. A 17-case suite can only flag specific must-pass cases.
This script computes Wilson intervals and sample sizes, then checks the paired formula by simulating an exact McNemar test.
#!/usr/bin/env python3
"""Sample-size math for LLM evals. Standard library only."""
import math
import random
from statistics import NormalDist
Z = NormalDist().inv_cdf
ALPHA, POWER = 0.05, 0.80
Z_A, Z_B = Z(1 - ALPHA / 2), Z(POWER)
def wilson(p, n, z=Z_A):
centre = (p + z**2 / (2 * n)) / (1 + z**2 / n)
half = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / (1 + z**2 / n)
return centre - half, centre + half
def unpaired_n(p1, p2):
bar = (p1 + p2) / 2
num = Z_A * math.sqrt(2 * bar * (1 - bar)) + Z_B * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))
return math.ceil(num**2 / (p1 - p2) ** 2)
def paired_n(delta, flip_up):
"""McNemar sample size. b = delta + flip_up flips down, flip_up flips up."""
psi = delta + 2 * flip_up
num = Z_A * math.sqrt(psi) + Z_B * math.sqrt(psi - delta**2)
return math.ceil(num**2 / delta**2)
def exact_mcnemar_p(b, c):
n = b + c
if n == 0:
return 1.0
tail = sum(math.comb(n, i) for i in range(min(b, c) + 1)) / 2**n
return min(1.0, 2 * tail)
def simulate_power(n, delta, flip_up, trials=4000, seed=7):
rng = random.Random(seed)
down, up = delta + flip_up, flip_up
hits = 0
for _ in range(trials):
b = c = 0
for _ in range(n):
u = rng.random()
if u < down:
b += 1
elif u < down + up:
c += 1
if b > c and exact_mcnemar_p(b, c) < ALPHA:
hits += 1
return hits / trials
print("Wilson 95% interval when the observed pass rate is 90%")
for n in (50, 100, 200, 400, 800, 1600):
low, high = wilson(0.90, n)
print(f" n={n:>5}: {low:.3f} to {high:.3f} (width {high - low:.3f})")
print("\nCases to detect a drop from a 90% pass rate (alpha 0.05, power 0.80)")
print(" drop unpaired per arm paired (1.5% flip up)")
for drop in (0.10, 0.05, 0.03, 0.02):
print(f" {drop:.2f} {unpaired_n(0.90, 0.90 - drop):>16} {paired_n(drop, 0.015):>21}")
n = paired_n(0.05, 0.015)
print(f"\nSimulated power of exact McNemar, drop 0.05, n={n}: {simulate_power(n, 0.05, 0.015):.3f}")
for try_n in (260, 280, 300):
print(f"Simulated power, n={try_n}: {simulate_power(try_n, 0.05, 0.015):.3f}")
print("\nOne-sided 95% upper bound on the failure rate after zero failures in n cases")
for n in (30, 100, 300, 1000):
print(f" n={n:>5}: {1 - 0.05 ** (1 / n):.4f}")Tested with Python 3.15.1. The Wilson intervals and exact p-values match SciPy 1.18.1 on the cases checked.
Wilson 95% interval when the observed pass rate is 90%
n= 50: 0.786 to 0.957 (width 0.170)
n= 100: 0.826 to 0.945 (width 0.119)
n= 200: 0.851 to 0.934 (width 0.084)
n= 400: 0.867 to 0.926 (width 0.059)
n= 800: 0.877 to 0.919 (width 0.042)
n= 1600: 0.884 to 0.914 (width 0.029)
Cases to detect a drop from a 90% pass rate (alpha 0.05, power 0.80)
drop unpaired per arm paired (1.5% flip up)
0.10 199 100
0.05 686 249
0.03 1774 521
0.02 3841 979
Simulated power of exact McNemar, drop 0.05, n=249: 0.769
Simulated power, n=260: 0.779
Simulated power, n=280: 0.817
Simulated power, n=300: 0.855
One-sided 95% upper bound on the failure rate after zero failures in n cases
n= 30: 0.0950
n= 100: 0.0295
n= 300: 0.0099
n= 1000: 0.0030| Drop to detect | Unpaired, cases per group | Paired, 1.5 percent flip up |
|---|---|---|
| 10 points | 199 | 100 |
| 5 points | 686 | 249 |
| 3 points | 1,774 | 521 |
| 2 points | 3,841 | 979 |
Read this as a planning table. The paired columns assume 1.5 percent of cases flip from fail to pass, which is an assumption made for this article.
Measure your own rate by running the same baseline twice and counting flips. Pairing helps because only the cases that changed carry information.
The formula is an approximation. At the 5-point drop, the formula's 249 cases simulated to 0.769 power, and it took about 280 to pass 0.80. Zero failures are weaker evidence than they look, since 300 clean passes bound the failure rate only below 1 percent at one-sided 95 percent confidence.
Use these sizes to set expectations. Up to about 50 cases, trust must-pass cases and individual regressions, and read averages as direction only. Around 300 paired cases per task, a 5-point drop becomes detectable.
#Gate a deploy in CI
A CI gate is the eval runner's exit code wired to a required check. GitHub Actions fails a step when the last command exits non-zero, so the gate needs no plugin. The runner above exited 1 for the regressed candidate and 0 for the fixed one.
name: evals
on:
pull_request:
jobs:
eval-gate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.15"
- name: Run the eval gate
run: python3 evals/run_evals.py candidateThis workflow was not run. The action versions came from the actions/checkout and actions/setup-python repositories on November 8, 2026. Only the YAML was parsed.
GitHub documents how a run step reports status: the runner marks the step failed or succeeded from the exit code, and continue-on-error prevents a job from failing when a step fails. Leave that off the gate. Non-zero exits also fail the action if you package this as one.
Choose gates in this order:
- Must-pass cases, where a single failure blocks.
- Per-task floors, for the deterministic metrics.
- Net regressions per task, new failures minus new passes.
- Judged metrics, report-only until the judge is validated.
Run deterministic graders on every pull request and slower judged metrics on a schedule or before release. GitHub warns that a workflow skipped by a path filter leaves its checks pending, which can block a pull request that requires them. If you make this check required, do not add a paths filter without a job that always reports a status.
#Agent evaluation: outcome first, trajectory second
Agent evaluation should grade the final state first and the path second. Anthropic's agent eval guide calls exact step-order checks brittle, and tau-bench compares the final database state with an annotated goal state. Add trajectory checks only for rules that must hold on every path.
An agent loop can reach the right result by many routes, so an exact sequence check fails on harmless variation. An outcome check alone misses a policy violation that happens to end well. This script grades four scripted runs with both, and compares them with the agent's own claim of success.
#!/usr/bin/env python3
"""Grade scripted agent runs by outcome and by trajectory rules."""
GOAL = {"R-1042": "refunded"}
EXPECTED_SEQUENCE = ["get_order", "verify_identity", "issue_refund"]
RUNS = {
"T1": (["get_order", "verify_identity", "issue_refund"], {"R-1042": "refunded"}, "Refund issued."),
"T2": (["verify_identity", "get_order", "issue_refund"], {"R-1042": "refunded"}, "Refund issued."),
"T3": (["get_order", "issue_refund", "verify_identity"], {"R-1042": "refunded"}, "Refund issued."),
"T4": (["get_order", "verify_identity"], {"R-1042": "open"}, "Refund issued."),
}
def outcome_passed(final_state):
return final_state == GOAL
def exact_sequence_passed(calls):
return calls == EXPECTED_SEQUENCE
def policy_passed(calls):
"""Identity check must happen before any refund."""
if "issue_refund" not in calls:
return True
return "verify_identity" in calls[: calls.index("issue_refund")]
def text_claims_success(reply):
return "refund issued" in reply.lower()
print(f"{'run':<4}{'reply says done':<17}{'outcome':<9}{'exact seq':<11}{'policy':<8}verdict")
for run, (calls, state, reply) in RUNS.items():
outcome, policy = outcome_passed(state), policy_passed(calls)
verdict = "pass" if outcome and policy else "fail"
print(f"{run:<4}{str(text_claims_success(reply)):<17}{str(outcome):<9}"
f"{str(exact_sequence_passed(calls)):<11}{str(policy):<8}{verdict}")Tested with Python 3.15.1:
run reply says done outcome exact seq policy verdict
T1 True True True True pass
T2 True True False True pass
T3 True True False False fail
T4 True False False True failT2 fails the exact-sequence check even though it did the right thing in a different order. T3 reaches the goal but issues the refund before verifying identity, which a final-state check cannot see. T4 replies that the refund happened while the database says it did not, which is why you grade state and ignore the reply.
Outcome checks are how the benchmarks work. SWE-bench applies a generated patch and runs the repository's tests, and tau-bench checks the database. Tau-bench also reported in 2024 that gpt-4o solved fewer than 50 percent of tasks, with pass^8 below 25 percent in its retail domain.
Report both rates for an agent. Anthropic's guide defines pass@k as at least one success in k attempts, and pass^k as success on all k attempts. Use pass^k for customer-facing agents, where one bad run in eight is a real incident.
See what is an AI agent and the agents hub for the loop itself.
#RAG evaluation: score retrieval and generation separately
RAG evaluation splits one score into two, because a bad answer can come from the retriever or the generator. Measure recall at k on labeled documents first. Then measure whether the answer stays faithful to the retrieved text, using a judge you have validated against human labels.
| Recall at k | Faithfulness | Likely cause | Fix first |
|---|---|---|---|
| Low | High | Retriever misses the answer | Chunking, hybrid search, reranking |
| High | Low | Generator ignores or misreads the context | Prompt, context order, model |
| Low | Low | Both stages are failing | Retriever, then generator |
| High | High, answers still wrong | Stale labels or stale documents | Re-check labels and corpus dates |
Ragas (Es et al., 2023) defines faithfulness as the share of an answer's statements that can be inferred from the retrieved context. It also scores answer relevance and context relevance. Those metrics call a language model internally, so they inherit judge bias and need the same validation.
Label a few hundred question-to-document pairs by hand, and treat them as part of the golden dataset. Hybrid search, reranking, and new chunking choices should each move recall at k before you expect answers to improve.
Retrieval order matters too. The context engineering guide covers what position research found, and the vector database guide and RAG vs fine-tuning cover the retrieval stack.
#Start your first LLM evaluation this week
Start your first LLM evaluation with the last ten bugs your feature shipped. Write each as a golden case, mark the worst as must-pass, and wire the exit code into CI before you add a single judged metric. That suite is small, and it already blocks the regressions you have paid for once.
Then grow the gate as the dataset grows:
- Under 50 cases: block on must-pass cases only, and report everything else.
- 50 to 200 cases: add per-task floors and net-regression checks.
- Over 200 paired cases per task: add a significance check on aggregate changes.
- Any judged metric: keep it report-only until it passes the human-label validation in LLM as a judge.
The evals hub collects the rest of the reading on this topic.
#Questions
What is LLM evaluation?
LLM evaluation is a repeatable test of a feature that uses a language model. It runs the feature on fixed inputs, scores each output with a metric chosen for that task, and compares the scores with the last version you shipped. A good suite tells you which cases got worse and blocks a release when a critical case fails.
What are the main LLM evaluation metrics?
Use exact match for classification, schema validity for extraction, recall at k and precision at k for retrieval, and a validated judge for open-ended answers. Agents add outcome checks against the final state. Pick one primary metric per task type and treat latency and cost as separate limits, so one averaged score cannot hide a regression.
How many test cases do I need for LLM evals?
Start with 20 to 50 cases drawn from real failures, which Anthropic says is enough for an early agent suite. To measure a 5-point drop from a 90 percent pass rate with 80 percent power, this guide calculated about 280 paired cases, or 686 per group if the runs are unpaired, under the stated assumptions.
What is a golden dataset in LLM evaluation?
A golden dataset is a versioned set of real inputs paired with the expected behavior for each one. Build it from sampled production traffic plus known failures, label it with people who know the domain, and keep part of it out of prompt tuning. Treat each change to the file as a change to your test suite.
Should I use an LLM judge or code-based checks?
Use code-based checks wherever the correct behavior can be written as a rule, because they are free, fast, and deterministic. Use an LLM judge for open-ended qualities such as whether an answer stays faithful to its sources. Validate the judge against human labels before it can block a deploy, and keep it report-only until it passes.
How do I run LLM evals in CI?
Write a script that runs the suite and exits non-zero when the gate blocks, then call it from a required check. GitHub Actions marks a bash step as failed when the last command exits non-zero. Run deterministic graders on every pull request and run slower judged metrics on a schedule or before release, so the gate stays fast.