AI engineering

Building a Golden Dataset to Regression-Test LLM Features

Build a golden dataset for an LLM feature: collect cases, label them, choose code-based and LLM graders, run it in CI and grow it from production failures.

By EpiFive Editorial TeamReviewed/updated 10 min readHow we check articles
On this page

A golden dataset is a versioned collection of realistic inputs, each paired with an expected answer or a set of properties the answer must have, that you rerun automatically whenever you change a prompt, model, retrieval step or tool. Run it before and after a change, compare the scores, and block the change if quality drops. This guide builds one in Python, from collecting cases to wiring it into CI.

Who this is for and what you will have

This article is for developers who have shipped, or are about to ship, an LLM feature such as a support assistant, extractor or classifier, and who currently check changes by trying a few prompts by hand. By the end you will have a JSONL dataset format, a runner with three kinds of graders, pass thresholds, a CI step, and a routine for growing the set from real failures.

OpenAI's evaluation guidance names the failure mode this prevents: "vibe-based evals", or putting evaluation off until after launch. Anthropic's guidance for building tests makes the same case: define success criteria first, then design evals that mirror your real task.

Step 1: Define success criteria

Before collecting data, write down what good means. Anthropic's guidance says criteria should be specific, measurable, achievable and relevant, and that most use cases need several at once. "Good performance" is not a criterion; "accurate sentiment classification" is, and so is "an F1 score of at least 0.85" or "answers cite a policy document ID".

Typical criteria for common features:

FeatureExample criteria
ClassifierCorrect label on at least a set percentage of cases
ExtractorValid JSON, all required fields present, field values match expected
Support assistantAnswers only from provided policy, cites the source, refuses out-of-scope questions
SummariserContains key facts, stays within the length limit, adds no unsupported claims

Pick the thresholds from your own needs and baseline measurements, not from a rule of thumb.

Step 2: Collect cases that mirror reality

Both vendors give the same advice: make the dataset reflect your real task distribution, and include edge cases. OpenAI lists sources for building a dataset: synthetic data, domain-specific data, human-curated examples, production data and historical logs. Anthropic lists edge cases to cover:

  • Irrelevant or nonexistent input.
  • Overly long input.
  • Poor or harmful user input.
  • Ambiguous cases where humans would also disagree.

A practical collection plan:

  1. Take 20 to 50 real, anonymised inputs from logs or support tickets. Remove personal data before storing them.
  2. Add edge cases from the list above, plus cases for each behaviour you promise, such as refusal when the answer is not in the documents.
  3. Add known failures from bug reports. Each one is a regression test waiting to happen.
  4. Use a capable model to suggest more variations if you need volume, but have a person review them. OpenAI's guidance suggests a model can help generate diverse scenarios while human experts label.

Choose a file format

JSONL, one JSON object per line, diffs cleanly in version control and is easy to append to.

{"id": "refund-001", "input": "How long do I have to return an item?", "expected": "30 days", "type": "contains", "tags": ["policy", "typical"]}
{"id": "refund-002", "input": "Can I return a gift card?", "expected": "no", "type": "contains", "tags": ["policy", "edge"]}
{"id": "scope-001", "input": "What is the capital of France?", "rubric": "Politely declines because the question is outside the support policy.", "type": "judge", "tags": ["refusal"]}
{"id": "format-001", "input": "Order CONTOSO-1042 shipped via Fabrikam Freight.", "expected": {"order_id": "CONTOSO-1042", "carrier": "Fabrikam Freight"}, "type": "json", "tags": ["extraction"]}

Each case has a stable id, the input, the way to grade it (type), and either an expected value or a rubric. The contains examples use Contoso-style placeholder policies; replace them with your own.

Step 3: Choose graders

Anthropic's guidance recommends automating grading when possible, and describes three families. OpenAI's guidance describes metric-based graders, human graders and LLM-as-judge. Combine them:

GraderUse forLimits
Exact or string matchLabels, short constrained answersBrittle for free text
Structural checks (JSON parses, fields exist, values equal)Extraction and tool argumentsDoesn't judge quality
Executable testsCode generationNeeds a sandbox
LLM-as-judge with a rubricTone, completeness, faithfulnessNeeds validation against humans
Human reviewCalibration and hard casesSlow and costly

Anthropic's exact-match example is simply a comparison after stripping whitespace and lowercasing. OpenAI notes metric-based graders are useful for regression testing but can miss nuance, which is why you pair them with judged cases.

LLM-as-judge rules

From the vendors' guidance:

  • Use a different model from the one that generated the output (Anthropic).
  • Favour pairwise comparison or pass/fail scoring over open-ended scores, and ask for reasoning before the score (OpenAI).
  • Watch for position bias and verbosity bias, and vary response order or control for length (OpenAI).
  • Write a clear, detailed rubric (both).
  • Validate the judge against human labels before relying on it (OpenAI).

Step 4: Write the runner

The runner below loads the JSONL file, calls your feature, grades each case and prints a summary. answer is a placeholder for your application's entry point; it takes a question and returns a string.

import json
import sys
from collections import defaultdict
from pathlib import Path
 
import anthropic
 
from myapp import answer  # your feature: str -> str
 
judge_client = anthropic.Anthropic()
JUDGE_MODEL = "claude-sonnet-5-5"  # use a different model from the one in `answer`
 
 
def grade_contains(output, case):
    return str(case["expected"]).lower() in output.lower()
 
 
def grade_json(output, case):
    try:
        data = json.loads(output)
    except json.JSONDecodeError:
        return False
    return all(data.get(k) == v for k, v in case["expected"].items())
 
 
def grade_judge(output, case):
    prompt = (
        "You are grading an AI assistant's answer.\n"
        f"Question: {case['input']}\n"
        f"Answer: {output}\n"
        f"Rubric: {case['rubric']}\n"
        "Think briefly, then finish with a final line that is exactly PASS or FAIL."
    )
    reply = judge_client.messages.create(
        model=JUDGE_MODEL,
        max_tokens=300,
        messages=[{"role": "user", "content": prompt}],
    )
    text = reply.content[0].text.strip()
    return text.splitlines()[-1].strip() == "PASS"
 
 
GRADERS = {"contains": grade_contains, "json": grade_json, "judge": grade_judge}
 
 
def run(path: str, threshold: float = 0.9) -> int:
    cases = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
    failures, by_tag = [], defaultdict(lambda: [0, 0])
 
    for case in cases:
        output = answer(case["input"])
        ok = GRADERS[case["type"]](output, case)
        for tag in case.get("tags", []):
            by_tag[tag][1] += 1
            by_tag[tag][0] += ok
        if not ok:
            failures.append((case["id"], output[:200]))
 
    passed = len(cases) - len(failures)
    rate = passed / len(cases)
    print(f"Passed {passed}/{len(cases)} ({rate:.0%})")
    for tag, (p, n) in sorted(by_tag.items()):
        print(f"  {tag}: {p}/{n}")
    for case_id, out in failures:
        print(f"FAIL {case_id}: {out!r}")
    return 0 if rate >= threshold else 1
 
 
if __name__ == "__main__":
    sys.exit(run(sys.argv[1]))

Run it with python run_evals.py golden.jsonl. The process exits non-zero when the pass rate falls below the threshold, which is what CI needs.

Handle non-determinism

OpenAI's guidance asks you to identify where variability enters: developer and user inputs, model outputs, tool selection and arguments, and agent handoffs. Practical consequences:

  • Grade properties, such as "contains 30 days", instead of exact wording where you can.
  • Use a pass-rate threshold across the set, not a requirement that every case passes every run.
  • Re-run borderline cases a few times before declaring a regression.
  • Record the model name, prompt version and dataset version with every run so results are comparable.

Step 5: Validate your graders

An LLM judge is itself a component that can be wrong. Before you trust it:

  1. Pick 30 to 50 judged cases and have a person label each answer pass or fail.
  2. Run the judge on the same answers.
  3. Compare. Where they disagree, fix the rubric or the case, not the labels.
  4. Re-check whenever you change the judge model or the rubric.

OpenAI's guidance says to check automated scores against human feedback, and not to ignore human feedback when calibrating automated metrics.

Step 6: Run it in CI

Gate changes that can alter behaviour: prompt files, model identifiers, retrieval settings and tool definitions. A GitHub Actions job, for example:

name: llm-evals
on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'app/**'
jobs:
  evals:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: python run_evals.py golden.jsonl
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Keep a fast subset, tagged smoke, for every pull request, and run the full set nightly or before release, so cost and latency stay reasonable. If many eval calls run at once, expect rate limit errors; see Handling LLM API Rate Limits: Retry, Backoff and Queues in Python. For a batch of non-urgent judged cases, a provider's batch interface can also help.

A note on hosted evaluation tools

If you use a hosted evaluation product, check its lifecycle. OpenAI's evaluation best practices page states that the OpenAI Evals platform is being deprecated, with read-only access starting on 31 October 2026 and shutdown scheduled for 30 November 2026. A dataset stored in plain JSONL in your repository, with a runner you own, is unaffected by vendor changes.

Step 7: Grow the dataset

A golden dataset is never finished. OpenAI's guidance recommends logging everything during development so you can mine the logs for good test cases, running evals on every change, monitoring production and continuing to grow the set.

  • Every production failure, bad answer or support complaint becomes a new case with an id, the failing input and the corrected expectation.
  • Tag cases by behaviour so you can see where a change helps or hurts.
  • Review the set quarterly. Remove cases that no longer reflect the product, and keep a changelog of dataset versions.
  • Keep the dataset independent of any framework. A runner that calls answer(question) works with a hand-written SDK loop and with any framework discussed in LangChain vs Semantic Kernel vs the Plain SDK: When a Framework Pays Off.

The same dataset is also how you compare retrieval backends fairly; the test plan in pgvector vs Azure AI Search vs Qdrant: Choosing a Vector Store reuses it.

Common mistakes

  • Testing only happy-path questions, so edge cases and refusals regress unnoticed.
  • Using the same model as generator and judge.
  • Tuning prompts until the dataset passes, then never adding fresh cases. The set stops predicting production quality.
  • Storing real customer data in the dataset without removing personal information.
  • Requiring a perfect score, so flaky cases train the team to ignore failures.

Troubleshooting

SymptomLikely causeFix
Scores swing between runsNon-deterministic outputs or borderline casesGrade properties, repeat borderline cases, use a threshold
Judge passes obviously bad answersVague rubric or biased judgeTighten the rubric; test against human labels; try pairwise comparison
CI is slow or expensiveFull set on every pull requestAdd a smoke subset and run the full set nightly
New prompt passes tests but users complainDataset doesn't mirror productionMine logs and complaints for new cases
Tests fail with 429 errorsToo many parallel eval callsAdd a concurrency limit and backoff

Checklist

  • Success criteria are written down and measurable.
  • Cases cover typical, edge, adversarial and refusal behaviour, and contain no personal data.
  • Each case has a stable ID, a grader type and tags.
  • Judges use a different model and have been checked against human labels.
  • The runner exits non-zero below a threshold and runs in CI.
  • Every production failure is added as a new case.

References

Questions people ask

How many test cases does a golden dataset need?

Start with a few dozen cases that mirror real traffic and add more over time. Anthropic's guidance favours more questions with automated grading over fewer hand-graded ones, and OpenAI advises growing the set continuously. The right size is the smallest set that detects the regressions you care about.

Can I use an LLM to grade another LLM's output?

Yes, for qualities that code cannot check, such as tone or completeness. Anthropic recommends using a different model from the one that produced the output, and OpenAI recommends validating the judge against human labels, using clear rubrics and watching for position and verbosity bias.

Should regression tests run on every commit?

Run a small, fast subset on every change that touches prompts, models or retrieval, and the full set on a schedule or before release. OpenAI's guidance is to run evals on every change, so keep the quick subset cheap enough to make that realistic.

Why do my LLM tests give different results each run?

Model outputs can vary between runs, so identify where variability enters and grade properties rather than exact wording where you can. Use exact match only for constrained answers, repeat borderline cases and set a pass rate threshold instead of requiring every case to pass every time.

EvaluationPythonLLM TestingRegression Testing
  1. LLM API Timeouts, Retries and Rate Limits: A Python Reliability Guide

    Make Claude and OpenAI API calls in Python resilient: set timeouts, retry only the right errors, honor retry-after on 429, and tell spend limits from rate limits.

    AI engineering10 min read
  2. LLM Model Routing: Send Easy Requests to a Cheaper Model

    Build a simple Python router that sends each request to a small or large Claude model, then measure cost and quality against an eval set before you ship it.

    AI engineering12 min read
  3. Migrate from OpenAI Chat Completions to the Responses API: What Changes

    Port Chat Completions code to the OpenAI Responses API: new request shape, typed output items, multi-turn state, function calling, structured outputs and streaming.

    AI engineering9 min read
  4. Getting Started with LLM Evals: A Small-Team Setup with Code

    Build your first LLM eval in an afternoon: write 30 test cases in a JSONL file, grade them with simple code, add an LLM grader for tone, and track the score between prompt changes.

    AI engineering9 min read
  5. Semantic Search With Azure OpenAI Embeddings in Python: A Minimal Example

    Build a small semantic search in Python with Azure OpenAI embeddings: split text into chunks, embed them in batches, and rank results by cosine similarity.

    AI engineering8 min read
  6. Claude Message Batches API: Cut Bulk Job Costs in Python

    Run large asynchronous Claude jobs at 50 percent of standard API prices with the Message Batches API: Python create, poll and results code, limits and fixes.

    AI engineering9 min read