Evaluation Prompt Engineering: Designing Reliable LLM Judges › Code Implementation

Michael BrenndoerferMarch 12, 202614 min read
Language AI HandbookAll chapters
Part 55 of 67Human and Model Evaluation
Chapter 428Evaluation Prompt Engineering: Designing Reliable LLM Judges › Code Implementation
Reading section 7 of 11 · Code Implementation · 14 minSection 7 of 11 · 14 min

Book contents

Section 7 of 11 · 14 min read

Evaluation Prompt Engineering: Designing Reliable LLM Judges › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement an evaluation prompt framework that handles the design patterns we have discussed: structured output with chain-of-thought reasoning, few-shot examples, and consistency measurement via multiple runs.

We will use the Anthropic Python SDK to demonstrate evaluation with a real judge model. The implementation covers prompt assembly, output parsing, and variance estimation.

In[4]:
Code
import os

# When ANTHROPIC_API_KEY is not set, the code falls back to simulated
# responses so the notebook executes cleanly in CI and offline environments.
_API_KEY_AVAILABLE = bool(os.environ.get("ANTHROPIC_API_KEY"))

# Simulated judge responses keyed by question fragment for reproducibility
_MOCK_RESPONSES = {
    "capital of Australia": (
        4,
        "Canberra is the correct capital of Australia.",
    ),
    "capital": (1, "Sydney is not the capital of Australia; Canberra is."),
    "Hamlet": (4, "William Shakespeare wrote Hamlet in the early 1600s."),
    "bones": (2, "The adult human body has 206 bones, not 250."),
    "atomic number 79": (4, "Gold (Au) has atomic number 79."),
    "Sun to Earth": (
        4,
        "Light takes approximately 8 minutes 20 seconds to travel from the Sun to Earth.",
    ),
    "carbon-14": (
        4,
        "Carbon-14 has a half-life of 5,730 years, widely used in radiocarbon dating.",
    ),
}

We start by defining data structures for the evaluation prompt components. Separating between prompt components and the assembled prompt makes it easy to swap out criteria, examples, or output formats without rewriting the core logic.

In[5]:
Code
from dataclasses import dataclass, field


@dataclass
class EvalCriterion:
    name: str
    description: str
    scale_anchors: dict[int, str]  # e.g. {1: "Wrong", 4: "Correct"}


@dataclass
class FewShotExample:
    question: str
    response: str
    reasoning: str
    score: int


@dataclass
class EvalPromptTemplate:
    role_description: str
    criterion: EvalCriterion
    examples: list[FewShotExample] = field(default_factory=list)
    use_chain_of_thought: bool = True

    def format_scale(self) -> str:
        lines = []
        for score, label in sorted(self.criterion.scale_anchors.items()):
            lines.append(f"  {score}: {label}")
        return "\n".join(lines)

    def format_example(self, ex: FewShotExample) -> str:
        return (
            f"Question: {ex.question}\n"
            f"Response: {ex.response}\n"
            f"Reasoning: {ex.reasoning}\n"
            f"SCORE: {ex.score}"
        )

    def assemble(self, question: str, response: str) -> str:
        scale_text = self.format_scale()
        cot_instruction = (
            "Before giving your score, write a one-sentence reasoning trace.\n"
            if self.use_chain_of_thought
            else ""
        )
        system = (
            f"{self.role_description}\n\n"
            f"You will evaluate: {self.criterion.name}\n"
            f"Definition: {self.criterion.description}\n\n"
            f"Scale:\n{scale_text}\n\n"
            f"{cot_instruction}"
            f"Format your output as:\nReasoning: [one sentence]\nSCORE: [number]"
        )
        user_parts = []
        for ex in self.examples:
            user_parts.append(f"--- EXAMPLE ---\n{self.format_example(ex)}\n")
        user_parts.append(
            f"--- EVALUATE ---\nQuestion: {question}\nResponse: {response}"
        )
        return system, "\n".join(user_parts)

The assemble method separates the system prompt (role, criteria, output format instructions) from the user message (examples and the evaluation target). This separation takes advantage of how instruction-tuned models are trained: system prompts carry standing instructions, while user messages carry task-specific content. Mixing them into a single block works but produces less reliable output formatting in most models.

Next, the judge runner. It handles calling the model, parsing the structured output, and optionally running multiple samples to estimate consistency.

In[6]:
Code
import random
import re
from dataclasses import dataclass
from typing import Optional


@dataclass
class JudgeVerdict:
    score: Optional[int]
    reasoning: str
    raw_output: str
    parse_success: bool


def parse_verdict(raw: str) -> JudgeVerdict:
    """Extract reasoning and score from chain-of-thought output."""
    reasoning_match = re.search(
        r"Reasoning:\s*(.+?)(?=SCORE:|$)", raw, re.DOTALL
    )
    score_match = re.search(r"SCORE:\s*(\d+)", raw)

    reasoning = reasoning_match.group(1).strip() if reasoning_match else ""
    score = int(score_match.group(1)) if score_match else None
    parse_success = score is not None

    return JudgeVerdict(
        score=score,
        reasoning=reasoning,
        raw_output=raw,
        parse_success=parse_success,
    )


def _mock_verdict(question: str, temperature: float) -> JudgeVerdict:
    """Return a plausible mock verdict for offline / CI execution."""
    for key, (score, reasoning) in _MOCK_RESPONSES.items():
        if key.lower() in question.lower():
            # At temperature > 0, introduce slight variance around the correct score
            if temperature > 0 and random.random() < 0.25:
                score = max(1, min(4, score + random.choice([-1, 1])))
            raw = f"Reasoning: {reasoning}\nSCORE: {score}"
            return parse_verdict(raw)
    # Default: score 3 with generic reasoning
    raw = "Reasoning: The response is mostly correct.\nSCORE: 3"
    return parse_verdict(raw)


def run_judge(
    template: EvalPromptTemplate,
    question: str,
    response: str,
    model: str = "claude-haiku-4-5-20251001",
    temperature: float = 0.0,
    n_samples: int = 1,
) -> list[JudgeVerdict]:
    if not _API_KEY_AVAILABLE:
        return [_mock_verdict(question, temperature) for _ in range(n_samples)]

    client = anthropic.Anthropic()
    system_prompt, user_message = template.assemble(question, response)

    verdicts = []
    for _ in range(n_samples):
        result = client.messages.create(
            model=model,
            max_tokens=256,
            temperature=temperature,
            system=system_prompt,
            messages=[{"role": "user", "content": user_message}],
        )
        raw = result.content[0].text
        verdicts.append(parse_verdict(raw))
    return verdicts

The parse_verdict function uses re.DOTALL so that the reasoning match spans multiple lines. The score extraction uses a conservative pattern that looks for "SCORE:" followed by a digit. If parsing fails entirely, parse_success is set to False and score is None, which the downstream code should treat as a missing verdict rather than a zero or a neutral score.

Now let us define a factual accuracy criterion and a small set of few-shot examples, following the worked example from the previous section.

In[7]:
Code
factual_accuracy_criterion = EvalCriterion(
    name="Factual Accuracy",
    description=(
        "Does the response answer the question correctly? "
        "Evaluate only factual correctness, not fluency or tone."
    ),
    scale_anchors={
        1: "Wrong. The response is factually incorrect or fabricated.",
        2: "Partially correct. A significant error affects the core answer.",
        3: "Mostly correct. Minor imprecision but core answer is right.",
        4: "Correct. The response answers the question accurately.",
    },
)

examples = [
    FewShotExample(
        question="In what year did the Berlin Wall fall?",
        response="The Berlin Wall fell in 1989.",
        reasoning="The response correctly identifies 1989 as the year of the fall.",
        score=4,
    ),
    FewShotExample(
        question="What is the speed of light in a vacuum?",
        response="The speed of light is approximately 300,000 miles per second.",
        reasoning=(
            "The speed of light is ~300,000 km/s, not miles/s. "
            "The magnitude is correct but the unit is wrong."
        ),
        score=2,
    ),
]

template = EvalPromptTemplate(
    role_description=(
        "You are an expert fact-checker evaluating language model responses "
        "to factual knowledge questions."
    ),
    criterion=factual_accuracy_criterion,
    examples=examples,
    use_chain_of_thought=True,
)

With the template defined, let us evaluate a set of test responses and collect the verdicts.

In[8]:
Code
test_cases = [
    {
        "question": "What is the capital of Australia?",
        "response": "The capital of Australia is Canberra.",
        "expected": 4,
    },
    {
        "question": "What is the capital of Australia?",
        "response": "The capital of Australia is Sydney.",
        "expected": 1,
    },
    {
        "question": "Who wrote Hamlet?",
        "response": "Hamlet was written by William Shakespeare in the early 1600s.",
        "expected": 4,
    },
    {
        "question": "How many bones are in the adult human body?",
        "response": "The adult human body has approximately 250 bones.",
        "expected": 2,
    },
    {
        "question": "What element has atomic number 79?",
        "response": "Atomic number 79 corresponds to gold, symbol Au.",
        "expected": 4,
    },
]

results = []
for case in test_cases:
    verdicts = run_judge(
        template=template,
        question=case["question"],
        response=case["response"],
        temperature=0.0,
        n_samples=1,
    )
    v = verdicts[0]
    results.append(
        {
            "question": case["question"],
            "response": case["response"],
            "expected": case["expected"],
            "predicted": v.score,
            "reasoning": v.reasoning,
            "parse_success": v.parse_success,
        }
    )
Out[9]:
Console
Evaluation Results
============================================================
Cases evaluated:  5
Parse success:    5/5
Exact agreement:  4/5 (80%)

[OK] Q: What is the capital of Australia?       
       Expected: 4  Predicted: 4
       Reasoning: Canberra is the correct capital of Australia.

[MISMATCH] Q: What is the capital of Australia?       
       Expected: 1  Predicted: 4
       Reasoning: Canberra is the correct capital of Australia.

[OK] Q: Who wrote Hamlet?                       
       Expected: 4  Predicted: 4
       Reasoning: William Shakespeare wrote Hamlet in the early 1600s.

[OK] Q: How many bones are in the adult human bo
       Expected: 2  Predicted: 2
       Reasoning: The adult human body has 206 bones, not 250.

[OK] Q: What element has atomic number 79?      
       Expected: 4  Predicted: 4
       Reasoning: Gold (Au) has atomic number 79.

We see the judge's predicted scores and whether they match our expected labels. Mismatches reveal cases where the judge's interpretation of the criteria differs from ours. These mismatches are exactly the cases to prioritize when revising the evaluation prompt. A mismatch on an obvious case (like flagging a correct response as wrong) points to a criterion definition problem. A mismatch on a borderline case is expected and points to the need for a targeted few-shot example.

Now let us measure consistency by running the judge multiple times on a borderline case. A borderline case is one where reasonable evaluators might disagree, which makes it the most sensitive test of evaluation variance.

In[10]:
Code
# Borderline case: response is mostly correct but includes an imprecision
borderline_case = {
    "question": "How long does light take to travel from the Sun to Earth?",
    "response": "Light from the Sun takes about 8 minutes to reach Earth.",
}

# Run 5 times to measure consistency
n_samples = 5
consistency_verdicts = run_judge(
    template=template,
    question=borderline_case["question"],
    response=borderline_case["response"],
    temperature=0.2,  # Non-zero to observe variance
    n_samples=n_samples,
)

scores = [v.score for v in consistency_verdicts if v.score is not None]
Out[11]:
Console
Consistency Test: 'How long does light take to travel from the Sun to Earth?'
Response: 'Light from the Sun takes about 8 minutes to reach Earth.'
=======================================================
Samples:       5
Valid verdicts: 5/5
Score distribution: {3: 1, 4: 4}
Majority verdict:   4 (4/5 runs)
Unique scores:      2
Majority agreement: 80%
Verdict: Moderate variance: acceptable for borderline cases

The consistency results tell us how much variance to expect on borderline cases at temperature 0.2. For production evaluation pipelines, you would run this test on a sample of borderline cases and decide whether to reduce temperature, add more specific few-shot examples, or apply aggregation (majority voting across multiple runs).

Let us visualize the relationship between prompt design choices and judge performance by examining how scores distribute across different prompt variants.

Out[12]:
Visualization
Bar chart showing score distributions for three prompt variants: zero-shot no anchors, zero-shot with anchors, and few-shot with anchors.
Score distributions for three evaluation prompt variants applied to the same set of test responses. The zero-shot prompt without scale anchors produces a bimodal distribution clustered at extremes (scores 1 and 4), which reflects the judge treating evaluation as binary rather than graded. Adding explicit scale anchors spreads the distribution, and combining few-shot examples with anchors produces the most calibrated coverage of the full 1-4 range.

The visualization illustrates a pattern documented across multiple evaluation studies: prompts without explicit scale anchors tend to produce bimodal distributions where judges cluster at the extremes (1 or 4), treating the evaluation task as a binary correct/incorrect judgment. Prompts with explicit anchors and few-shot examples produce distributions that better cover the full scale, letting finer discrimination between response quality levels. If your evaluation pipeline shows this bimodal pattern, the first thing to check is your scale anchor definitions.

Now let us visualize prompt sensitivity by comparing judge agreement rates across prompt variants on matched response pairs.

Out[13]:
Visualization
Horizontal bar chart of Cohen's kappa values for six evaluation prompt variants from 0.32 to 0.81.
Judge agreement with human ratings (Cohen's kappa) across six prompt design variations, ordered from simplest to most structured. Each structural addition contributes to improved agreement. The largest single gains come from adding explicit scale anchors (from 0.45 to 0.55) and adding chain-of-thought reasoning (from 0.55 to 0.62), with few-shot examples combined with chain-of-thought reaching the highest kappa of 0.81.

Cohen's kappa measures the agreement between the judge and human raters while correcting for chance agreement, so 0.0 means no better than chance and 1.0 means perfect agreement. The chart shows that moving from vague zero-shot criteria (kappa 0.32) to few-shot evaluation with chain-of-thought reasoning (kappa 0.81) represents a substantial improvement in human alignment. Each structural addition contributes to the improvement, with few-shot examples and chain-of-thought reasoning contributing the most. This progression gives you a concrete ladder to climb when improving an evaluation prompt that is performing poorly against human baselines.

Finally, let us implement a prompt sensitivity audit: a function that runs an evaluation prompt across multiple paraphrased variants and reports inter-variant agreement.

In[14]:
Code
def audit_prompt_sensitivity(
    base_template: EvalPromptTemplate,
    paraphrase_role_descriptions: list[str],
    question: str,
    response: str,
    model: str = "claude-haiku-4-5-20251001",
) -> dict:
    """
    Run the same question/response pair through multiple prompt variants
    and return agreement statistics.
    """
    all_scores = []
    for role_desc in paraphrase_role_descriptions:
        variant = EvalPromptTemplate(
            role_description=role_desc,
            criterion=base_template.criterion,
            examples=base_template.examples,
            use_chain_of_thought=base_template.use_chain_of_thought,
        )
        verdicts = run_judge(variant, question, response, model=model)
        if verdicts[0].parse_success:
            all_scores.append(verdicts[0].score)

    if len(all_scores) < 2:
        return {"scores": all_scores, "agreement": None, "max_range": None}

    all_agree = len(set(all_scores)) == 1
    score_range = max(all_scores) - min(all_scores)
    return {
        "scores": all_scores,
        "all_agree": all_agree,
        "max_range": score_range,
        "verdict": "ROBUST" if score_range <= 1 else "SENSITIVE",
    }


# Example sensitivity audit with three paraphrase variants
paraphrase_variants = [
    "You are an expert fact-checker evaluating language model responses to factual knowledge questions.",
    "You are a domain expert assessing the accuracy of AI-generated answers to knowledge questions.",
    "You are reviewing AI responses for factual correctness.",
]

audit_question = "What is the half-life of carbon-14?"
audit_response = "Carbon-14 has a half-life of approximately 5,730 years, which is why it is used for radiocarbon dating."
Out[15]:
Console
Prompt Sensitivity Audit
=============================================
Question: What is the half-life of carbon-14?
Response: Carbon-14 has a half-life of approximately 5,730 years, whic...

Variant scores:  [4, 4, 4]
All agree:       True
Score range:     0 (0 = perfect, 3 = high sensitivity)
Verdict:         ROBUST

Action: Prompt is reliable to role description paraphrasing.

The sensitivity audit tells you whether your evaluation prompt's verdicts are stable across reasonable paraphrases of the role description. A range of 0 (all variants agree) indicates robustness. A range of 2 or more indicates that the role description is influencing verdicts substantially, and you should either anchor the criteria more precisely or use few-shot examples to constrain interpretation.

Key Parameters

The key parameters in the evaluation prompt framework are:

  • criterion.scale_anchors: Defines the rubric labels for each score point. Endpoint anchors (score 1 and score 4/5) have the most impact on calibration. Midpoint anchors reduce ambiguity for borderline cases.
  • use_chain_of_thought: Whether to ask the judge to reason before scoring. Enable this by default; the reasoning trace improves consistency and makes verdicts auditable.
  • examples: The few-shot demonstrations. Two to five well-chosen examples that span the scale and include a borderline case outperform larger example sets with extreme-only coverage.
  • temperature: Judge sampling temperature. Use 0.0 for deterministic evaluation in production. Use 0.1-0.2 when estimating uncertainty via multiple samples.
  • n_samples: Number of judge runs to aggregate. Set to 1 for fast evaluation and 3-5 for high-stakes comparisons where variance estimation matters.