AI System Design
IC5IC6

Design an LLM Eval Platform

A version-controlled judge harness that turns "the model feels worse this week" into a red CI check with a diff you can point at.

15 min read · 13 sections
0

1. Quick anchor

An eval platform is a compiler for model quality: it takes a frozen set of inputs (the golden set), runs your system against them, scores each output with deterministic checks plus LLM judges, and emits a single verdict — pass, regress, or improve — that a human or a CI gate can act on. The hard parts are not the judges; they are the boring distributed-systems parts wrapped around judges: making runs reproducible, caching judge calls so a 5,000-case suite costs cents instead of dollars, detecting a real regression versus judge noise, and isolating twelve teams on one shared budget. The mental model: treat every eval run as an experiment keyed by (dataset_version, system_version, judge_version), store the per-case results immutably, and compare versions as deltas — never absolute scores in a vacuum. If you can't answer "what changed and by how much, beyond noise" in one query, you have a dashboard, not an eval platform.

2. Why interviewers probe this

  • IC5 signal — Can you build the pipeline, not just call a judge? They want golden-set versioning, deterministic-checks-before-LLM-judge ordering, judge caching keyed correctly, a regression gate with a defensible threshold, and explicit cost math. Red flag: "I'd use an LLM to score it" with no calibration, no caching, no noise model.
  • IC5 signal — Do you know judges are measurement instruments that must themselves be validated? They probe judge-human agreement, calibration sets, pairwise-vs-pointwise tradeoffs, and what you do when the judge is the thing that's wrong.
  • IC6 signal — Multi-tenancy and platform thinking. One eval service for many teams: quota isolation, schema governance, judge-version migration without breaking everyone, online-vs-offline separation, and cost attribution per team. They want to hear failure modes (Goodhart, judge drift, golden-set rot) named before they ask.
  • IC6 signal — Org-level judgment: when is an eval platform worth building versus buying (Langfuse/Braintrust/Comet)? What's the build/buy line, and what do you never outsource (your golden sets, your gate policy)?

3. Concept build-up

Beginner explainerNew here? The words first

The words first.

  • Golden set — a frozen, curated list of input→expected-output pairs the system should handle well; the ruler everything is measured against.
  • LLM-as-judge — using a second, usually stronger, model to score your system's output against a rubric instead of (or before) a human.
  • Pointwise — judge scores one output on its own against a rubric (e.g. 1–5 or pass/fail).
  • Pairwise — judge picks the better of two outputs; easier and more reliable than absolute scoring.
  • Regression — a new version scores meaningfully worse than the last on the golden set.
  • CI gate — an automated check that blocks a merge/deploy if eval scores drop past a threshold.
  • Online eval — scoring real production traffic after the fact; offline eval — scoring against a fixed dataset before shipping.
  • Calibration set — a held-out batch where you've measured how often the judge agrees with humans, so you trust (or distrust) it.

Step by step.

  1. Curate a golden set: 30–50 expert-labeled cases per dimension you care about.
  2. Run your current system over every case, capturing the full trace.
  3. Apply cheap deterministic checks first (format, must-contain, no-PII).
  4. Send survivors to an LLM judge with a tight rubric; cache by (input, output, judge_version).
  5. Aggregate scores per dimension and segment (use case, user tier, failure mode).
  6. Compare against the last version's run; flag deltas larger than your noise band.
  7. Gate CI on the verdict; send the diff to a dashboard and alert on production drift.

Remember this: an eval platform measures deltas between versions, and the judge is an instrument you must calibrate before you trust its readings.

3.1 Golden sets are the product; everything else is plumbing

The single highest-leverage artifact is the golden set, and it is the one thing you should never outsource or auto-generate carelessly. Per the Confident AI playbook and Comet's LLM-as-judge guide, the working recipe is 30–50 expert-annotated examples per judge dimension, segmented by use case, user segment, and failure mode, with a target of under 20% annotator disagreement on clear cases. That last number is your sanity floor: if humans can't agree on the easy cases, no judge will, and the dimension is underspecified.

Treat golden sets as versioned data, not config. Each case is {id, input, reference_output?, metadata, labels, dimension} and lives in a content-addressed store so a run can pin dataset_version = sha256(sorted cases). This is non-negotiable for reproducibility: a regression alarm is meaningless if you can't prove the dataset didn't move underneath you. Golden sets also rot — a policy document from 2021 contradicts its 2026 version, and a case whose "correct" answer changed becomes a silent false-positive regression. Build a review cadence and track per-case last-validated dates.

A subtle trap: golden sets must be balanced toward failure modes you've actually seen, not toward easy happy-path cases. A suite that's 95% trivial queries will report 96% pass and tell you nothing the day a real bug ships. Pull hard cases from production traces (online → offline pipeline) so the static set tracks the live distribution.

3.2 Judges are instruments — calibrate before you trust

There are three judge architectures and you'll use all three for different jobs:

  • Pointwise is the backbone — score every trace against a rubric, no baseline needed, runs in production. A stronger secondary model scores against a strict rubric. This scales: thousands of responses in minutes at a fraction of a human team's cost.
  • Pairwise asks "is A or B better?" — inherently easier than absolute scoring, so judge-human agreement is higher. The cost: it scales quadratically — 5 variants = 10 comparisons — so it's for offline model/prompt bake-offs, not per-trace production scoring.
  • G-Eval (chain-of-thought) has the judge write evaluation steps before emitting a score, which improves correlation with human judgment over direct scoring. Use it for fuzzy dimensions (helpfulness, tone) where a single number from a cold judge is noisy.

The discipline most candidates skip: a judge is a model that can be wrong, so you measure its error. Build a calibration set where you have human labels, run the judge, and compute agreement. The rubric design rules that move agreement: 3–5 dimensions per judge call maximum (more and quality collapses), few-shot examples with scores (consistency jumps), separate specialized judges for distinct concerns (a hallucination judge and a tone judge should be different prompts), and start binary pass/fail before numeric scales — a judge that can't reliably split pass from fail has no business emitting a 7.3/10.

Judge-human agreement and the noise band — on real numbers

Symbols in plain words: agreement = fraction of calibration cases where the judge's verdict matches the human label. noise band = how much two runs of the same system can differ purely from judge randomness. We use these to decide if a score drop is real or just the judge flickering.

Concrete example. Calibration set = 100 human-labeled cases. The judge matches the human on 82 of them → agreement = 0.82. Now run the unchanged system through the judge twice on the 500-case golden set. Run A passes 451/500 = 0.902. Run B passes 446/500 = 0.892. The runs differ by 0.010 with nothing changed — that's judge noise. Repeat a few times and you find run-to-run pass-rate swings sit within about ±0.012. So your noise band ≈ 1.2 percentage points.

Decision rule that falls out: a new version that scores 0.9020.898 (drop of 0.4 pts) is inside the noise — do not page anyone. A drop of 0.9020.871 (3.1 pts) is ~2.5x the noise band — that's a real regression, fail the gate. What this did to the data: it converted a raw score delta into a significance decision, so the gate fires on signal, not jitter.

The honest caveat: agreement of 0.82 means the judge disagrees with humans on ~1 in 5 cases. For a deploy gate that's often fine because you care about aggregate movement across hundreds of cases, where per-case noise averages out — but it is not fine for auto-rejecting an individual user's output in production. Match the instrument's precision to the decision's blast radius.

3.3 The run pipeline: deterministic first, judge last

Order matters for both cost and correctness. For each case:

  1. Deterministic checks — format/schema validation, regex must-contain/must-not-contain, JSON parseability, PII scan, exact-match where a reference exists. These are free, fast, and catch the dumb regressions (broken JSON, dropped citation) that a judge would waste tokens confirming.
  2. Only survivors reach the LLM judge. A response that fails schema validation is already a fail; don't pay a judge to tell you a malformed blob is bad.
  3. Aggregate per dimension and per segment, store immutable per-case results keyed by the experiment triple.

This is the 5-step regression workflow distilled: prepare datasets → instrument the app to log traces → deterministic checks → LLM-judge for semantic quality → compare versions via experiment tracking.

3.4 Caching: where the cost actually lives

The judge calls dominate cost, and they are highly cacheable because in CI you re-run the same golden set against systems that change one prompt at a time. Cache key = hash(judge_model, judge_version, judge_prompt_version, input, candidate_output). If the system output for a case is byte-identical to last run (common — most cases are unaffected by a one-line prompt tweak), the judge verdict is reused for free. In a typical PR that changes 8% of outputs, you pay for ~8% of judge calls. Two layers, mirroring semantic-cache practice: an exact K-V layer for identical (input, output) and optionally a semantic layer (cosine ≥ ~0.95) for near-identical outputs where the verdict is safe to reuse. Be conservative with the semantic layer on a judge — a 0.95-similar output can flip a borderline pass/fail, so high-precision threshold and only on non-gating dimensions.

3.5 Online vs offline, and multi-team isolation

Offline = the gate. Fixed golden set, before merge/deploy, blocks bad versions. Online = production monitoring: sample 10–20% of live traces for detailed LLM-judge scoring (logging cheap metrics — tokens, cost, latency — for 100%), route flagged responses to a manual-review queue, and feed the gnarliest ones back into the offline golden set. The two form a loop; online finds the failure modes offline didn't anticipate.

For multi-team (the IC6 core): one eval service, many tenants. Each team owns its golden sets, rubrics, and gate thresholds (namespaced, isolated — team A's judge-version bump must not move team B's baseline). The platform owns the shared runner, the judge-call cache, quota enforcement, and cost attribution. Per-team token budgets with rate limits at multiple levels (per-team, per-judge-model, system-wide) stop one team's 5,000-case suite from starving the rest. Cost is attributed per team/feature/model on every trace so the bill is legible and chargeable back.

4. Minimal implementation

A real, runnable offline eval runner with deterministic-checks-first ordering, judge caching, and a noise-band gate. This is the shape of the CI step.

import hashlib, json, sqlite3, statistics, re
from dataclasses import dataclass, asdict
 
# ---- content-addressed caches & result store (sqlite stands in for Redis/Postgres) ----
db = sqlite3.connect("eval.db")
db.execute("CREATE TABLE IF NOT EXISTS judge_cache(k TEXT PRIMARY KEY, verdict INT, reason TEXT)")
db.execute("CREATE TABLE IF NOT EXISTS runs(exp TEXT, case_id TEXT, dim TEXT, score INT)")
 
@dataclass(frozen=True)
class Case:
    id: str; input: str; dimension: str
    must_contain: tuple = ()        # deterministic check
    must_be_json: bool = False
 
def dataset_version(cases): 
    blob = json.dumps([asdict(c) for c in cases], sort_keys=True, default=list)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]
 
# ---- LAYER 1: deterministic checks (free, run first, short-circuit) ----
def deterministic(case: Case, output: str):
    if case.must_be_json:
        try: json.loads(output)
        except Exception: return ("fail", "invalid_json")
    for token in case.must_contain:
        if token.lower() not in output.lower(): return ("fail", f"missing:{token}")
    if re.search(r"\b\d{3}-\d{2}-\d{4}\b", output): return ("fail", "pii_ssn")
    return ("pass", "deterministic_ok")           # not a final pass — just survived
 
# ---- LAYER 2: LLM judge (cached, only for survivors) ----
JUDGE_VERSION = "hallucination-judge-v3"
def judge(case: Case, output: str, call_model) -> tuple[int, str, bool]:
    key = hashlib.sha256(f"{JUDGE_VERSION}|{case.input}|{output}".encode()).hexdigest()
    hit = db.execute("SELECT verdict, reason FROM judge_cache WHERE k=?", (key,)).fetchone()
    if hit: return hit[0], hit[1], True           # cache hit -> zero cost
    # binary pass/fail, single dimension, few-shot rubric lives in call_model's prompt
    score, reason = call_model(case.dimension, case.input, output)
    db.execute("INSERT OR REPLACE INTO judge_cache VALUES(?,?,?)", (key, score, reason))
    return score, reason, False
 
def run_eval(cases, system, call_judge, exp_id):
    cache_hits = total_judged = 0
    for c in cases:
        out = system(c.input)
        verdict, reason = deterministic(c, out)
        if verdict == "fail":
            score = 0
        else:
            score, reason, hit = judge(c, out, call_judge)
            total_judged += 1; cache_hits += int(hit)
        db.execute("INSERT INTO runs VALUES(?,?,?,?)", (exp_id, c.id, c.dimension, score))
    db.commit()
    hit_rate = cache_hits / max(total_judged, 1)
    return hit_rate
 
def pass_rate(exp_id, dim):
    rows = db.execute("SELECT score FROM runs WHERE exp=? AND dim=?", (exp_id, dim)).fetchall()
    return statistics.mean(s for (s,) in rows) if rows else 0.0
 
# ---- the CI gate: compare to baseline against a measured noise band ----
def gate(new_exp, base_exp, dim, noise_band=0.012):
    new, base = pass_rate(new_exp, dim), pass_rate(base_exp, dim)
    delta = new - base
    if delta < -2 * noise_band:        # >2x noise = real regression
        return "FAIL", f"{dim}: {base:.3f} -> {new:.3f}{delta:+.3f}, regression)"
    if delta > 2 * noise_band:
        return "IMPROVE", f"{dim}: {base:.3f} -> {new:.3f}{delta:+.3f})"
    return "PASS", f"{dim}: Δ{delta:+.3f} within noise"

What's load-bearing: (1) dataset_version pins the ruler so a regression can't be a phantom of a moved dataset; (2) deterministic checks run before and short-circuit the judge — broken JSON never costs a token; (3) the judge cache key includes JUDGE_VERSION, so bumping the judge correctly invalidates everything (a judge change is a measurement change and must re-score); (4) the gate compares to a baseline against an empirically measured noise_band, not a hardcoded "must be >0.9". Swap the sqlite tables for Redis (judge cache) + Postgres (immutable run store) and call_judge for a real Claude/GPT call with a few-shot binary rubric, and this is production-shaped.

5. Production tradeoffs

Decision Cheap / fast option Expensive / accurate option What changes at scale
Judge architecture Pointwise binary, small judge G-Eval CoT, large judge, pairwise Pairwise's quadratic blowup makes it offline-only; pointwise is the only thing that survives per-trace online
Judge model Haiku-class judge (~12x cheaper than Sonnet-class) Frontier judge for fuzzy dims At 5k cases × 12 teams × N PRs/day, judge model choice is the budget
Caching Exact K-V on (input,output,judge_ver) + semantic layer (cos ≥ 0.95) Cache hit rate 85–95% in CI (most outputs unchanged per PR) is the difference between cents and dollars per run
Gate strictness Aggregate pass-rate + noise band Per-segment gates + per-case veto Per-segment gates catch regressions hidden by averages; cost is more flaky-flag triage
Online sampling 10–20% traces judged, 100% basic metrics 100% judged Judging 100% of production at scale costs as much as serving; sample and route flags to review
Freshness of golden set Quarterly manual review Continuous online→offline mining Stale sets silently rot into false regressions; mining keeps them on-distribution

Cost math you should say out loud. A 5,000-case suite, judge at ~$0.40/M tokens (current frontier-level pricing), ~1.5k tokens/judge-call ≈ 7.5M tokens ≈ $3 per cold full run. With an 88% cache hit rate on a typical PR, the marginal run is ~$0.36. Across 12 teams running CI dozens of times a day, the cache is the only thing standing between you and a four-figure daily judge bill — and remember the agentic-era trap: cheap per-token prices hide expensive per-task costs when a judge fans out across thousands of cases.

Latency. Offline runs are throughput-bound, not latency-bound — batch the judge calls, use continuous batching on a self-hosted judge if you own one (3–10x throughput), and the gate finishes in the time it takes to judge the ~8% of changed outputs. Online scoring is async and off the request path entirely; never put an LLM judge in the user's critical path.

Failure modes. (1) Goodhart — teams optimize the judge, not the product; the score climbs while users complain. Rotate held-out cases and keep human review in the loop. (2) Judge drift — the judge provider silently updates the model and your baseline shifts under you; pin judge model versions and re-run calibration on every judge bump. (3) Golden-set rot — covered above. (4) Averaged-away regressions — a 3-point drop on your highest-value segment hides inside a flat aggregate; gate per-segment. (5) Self-preference bias — a judge from the same family as the system under test tends to favor it; use a different judge family for bake-offs.

6. How it's asked

[IC5] Your LLM-as-judge agrees with humans 78% of the time. Is that good enough to gate deploys? For an aggregate deploy gate, often yes — you're measuring movement across hundreds of cases, so per-case judge noise averages out, and what matters is whether the delta exceeds your measured noise band. I'd run the unchanged system through the judge several times to find that band (say ±1.5 pts), and only fail on drops larger than ~2x it. For per-output production decisions (auto-rejecting a user's response), 78% is nowhere near enough — that's a 1-in-5 wrong call with direct user blast radius. So the same instrument is fine for one decision and unacceptable for another; precision must match the decision's blast radius.
[IC5] Walk me through a PR opening to the gate going green or red. Where does it cache, where can it flake? PR opens → CI pins dataset_version from the golden-set hash and system_version from the commit → runs the system over all cases → deterministic checks short-circuit obvious fails for free → survivors hit the judge, keyed by (judge_version, input, output), so unchanged outputs (the ~90% a one-line change didn't touch) are cache hits at zero cost → aggregate per dimension/segment → compare to the baseline experiment, fail if any segment drops beyond 2x the noise band. Flake sources: judge nondeterminism (mitigate with temperature 0 and the noise-band gate), a provider-side judge update invalidating the baseline (pin versions), and golden-set rot producing phantom regressions (cache key won't save you — that's a data problem).
[IC5] Pointwise vs pairwise — when each? Pointwise scores one output against a rubric, needs no baseline, runs on every trace including production online eval — it's the backbone. Pairwise picks the better of two, which is inherently easier so judge-human agreement is higher, but it scales quadratically (5 variants → 10 comparisons), so it's strictly for offline bake-offs: choosing between prompt candidates or model versions. Rule of thumb: pointwise for monitoring and gating, pairwise for choosing.
[IC6] Twelve teams, one shared judge budget. Stop one noisy 5,000-case suite from starving everyone. Multi-tenant by construction: each team namespaces its golden sets, rubrics, and gate thresholds; the platform owns the shared runner, judge cache, and quota. Enforce per-team token budgets with rate limits at three levels — per-team, per-judge-model, system-wide — and attribute cost per team/feature on every trace so the bill is chargeable back. The judge cache is the real lever: 85–95% hit rates in CI mean a "5,000-case suite" usually costs ~8% of one. For the genuinely heavy team, push them to a cheaper judge model for non-gating dimensions (Haiku-class is ~12x cheaper) and reserve the frontier judge for the dimensions that actually gate. Backpressure, not a hard cliff: queue and degrade, don't drop their run silently.
[IC6] When do you build this versus buy Langfuse/Braintrust/Comet? Buy the plumbing — tracing, dashboards, experiment tracking, the judge-run UI — because it's undifferentiated and these are mature. Never outsource the two things that are your actual moat: your golden sets (your hardest production failures, your domain truth) and your gate policy (what counts as a regression worth blocking a deploy). I'd build the thin layer that owns dataset versioning, the noise-band gate logic, and online→offline mining, and buy everything around it. The build/buy line is "is this our quality judgment, or is it infrastructure?" — own the judgment, rent the infrastructure.

7. Pitfalls & flashcards

  • Treating the judge as ground truth. It's an instrument with measured error; calibrate it on human labels before you trust a single gate decision.
  • Absolute scores instead of deltas. "We're at 0.91" means nothing without last version's number and the noise band. Eval is differential.
  • Skipping deterministic checks. Paying a judge to confirm broken JSON is bad is pure waste and adds noise; cheap checks first, always.
  • Judge cache key missing judge_version. Bump the judge prompt and you silently mix two instruments' readings in one baseline.
  • Averaged-away regressions. Gate per-segment; a flat aggregate hides a cliff on your most valuable cohort.
  • Same-family judge. Self-preference bias inflates scores for systems from the judge's own model family; use a different family for bake-offs.
  • Golden set of happy paths. A suite that's 95% trivial reports 96% pass and catches nothing real. Weight toward observed failure modes, mined from production.
  • LLM judge on the request path. Online scoring is async sampling (10–20%), never blocking the user.

Flashcard. Eval platform = experiments keyed by (dataset_version, system_version, judge_version) → deterministic checks short-circuit before a calibrated, cached judge → gate on the delta vs baseline beyond a measured noise band, per segment. The judge is an instrument; the golden set is the product.

8. Further reading

Next: /evals for the eval-design depth behind these platform mechanics, then /system-design/design-a-rag-chatbot to see where online-mined failure cases come from.

Primary sources
← More in AI System Design