Module 7: Evals, Observability & Safety · Lesson 2 of 5 · 40 min

LLM-as-Judge, Done Honestly

When correctness is subjective — faithfulness, helpfulness, tone — you reach for a model to grade a model. That's fine, but an unvalidated judge is a random number generator with a rubric. How to validate it against humans, and how to beat position, verbosity, and self-preference bias.

Some qualities can't be asserted. Was the answer faithful to the retrieved documents? Was it helpful rather than technically-correct-but-useless? Was the tone appropriate for an upset customer? For these you use an LLM as a judge: a second model call that reads the input, the agent's output, and a rubric, and returns a score. It's powerful and cheap. It's also easy to fool yourself with.

An unvalidated judge is worthless
The single most common eval mistake is trusting a judge you never checked. If your judge agrees with human labels only 60% of the time, its scores are barely better than noise — and worse, they're confidently noisy. You must measure judge-human agreement before you let a judge gate anything.

Validate the judge first

The recipe is not optional. Hand-label a set of examples — at least ~30 to start, more is better — with the verdict you actually want. Run your judge on the same examples. Compute agreement. If it's low, fix the rubric (add anchored definitions, concrete examples of pass and fail, tighter scales) and re-measure. Only once agreement clears a bar you set in advance — many teams target roughly 85%+ — do you trust the judge to run unattended. The judge is now a validated instrument; treat any later rubric edit as re-invalidating it.

measuring judge-human agreement
# Colab cell — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic

import os
try:
    from google.colab import userdata
    os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
    from getpass import getpass
    os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))

import anthropic

client = anthropic.Anthropic()

# In production these are your 30+ hand-labeled examples in labels.json and
# an anchored rubric file; inlined here as tiny fixtures so the cell runs.
labels = [
    {"id": "grounded",   "output": "Your plan renews on the 1st, per your account page.", "human": "pass"},
    {"id": "invented",   "output": "Your plan renews on the 15th and includes free flights.", "human": "fail"},
    {"id": "abstained",  "output": "I don't see that detail in the provided context.", "human": "pass"},
]
RUBRIC = (
    "You grade whether a support reply stays faithful to known account facts "
    "(renewal date is the 1st; no travel perks). PASS if every claim is "
    "supported or the reply abstains; FAIL if it invents any fact."
)

def run_judge(output: str, rubric: str) -> str:
    # Force a structured verdict via a tool schema (Module 1 pattern).
    # Judges run in volume, so default to the cheap tier — validation
    # against human labels, not judge size, is what earns trust.
    resp = client.messages.create(
        model="claude-haiku-4-5", max_tokens=512,
        tools=[{
            "name": "record_verdict",
            "description": "Record the grading verdict for one answer.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "verdict": {"type": "string", "enum": ["pass", "fail"]},
                    "reason": {"type": "string"},
                },
                "required": ["verdict", "reason"],
            },
        }],
        tool_choice={"type": "tool", "name": "record_verdict"},
        system=rubric,
        messages=[{"role": "user", "content": f"Answer to grade:\n{output}"}],
    )
    block = next(b for b in resp.content if b.type == "tool_use")
    return block.input["verdict"]

def agreement(labels: list[dict], rubric: str) -> float:
    hits = 0
    for ex in labels:
        if run_judge(ex["output"], rubric) == ex["human"]:
            hits += 1
    return hits / len(labels)

rate = agreement(labels, RUBRIC)
print(f"judge-human agreement: {rate:.0%}")
# In your real suite make this a gate: assert rate >= 0.85 so a judge below
# your bar can never be promoted to production.
print("trustworthy" if rate >= 0.85 else "tune the rubric and re-measure")
Two things make this real: forcing a structured verdict so parsing never fails (the forced-tool-call trick from Module 1), and treating the agreement number as a gate. In your real suite the last line is an assert rate >= 0.85 — a judge below your bar does not get promoted to production; it's a print here only so the demo cell shows the number instead of raising. When you later change the rubric, you have changed the instrument, so you re-run this measurement. Note the model choice: a cheap, fast tier is the right default for a judge — and picking a judge from a different provider or family than the system under test blunts the self-preference bias named below.

The three biases that fool judges

  • Position bias: when comparing two answers, judges systematically favor whichever came first (or, for some models, second). Mitigation: run each comparison both ways and only count it if the verdict is consistent, or randomize order across the suite.
  • Verbosity bias: judges reward longer, more elaborate answers even when a short one is more correct. Mitigation: anchor the rubric explicitly on correctness and relevance, and penalize padding; consider length-controlled comparisons.
  • Self-preference bias: a judge tends to prefer outputs generated by itself or its own model family. Mitigation: use a different model — ideally a different provider — as judge than the one under test where feasible (judge a Claude agent with gpt-5.4-mini, an OpenAI agent with claude-haiku-4-5), and keep humans in the sampling loop to catch drift.
Prefer pairwise over absolute scores
Asking a model 'rate this 1–10' produces mushy, drifty numbers — a 7 today is an 8 next week. Asking 'which of these two is better, A or B?' is far more stable and reliable. Pairwise comparison is the workhorse of honest LLM evaluation. Reserve absolute scores for coarse pass/fail gates, not fine ranking.
pairwise comparison with position-bias control
# Colab cell — pure Python, no key needed. Fake judges make the
# position-bias control visible without spending a token.
import random
from collections import namedtuple

def pairwise(judge_call, prompt: str, answer_a: str, answer_b: str) -> str:
    """Return 'A', 'B', or 'tie', controlling for position bias."""
    # Run once in each order; the labels A/B track the ORIGINAL answers.
    order1 = judge_call(prompt, first=answer_a, second=answer_b)   # -> 'first'/'second'
    order2 = judge_call(prompt, first=answer_b, second=answer_a)

    # Translate each verdict back to the original answer it points at.
    pick1 = "A" if order1 == "first" else "B"
    pick2 = "A" if order2 == "second" else "B"

    if pick1 == pick2:
        return pick1                 # consistent across orders — trustworthy
    return "tie"                     # flipped with position — treat as no signal

def win_rate(cases, judge_call, candidate, baseline) -> float:
    wins = ties = 0
    for c in cases:
        # Randomize which is presented first at the suite level too.
        a, b = candidate[c], baseline[c]
        verdict = pairwise(judge_call, c.prompt, a, b)
        if verdict == "A":
            wins += 1
        elif verdict == "tie":
            ties += 1
    # Ties count as half; a fair coin lands near 0.5.
    return (wins + 0.5 * ties) / len(cases)


# A position-biased judge (always picks whatever is shown first) flips with
# order, so pairwise correctly scores it a tie -- "no signal":
biased = lambda prompt, first, second: "first"
print("position-biased judge ->", pairwise(biased, "q", "answer A", "answer B"))

# A quality judge (prefers the more detailed answer) survives both orders:
quality = lambda prompt, first, second: "first" if len(first) >= len(second) else "second"
print("quality judge        ->", pairwise(quality, "q", "a detailed answer", "short"))

# win_rate over a few cases, candidate more detailed than baseline:
Case = namedtuple("Case", "prompt")
cases = [Case("q1"), Case("q2"), Case("q3")]
candidate = {c: "a thorough, detailed answer" for c in cases}
baseline = {c: "brief" for c in cases}
print("candidate win rate   ->", win_rate(cases, quality, candidate, baseline))
The core trick: present each pair in both orders and only count a decisive verdict when the judge picks the same original answer regardless of position. If flipping the order flips the answer, the judge was reacting to position, not quality — so you score it a tie. The demo makes this concrete with no API cost: the always-picks-first judge scores tie (its bias is caught), while the quality judge yields a decisive A and a 1.0 win rate. A candidate prompt that clears ~0.55+ win rate against your baseline across a decent-sized set is real signal; hovering at 0.5 is not.

Rubric design: the real work of judge quality

Most judge failures trace back to a vague rubric, not a weak model. 'Rate the helpfulness of this response, 1–10' looks like an instruction but functions as an inkblot — the judge (and every human labeler) projects their own definition of 'helpful' onto the number, so two runs, two models, or two humans can disagree not because the answer is ambiguous but because the question is. The fix is decomposition: break a holistic quality into a handful of narrow, independently checkable criteria, each phrased as something closer to yes/no than a scale — 'does the response answer the literal question asked?', 'does it name a concrete next step?', 'does it avoid asserting anything not present in the provided context?' — and combine the per-criterion verdicts into a final score with an explicit rule, rather than asking one model call to hold the whole judgment in its head at once. Anchor each criterion with one real pass example and one real fail example pulled from your own data; abstract descriptions ('be helpful') anchor nothing. Treat the rubric itself as a versioned artifact — check it into source control next to the eval code, and any edit to it is a new instrument that needs re-calibration, exactly like a code change needs new tests.

a decomposed rubric beats a holistic score
# Colab cell — run block 1 first (it sets up client with your key).

# Holistic — mushy, drifts, disagreement is unexplainable:
BAD_RUBRIC = "You are an expert evaluator. Rate helpfulness from 1 to 10."

# Decomposed — each field is independently checkable and debuggable:
GOOD_RUBRIC = """You are grading a customer-support reply against three
criteria. Answer each independently as true/false, citing the exact
sentence that supports your answer.

1. answers_question: Does the reply directly address what the customer
   asked, without dodging into unrelated information?
2. names_next_step: Does the reply tell the customer what happens next
   or what they should do?
3. no_unsupported_claims: Does the reply avoid stating any policy, price,
   or fact not present in the provided account/policy context?

A reply "passes" only if all three are true."""

def run_decomposed_judge(reply: str, context: str) -> dict:
    resp = client.messages.create(
        model="claude-haiku-4-5", max_tokens=512,
        tools=[{
            "name": "record_criteria",
            "description": "Record the three-criteria verdict.",
            "input_schema": {
                "type": "object",
                "properties": {
                    "answers_question": {"type": "boolean"},
                    "names_next_step": {"type": "boolean"},
                    "no_unsupported_claims": {"type": "boolean"},
                    "evidence": {"type": "string"},
                },
                "required": ["answers_question", "names_next_step",
                            "no_unsupported_claims", "evidence"],
            },
        }],
        tool_choice={"type": "tool", "name": "record_criteria"},
        system=GOOD_RUBRIC,
        messages=[{"role": "user",
                   "content": f"Context:\n{context}\n\nReply:\n{reply}"}],
    )
    block = next(b for b in resp.content if b.type == "tool_use")
    verdict = block.input
    verdict["pass"] = all(verdict[k] for k in
                          ("answers_question", "names_next_step",
                           "no_unsupported_claims"))
    return verdict


print(run_decomposed_judge(
    reply="Your plan renews on the 1st. I've logged your request; support "
          "will follow up within one business day.",
    context="Account: plan renews on the 1st. No travel perks."))
The evidence field is not decoration — forcing the judge to cite the sentence it's grading on makes disagreements auditable: when a human reviewer disputes a verdict, they check the cited evidence instead of re-litigating a fuzzy 1–10 impression. Notice each criterion is small enough that a human could grade it the same way every time — that's the bar a rubric criterion should clear before you trust a model to grade it.

Judge ensembles: when they earn their cost

A single judge call is a single point of failure in your test suite — one model's idiosyncratic blind spot can flip a verdict. An ensemble (the same rubric run across multiple model families, or the same call sampled multiple times, combined by majority vote or median) reduces that variance, but it multiplies cost and latency roughly linearly with ensemble size, so it's not a default. Reach for an ensemble where a single wrong verdict has outsized consequence: gating a merge, triggering an autonomous refund, or picking which of two prompts ships to 100% of traffic. Skip it for aggregate trend tracking over dozens or hundreds of cases — a nightly dashboard tracking a pass rate across 200 cases already averages out single-judge noise across the batch, and the same budget is almost always better spent widening the sample (more cases) than deepening the vote (more judges per case), for the statistical reason Lesson 3 makes precise: at small n, per-item noise dominates, and more independent items shrinks the noise faster than more opinions on the same item.

Spot the bug
Your team's entire judge rubric is: 'You are an expert evaluator. Rate the response's helpfulness from 1 to 10.' Judge-human agreement on your 40-example calibration set comes back at 61%. Before touching the judge model or trying a bigger model as judge, what's the first thing to fix, and how?

Whiteboard drills

Check yourself
Drill: "Walk me through building a judge for 'is this response appropriately empathetic' from scratch — rubric to production."
Check yourself
Drill: "When would you spend the extra cost on a judge ensemble instead of a single judge call?"
Key takeaways
  • Use an LLM judge only for genuinely subjective qualities — faithfulness, helpfulness, tone.
  • Validate the judge against human labels (~30+ examples) and report agreement before trusting it; target a bar you set in advance.
  • Position, verbosity, and self-preference are the three biases that will fool you.
  • Randomize/flip order to beat position bias; anchor the rubric to beat verbosity; cross-model + human sampling for self-preference.
  • Pairwise comparison beats absolute 1–10 scoring for stability; count ties as half.
  • Any rubric edit re-invalidates the judge — re-measure agreement.
  • Decompose holistic rubrics into narrow, anchored, independently checkable criteria — most 'weak judge' problems are actually vague-rubric problems.
  • Ensembles reduce single-judge variance but cost linearly more; reserve them for high-stakes single verdicts, and spend budget on more items instead for aggregate trend tracking.