7. Testing & versioning prompts¶
Intermediate · 13 min read
You can't tell whether a prompt works by reading it, or by trying three inputs. A change that fixes one case often breaks two others you didn't look at. The fix is the same as for code: tests, versions and a record of what ran in production.
7.1 Build an eval set¶
An eval set is a list of inputs with what a good output must satisfy. Start with 20–50 cases and grow it from real failures.
| Source | Example |
|---|---|
| Typical inputs | The 10 most common question types from logs |
| Edge cases | Empty input, very long input, Hindi or Hinglish, typos |
| Known failures | Every bug report becomes a case, so it never comes back |
| Attacks | The injection set |
7.2 Score with checks¶
Prefer code checks wherever the requirement is objective; they're free, fast and consistent. Use an LLM judge only for qualities code can't check (helpfulness, tone, faithfulness).
import json
def checks(case: dict, output: str) -> dict[str, bool]:
results = {}
try:
data = json.loads(output)
results["valid_json"] = True
except json.JSONDecodeError:
return {"valid_json": False}
results["label_correct"] = data.get("label") == case["expected_label"]
results["reason_short"] = len(data.get("reason", "").split()) <= 15
return results
case = {"input": "I was charged twice", "expected_label": "billing"}
print(checks(case, '{"label": "billing", "reason": "duplicate charge"}'))
print(checks(case, '{"label": "bug", "reason": "the payment system has a problem that caused a duplicate charge on the card yesterday afternoon"}'))
print(checks(case, "billing"))
{'valid_json': True, 'label_correct': True, 'reason_short': True}
{'valid_json': True, 'label_correct': False, 'reason_short': False}
{'valid_json': False}
7.3 Compare versions before shipping¶
Run the old and new prompt on the same eval set and compare pass rates, and look at the cases that changed:
CASES = [
{"input": "I was charged twice", "expected_label": "billing"},
{"input": "App crashes on login", "expected_label": "bug"},
{"input": "Add dark mode please", "expected_label": "feature_request"},
{"input": "Where's my refund?", "expected_label": "billing"},
{"input": "Thanks, great app!", "expected_label": "other"},
]
def fake_model(prompt_version: str, text: str) -> str:
"""v1 has no 'other' label, so it forces praise into 'feature_request'; v2 fixes that."""
rules = {"charged": "billing", "refund": "billing", "crash": "bug", "dark mode": "feature_request"}
label = next((v for k, v in rules.items() if k in text.lower()), None)
label = label or ("other" if prompt_version == "v2" else "feature_request")
return json.dumps({"label": label, "reason": "short reason"})
def run(version: str) -> list[bool]:
return [all(checks(c, fake_model(version, c["input"])).values()) for c in CASES]
old, new = run("v1"), run("v2")
print(f"v1: {sum(old)}/{len(CASES)} v2: {sum(new)}/{len(CASES)}")
for case, a, b in zip(CASES, old, new):
if a != b:
print(f"changed: {case['input']!r}: {'pass' if a else 'fail'} -> {'pass' if b else 'fail'}")
Ship only when the new version is at least as good overall and on every critical case. With real models, run each case several times: outputs vary, and a 1-case difference on 50 cases is usually noise.
7.4 Regression tests in CI¶
Turn the must-pass cases into ordinary tests, so a prompt change that breaks them fails the build:
# no-run
# tests/test_prompts.py
import pytest
from app.prompts import render
from app.llm import classify
@pytest.mark.parametrize("text, label", [
("I was charged twice", "billing"),
("Thanks, great app!", "other"),
])
def test_ticket_labels(text, label):
assert classify(text)["label"] == label
def test_template_has_no_missing_variables():
render("classify_ticket", "v2", ticket="x") # raises if a variable is missing
Real-model tests are slow and cost money: run a small must-pass set on every pull request, and the full eval nightly or before a release. More in Running evals in CI.
7.5 A prompt registry¶
Keep prompts in one place, with a version, an owner and the model they were tested with. Log the version with every call.
from string import Template
REGISTRY = {
"classify_ticket": {
"v1": {"model": "small", "template": Template("Classify: $ticket")},
"v2": {"model": "small", "template": Template(
"Classify the ticket as billing, bug, feature_request or other.\n"
"Reply with JSON: {\"label\": ..., \"reason\": <at most 15 words>}\n<ticket>$ticket</ticket>")},
},
}
ACTIVE = {"classify_ticket": "v2"}
def render(name: str, version: str | None = None, **values) -> tuple[str, dict]:
version = version or ACTIVE[name]
entry = REGISTRY[name][version]
log = {"prompt": name, "version": version, "model": entry["model"]} # store this with every call
return entry["template"].substitute(**values), log
prompt, log = render("classify_ticket", ticket="I was charged twice")
print(prompt)
print(log)
Classify the ticket as billing, bug, feature_request or other.
Reply with JSON: {"label": ..., "reason": <at most 15 words>}
<ticket>I was charged twice</ticket>
{'prompt': 'classify_ticket', 'version': 'v2', 'model': 'small'}
With the version in every log line, you can answer "which prompt produced this bad answer?" and roll back by changing one line. Tools like Langfuse and LangSmith offer hosted prompt registries with the same idea.
7.6 Optimise for cost and latency¶
Once a prompt is correct, make it cheaper:
- Trim: remove rules no eval needs, and repeated instructions. Every token is paid on every call.
- Cache: keep the long stable part (system prompt, examples, documents) first and identical, so prompt caching applies.
- Shorter outputs: ask for exactly the fields you need; output tokens cost more than input tokens.
- Smaller model: re-run the eval on a cheaper model; a better prompt often lets a small model pass.
- Automatic optimisation: tools such as DSPy search over instructions and examples against your eval metric. Useful once you have a good eval set; useless without one.
Interview questions¶
How do you know a prompt change is an improvement?
Run the old and new versions on the same eval set of typical, edge, failure and attack cases, compare pass rates and inspect every case that changed. Repeat runs to account for randomness, and require no regressions on critical cases before shipping.
How do you manage prompts in production?
Keep them in a versioned registry with an owner and the model they were tested with, render them with checked variables, log the prompt name and version with every call, run must-pass tests in CI, and roll out changes with the ability to switch back quickly.
When should you use an LLM judge instead of code checks?
Use code for objective requirements: valid JSON, correct label, length, required fields, banned phrases. Use an LLM judge for subjective qualities like helpfulness, tone or faithfulness to sources, and validate the judge against human ratings on a sample.
Practice¶
- Add a case to
CASESthat both versions fail, and decide what av3prompt should change. - Make
renderraise a clear error when the requested version doesn't exist.
Next: 8. Prompt recipes for common tasks Back to: Prompt Engineering overview · Notes overview