n4nAI

What is LLM-as-a-judge? A practical introduction

A practical definition of LLM-as-a-judge with code examples, evaluation patterns, and common pitfalls engineers encounter when automating model assessment.

n4n Team5 min read996 words

Audio narration

Coming soon — every post will get a voice note here.

LLM-as-a-judge is an evaluation paradigm where a large language model scores, ranks, or classifies the outputs of another model (or itself) according to defined criteria. Instead of relying solely on human annotators or rigid heuristics like BLEU and ROUGE, you prompt a capable model to act as an evaluator, producing structured judgments that can be aggregated into metrics, fed into RLHF pipelines, or used for automated regression testing. The approach trades annotation cost and latency for coverage and nuance, but it introduces its own failure modes that require careful calibration.

How LLM-as-a-judge works

The core loop is straightforward: you feed the judge model a prompt containing the evaluation criteria, the input (optional), the reference answer (optional), and the candidate output. The judge returns a structured response — typically a score, a preference label, or a critique — which you parse and aggregate.

from openai import OpenAI
import json

client = OpenAI()

JUDGE_PROMPT = """You are an expert evaluator. Assess the candidate answer against the reference on these criteria:
- Accuracy: Does the candidate correctly address the question?
- Completeness: Does it cover all key points in the reference?
- Conciseness: Is it free of fluff and hallucination?

Return JSON only: {"score": 1-5, "reasoning": "..."}"""

def judge_answer(question: str, reference: str, candidate: str) -> dict:
    messages = [
        {"role": "system", "content": JUDGE_PROMPT},
        {"role": "user", "content": f"Question: {question}\nReference: {reference}\nCandidate: {candidate}"}
    ]
    resp = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        temperature=0,
        response_format={"type": "json_object"}
    )
    return json.loads(resp.choices[0].message.content)

This pattern scales to pairwise comparisons (A vs B), listwise ranking (rank N outputs), or categorical labeling (safe/unsafe, hallucinated/grounded). The judge prompt is your evaluation spec — version it like code.

Why it matters for engineering teams

Human evaluation is the gold standard but it doesn’t scale. A team shipping weekly model updates cannot run 500 human annotations per release. Heuristic metrics (exact match, token overlap, embedding similarity) are fast but correlate poorly with human preference on open-ended tasks like summarization, code generation, or multi-turn dialogue.

LLM-as-a-judge fills the middle ground:

  • Throughput: Thousands of evaluations per hour at a fraction of human cost
  • Nuance: Captures semantic equivalence, style adherence, instruction following — things n-gram metrics miss
  • Programmability: Judgments are structured data you can alert on, plot over time, or use as reward signals
  • Iteration speed: Change the rubric, re-run the eval suite, get signal in minutes

The tradeoff is reliability. Judges hallucinate, exhibit position bias, favor verbose outputs, and drift with prompt changes. You mitigate this with calibration, not hope.

Concrete example: Evaluating a RAG pipeline

Suppose you ship a retrieval-augmented generation system. You need to know if a new embedding model or chunking strategy degrades answer quality. You build a golden set of 200 questions with reference answers, then run the judge on each candidate configuration.

# eval_harness.py
import pandas as pd
from concurrent.futures import ThreadPoolExecutor, as_completed

GOLDEN_SET = pd.read_parquet("eval/golden_set.parquet")  # columns: question, reference, context

def evaluate_config(config_name: str, generate_fn) -> pd.DataFrame:
    results = []
    with ThreadPoolExecutor(max_workers=8) as executor:
        futures = {
            executor.submit(generate_fn, row.question, row.context): row
            for _, row in GOLDEN_SET.iterrows()
        }
        for fut in as_completed(futures):
            row = futures[fut]
            candidate = fut.result()
            judgment = judge_answer(row.question, row.reference, candidate)
            results.append({
                "config": config_name,
                "question": row.question,
                "candidate": candidate,
                "score": judgment["score"],
                "reasoning": judgment["reasoning"]
            })
    return pd.DataFrame(results)

# Compare chunking strategies
from rag import generate_answer

df_fixed = evaluate_config("fixed_512", lambda q, c: generate_answer(q, c, chunk_size=512))
df_semantic = evaluate_config("semantic", lambda q, c: generate_answer(q, c, chunk_strategy="semantic"))

print(df_fixed.groupby("config")["score"].mean())
print(df_semantic.groupby("config")["score"].mean())

The output gives you a statistically grounded comparison. You can also slice by question type, difficulty, or retrieval quality to understand where a config fails.

Calibration: Making the judge trustworthy

An uncalibrated judge is a liability. Run these steps before trusting any automated evaluation:

1. Human alignment study

Sample 50–100 outputs, have 2–3 annotators score them independently, then compute agreement (Cohen’s kappa, Krippendorff’s alpha) between humans and the judge. Target ≥0.7 kappa on your primary metric. If you’re lower, refine the rubric or switch judges.

2. Position bias test

For pairwise judgments, swap the order of candidate A and B. The judge should prefer the same output regardless of position. If it doesn’t, add a “flip and average” step or use a listwise format.

def pairwise_judge(question: str, output_a: str, output_b: str) -> str:
    # Run both orders
    prompt_ab = f"Question: {question}\nOutput A: {output_a}\nOutput B: {output_b}"
    prompt_ba = f"Question: {question}\nOutput A: {output_b}\nOutput B: {output_a}"
    
    judge_ab = client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": prompt_ab}], temperature=0)
    judge_ba = client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": prompt_ba}], temperature=0)
    
    pref_ab = parse_preference(judge_ab)
    pref_ba = parse_preference(judge_ba)
    
    if pref_ab == pref_ba:
        return pref_ab  # Consistent
    return "tie"  # Position bias detected

3. Rubric versioning

Treat the judge prompt as production code. Store it in version control, tag releases, and run regression tests when you change it.

# eval/rubrics/v3_accuracy.yaml
version: "3.0"
criteria:
  - name: factual_accuracy
    weight: 0.5
  - name: instruction_following
    weight: 0.3
  - name: completeness
    weight: 0.2
scale: 1-5
few_shot_examples:
  - input: "..."
    reference: "..."
    candidate: "..."
    score: 5
    reasoning: "..."

4. Judge model selection

Stronger models make better judges, but cost more. A common pattern: use a frontier model (GPT-4o, Claude 3.5 Sonnet) for your golden-set calibration and critical release gates; use a smaller model (GPT-4o-mini, Llama 3.1 70B) for high-volume CI runs. Validate the smaller judge against the stronger one on a held-out set.

Common misconceptions

“The judge replaces human evaluation”

It doesn’t. It amplifies it. You still need human annotators for calibration, edge-case discovery, and final sign-off on high-stakes deployments. The judge handles the 95% of evaluations that are routine; humans handle the 5% that are ambiguous or safety-critical.

“One judge prompt works for everything”

A rubric tuned for summarization fails on code generation. A safety judge needs different few-shot examples than a style judge. Maintain separate rubrics per task type, each with its own calibration data.

“Temperature 0 guarantees consistency”

It reduces variance but doesn’t eliminate it. Non-determinism in model serving infrastructure (batch scheduling, kv-cache eviction) can still produce different outputs. Run each evaluation 2–3 times and take the median for high-stakes decisions.

“LLM-as-a-judge is only for RLHF”

The paradigm predates RLHF and applies anywhere you need scalable semantic evaluation: regression testing, A/B testing model configs, monitoring production drift, filtering synthetic data, or ranking retrieval results.

“If the judge agrees with humans on average, it’s calibrated”

Average agreement masks systematic errors. The judge might over-score verbose outputs and under-score concise correct ones, netting out to decent correlation. Slice your calibration data by output length, difficulty, and error type. Look for conditional biases.

Production patterns

Continuous evaluation in CI

Gate merges on eval regression. A typical pipeline:

# .github/workflows/eval.yml
name: LLM Evaluation
on: [pull_request]
jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run golden set eval
        run: |
          python -m eval.run --config ${{ github.event.pull_request.head.sha }} --baseline main
      - name: Check regression
        run: |
          python -m eval.assert_no_regression --threshold -0.05

The assert_no_regression step fails if the mean score drops more than 5% relative to the baseline branch.

Online monitoring with sampled judgments

In production, you can’t judge every request. Sample 1–5% of traffic, run the judge asynchronously, and alert on drift.

# monitoring/judge_consumer.py
import kafka
from prometheus_client import Gauge, Histogram

JUDGE_SCORE = Gauge("llm_judge_score", "Judge score", ["model", "task_type"])
JUDGE_LATENCY = Histogram("llm_judge_latency_seconds", "Judge latency")

consumer = kafka.KafkaConsumer("production_outputs", bootstrap_servers="kafka:9092")

for msg in consumer:
    record = json.loads(msg.value)
    if should_sample(record, rate=0.02):  # 2% sample
        with JUDGE_LATENCY.time():
            judgment = judge_answer(record["input"], record["reference"], record["output"])
        JUDGE_SCORE.labels(model=record["model"], task_type=record["task"]).set(judgment["score"])

Synthetic data filtering

When generating training data with a model, use a judge to filter low-quality samples before they enter your fine-tuning corpus.

def filter_synthetic_data(samples: list[dict], threshold: float = 4.0) -> list[dict]:
    filtered = []
    for sample in samples:
        judgment = judge_answer(
            question=sample["prompt"],
            reference=sample["target"],
            candidate=sample["generation"]
        )
        if judgment["score"] >= threshold:
            filtered.append(sample)
    return filtered

This prevents garbage-in-garbage-out at scale.

When not to use LLM-as-a-judge

  • Deterministic tasks: SQL generation, API calling, format conversion — use exact match, schema validation, or unit tests
  • High-stakes safety: Medical, legal, financial advice — human review is non-negotiable
  • Latency-critical paths: If you need a judgment in <100ms, heuristic metrics or a distilled classifier are faster
  • Adversarial contexts: If users can craft inputs to fool the judge (prompt injection, reward hacking), the signal is compromised

Closing thought

LLM-as-a-judge is not a metric — it’s an evaluation system. The prompt, the judge model, the calibration data, the aggregation logic, and the alerting thresholds all move together. Treat it like any other production component: version it, test it, monitor it, and have a rollback plan. The teams that ship reliable LLM products are the ones who built an evaluation flywheel they trust.

Tagsllm-as-a-judgemodel-evaluationai-evaluationglossary

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llm-as-a-judge & model evaluation posts →