
Here's a scenario that plays out in enterprise AI teams every few weeks: your customer support automation has been running smoothly for three months. Response quality has been good, tickets are resolving faster, satisfaction scores are up. Then someone — maybe you, maybe a well-meaning colleague — tweaks the system prompt to handle a new edge case. Suddenly escalation rates spike, the model starts giving confidently wrong answers about your refund policy, and you have no clear record of what changed, when, or why. You roll back by memory, you think you've restored the old prompt, but you're not quite sure, and now you're A/B testing in production while customer complaints accumulate.
This is the prompt versioning problem. Unlike code, where git has been a standard tool for decades, prompts live in a wild variety of places: Google Docs, Notion pages, hardcoded strings in Python files, configuration tables in databases, spreadsheets maintained by prompt engineers who've since left the company. There's no merge conflict when two people edit the same prompt. There's no diff showing you what changed. There's no automated test suite telling you whether the new version is better or worse. This is the state of prompt management at most organizations today, even sophisticated ones.
By the end of this lesson, you'll have the conceptual framework and practical tooling knowledge to treat prompts as first-class software artifacts. You'll be able to design a versioning system, build evaluation pipelines that actually measure prompt quality, run controlled experiments, and make promotion decisions based on evidence rather than intuition.
What you'll learn:
This lesson assumes you're comfortable with:
You don't need prior experience with MLflow, LangSmith, or other specialized prompt management tools — we'll introduce what's relevant as we go.
Before designing a versioning system, you need to understand what makes prompts fundamentally different from the code that wraps them. This isn't just philosophical framing — it has direct implications for how you structure your tooling.
Prompts are probabilistic interfaces. When you change a function signature in Python, you get a TypeError immediately. When you change a prompt, you get outputs that seem reasonable but may have subtly degraded in ways you won't notice until you look at aggregate metrics. The feedback loop is slow and noisy.
Prompts are entangled with model versions. A prompt that works beautifully with GPT-4 Turbo may perform significantly worse with GPT-4o, or with a fine-tuned variant of the same base model. The prompt and the model are co-dependent artifacts, which means your versioning system needs to track both together. When OpenAI deprecates a model and you're forced to migrate, you can't just swap the model identifier — you may need to re-evaluate and potentially re-tune every prompt in your system.
Prompts encode implicit assumptions. A prompt that says "You are a financial advisor assistant" encodes assumptions about regulatory compliance, tone, what constitutes helpful vs. harmful advice, and how to handle edge cases. These assumptions aren't documented anywhere in the prompt text itself. Six months from now, when someone adds "Always recommend consulting a licensed professional before making investment decisions," they may not realize that sentence now conflicts with three few-shot examples that show the assistant giving specific allocation percentages.
Evaluation is multi-dimensional and context-dependent. Code quality has relatively objective metrics — does it pass the tests, how fast does it run, does it handle edge cases? Prompt quality depends on what you're optimizing for. A prompt for a creative writing assistant and a prompt for a compliance document analyzer have completely different quality criteria. Your versioning system needs to be flexible enough to accommodate this.
These differences mean you can't just drop prompts into a git repository and call it done — though git is part of the answer. What you need is a system that handles prompt content versioning, model-prompt pairing, evaluation, and promotion decisions in an integrated way.
Let's start by defining what you're actually versioning. Most teams think of a "prompt" as a string — the text they send to the API. That's too narrow. A complete prompt artifact for versioning purposes includes:
{
"prompt_id": "customer-support-classifier",
"version": "2.4.1",
"created_at": "2024-09-15T14:23:00Z",
"created_by": "sarah.chen@company.com",
"status": "production", # draft | staging | production | deprecated
"model_binding": {
"provider": "openai",
"model": "gpt-4o",
"temperature": 0.2,
"max_tokens": 500,
"top_p": 1.0
},
"content": {
"system": "You are a customer support routing specialist...",
"user_template": "Customer message: {{message}}\nAccount tier: {{tier}}\nPrevious tickets: {{ticket_count}}",
"few_shot_examples": [
{
"user": "My subscription charged me twice this month",
"assistant": '{"category": "billing", "urgency": "high", "route_to": "billing_team"}'
}
]
},
"variables": ["message", "tier", "ticket_count"],
"evaluation_criteria": {
"primary_metric": "category_accuracy",
"secondary_metrics": ["routing_precision", "latency_p95"],
"passing_threshold": 0.92
},
"lineage": {
"parent_version": "2.3.0",
"change_rationale": "Added enterprise tier handling after escalation spike on Oct 12",
"change_type": "behavioral" # cosmetic | behavioral | structural
},
"evaluation_results": {
"test_suite": "v3.2",
"accuracy": 0.947,
"routing_precision": 0.931,
"latency_p95_ms": 1240,
"evaluated_at": "2024-09-15T15:00:00Z"
}
}
The change_type field deserves special attention. A cosmetic change fixes a typo or clarifies wording without changing expected behavior. A behavioral change modifies what outputs the prompt should produce. A structural change alters the format, adds or removes variables, or changes the few-shot structure. These distinctions matter for your testing strategy: cosmetic changes might only need a smoke test, behavioral changes need full regression testing, and structural changes need integration testing with all downstream systems that parse the output.
The lineage section is where most teams shortchange themselves. "Updated prompt" is not a useful change rationale. Future you — and future your colleagues — need to understand why the change was made, what problem it solved, and what tradeoffs were considered. Treat this the same way you'd treat a git commit message on an important change.
A prompt registry is the source of truth for all prompt artifacts in your organization. Think of it as a combination of a package registry (like npm or PyPI) and a model registry (like MLflow's model registry). It needs to support:
Let's look at a practical implementation using a PostgreSQL backend, which most enterprise teams already have access to:
-- Core prompt versions table
CREATE TABLE prompt_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_id VARCHAR(255) NOT NULL,
version SEMVER NOT NULL, -- use semver extension or VARCHAR with constraints
status VARCHAR(50) NOT NULL DEFAULT 'draft',
-- Content stored as JSONB for flexibility
content JSONB NOT NULL,
model_binding JSONB NOT NULL,
variables TEXT[] NOT NULL DEFAULT '{}',
evaluation_criteria JSONB,
-- Lineage
parent_version_id UUID REFERENCES prompt_versions(id),
change_rationale TEXT NOT NULL,
change_type VARCHAR(50) NOT NULL,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
promoted_at TIMESTAMPTZ,
promoted_by VARCHAR(255),
deprecated_at TIMESTAMPTZ,
CONSTRAINT valid_status CHECK (status IN ('draft', 'staging', 'production', 'deprecated')),
CONSTRAINT valid_change_type CHECK (change_type IN ('cosmetic', 'behavioral', 'structural')),
CONSTRAINT one_production_version UNIQUE (prompt_id, status)
WHERE status = 'production'
);
-- Evaluation results stored separately to allow multiple eval runs
CREATE TABLE prompt_evaluations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_version_id UUID NOT NULL REFERENCES prompt_versions(id),
test_suite_version VARCHAR(50) NOT NULL,
metrics JSONB NOT NULL,
passed_threshold BOOLEAN NOT NULL,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
evaluated_by VARCHAR(255) NOT NULL, -- can be 'ci-pipeline' for automated
-- Store the actual test results for debugging
detailed_results JSONB
);
-- Audit log
CREATE TABLE prompt_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt_version_id UUID NOT NULL REFERENCES prompt_versions(id),
action VARCHAR(100) NOT NULL,
actor VARCHAR(255) NOT NULL,
details JSONB,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The UNIQUE constraint on (prompt_id, status) WHERE status = 'production' is a small but important detail — it enforces at the database level that only one version of a prompt can be in production at any time, preventing the class of bugs where you accidentally promote a new version without deprecating the old one.
Now let's build a Python client that wraps this registry:
import uuid
from datetime import datetime, timezone
from typing import Optional
import psycopg2
from psycopg2.extras import RealDictCursor
import semver
class PromptRegistry:
def __init__(self, connection_string: str):
self.conn = psycopg2.connect(connection_string)
def create_version(
self,
prompt_id: str,
content: dict,
model_binding: dict,
change_rationale: str,
change_type: str,
created_by: str,
variables: list[str] = None,
evaluation_criteria: dict = None,
parent_version_id: str = None,
version: str = None
) -> dict:
"""
Create a new prompt version. Auto-increments version if not specified.
New versions always start as 'draft'.
"""
if version is None:
# Auto-increment based on change_type
latest = self._get_latest_version(prompt_id)
if latest is None:
version = "1.0.0"
else:
parsed = semver.VersionInfo.parse(latest['version'])
if change_type == 'structural':
version = str(parsed.bump_major())
elif change_type == 'behavioral':
version = str(parsed.bump_minor())
else: # cosmetic
version = str(parsed.bump_patch())
with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
INSERT INTO prompt_versions
(prompt_id, version, content, model_binding, variables,
evaluation_criteria, change_rationale, change_type,
created_by, parent_version_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
""", (
prompt_id, version,
psycopg2.extras.Json(content),
psycopg2.extras.Json(model_binding),
variables or [],
psycopg2.extras.Json(evaluation_criteria),
change_rationale, change_type,
created_by, parent_version_id
))
result = cur.fetchone()
self.conn.commit()
# Log the creation
self._audit_log(result['id'], 'created', created_by)
return dict(result)
def promote_to_production(
self,
version_id: str,
promoted_by: str,
require_passing_eval: bool = True
) -> dict:
"""
Promote a version to production. Automatically deprecates the current
production version. Will fail if no passing evaluation exists and
require_passing_eval is True.
"""
with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
# Fetch the version we want to promote
cur.execute("SELECT * FROM prompt_versions WHERE id = %s", (version_id,))
candidate = cur.fetchone()
if candidate is None:
raise ValueError(f"Version {version_id} not found")
if candidate['status'] != 'staging':
raise ValueError(
f"Can only promote from staging. "
f"Current status: {candidate['status']}"
)
if require_passing_eval:
cur.execute("""
SELECT id FROM prompt_evaluations
WHERE prompt_version_id = %s AND passed_threshold = true
ORDER BY evaluated_at DESC LIMIT 1
""", (version_id,))
if cur.fetchone() is None:
raise ValueError(
"No passing evaluation found for this version. "
"Run evaluation suite before promoting."
)
# Deprecate current production version
cur.execute("""
UPDATE prompt_versions
SET status = 'deprecated', deprecated_at = NOW()
WHERE prompt_id = %s AND status = 'production'
RETURNING id
""", (candidate['prompt_id'],))
deprecated = cur.fetchone()
# Promote the candidate
cur.execute("""
UPDATE prompt_versions
SET status = 'production', promoted_at = NOW(), promoted_by = %s
WHERE id = %s
RETURNING *
""", (promoted_by, version_id))
promoted = cur.fetchone()
self.conn.commit()
if deprecated:
self._audit_log(deprecated['id'], 'deprecated', promoted_by)
self._audit_log(version_id, 'promoted_to_production', promoted_by)
return dict(promoted)
def get_production_version(self, prompt_id: str) -> Optional[dict]:
with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT * FROM prompt_versions
WHERE prompt_id = %s AND status = 'production'
""", (prompt_id,))
result = cur.fetchone()
return dict(result) if result else None
def rollback(self, prompt_id: str, rolled_back_by: str) -> dict:
"""
Rollback to the most recent deprecated version.
This is an emergency operation — it promotes the previous version
and deprecates the current production version.
"""
with self.conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT * FROM prompt_versions
WHERE prompt_id = %s AND status = 'deprecated'
ORDER BY deprecated_at DESC LIMIT 1
""", (prompt_id,))
previous = cur.fetchone()
if previous is None:
raise ValueError(f"No deprecated version found for {prompt_id}")
# Re-activate the previous version to staging so promote() can run
cur.execute("""
UPDATE prompt_versions
SET status = 'staging', deprecated_at = NULL
WHERE id = %s
""", (previous['id'],))
self.conn.commit()
# Use the standard promotion path
return self.promote_to_production(
str(previous['id']),
rolled_back_by,
require_passing_eval=False # Skip eval for emergency rollbacks
)
Important: The
rollbackmethod bypasses the evaluation requirement. This is intentional — rollbacks happen under pressure when something is actively broken. But log these carefully and always follow up with a proper root cause analysis. An emergency rollback that doesn't result in a post-mortem is a missed learning opportunity.
A prompt registry is only useful if you're measuring whether prompt changes actually improve things. Let's build the evaluation infrastructure that makes promotion decisions evidence-based.
The key insight here is that you need static test suites — fixed collections of inputs with expected outputs or grading criteria — that you run against every candidate version. Without static test suites, you're measuring how good a prompt is in the abstract, which tells you nothing about whether it regressed on the cases that matter.
A good test suite for a production prompt has four categories of cases:
Golden path cases — typical inputs that represent the majority of your traffic. These should pass easily, and if a new version fails them, that's an immediate red flag.
Edge cases — unusual inputs you know the model handles poorly or that are disproportionately important. For a support classifier, this might be tickets in multiple languages, tickets with hostile or ambiguous phrasing, or tickets about very new product features.
Regression cases — specific inputs that caused problems in the past. Every incident that resulted in a prompt change should contribute at least one test case to this suite.
Adversarial cases — inputs designed to confuse, mislead, or jailbreak the prompt. For enterprise applications, your adversarial cases should reflect your actual threat model.
import json
import asyncio
import time
from dataclasses import dataclass
from typing import Callable, Any
from openai import AsyncOpenAI
@dataclass
class TestCase:
case_id: str
category: str # golden_path | edge_case | regression | adversarial
variables: dict # The template variables for this test
expected_output: Any # Could be exact match, pattern, or grading criteria
grading_strategy: str # exact_match | json_field | llm_judge | custom
weight: float = 1.0 # Higher weight = more impact on overall score
tags: list[str] = None
@dataclass
class EvaluationResult:
case_id: str
passed: bool
score: float # 0.0 to 1.0
actual_output: str
expected_output: Any
latency_ms: float
error: str = None
class PromptEvaluator:
def __init__(self, openai_client: AsyncOpenAI, registry: PromptRegistry):
self.client = openai_client
self.registry = registry
async def run_single_case(
self,
prompt_version: dict,
test_case: TestCase,
custom_graders: dict[str, Callable] = None
) -> EvaluationResult:
"""Run a single test case against a prompt version."""
# Render the prompt template with test variables
content = prompt_version['content']
user_message = self._render_template(
content['user_template'],
test_case.variables
)
# Build the messages array
messages = [
{"role": "system", "content": content['system']}
]
# Add few-shot examples if present
for example in content.get('few_shot_examples', []):
messages.append({"role": "user", "content": example['user']})
messages.append({"role": "assistant", "content": example['assistant']})
messages.append({"role": "user", "content": user_message})
model_binding = prompt_version['model_binding']
start_time = time.time()
try:
response = await self.client.chat.completions.create(
model=model_binding['model'],
messages=messages,
temperature=model_binding.get('temperature', 0.0),
max_tokens=model_binding.get('max_tokens', 1000)
)
latency_ms = (time.time() - start_time) * 1000
actual_output = response.choices[0].message.content
except Exception as e:
return EvaluationResult(
case_id=test_case.case_id,
passed=False,
score=0.0,
actual_output="",
expected_output=test_case.expected_output,
latency_ms=(time.time() - start_time) * 1000,
error=str(e)
)
# Grade the output
score = await self._grade(
actual_output,
test_case,
custom_graders
)
return EvaluationResult(
case_id=test_case.case_id,
passed=score >= 0.8, # Configurable threshold
score=score,
actual_output=actual_output,
expected_output=test_case.expected_output,
latency_ms=latency_ms
)
async def _grade(
self,
actual_output: str,
test_case: TestCase,
custom_graders: dict
) -> float:
"""Grade a model output against expected."""
strategy = test_case.grading_strategy
expected = test_case.expected_output
if strategy == 'exact_match':
return 1.0 if actual_output.strip() == expected.strip() else 0.0
elif strategy == 'json_field':
# Validate specific fields in JSON output
# expected = {"category": "billing", "urgency": "high"}
try:
actual_json = json.loads(actual_output)
matches = sum(
1 for k, v in expected.items()
if actual_json.get(k) == v
)
return matches / len(expected)
except json.JSONDecodeError:
return 0.0
elif strategy == 'llm_judge':
# Use a separate LLM call to grade quality
# expected = "The response should acknowledge the billing issue
# and provide next steps without promising specific
# refund amounts"
judge_response = await self.client.chat.completions.create(
model="gpt-4o", # Use your most capable model for judging
messages=[{
"role": "user",
"content": f"""You are evaluating a customer support AI response.
Grading criteria: {expected}
Response to grade:
{actual_output}
Rate from 0.0 to 1.0 where 1.0 is perfect. Return ONLY a float, nothing else."""
}],
temperature=0.0
)
try:
return float(judge_response.choices[0].message.content.strip())
except ValueError:
return 0.0
elif strategy == 'custom' and custom_graders:
grader = custom_graders.get(test_case.case_id)
if grader:
return grader(actual_output, expected)
raise ValueError(f"Unknown grading strategy: {strategy}")
async def evaluate_version(
self,
prompt_version_id: str,
test_suite: list[TestCase],
test_suite_version: str,
evaluated_by: str,
custom_graders: dict = None,
concurrency: int = 10
) -> dict:
"""Run full evaluation suite against a prompt version."""
version = self._fetch_version(prompt_version_id)
# Run all test cases with bounded concurrency
semaphore = asyncio.Semaphore(concurrency)
async def run_with_semaphore(test_case):
async with semaphore:
return await self.run_single_case(
version, test_case, custom_graders
)
results = await asyncio.gather(
*[run_with_semaphore(tc) for tc in test_suite]
)
# Compute aggregate metrics
weighted_score = sum(
r.score * tc.weight
for r, tc in zip(results, test_suite)
) / sum(tc.weight for tc in test_suite)
pass_rate = sum(1 for r in results if r.passed) / len(results)
# Break down by category
category_scores = {}
for result, test_case in zip(results, test_suite):
cat = test_case.category
if cat not in category_scores:
category_scores[cat] = []
category_scores[cat].append(result.score)
category_summary = {
cat: sum(scores) / len(scores)
for cat, scores in category_scores.items()
}
latencies = [r.latency_ms for r in results if r.error is None]
criteria = version.get('evaluation_criteria', {})
passing_threshold = criteria.get('passing_threshold', 0.90)
passed = weighted_score >= passing_threshold
metrics = {
"weighted_score": weighted_score,
"pass_rate": pass_rate,
"category_scores": category_summary,
"latency_p50_ms": sorted(latencies)[len(latencies)//2] if latencies else None,
"latency_p95_ms": sorted(latencies)[int(len(latencies)*0.95)] if latencies else None,
"error_count": sum(1 for r in results if r.error),
"total_cases": len(results)
}
# Store results in registry
detailed_results = [
{
"case_id": r.case_id,
"passed": r.passed,
"score": r.score,
"actual_output": r.actual_output,
"latency_ms": r.latency_ms,
"error": r.error
}
for r in results
]
self.registry.record_evaluation(
prompt_version_id=prompt_version_id,
test_suite_version=test_suite_version,
metrics=metrics,
passed_threshold=passed,
evaluated_by=evaluated_by,
detailed_results=detailed_results
)
return {
"passed": passed,
"metrics": metrics,
"detailed_results": detailed_results
}
def _render_template(self, template: str, variables: dict) -> str:
"""Simple {{variable}} template rendering."""
result = template
for key, value in variables.items():
result = result.replace(f"{{{{{key}}}}}", str(value))
return result
Warning about LLM-as-judge: Using an LLM to grade LLM outputs is powerful but introduces its own biases. The judge model tends to prefer outputs that are verbose, well-structured, and confident — even when those qualities are irrelevant to your actual use case. Always calibrate your judge by running it against a set of human-labeled examples first, and report judge accuracy alongside your main metrics.
You have a versioning system and evaluation harness. Now let's talk about how you actually make the decision to promote a new prompt version.
The naive approach is to evaluate candidate and current versions separately against your test suite and compare scores. The problem is that test suite scores have variance — the same prompt run twice against the same test suite will produce slightly different scores due to model non-determinism, especially at higher temperatures. If version 2.5 scores 0.947 and version 2.4 scores 0.931, how confident are you that 2.5 is actually better? This isn't statistics for its own sake — making the wrong call here directly impacts your users.
The right approach is to run both versions against the same test cases in the same evaluation window and use statistical testing to determine whether the difference is meaningful:
import numpy as np
from scipy import stats
def compare_versions(
results_a: list[EvaluationResult],
results_b: list[EvaluationResult],
test_suite: list[TestCase],
alpha: float = 0.05
) -> dict:
"""
Compare two prompt versions using paired statistical tests.
results_a and results_b must correspond to the same test cases.
"""
# Build score arrays aligned by case_id
case_order = [tc.case_id for tc in test_suite]
scores_a = {r.case_id: r.score for r in results_a}
scores_b = {r.case_id: r.score for r in results_b}
a_scores = np.array([scores_a[c] for c in case_order])
b_scores = np.array([scores_b[c] for c in case_order])
# Paired Wilcoxon signed-rank test
# We use Wilcoxon rather than paired t-test because:
# 1. Scores are bounded [0,1], not necessarily normal
# 2. We often have unequal variance between versions
statistic, p_value = stats.wilcoxon(a_scores, b_scores)
mean_a = float(np.mean(a_scores))
mean_b = float(np.mean(b_scores))
# Effect size (Cohen's d equivalent for paired data)
differences = b_scores - a_scores
effect_size = float(np.mean(differences) / np.std(differences)) if np.std(differences) > 0 else 0
# Category-level breakdown
category_comparison = {}
for test_case, score_a, score_b in zip(test_suite, a_scores, b_scores):
cat = test_case.category
if cat not in category_comparison:
category_comparison[cat] = {'a': [], 'b': []}
category_comparison[cat]['a'].append(score_a)
category_comparison[cat]['b'].append(score_b)
category_delta = {
cat: {
'mean_a': np.mean(scores['a']),
'mean_b': np.mean(scores['b']),
'delta': np.mean(scores['b']) - np.mean(scores['a'])
}
for cat, scores in category_comparison.items()
}
# Latency comparison
latencies_a = [r.latency_ms for r in results_a if r.error is None]
latencies_b = [r.latency_ms for r in results_b if r.error is None]
significant = p_value < alpha
b_is_better = mean_b > mean_a
recommendation = "promote" if (significant and b_is_better) else \
"keep_current" if (significant and not b_is_better) else \
"inconclusive"
return {
"recommendation": recommendation,
"mean_score_a": mean_a,
"mean_score_b": mean_b,
"score_delta": mean_b - mean_a,
"p_value": float(p_value),
"significant": significant,
"effect_size": effect_size,
"category_comparison": category_delta,
"latency_p95_a": float(np.percentile(latencies_a, 95)) if latencies_a else None,
"latency_p95_b": float(np.percentile(latencies_b, 95)) if latencies_b else None
}
Practical note on statistical power: The Wilcoxon test's ability to detect real differences depends on your test suite size. With 50 test cases, you can reliably detect effect sizes of ~0.3 or larger. With 200 cases, you can detect ~0.15. If your test suite has only 20 cases, even a 5-percentage-point improvement might not reach statistical significance. This is one reason to invest in building a large, representative test suite up front — it directly affects your ability to make confident promotion decisions later.
A prompt versioning system that requires manual evaluation runs before every deployment is better than nothing, but it won't stay rigorous under deadline pressure. The goal is to make automated evaluation the default path and manual bypasses the exception that requires explicit sign-off.
Here's a GitHub Actions workflow that enforces this:
# .github/workflows/prompt-evaluation.yml
name: Prompt Evaluation Pipeline
on:
pull_request:
paths:
- 'prompts/**'
push:
branches: [main]
paths:
- 'prompts/**'
jobs:
evaluate-changed-prompts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for diff
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements-eval.txt
- name: Detect changed prompts
id: detect-changes
run: |
# Find prompt definition files that changed
CHANGED=$(git diff --name-only ${{ github.event.before }} HEAD \
| grep '^prompts/' | grep '\.json$')
echo "changed_prompts=$CHANGED" >> $GITHUB_OUTPUT
- name: Register new prompt versions
if: steps.detect-changes.outputs.changed_prompts != ''
env:
PROMPT_REGISTRY_DSN: ${{ secrets.PROMPT_REGISTRY_DSN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/register_changed_prompts.py \
--files "${{ steps.detect-changes.outputs.changed_prompts }}" \
--created-by "ci-pipeline@github"
- name: Run evaluation suite
id: evaluation
env:
PROMPT_REGISTRY_DSN: ${{ secrets.PROMPT_REGISTRY_DSN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/evaluate_changed_prompts.py \
--files "${{ steps.detect-changes.outputs.changed_prompts }}" \
--output evaluation-results.json
- name: Post evaluation results as PR comment
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const results = JSON.parse(
fs.readFileSync('evaluation-results.json', 'utf8')
);
let comment = '## 📊 Prompt Evaluation Results\n\n';
for (const [promptId, result] of Object.entries(results)) {
const emoji = result.passed ? '✅' : '❌';
comment += `### ${emoji} ${promptId}\n`;
comment += `- **Score:** ${(result.score * 100).toFixed(1)}% `;
comment += `(threshold: ${(result.threshold * 100).toFixed(1)}%)\n`;
comment += `- **Recommendation:** ${result.recommendation}\n`;
if (result.regression_cases_failed > 0) {
comment += `- ⚠️ **${result.regression_cases_failed} regression cases failed**\n`;
}
comment += '\n';
}
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
- name: Fail if any prompts below threshold
run: |
python -c "
import json, sys
results = json.load(open('evaluation-results.json'))
failures = [p for p, r in results.items() if not r['passed']]
if failures:
print(f'FAILED: {failures}')
sys.exit(1)
print('All prompts passed evaluation')
"
- name: Auto-promote to staging on main branch
if: github.ref == 'refs/heads/main'
env:
PROMPT_REGISTRY_DSN: ${{ secrets.PROMPT_REGISTRY_DSN }}
run: |
python scripts/promote_to_staging.py \
--files "${{ steps.detect-changes.outputs.changed_prompts }}" \
--promoted-by "ci-pipeline@github"
The workflow auto-promotes to staging on a passing main branch merge but requires a human action to promote to production. That final gate exists because evaluation suites, no matter how well-designed, can't fully capture production behavior. You want a human who understands the business context to make that call.
Getting a prompt into production is the beginning, not the end. Prompts degrade in production for reasons that don't show up in static evaluations:
gpt-4o doesn't guarantee identical behavior over time.Build a production monitoring layer that samples live traffic and continuously re-evaluates a subset against your evaluation criteria:
import random
from collections import deque
from datetime import datetime, timedelta
class ProductionMonitor:
def __init__(self, registry: PromptRegistry, evaluator: PromptEvaluator, sample_rate: float = 0.05):
self.registry = registry
self.evaluator = evaluator
self.sample_rate = sample_rate
self.metric_windows = {} # prompt_id -> deque of recent scores
async def log_production_call(
self,
prompt_id: str,
input_variables: dict,
actual_output: str,
metadata: dict = None
):
"""
Called for every production inference. Samples a fraction for evaluation.
"""
# Always log the call for volume metrics
self._record_call(prompt_id, metadata)
# Sample for quality evaluation
if random.random() > self.sample_rate:
return
version = self.registry.get_production_version(prompt_id)
if version is None:
return
# For sampled calls, run automated grading if possible
# Not all prompts have automatable grading — this is a partial signal
grading_result = await self._grade_production_output(
prompt_id,
input_variables,
actual_output,
version
)
if grading_result is not None:
window = self.metric_windows.setdefault(
prompt_id,
deque(maxlen=1000)
)
window.append({
"score": grading_result,
"timestamp": datetime.now(timezone.utc),
"version": version['version']
})
# Check for anomalies
await self._check_for_drift(prompt_id)
async def _check_for_drift(self, prompt_id: str):
"""
Alert if recent scores have significantly degraded from baseline.
Uses a simple CUSUM (cumulative sum) control chart.
"""
window = self.metric_windows.get(prompt_id)
if not window or len(window) < 50:
return
recent_scores = [w['score'] for w in list(window)[-100:]]
baseline_scores = [w['score'] for w in list(window)[-500:-100]]
if len(baseline_scores) < 50:
return
baseline_mean = np.mean(baseline_scores)
baseline_std = np.std(baseline_scores)
recent_mean = np.mean(recent_scores)
# Alert if recent mean is more than 2 standard deviations below baseline
z_score = (baseline_mean - recent_mean) / (baseline_std + 1e-8)
if z_score > 2.0:
await self._send_drift_alert(
prompt_id=prompt_id,
baseline_mean=float(baseline_mean),
recent_mean=float(recent_mean),
z_score=float(z_score)
)
Now it's time to put this together. This exercise will take you from a broken prompt to a tested, versioned, production-ready improvement using the systems we've built.
The scenario: You work on a data platform team that uses an LLM to generate SQL queries from natural language. Your current production prompt works well for simple SELECT queries but has been generating incorrect JOIN conditions when users ask about multi-table relationships. Support tickets have been escalating.
Step 1: Set up your environment
pip install openai psycopg2-binary semver scipy numpy
# Initialize your registry database
psql $DATABASE_URL -f schema.sql
Step 2: Create your baseline test suite
Create a file called test_suite_v1.json with at least 20 test cases. Include:
For grading strategy, use json_field to check that the generated SQL contains required clauses, and llm_judge for semantic correctness on complex cases.
Step 3: Register and evaluate the current production prompt
registry = PromptRegistry(os.environ['DATABASE_URL'])
# Load your current prompt from wherever it lives now
# (This is often the painful part — finding all the places prompts are stored)
current_prompt_content = {
"system": "...", # your current system prompt
"user_template": "Generate SQL for: {{natural_language_query}}\nDatabase schema: {{schema}}"
}
current_version = registry.create_version(
prompt_id="nl-to-sql",
content=current_prompt_content,
model_binding={"provider": "openai", "model": "gpt-4o", "temperature": 0.0},
change_rationale="Initial version imported from production",
change_type="structural",
created_by="your-email@company.com"
)
# Evaluate it
evaluator = PromptEvaluator(AsyncOpenAI(), registry)
results = await evaluator.evaluate_version(
current_version['id'],
test_suite,
test_suite_version="v1.0",
evaluated_by="your-email@company.com"
)
print(f"Baseline score: {results['metrics']['weighted_score']:.3f}")
print(f"Category breakdown: {results['metrics']['category_scores']}")
Step 4: Develop your improved prompt
Analyze the failing test cases in the detailed results. Write an improved prompt that specifically addresses the JOIN condition issues. Create it as a new version with change_type="behavioral" and a descriptive change rationale.
Step 5: Run the comparison
Run both versions against the same test suite and use compare_versions() to determine whether your improvement is statistically significant. If p_value < 0.05 and the recommendation is "promote", proceed to staging.
Checkpoint questions:
Mistake: Treating test suite score as ground truth
Your test suite is a proxy for real-world performance, not a perfect measurement of it. If you optimize heavily against a fixed test suite, you will eventually overfit to it — your prompts will be excellent at the specific cases in your suite while subtly degrading on real traffic. Mitigate this by continuously adding new cases from production failures and rotating a portion of your test suite regularly.
Mistake: Version numbers without semantic meaning
If you auto-increment version numbers without tying them to change types, you lose important signal. A jump from 1.0 to 2.0 should immediately communicate "this is a breaking structural change" to anyone looking at the version history.
Mistake: Testing the new version in isolation
Always run A/B evaluations with the same test suite in the same evaluation window. Scores from different evaluation runs aren't directly comparable due to model temperature variance and potential API-side changes.
Mistake: Skipping the change_rationale field
"Updated prompt" is useless six months from now. "Added explicit instruction to format currency values in USD with two decimal places after observing that European locale users were getting comma-separated outputs that broke downstream parsing" is useful. The extra minute it takes to write a good rationale will save hours of archaeology later.
Mistake: Using high-stakes production traffic for initial prompt experiments
Use your evaluation harness with offline test cases first. Shadow testing (running both versions on production traffic but only serving one) is a reasonable intermediate step. Never "try it in production" as your first signal — by the time you have enough signal, you've already harmed real users.
Troubleshooting: LLM judge scores are inconsistent across runs
If your LLM judge produces different scores for the same output on repeated runs, you likely have temperature > 0. Set judge model temperature to 0.0. If scores still vary, your grading criteria prompt is underspecified — add examples of 0.0, 0.5, and 1.0 scores to the judge prompt itself.
Troubleshooting: Evaluation pipeline is too slow for CI/CD
If running your full test suite takes 20 minutes, engineers will start skipping it. Solutions in order of preference: (1) reduce test suite to a fast-running "smoke test" subset of 20-30 cases for PR checks, with the full suite running nightly; (2) increase concurrency in the evaluator; (3) cache results for unchanged prompts using content hashing.
You now have the complete picture of enterprise prompt management. Let's consolidate what we built:
The Prompt Registry provides a source of truth for every prompt version, with immutable history, promotion workflows, and rollback capability. Critically, it ties prompt content to model bindings, evaluation results, and human-readable lineage.
The Evaluation Harness turns qualitative "does this seem better?" judgments into quantitative measurements. By maintaining static test suites with golden path, edge case, regression, and adversarial cases, you can detect degradation before it reaches production.
Statistical Comparison ensures you make promotion decisions based on evidence. Paired statistical testing tells you whether a score difference is real or noise, and effect size tells you whether it's meaningful.
CI/CD Integration makes rigorous evaluation the default path, not the exception. Automatic evaluation on PR opens a feedback loop that catches problems during development rather than after deployment.
Production Monitoring closes the loop by continuously checking that your production prompts are performing as expected in the real distribution of inputs, not just your test cases.
The architecture we've built here is deliberately technology-agnostic at the infrastructure layer — it works with any LLM API, any cloud provider, and any CI/CD system. Specialized tools like LangSmith, Weights & Biases Prompts, and PromptLayer can replace or augment parts of this stack, but understanding the underlying principles means you can evaluate those tools critically rather than adopting them because they're marketed as "prompt management."
Where to go next:
Learning Path: Intro to AI & Prompt Engineering