Prompt Management & Evaluation
Version, test, and measure your prompts like production software
Why Prompt Management Matters
In production, prompts are code. They change behavior, introduce bugs, and need the same rigor as any other deployment artifact. Without systematic management, you'll face "prompt drift" — subtle regressions that erode quality over weeks.
The Prompt Lifecycle
- Author — Write and iterate on prompts locally
- Version — Track changes with meaningful diffs
- Test — Run against evaluation suites before deploy
- Deploy — Ship to production with rollback capability
- Monitor — Track quality metrics in real-time
- Iterate — A/B test improvements against baseline
The Prompt Evaluation Pipeline
Every prompt change should flow through this pipeline before reaching production. Automated evaluation catches regressions that manual testing misses.
Prompt Versioning
Store prompts as versioned artifacts with metadata:
import hashlib
import json
from datetime import datetime
from dataclasses import dataclass, asdict
from pathlib import Path
@dataclass
class PromptVersion:
name: str
version: str
template: str
model: str
temperature: float
description: str
author: str
created_at: str = None
content_hash: str = None
def __post_init__(self):
self.created_at = self.created_at or datetime.now().isoformat()
self.content_hash = hashlib.sha256(self.template.encode()).hexdigest()[:12]
class PromptRegistry:
"""File-based prompt registry with versioning."""
def __init__(self, base_dir: str = "./prompts"):
self.base_dir = Path(base_dir)
self.base_dir.mkdir(parents=True, exist_ok=True)
def register(self, prompt: PromptVersion) -> str:
prompt_dir = self.base_dir / prompt.name
prompt_dir.mkdir(exist_ok=True)
version_file = prompt_dir / f"v{prompt.version}.json"
version_file.write_text(json.dumps(asdict(prompt), indent=2))
# Update latest pointer
latest_file = prompt_dir / "latest.json"
latest_file.write_text(json.dumps({"version": prompt.version, "hash": prompt.content_hash}))
return prompt.content_hash
def get(self, name: str, version: str = None) -> PromptVersion:
prompt_dir = self.base_dir / name
if version is None:
latest = json.loads((prompt_dir / "latest.json").read_text())
version = latest["version"]
data = json.loads((prompt_dir / f"v{version}.json").read_text())
return PromptVersion(**data)
def list_versions(self, name: str) -> list[str]:
prompt_dir = self.base_dir / name
return sorted([f.stem for f in prompt_dir.glob("v*.json")])
# Usage
registry = PromptRegistry()
registry.register(PromptVersion(
name="sentiment_classifier",
version="1.0",
template="Classify the sentiment: {text}\nRespond: POSITIVE, NEGATIVE, or NEUTRAL",
model="gpt-4o",
temperature=0.0,
description="Basic sentiment classification v1",
author="alice"
))
registry.register(PromptVersion(
name="sentiment_classifier",
version="1.1",
template="You are a sentiment analysis expert.\n\nClassify this text into exactly one category: POSITIVE, NEGATIVE, or NEUTRAL.\nConsider sarcasm and context.\n\nText: {text}\nSentiment:",
model="gpt-4o",
temperature=0.0,
description="Added expert role and sarcasm handling",
author="alice"
))
Evaluation Metrics
Different tasks need different metrics. Here's when to use each:
| Metric | Best For | How It Works | Limitation |
|---|---|---|---|
| Exact Match | Classification, extraction | Output == expected (case-insensitive) | Too strict for free-text |
| BLEU | Translation, short generation | N-gram overlap with reference | Ignores semantics |
| ROUGE | Summarization | Recall-oriented n-gram overlap | Doesn't measure factuality |
| Semantic Similarity | Open-ended generation | Embedding cosine similarity | High scores for vague matches |
| LLM-as-Judge | Complex, subjective quality | Another LLM rates the output | Expensive, potential bias |
| Human Eval | Final validation, ambiguous tasks | Human raters score outputs | Slow, expensive, inconsistent |
LLM-as-Judge Pattern
Use a powerful model to evaluate outputs from your production model. This is the most flexible evaluation method for complex tasks:
import openai
import json
client = openai.OpenAI()
JUDGE_PROMPT = """You are an expert evaluator. Score the following AI response on these criteria:
CRITERIA:
1. Accuracy (1-5): Are the facts correct? Is the information grounded in the provided context?
2. Completeness (1-5): Does it address all parts of the question?
3. Clarity (1-5): Is it well-organized and easy to understand?
4. Conciseness (1-5): Is it appropriately brief without being incomplete?
CONTEXT PROVIDED TO THE AI:
{context}
USER QUESTION:
{question}
AI RESPONSE TO EVALUATE:
{response}
REFERENCE ANSWER (gold standard):
{reference}
Score each criterion and provide a brief justification.
Respond in JSON:
{{
"accuracy": {{"score": int, "reason": "..."}},
"completeness": {{"score": int, "reason": "..."}},
"clarity": {{"score": int, "reason": "..."}},
"conciseness": {{"score": int, "reason": "..."}},
"overall_score": float,
"critical_issues": ["issue1", ...] or []
}}"""
def llm_judge(context: str, question: str, response: str, reference: str) -> dict:
"""Use GPT-4o as a judge to evaluate a response."""
prompt = JUDGE_PROMPT.format(
context=context,
question=question,
response=response,
reference=reference
)
result = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a fair, consistent evaluator. Be strict but reasonable."},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.0
)
return json.loads(result.choices[0].message.content)
def evaluate_prompt_version(prompt_template: str, test_cases: list[dict], model: str = "gpt-4o") -> dict:
"""Evaluate a prompt version against a test suite using LLM-as-judge."""
scores = []
for case in test_cases:
# Generate response using the prompt under test
formatted_prompt = prompt_template.format(**case["input"])
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": formatted_prompt}],
temperature=0.0
)
ai_response = response.choices[0].message.content
# Judge the response
judgment = llm_judge(
context=case.get("context", ""),
question=case["input"].get("question", formatted_prompt),
response=ai_response,
reference=case["expected"]
)
judgment["test_id"] = case.get("id", "unknown")
judgment["ai_response"] = ai_response
scores.append(judgment)
# Aggregate
avg_score = sum(s["overall_score"] for s in scores) / len(scores)
return {
"avg_overall_score": round(avg_score, 2),
"pass_rate": sum(1 for s in scores if s["overall_score"] >= 3.5) / len(scores),
"individual_scores": scores,
"critical_failures": [s for s in scores if s["overall_score"] < 2.5]
}
A/B Testing Prompts
import random
import hashlib
from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class ABTest:
name: str
variant_a: str # prompt template A (control)
variant_b: str # prompt template B (challenger)
traffic_split: float = 0.5 # fraction going to B
results: dict = field(default_factory=lambda: {"a": [], "b": []})
def assign_variant(self, user_id: str) -> str:
"""Deterministic assignment based on user_id for consistency."""
hash_val = int(hashlib.md5(f"{self.name}:{user_id}".encode()).hexdigest(), 16)
return "b" if (hash_val % 100) < (self.traffic_split * 100) else "a"
def get_prompt(self, user_id: str) -> tuple[str, str]:
"""Returns (variant_name, prompt_template)."""
variant = self.assign_variant(user_id)
template = self.variant_b if variant == "b" else self.variant_a
return variant, template
def record_result(self, variant: str, score: float):
self.results[variant].append(score)
def get_stats(self) -> dict:
stats = {}
for v in ["a", "b"]:
scores = self.results[v]
if scores:
stats[v] = {
"n": len(scores),
"mean": sum(scores) / len(scores),
"min": min(scores),
"max": max(scores),
}
# Statistical significance (simplified)
if stats.get("a") and stats.get("b"):
diff = stats["b"]["mean"] - stats["a"]["mean"]
stats["improvement"] = f"{diff:+.2f}"
stats["winner"] = "b" if diff > 0.1 else "a" if diff < -0.1 else "tie"
return stats
# Usage
test = ABTest(
name="summarizer_v2_test",
variant_a="Summarize this article in 3 bullet points:\n{text}",
variant_b="You are an expert editor at The Economist. Summarize in exactly 3 bullets. Each bullet: bold key term, max 20 words, focus on implications.\n\nArticle:\n{text}",
traffic_split=0.5
)
# In production request handler:
variant, prompt = test.get_prompt(user_id="user_123")
# ... get response, score it ...
test.record_result(variant, score=4.2)
print(test.get_stats())
Regression Testing
Run your evaluation suite on every prompt change, just like unit tests for code:
from dataclasses import dataclass
@dataclass
class RegressionResult:
version: str
total_tests: int
passed: int
failed: int
avg_score: float
regressions: list[str] # test IDs that got worse
def run_regression_suite(
registry: PromptRegistry,
prompt_name: str,
new_version: str,
test_cases: list[dict],
threshold: float = 0.9
) -> RegressionResult:
"""Compare new prompt version against the current production version."""
# Get current and new prompts
try:
current = registry.get(prompt_name) # latest
except FileNotFoundError:
current = None
new = registry.get(prompt_name, version=new_version)
# Evaluate new version
new_results = evaluate_prompt_version(new.template, test_cases, model=new.model)
regressions = []
if current:
current_results = evaluate_prompt_version(current.template, test_cases, model=current.model)
# Compare per-test
for new_s, old_s in zip(new_results["individual_scores"], current_results["individual_scores"]):
if new_s["overall_score"] < old_s["overall_score"] - 0.5:
regressions.append(new_s["test_id"])
passed = sum(1 for s in new_results["individual_scores"] if s["overall_score"] >= 3.5)
result = RegressionResult(
version=new_version,
total_tests=len(test_cases),
passed=passed,
failed=len(test_cases) - passed,
avg_score=new_results["avg_overall_score"],
regressions=regressions
)
# Gate deployment
pass_rate = passed / len(test_cases)
if pass_rate < threshold:
print(f"❌ BLOCKED: Pass rate {pass_rate:.0%} below threshold {threshold:.0%}")
elif regressions:
print(f"⚠️ WARNING: {len(regressions)} regressions detected: {regressions}")
else:
print(f"✅ PASSED: {pass_rate:.0%} pass rate, no regressions")
return result
🛠 Mini-Project: Prompt Evaluation Framework
Build a complete evaluation framework that versions prompts, runs evaluation suites, and gates deployment.
"""
Prompt Evaluation Framework
============================
A complete system for managing and evaluating prompts in production.
"""
import json
import time
from pathlib import Path
from dataclasses import dataclass, asdict
@dataclass
class EvalCase:
id: str
input: dict
expected: str
context: str = ""
tags: list[str] = None
@dataclass
class EvalSuite:
name: str
cases: list[EvalCase]
pass_threshold: float = 0.9
metrics: list[str] = None # ["exact_match", "llm_judge", "semantic_sim"]
def save(self, path: str):
Path(path).write_text(json.dumps(asdict(self), indent=2))
@classmethod
def load(cls, path: str) -> "EvalSuite":
data = json.loads(Path(path).read_text())
data["cases"] = [EvalCase(**c) for c in data["cases"]]
return cls(**data)
class PromptEvalFramework:
def __init__(self, prompts_dir: str = "./prompts", results_dir: str = "./eval_results"):
self.registry = PromptRegistry(prompts_dir)
self.results_dir = Path(results_dir)
self.results_dir.mkdir(parents=True, exist_ok=True)
def evaluate(self, prompt_name: str, version: str, suite: EvalSuite) -> dict:
"""Run full evaluation pipeline."""
prompt = self.registry.get(prompt_name, version)
print(f"Evaluating '{prompt_name}' v{version} against '{suite.name}'")
print(f" Model: {prompt.model} | Temp: {prompt.temperature}")
print(f" Test cases: {len(suite.cases)} | Threshold: {suite.pass_threshold:.0%}")
print("-" * 50)
results = []
start = time.time()
for case in suite.cases:
case_result = self._evaluate_case(prompt, case, suite.metrics or ["llm_judge"])
results.append(case_result)
status = "✅" if case_result["passed"] else "❌"
print(f" {status} [{case.id}] score={case_result['score']:.2f}")
elapsed = time.time() - start
passed = sum(1 for r in results if r["passed"])
pass_rate = passed / len(results)
report = {
"prompt_name": prompt_name,
"version": version,
"suite": suite.name,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"total": len(results),
"passed": passed,
"failed": len(results) - passed,
"pass_rate": pass_rate,
"avg_score": sum(r["score"] for r in results) / len(results),
"elapsed_seconds": round(elapsed, 1),
"threshold_met": pass_rate >= suite.pass_threshold,
"details": results
}
# Save report
report_path = self.results_dir / f"{prompt_name}_v{version}_{suite.name}.json"
report_path.write_text(json.dumps(report, indent=2))
print(f"\n{'='*50}")
print(f"Results: {passed}/{len(results)} passed ({pass_rate:.0%})")
print(f"{'✅ DEPLOY OK' if report['threshold_met'] else '❌ BLOCKED'}")
return report
def _evaluate_case(self, prompt: PromptVersion, case: EvalCase, metrics: list) -> dict:
"""Evaluate a single test case."""
formatted = prompt.template.format(**case.input)
response = client.chat.completions.create(
model=prompt.model,
messages=[{"role": "user", "content": formatted}],
temperature=prompt.temperature
)
output = response.choices[0].message.content.strip()
scores = {}
if "exact_match" in metrics:
scores["exact_match"] = 5.0 if output.lower() == case.expected.lower() else 1.0
if "llm_judge" in metrics:
judgment = llm_judge(case.context, str(case.input), output, case.expected)
scores["llm_judge"] = judgment["overall_score"]
if "semantic_sim" in metrics:
scores["semantic_sim"] = self._semantic_similarity(output, case.expected)
avg_score = sum(scores.values()) / len(scores)
return {
"case_id": case.id,
"output": output,
"expected": case.expected,
"scores": scores,
"score": avg_score,
"passed": avg_score >= 3.5
}
def _semantic_similarity(self, text_a: str, text_b: str) -> float:
"""Compute semantic similarity using embeddings."""
resp = client.embeddings.create(
model="text-embedding-3-small",
input=[text_a, text_b]
)
import numpy as np
a = np.array(resp.data[0].embedding)
b = np.array(resp.data[1].embedding)
sim = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
return float(sim) * 5 # Scale to 1-5
def compare_versions(self, prompt_name: str, v1: str, v2: str, suite: EvalSuite) -> dict:
"""Compare two prompt versions head-to-head."""
r1 = self.evaluate(prompt_name, v1, suite)
r2 = self.evaluate(prompt_name, v2, suite)
return {
"v1": {"version": v1, "score": r1["avg_score"], "pass_rate": r1["pass_rate"]},
"v2": {"version": v2, "score": r2["avg_score"], "pass_rate": r2["pass_rate"]},
"winner": v2 if r2["avg_score"] > r1["avg_score"] else v1,
"improvement": r2["avg_score"] - r1["avg_score"]
}
# Example usage
framework = PromptEvalFramework()
suite = EvalSuite(
name="sentiment_basic",
cases=[
EvalCase(id="pos_1", input={"text": "Absolutely love this product!"}, expected="POSITIVE"),
EvalCase(id="neg_1", input={"text": "Worst experience ever. Never again."}, expected="NEGATIVE"),
EvalCase(id="neu_1", input={"text": "It works as described. Nothing special."}, expected="NEUTRAL"),
EvalCase(id="sarc_1", input={"text": "Oh great, another update that breaks everything."}, expected="NEGATIVE", tags=["sarcasm"]),
EvalCase(id="mixed_1", input={"text": "Good quality but terrible customer service."}, expected="NEGATIVE", tags=["mixed"]),
],
pass_threshold=0.8,
metrics=["exact_match", "llm_judge"]
)
# Evaluate and compare
report = framework.compare_versions("sentiment_classifier", "1.0", "1.1", suite)
print(f"Winner: v{report['winner']} (improvement: {report['improvement']:+.2f})")
📌 Key Takeaways
- Treat prompts as versioned artifacts — store with metadata, track changes, enable rollback
- Use LLM-as-Judge for flexible evaluation of complex outputs (but watch for bias)
- Match metrics to task type: exact match for classification, ROUGE for summarization, semantic similarity for open-ended
- A/B test prompt changes with deterministic user assignment and statistical significance
- Gate deployments with regression tests — block if pass rate drops below threshold
- Build evaluation into CI/CD — every prompt change triggers automated evaluation