Skip to content
Learn Agentic AI11 min read0 views

Evaluation-Driven Prompt Development: Using Metrics to Improve Prompts Systematically

Learn how to build evaluation frameworks with scoring rubrics, A/B testing, and regression testing to systematically improve prompt quality and catch regressions before production.

The Problem with Vibes-Based Prompt Engineering

Most prompt engineering follows an informal process: write a prompt, try a few examples, adjust until the output "looks right," and ship to production. This approach has three critical flaws. First, "looks right" is subjective — different team members evaluate differently. Second, improving one case often silently breaks others. Third, there is no way to measure whether a change actually improved the prompt or just shifted the failure pattern.

Evaluation-driven prompt development replaces vibes with metrics. You define what good output looks like, build a test suite, and measure every prompt change against that suite before deploying.

Building an Evaluation Framework

The foundation is a structured test suite with inputs, expected behaviors, and scoring criteria:

from dataclasses import dataclass, field
from enum import Enum
import json
import openai

client = openai.OpenAI()

class ScoreType(Enum):
    BINARY = "binary"         # 0 or 1
    LIKERT = "likert"         # 1-5 scale
    CONTINUOUS = "continuous"  # 0.0-1.0

@dataclass
class EvalCase:
    input_text: str
    expected_output: str
    criteria: list[str]
    tags: list[str] = field(default_factory=list)
    weight: float = 1.0

@dataclass
class EvalResult:
    case: EvalCase
    output: str
    scores: dict[str, float]
    overall_score: float

def create_eval_suite() -> list[EvalCase]:
    """Define evaluation cases with explicit criteria."""
    return [
        EvalCase(
            input_text="What causes a 502 error?",
            expected_output="server-side gateway/proxy issue",
            criteria=[
                "Mentions that 502 is a server-side error",
                "Explains the gateway or proxy role",
                "Suggests actionable troubleshooting steps",
                "Does not blame the user's browser or device",
            ],
            tags=["technical", "error-codes"],
        ),
        EvalCase(
            input_text="How do I cancel my subscription?",
            expected_output="clear cancellation steps",
            criteria=[
                "Provides step-by-step cancellation instructions",
                "Mentions any data retention or refund policies",
                "Tone is empathetic, not defensive",
                "Does not try to dissuade cancellation aggressively",
            ],
            tags=["billing", "customer-service"],
        ),
    ]

LLM-as-Judge Scoring

For criteria that cannot be evaluated with simple string matching, use an LLM as a judge:

def llm_judge_score(
    input_text: str,
    output: str,
    criteria: list[str],
) -> dict[str, float]:
    """Score each criterion using an LLM judge."""
    criteria_text = "\n".join(f"{i+1}. {c}" for i, c in enumerate(criteria))

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": (
                "You are an evaluation judge. Score the output against "
                "each criterion on a scale of 0.0 (completely fails) to "
                "1.0 (fully meets). Return JSON with criterion numbers "
                "as keys and scores as values. Be strict and consistent."
            )},
            {"role": "user", "content": (
                f"Input: {input_text}\n\n"
                f"Output to evaluate: {output}\n\n"
                f"Criteria:\n{criteria_text}"
            )},
        ],
        response_format={"type": "json_object"},
        temperature=0,
    )
    data = json.loads(response.choices[0].message.content)
    return {
        criteria[int(k) - 1]: float(v)
        for k, v in data.items()
        if k.isdigit() and int(k) - 1 < len(criteria)
    }

Running Evaluations

The evaluation runner tests a prompt against the full suite and aggregates results:

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

def run_evaluation(
    system_prompt: str,
    eval_suite: list[EvalCase],
    model: str = "gpt-4o",
) -> dict:
    """Run a full evaluation of a prompt against the test suite."""
    results = []

    for case in eval_suite:
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": case.input_text},
            ],
            temperature=0,
        )
        output = response.choices[0].message.content
        scores = llm_judge_score(case.input_text, output, case.criteria)
        overall = sum(scores.values()) / len(scores) if scores else 0.0

        results.append(EvalResult(
            case=case,
            output=output,
            scores=scores,
            overall_score=overall,
        ))

    # Aggregate by tag
    tag_scores = {}
    for r in results:
        for tag in r.case.tags:
            tag_scores.setdefault(tag, []).append(r.overall_score)

    return {
        "overall_score": sum(r.overall_score for r in results) / len(results),
        "tag_scores": {
            tag: sum(s) / len(s) for tag, s in tag_scores.items()
        },
        "worst_cases": sorted(results, key=lambda r: r.overall_score)[:3],
        "results": results,
    }

A/B Testing Prompt Variants

With evaluation in place, A/B testing becomes straightforward:

def ab_test_prompts(
    prompt_a: str,
    prompt_b: str,
    eval_suite: list[EvalCase],
    label_a: str = "Control",
    label_b: str = "Variant",
) -> dict:
    """Compare two prompts on the same evaluation suite."""
    results_a = run_evaluation(prompt_a, eval_suite)
    results_b = run_evaluation(prompt_b, eval_suite)

    comparison = {
        label_a: {
            "overall_score": results_a["overall_score"],
            "tag_scores": results_a["tag_scores"],
        },
        label_b: {
            "overall_score": results_b["overall_score"],
            "tag_scores": results_b["tag_scores"],
        },
        "winner": label_b if results_b["overall_score"] > results_a["overall_score"] else label_a,
        "improvement": results_b["overall_score"] - results_a["overall_score"],
    }

    # Find regressions — cases where B is worse than A
    regressions = []
    for ra, rb in zip(results_a["results"], results_b["results"]):
        if rb.overall_score < ra.overall_score - 0.1:
            regressions.append({
                "input": ra.case.input_text,
                "score_a": ra.overall_score,
                "score_b": rb.overall_score,
            })

    comparison["regressions"] = regressions
    return comparison

Regression Testing in CI

The most valuable application is automated regression testing. Add prompt evaluation to your CI pipeline so that prompt changes cannot ship without passing quality gates:

def regression_check(
    current_prompt: str,
    new_prompt: str,
    eval_suite: list[EvalCase],
    min_score: float = 0.8,
    max_regression: float = 0.05,
) -> dict:
    """Check that a new prompt does not regress quality."""
    current_results = run_evaluation(current_prompt, eval_suite)
    new_results = run_evaluation(new_prompt, eval_suite)

    regression = current_results["overall_score"] - new_results["overall_score"]

    return {
        "passed": (
            new_results["overall_score"] >= min_score
            and regression <= max_regression
        ),
        "current_score": current_results["overall_score"],
        "new_score": new_results["overall_score"],
        "regression": regression,
        "min_score_met": new_results["overall_score"] >= min_score,
        "regression_within_limit": regression <= max_regression,
    }

This ensures that no prompt change degrades quality by more than the allowed threshold, catching the silent regressions that vibes-based development misses entirely.

FAQ

How many evaluation cases do I need for reliable results?

Start with 20 to 30 cases covering your core use cases. For production systems handling diverse queries, aim for 50 to 100 cases with good coverage across categories. The key is diversity — 30 well-chosen cases that cover different failure modes are more valuable than 100 similar cases.

Is LLM-as-judge scoring reliable?

LLM judges correlate well with human ratings when given specific, well-defined criteria. Vague criteria like "is the response good" produce noisy scores. Specific criteria like "mentions the refund policy timeline" produce consistent scores. Always calibrate your judge against human ratings on a small sample before trusting it at scale.

How do I handle non-deterministic outputs in evaluation?

Run each eval case 3 times at temperature 0 and take the median score. If you need to evaluate at higher temperatures, run 5 to 7 times and aggregate. For A/B testing, use the same seed across both variants if the API supports it, or average over enough samples to wash out randomness.


#PromptEngineering #Evaluation #Testing #Metrics #Python #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.