LLM-as-Judge: How to Calibrate It So You Actually Trust the Score
An LLM judge that hasn't been calibrated against humans is just a random number generator with good vibes. Here's how to build one you can actually trust.
Every team running LLM evals eventually reaches for an LLM to grade another LLM's output. It's cheap, it scales, and it doesn't require hiring annotators. The problem is that most teams stop there โ they write a judge prompt, get a score, and treat it as ground truth.
It isn't. An uncalibrated LLM judge has systematic, measurable biases: it prefers longer answers, prefers answers from its own model family, and flips its verdict depending on which answer it sees first. If you're using judge scores to gate deploys or rank prompt variants, those biases are silently corrupting your decisions.
This guide covers how to build an LLM-as-judge pipeline that's actually calibrated โ with the specific biases to test for, a runnable calibration harness, and the point at which you should stop trusting the judge and bring in a human.
The core problem: a judge is a model, and models have priors
An LLM-as-judge setup looks simple: give a model the input, the output, and a rubric, ask it to score or compare. But the judge is subject to the same failure modes as any LLM completion โ it pattern-matches on surface features that correlate with quality without actually measuring quality.
Three biases show up consistently across judge implementations:
Verbosity bias. Judges tend to rate longer, more elaborated answers higher even when the extra length adds no information. Studies report roughly 10-15% score inflation for verbose responses compared to equally correct terse ones. If your candidate model tends to hedge and pad, and your baseline is terse, the judge will favor the candidate for reasons that have nothing to do with correctness.
Position bias. In pairwise comparisons ("which of these two responses is better?"), judges disagree with themselves depending on whether a response is shown first or second. Reported inconsistency rates run as high as 40% for some model/prompt combinations โ meaning nearly half the time, swapping the order of A and B flips the verdict.
Self-enhancement bias. A judge model tends to score outputs from its own model family a few points higher than equivalent outputs from a different family, even when a blinded human rater sees no difference. This matters directly if you're using GPT-5 to judge GPT-5 outputs against a Claude baseline, or vice versa.
None of these biases mean LLM judges are useless. They mean an uncalibrated judge is a noisy instrument, and noisy instruments need calibration before you trust their readings.
Step 1: Build a small human-labeled gold set
You cannot calibrate a judge without a reference point. Before writing judge prompts, pull 30-100 real examples from production (or your eval set) and have a domain expert โ not a crowdworker, someone who actually knows what a good answer looks like for your product โ label them.
The label doesn't need to be elaborate. A simple pass/fail or 1-5 rating is enough to check agreement. What matters is that it's independent of the judge and produced by someone with the domain context to catch subtle correctness issues (wrong tool call, plausible-sounding but incorrect policy, etc.) that a generic rubric will miss.
# gold_set.py
import json
GOLD_SET = [
{
"id": "gs-001",
"input": "Can I get a refund if I used the annual plan for 2 months?",
"output": "Yes, we offer a prorated refund for annual plans...",
"human_label": "pass",
"human_notes": "Correct policy, cites the right prorate rule.",
},
{
"id": "gs-002",
"input": "Can I get a refund if I used the annual plan for 2 months?",
"output": "Refunds aren't available after the trial period.",
"human_label": "fail",
"human_notes": "Wrong โ annual plans do get prorated refunds.",
},
# ... 30-100 examples, covering easy cases and known edge cases
]
def load_gold_set():
return GOLD_SET
Deliberately include a few examples your team has argued about โ the ambiguous cases are where judge calibration matters most, because the easy cases will pass no matter what prompt you write.
Step 2: Write the judge with bias mitigations built in
A bare "rate this response 1-10" prompt will inherit all three biases above. Mitigate them directly in the judge design:
# judge.py
from dataclasses import dataclass
import random
JUDGE_PROMPT = """You are evaluating a customer support response for correctness against company policy.
Input: {input}
Response: {output}
Grade the response as PASS or FAIL based ONLY on factual and policy correctness.
Do not reward length, politeness, or formatting. A short, correct answer beats a
long, padded one. A confident but wrong answer is always FAIL.
First, write 2-3 sentences of reasoning about what the correct policy answer is
and whether the response matches it. Then output a final line:
VERDICT: PASS or VERDICT: FAIL
"""
@dataclass
class JudgeResult:
verdict: str
reasoning: str
def judge_single(input_text: str, output_text: str, llm_call) -> JudgeResult:
prompt = JUDGE_PROMPT.format(input=input_text, output=output_text)
response = llm_call(prompt)
verdict = "PASS" if "VERDICT: PASS" in response else "FAIL"
return JudgeResult(verdict=verdict, reasoning=response)
def judge_pairwise(input_text: str, a: str, b: str, llm_call) -> str:
"""Runs the comparison twice with swapped order and requires agreement."""
order1 = _pairwise_call(input_text, a, b, llm_call)
order2 = _pairwise_call(input_text, b, a, llm_call) # swapped
# order2's "A" is actually b, so flip its result back to a/b space
order2_normalized = "b" if order2 == "a" else "a"
if order1 == order2_normalized:
return order1
return "tie" # disagreement on swap = inconclusive, don't trust it
def _pairwise_call(input_text: str, a: str, b: str, llm_call) -> str:
prompt = f"""Input: {input_text}
Response A: {a}
Response B: {b}
Which response is more correct? Ignore length and tone. Answer with just "a" or "b"."""
result = llm_call(prompt).strip().lower()
return "a" if "a" in result[:3] else "b"
Three deliberate design choices here:
- Explicit instruction against length/tone bias. This alone doesn't eliminate verbosity bias, but it measurably reduces it โ cheap insurance.
- Chain-of-thought before the verdict. Forcing reasoning first, verdict last, makes the judge's decision more consistent and gives you a debug trail when scores look wrong.
- Position-swapped pairwise comparison with disagreement as a signal. Instead of trusting a single ordering, run both orderings and treat disagreement as "inconclusive" rather than silently picking one. This is the single highest-leverage fix for position bias.
Step 3: Calibrate against the gold set and compute agreement
Now measure how well the judge agrees with your human labels. Cohen's kappa is the standard metric here โ it corrects for chance agreement, unlike raw percentage match.
# calibrate.py
from sklearn.metrics import cohen_kappa_score
from gold_set import load_gold_set
from judge import judge_single
def run_calibration(llm_call):
gold = load_gold_set()
human_labels = []
judge_labels = []
disagreements = []
for example in gold:
result = judge_single(example["input"], example["output"], llm_call)
human_labels.append(example["human_label"])
judge_labels.append(result.verdict.lower())
if result.verdict.lower() != example["human_label"]:
disagreements.append({
"id": example["id"],
"human": example["human_label"],
"judge": result.verdict,
"judge_reasoning": result.reasoning,
"human_notes": example["human_notes"],
})
kappa = cohen_kappa_score(human_labels, judge_labels)
agreement_rate = sum(
h == j for h, j in zip(human_labels, judge_labels)
) / len(human_labels)
return {
"kappa": kappa,
"agreement_rate": agreement_rate,
"disagreements": disagreements,
}
if __name__ == "__main__":
from your_llm_client import call_model # your LLM wrapper
report = run_calibration(call_model)
print(f"Agreement: {report['agreement_rate']:.1%}")
print(f"Cohen's kappa: {report['kappa']:.2f}")
for d in report["disagreements"]:
print(f"\n[{d['id']}] human={d['human']} judge={d['judge']}")
print(f" human notes: {d['human_notes']}")
print(f" judge reasoning: {d['judge_reasoning'][:200]}")
Read the disagreements manually โ this is where you find out why the judge is wrong, which tells you whether to fix the prompt, swap the judge model, or accept that this category of question needs a human in the loop.
| Kappa range | Interpretation | Action |
|---|---|---|
| < 0.40 | Poor agreement | Judge prompt needs rework; don't ship this judge |
| 0.40 - 0.60 | Moderate | Usable for rough signal, not gating decisions |
| 0.60 - 0.80 | Substantial | Safe for automated regression gates |
| > 0.80 | Near-human | Safe for most production gating, still spot-check |
Step 4: Re-run calibration whenever anything changes
Calibration is not a one-time step. Re-run it when:
- You change the judge model or provider (different model family = different bias profile)
- You change the judge prompt, even slightly
- Your product's answer distribution shifts (new feature, new failure mode)
- Every 4-6 weeks regardless, since model provider updates can silently change judge behavior
Treat the calibration script as a CI job, not a one-off notebook run. A judge that was well-calibrated in March and hasn't been re-checked since is a judge you're trusting on faith.
Judge architecture patterns compared
| Pattern | Cost | Reliability | When to use |
|---|---|---|---|
| Single-call, single-model judge | Lowest | Lowest โ inherits all three biases | Fast iteration, non-gating dashboards |
| CoT + anti-bias prompting | Low | Moderate | Default for most teams |
| Position-swapped pairwise | 2x calls | High for pairwise ranking | A/B testing prompt or model variants |
| Ensemble (2-3 judge models, majority vote) | 2-3x calls | Highest | Gating production deploys |
| Distilled judge (fine-tuned small model on human labels) | Cheapest at scale | High, but needs retraining on drift | High-volume, stable rubric |
Most teams should start with CoT + anti-bias prompting, calibrate it, and only move to ensembles or distilled judges once volume or stakes justify the extra cost.
Common pitfalls
Trusting a judge you never calibrated. This is the most common mistake โ teams write a plausible-sounding judge prompt, see scores that "look reasonable," and ship it. Plausible is not calibrated.
Using the same model to judge and to generate. Self-enhancement bias is real and measurable. If you must use the same provider, use a different model size or version as the judge, and validate against the gold set to confirm the bias isn't material for your use case.
Treating judge score as a single source of truth for gating. Even a well-calibrated judge (kappa 0.7-0.8) disagrees with humans 20-30% of the time. Pair automated judge gates with periodic human spot-checks, especially for high-stakes categories like refunds, medical, or legal content.
Letting the gold set go stale. As your product changes, your gold set needs new examples covering new failure modes. A gold set from six months ago won't catch a regression in a feature that didn't exist then.
Skipping the "why" in disagreements. When the judge and a human disagree, the reasoning trace is more valuable than the score itself. It tells you whether the rubric is wrong, the judge is biased, or the human label itself was a mistake.
Next steps
Start small: pull 30 examples from your current eval set, get a domain expert to label them in an afternoon, and run the calibration script above against whatever judge prompt you're currently using. The kappa score will tell you, in about an hour of work, whether the judge you've been trusting is actually trustworthy โ and the disagreement log will tell you exactly what to fix first.
If your team is building out a full production eval and observability stack and wants hands-on practice with calibration, judge design, and eval infrastructure, that's exactly the kind of workflow we cover in Joinloop's AI Engineering cohort.
Want to build this yourself?
Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.