Skip to main content
Lesson 16 of 16 · Prompt Engineering for Developers

Automated Prompt Evaluation

Lesson 16 of 16 Updated November 3, 2026 Developer
On this page
  1. Automated Prompt Evaluation
  2. Types of Evaluation
  3. Complete Evaluation Pipeline
  4. Test Suite: Format
  5. CI/CD Integration
  6. Evaluation Dashboard
  7. Set Up Your Guardrails
  8. Key Takeaways
  9. Test Your Knowledge

Lesson updated November 3, 2026 based on OpenAI documentation: temperature and top_p are only accepted with a reasoning effort of none, whereas GPT-5.6 models reason by default (medium). Calls that set these parameters must therefore specify reasoning={"effort": "none"} (Responses API) or reasoning_effort="none" (Chat Completions). Sources: Using GPT-6 · GPT-5.6 Terra.

Automated Prompt Evaluation

Automated evaluation is the cornerstone of a mature prompt engineering pipeline. It lets you detect regressions, compare models, and validate changes before deployment. Without it, every prompt change is a blind gamble.

Types of Evaluation

Three families of evaluators divide the labor based on the nature of the expected output, and you will almost always combine several of them.

Exact Match Evaluation

For tasks with a single expected answer, direct comparison suffices. eval_exact_match normalizes case and whitespace before comparing: without this precaution, “Bug” and “bug” would count as an error, sending you to fix a problem that doesn’t exist.

eval_set_match handles cases where the answer is a set—entities found in a document, categories assigned to an article. It derives precision and recall, two figures that tell different stories: high precision with low recall signals an overly cautious model that extracts only what it’s sure of; the reverse betrays a model that hallucinates to fill gaps. F1 summarizes them conveniently but doesn’t replace them for diagnosis.

def eval_exact_match(prediction: str, reference: str) -> bool:
    """Exact match after normalization."""
    return prediction.strip().lower() == reference.strip().lower()

def eval_set_match(prediction: set, reference: set) -> dict:
    """Set match (entities, categories)."""
    tp = len(prediction & reference)
    fp = len(prediction - reference)
    fn = len(reference - prediction)

    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0
    f1 = (2 * precision * recall / (precision + recall)
          if (precision + recall) > 0 else 0)

    return {"precision": precision, "recall": recall, "f1": f1}

Semantic Evaluation (Embedding)

For text responses where wording can vary, exact match becomes absurd: “Your order will be delivered tomorrow” and “Delivery is scheduled for tomorrow” say the same thing yet would score zero. Cosine similarity between embeddings measures semantic proximity rather than character matching, returning a continuous score.

You set the threshold above which a response is deemed acceptable. Calibrate it on a few dozen pairs you’ve manually classified as “equivalent” or “different,” then pick the value that best separates the two groups: a threshold chosen by gut feeling produces results as arbitrary as no measurement at all, with the added illusion of rigor.

from openai import OpenAI
import numpy as np

client = OpenAI()

def eval_semantic_similarity(prediction: str,
                              reference: str) -> float:
    """Cosine similarity between embeddings."""
    response = client.embeddings.create(
        model="text-embedding-3-large",
        input=[prediction, reference]
    )
    emb_pred = np.array(response.data[0].embedding)
    emb_ref = np.array(response.data[1].embedding)

    similarity = float(
        np.dot(emb_pred, emb_ref) /
        (np.linalg.norm(emb_pred) * np.linalg.norm(emb_ref))
    )
    return similarity

LLM Evaluation (LLM-as-Judge)

The model evaluates the quality of a response. This is the only viable approach when no reference exists: no one will hand-write the “correct” support response for two hundred tickets.

What makes a judge usable comes down to a few things. First, the model used: you pick a more capable model as judge, here gpt-5.6-sol, because a judge at the same level as the evaluatee tends to validate its own flaws. Second, requiring justification: motivating each score stabilizes judgment and lets you spot an evaluator using the wrong criterion—a justification discussing length when the criterion was clarity is a red flag. Finally, temperature at 0.1 ensures score stability across runs.

def eval_llm_judge(question: str, prediction: str,
                    criteria: list[str]) -> dict:
    """Evaluation by a judge model."""
    criteria_text = "\n".join(
        f"- {c}: score from 1 to 5" for c in criteria
    )

    response = client.responses.create(
        model="gpt-5.6-sol",  # More capable model as judge
        reasoning={"effort": "none"},  # temperature requires effort none
        instructions="You are an expert, impartial evaluator.",
        input=f"""Evaluate this response according to the following criteria:

Question asked: {question}

Response to evaluate: {prediction}

Criteria:
{criteria_text}

For each criterion, give a score from 1 to 5 with justification.""",
        text={
            "format": {
                "type": "json_schema",
                "name": "evaluation",
                "schema": {
                    "type": "object",
                    "properties": {
                        "scores": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "criterion": {"type": "string"},
                                    "score": {"type": "integer"},
                                    "justification": {"type": "string"}
                                },
                                "required": ["criterion", "score",
                                              "justification"],
                                "additionalProperties": False
                            }
                        },
                        "overall_score": {"type": "number"},
                        "verdict": {"type": "string"}
                    },
                    "required": ["scores", "overall_score", "verdict"],
                    "additionalProperties": False
                },
                "strict": True
            }
        },
        temperature=0.1
    )
    return json.loads(response.output_text)

Complete Evaluation Pipeline

EvalPipeline assembles these building blocks: it loads a test suite, runs the prompt on each case, applies all provided evaluators, and aggregates scores. Evaluators are passed as a list of functions, all called with the same signature; adding a metric therefore requires no pipeline modification, and you can run exact match, semantic similarity, and a judge on the same execution without paying for generation three times.

The summary method also accumulates consumed tokens. This seemingly secondary line protects you from a common trap: a prompt version that gains two score points while tripling the bill isn’t necessarily the right choice, and it’s better to learn that at trade-off time than when the monthly invoice arrives.

import json
from dataclasses import dataclass
from pathlib import Path

@dataclass
class TestCase:
    """A test case for evaluation."""
    input_text: str
    expected_output: str = None
    metadata: dict = None

class EvalPipeline:
    """Automated evaluation pipeline."""

    def __init__(self, test_suite_path: str):
        """Loads the test suite from a JSON file."""
        data = json.loads(Path(test_suite_path).read_text())
        self.test_cases = [TestCase(**tc) for tc in data]
        self.results = []

    def run(self, prompt_config: dict,
            evaluators: list[callable]) -> dict:
        """Runs all tests and evaluates results."""
        self.results = []

        for tc in self.test_cases:
            # Generate response
            response = client.responses.create(
                model=prompt_config["model"],
                reasoning={"effort": "none"},  # temperature requires effort none
                instructions=prompt_config["system_prompt"],
                input=tc.input_text,
                temperature=prompt_config.get("temperature", 0.1)
            )

            prediction = response.output_text

            # Evaluate with each evaluator
            scores = {}
            for evaluator in evaluators:
                score = evaluator(
                    question=tc.input_text,
                    prediction=prediction,
                    reference=tc.expected_output
                )
                scores[evaluator.__name__] = score

            self.results.append({
                "input": tc.input_text,
                "expected": tc.expected_output,
                "prediction": prediction,
                "scores": scores,
                "tokens": {
                    "input": response.usage.input_tokens,
                    "output": response.usage.output_tokens
                }
            })

        return self.summary()

    def summary(self) -> dict:
        """Summarizes evaluation results."""
        if not self.results:
            return {"error": "No results"}

        # Aggregate scores by evaluator
        all_scores = {}
        for result in self.results:
            for eval_name, score in result["scores"].items():
                if eval_name not in all_scores:
                    all_scores[eval_name] = []
                if isinstance(score, (int, float)):
                    all_scores[eval_name].append(score)
                elif isinstance(score, dict) and "f1" in score:
                    all_scores[eval_name].append(score["f1"])

        summary = {
            "total_tests": len(self.results),
            "average_scores": {
                name: sum(scores) / len(scores)
                for name, scores in all_scores.items()
                if scores
            },
            "total_token_cost": sum(
                r["tokens"]["input"] + r["tokens"]["output"]
                for r in self.results
            )
        }
        return summary

Test Suite: Format

The test suite format stays intentionally minimal: an input, the expected output, and free-form metadata. This metadata is more useful than it appears—by tagging each case by category, you break down scores and discover that your classifier isn’t failing at random but on a specific family of requests, which a global average of 88% doesn’t reveal. The three examples below also show what a good suite should contain: clear cases, like a startup crash, but also cases that border another category—a question about a missing feature looks a lot like a bug report, and it’s on this boundary that a classifier’s quality is decided.

[
    {
        "input_text": "My package hasn't arrived",
        "expected_output": "complaint",
        "metadata": {"category": "delivery"}
    },
    {
        "input_text": "How do I enable dark mode?",
        "expected_output": "question",
        "metadata": {"category": "feature"}
    },
    {
        "input_text": "The app crashes on startup on iOS 18",
        "expected_output": "bug",
        "metadata": {"category": "technical"}
    }
]

CI/CD Integration

Run evaluation on every prompt change. The ci_eval_check function turns measurement into a guardrail: it runs the pipeline, compares the average score to a threshold, and returns a boolean that your integration chain interprets as a build success or failure. A change that drops precision below 85% no longer reaches production, just as a broken unit test blocks a merge.

Set the threshold based on the score already achieved by the current version, not from an ideal. Set it too high and you condemn the team to bypass verification with temporary disablements that become permanent; set it too low and you’re checking nothing while displaying a green badge. Finally, keep in mind that the suite must remain representative of real traffic: a threshold met on twenty cases written two years ago proves nothing about today’s requests.

def ci_eval_check(prompt_config: dict,
                   test_suite: str,
                   min_score: float = 0.85) -> bool:
    """CI/CD gate: checks that the prompt meets the minimum score."""
    pipeline = EvalPipeline(test_suite)

    def accuracy(question, prediction, reference):
        return 1.0 if prediction.strip().lower() == reference.strip().lower() else 0.0

    results = pipeline.run(prompt_config, [accuracy])
    avg_score = results["average_scores"].get("accuracy", 0)

    print(f"Average score: {avg_score:.2%}")
    print(f"Minimum threshold: {min_score:.2%}")

    if avg_score < min_score:
        print("FAILURE: score below threshold")
        return False

    print("SUCCESS: prompt validated")
    return True

Evaluation Dashboard

Track score evolution over time. A single score says nothing; the series tells your prompt’s story and answers the question that always comes up in meetings: are we making progress? The function below produces a Markdown table placing side by side, for each version, the average score, tokens consumed, and estimated cost. The format isn’t accidental: it pastes directly into a pull request, making comparison readable for people who will never open your code.

def comparison_report(results: dict[str, dict]) -> str:
    """Generates a comparative report between prompt versions."""
    report = "# Comparative Evaluation Report\n\n"
    report += "| Version | Average Score | Average Tokens | Cost |\n"
    report += "|---------|---------------|----------------|------|\n"

    for version, data in results.items():
        score = data.get("average_scores", {})
        score_val = list(score.values())[0] if score else 0
        tokens = data.get("total_token_cost", 0)
        report += (f"| {version} | {score_val:.2%} | "
                   f"{tokens} | ${tokens * 5 / 1e6:.4f} |\n")

    return report

Set Up Your Guardrails

Build a suite of twenty test cases for a real prompt, placing a handful of edge cases rather than twenty comfortable examples: a suite that passes at 100% on the first try teaches you nothing. Implement the three evaluator types and run them together on two versions of your prompt. Exact match gives a clean number, semantic similarity catches legitimate rewordings, and the judge illuminates cases where the first two disagree—these disagreements are richest in lessons about what you actually expect from the model.

Compare, identify the winning version, then set up the CI script that blocks deployment if the score drops. You’ll know the system works the day it rejects a change you thought was obvious.

Key Takeaways

  • Automated evaluation is essential for iterating with confidence
  • Combine exact, semantic, and LLM-as-judge evaluation as needed
  • Maintain a test suite that evolves with your application
  • Integrate evaluation into your CI/CD with a minimum threshold
  • Use a more capable model as judge to evaluate a cheaper model
  • Document each version’s scores to track evolution

Test Your Knowledge

Advanced techniques and industrialization: the final check.

1. What does an effective system prompt contain?

Answer: Role, scope, behavior rules, output format, and edge case handling—written like a specification, tested like code.

2. When should you use self-consistency?

Answer: On reasoning problems with verifiable answers: generate multiple independent reasonings and keep the majority answer—variance becomes an ally.

3. What does JSON mode with schema enforcement guarantee?

Answer: Output strictly conforming to the schema (types, required fields, enums)—validated on the API side and revalidated in code (JSON Schema, Zod) for a surprise-free chain.

4. What is grounding?

Answer: Anchoring responses in provided facts (documents, retrieved data) rather than the model’s memory—with the requirement to cite or abstain outside sources.

5. What is the purpose of a versioned prompt catalog?

Answer: To treat prompts as artifacts: versions, A/B tests, automated evaluations on every change—you know what’s running in production, and why.

The final loop of the course—version, test, evaluate automatically—transforms artisanal prompt engineering into a team practice.