Free lesson · GenAI Agent Engineering
Build prompt versioning and A/B testing
You can implement PromptVersion (with content hash for change detection), PromptRegistry with rollback, and ABTestingRegistry with sticky-by-user variant assignment; store prompt templates in version control alongside code.
Course: GenAI Agent Engineering · Chapter 17 · The Prompt Engineer (Dynamic)
Free to read — no subscription required.
Introduction
In production, prompt templates change frequently — tweaks for tone, adjustments for new model behavior, or experiments aimed at improving output quality. Without version control, it's impossible to know exactly what changed between deployments, roll back a regression, or run a controlled A/B test with confidence. Engineers who treat prompts as mutable strings rather than versioned artifacts accumulate invisible technical debt that surfaces as unexplained quality drops.
By the end of this lesson, you'll be able to build a PromptRegistry that tracks every version of a template, activates specific versions on demand, and maintains a change log suitable for audit and rollback.
Key Terminology
- PromptVersion — a Pydantic model that pairs a prompt
templatestring with traceability metadata (versionlabel,created_by,description, and a contenthash), forming the atomic, immutable unit stored inside the registry. - PromptRegistry — the container class that maintains the full version history for every named prompt in
self.prompts, tracks which version is currently active inself.active, and appends every activation event toself._change_log. - Content hash — an 8-character SHA-256 digest of the template text, computed automatically in
model_post_initso that any change to a prompt's wording produces a visibly different fingerprint without requiring manual diffing. - Change log — the append-only list
_change_logthat records each activation event withold_version,new_version, timestamp, and author, supplying the audit trail a rollback operation needs to recover a previous state. - Active version — the version label stored in
registry.active[name]that identifies whichPromptVersionis currently serving; advanced byregisterwheneveractivate=Trueis passed.
Concepts
Prompts Are Code, Not Mutable Config
A prompt template looks like a string, but in production it behaves like a deployable artifact: it ships to a live model, its wording directly determines output quality, and a silent edit can degrade results across every downstream user in ways that are hard to trace. Treating a prompt as a mutable string — overwriting it in place when something needs to change — eliminates the ability to correlate a quality regression with its cause, roll back to a known-good state, or run a controlled experiment comparing two variants. The discipline shift is the same one that moved configuration into version control: the value is not just storing text, it's making every change attributable and reversible.
The Append-Only Registry with an Active Pointer
The key structural insight is to separate two distinct concerns: the complete history of all versions and which version is currently serving. PromptRegistry stores these separately — self.prompts[name] is a list that only ever grows (each register call appends a new PromptVersion; nothing is deleted or overwritten), while self.active[name] is a single version label that can be updated independently. Rolling back is therefore a cheap pointer operation: point active at an earlier entry in the list. The register method's activate parameter makes this explicit — you can add a new version to history without immediately promoting it, which is useful for staged rollouts or pre-loading candidates before a planned switch (see Code Walkthrough).
Content Hashing and the Change Log as an Audit Layer
Two mechanisms together answer the question "what changed, when, and who changed it." The content hash — an 8-character SHA-256 prefix computed automatically in model_post_init — acts as a fingerprint: if two PromptVersion objects share a hash, their templates are byte-for-byte identical; a single character difference produces a different hash. This catches accidental re-registrations and makes template drift visible at a glance without diffing raw strings.
The change log complements the hash by recording transitions rather than snapshots. Each entry captures old_version, new_version, timestamp, and the by field — exactly the context a rollback needs. When a quality regression surfaces, you can walk the log backward to find the last activation before the drop, read the hash of that version to confirm the template content, and re-activate it. Together, hash identity and a transition log give the registry the same auditability guarantees that a git history gives source code.
Code Walkthrough
Now that you understand how versioning discipline keeps prompt changes auditable and reversible, the registry below puts those ideas into working code. The PromptVersion model captures template content together with the metadata needed for traceability — who created it, when, and a short SHA-256 hash that makes content changes visible at a glance. The PromptRegistry wraps a collection of these versions, tracks which one is currently active, and logs every activation event.
Code snippetpython
1import hashlib 2from datetime import datetime 3from typing import Dict, List, Optional 4from pydantic import BaseModel, Field 5 6class PromptVersion(BaseModel): 7 version: str 8 template: str 9 description: str 10 created_at: datetime = Field(default_factory=datetime.now) 11 created_by: str 12 hash: str = "" 13 metadata: Dict[str, str] = Field(default_factory=dict) 14 15 def model_post_init(self, __context): 16 if not self.hash: 17 self.hash = hashlib.sha256( 18 self.template.encode() 19 ).hexdigest()[:8] 20 21class PromptRegistry: 22 def __init__(self): 23 self.prompts: Dict[str, List[PromptVersion]] = {} 24 self.active: Dict[str, str] = {} 25 self._change_log: List[Dict] = [] 26 27 def register( 28 self, 29 name: str, 30 template: str, 31 description: str, 32 created_by: str, 33 activate: bool = True, 34 metadata: Optional[Dict[str, str]] = None, 35 ) -> PromptVersion: 36 if name not in self.prompts: 37 self.prompts[name] = [] 38 39 version = f"v{len(self.prompts[name]) + 1}" 40 pv = PromptVersion( 41 version=version, 42 template=template, 43 description=description, 44 created_by=created_by, 45 metadata=metadata or {}, 46 ) 47 self.prompts[name].append(pv) 48 49 if activate: 50 old = self.active.get(name) 51 self.active[name] = version 52 self._change_log.append({ 53 "timestamp": datetime.now().isoformat(), 54 "action": "activate", 55 "prompt": name, 56 "old_version": old, 57 "new_version": version, 58 "by": created_by, 59 }) 60 return pv
model_post_init runs automatically after Pydantic validates the fields, so the content hash is always present without callers needing to compute it manually. register auto-increments the version label — v1, v2, and so on — so engineers never manage version strings by hand. The change log records the previous active version alongside the new one, which is exactly what a rollback operation needs later.
The following snippet exercises the registry end-to-end with two versions of the same prompt:
Code snippetpython
1registry = PromptRegistry() 2 3registry.register( 4 name="summarizer", 5 template="Summarize the following in three sentences: {{ text }}", 6 description="Initial version", 7 created_by="alice", 8) 9 10registry.register( 11 name="summarizer", 12 template="Summarize the following in two concise sentences: {{ text }}", 13 description="Shortened output for mobile", 14 created_by="bob", 15) 16 17print(registry.active["summarizer"]) # v2 18print(len(registry.prompts["summarizer"])) # 2 19print(registry._change_log[-1]["old_version"]) # v1
The second register call does not overwrite the first — both versions remain in registry.prompts["summarizer"], and the change log captures the transition so the previous state is always recoverable. Confirm that registry.active["summarizer"] prints "v2" and that the final change-log entry shows old_version as "v1" — those two assertions together verify that the registry is tracking version transitions correctly.
Do's and Don'ts
Having walked through building prompt versioning above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do pass
activate=Falsewhen registering a draft version — storing a newPromptVersionin the registry without immediately flippingself.activelets you stage experimental templates alongside the live one, so A/B tests and canary rollouts don't disrupt production traffic the moment the template is written. - ✓Do rely on
model_post_initto compute the SHA-256 hash automatically — becausePromptVersionderiveshashfromself.template.encode()during Pydantic initialization, the eight-character fingerprint is always consistent with actual template content; manually setting or copying hash values bypasses this guarantee and makes content-change detection unreliable. - ✓Do inspect
_change_log[-1]["old_version"]before performing a rollback — the change log records bothold_versionandnew_versionon everyactivateevent, so you always have the exact version string needed to callregistry.active[name] = old_versionand restore the previous state without guessing.
Don'ts
- ✗Don't mutate a
PromptVersion.templatefield after registration — the SHA-256 hash is computed once inmodel_post_initand stored; editing the template string afterward causes the stored hash to no longer match the content, silently breaking any diff or content-change audit that relies on it. - ✗Don't manage version strings like
"v2"by hand —registerderives the label fromf"v{len(self.prompts[name]) + 1}"automatically; hard-coding or skipping version labels breaks the sequential numbering that_change_logentries and rollback logic depend on. - ✗Don't treat the registry's
activedict as the complete prompt history —self.activeholds only the currently active version string per name; the full list of every registered template lives inself.prompts[name], and discarding or replacing that list destroys the rollback chain the change log points into.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in GenAI Agent Engineering
- Ch 13Design cache-friendly prompt architectures
- Ch 15Use Pydantic for tool schemas
- Ch 16Build with LangGraph StateGraph
- Ch 17Build prompt versioning and A/B testingYou are here
- Ch 20Generate JSON Schema from Pydantic models
- Ch 20Build a Pydantic tool library
- Ch 24Create an MCP server with lifecycle management