AI Model Versioning — Managing Model Updates Without Breaking Your Application
Advertisement
Introduction
Deploying a new model version without breaking production requires more than a git commit and a push to main. A model update can silently change output format, shift tone, or alter accuracy on specific query types — breaking downstream parsers, failing SLAs, and confusing users. This guide covers semantic versioning for AI models, model registries, canary deployments, shadow testing, and automated rollback strategies that together make model updates safe and reversible.
Semantic Versioning for AI Models
Apply semantic versioning — MAJOR.MINOR.PATCH — to every model deployed to production:
- MAJOR: Breaking changes. Output schema changes, significant behavioral shifts, system prompt incompatibilities with downstream systems.
- MINOR: New capabilities or improved performance without breaking contracts. Better accuracy, faster latency, new supported languages.
- PATCH: Bug fixes and numerical improvements with no behavioral changes. Temperature tuning, minor prompt adjustments.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class ModelVersion:
name: str
version: str # semver: "2.1.3"
training_data_version: str
training_cutoff: datetime
evaluation_metrics: dict[str, float]
breaking_changes: list[str]
changelog: str
released_at: datetime
def parse_version(version: str) -> tuple[int, int, int]:
parts = version.split(".")
return int(parts[0]), int(parts[1]), int(parts[2])
def is_breaking_change(old: ModelVersion, new: ModelVersion) -> bool:
old_major, _, _ = parse_version(old.version)
new_major, _, _ = parse_version(new.version)
return new_major > old_major or len(new.breaking_changes) > 0
def check_metric_regression(
old: ModelVersion,
new: ModelVersion,
threshold: float = 0.02,
) -> list[str]:
regressions = []
for metric, old_val in old.evaluation_metrics.items():
new_val = new.evaluation_metrics.get(metric, 0.0)
if old_val > 0 and (old_val - new_val) / old_val > threshold:
regressions.append(
f"{metric}: {old_val:.3f} -> {new_val:.3f} "
f"({(old_val - new_val) / old_val * 100:.1f}% regression)"
)
return regressionsModel Registry Architecture
A model registry is the central source of truth for every model artifact, its metadata, and its production status:
from enum import Enum
class ModelStage(str, Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
ARCHIVED = "archived"
class ModelRegistry:
def __init__(self):
self._models: dict[str, list[ModelVersion]] = {}
self._stages: dict[str, ModelStage] = {} # "name:version" -> stage
def register(self, model: ModelVersion) -> None:
if model.name not in self._models:
self._models[model.name] = []
self._models[model.name].append(model)
self._stages[f"{model.name}:{model.version}"] = ModelStage.DEVELOPMENT
def promote(self, name: str, version: str, stage: ModelStage) -> None:
key = f"{name}:{version}"
if key not in self._stages:
raise ValueError(f"Model {key} not registered")
self._stages[key] = stage
print(f"Promoted {key} to {stage.value}")
def get_production_model(self, name: str) -> ModelVersion | None:
for model in reversed(self._models.get(name, [])):
key = f"{model.name}:{model.version}"
if self._stages.get(key) == ModelStage.PRODUCTION:
return model
return None
def list_versions(self, name: str) -> list[dict]:
return [
{
"version": m.version,
"stage": self._stages.get(f"{m.name}:{m.version}", "unknown"),
"released_at": m.released_at.isoformat(),
"metrics": m.evaluation_metrics,
}
for m in self._models.get(name, [])
]Canary Deployment Strategy
Never deploy to 100% of traffic immediately. Route a small fraction to the new model and monitor for regressions:
import random
class CanaryRouter:
def __init__(
self,
production_model,
canary_model,
canary_fraction: float = 0.05,
):
self.production_model = production_model
self.canary_model = canary_model
self.canary_fraction = canary_fraction
self.canary_metrics: list[dict] = []
def route(self, user_id: str, request: str) -> dict:
# Deterministic routing by user ID to ensure sticky sessions
is_canary = (hash(user_id) % 100) < (self.canary_fraction * 100)
model = self.canary_model if is_canary else self.production_model
response = model.generate(request)
self.canary_metrics.append({
"user_id": user_id,
"model": "canary" if is_canary else "production",
"latency_ms": response.latency_ms,
"error": response.error,
})
return {"response": response.text, "model_version": model.version}
def compute_canary_health(self) -> dict:
canary = [m for m in self.canary_metrics if m["model"] == "canary"]
production = [m for m in self.canary_metrics if m["model"] == "production"]
if not canary or not production:
return {"status": "insufficient_data"}
canary_error_rate = sum(1 for m in canary if m["error"]) / len(canary)
prod_error_rate = sum(1 for m in production if m["error"]) / len(production)
canary_p50 = sorted(m["latency_ms"] for m in canary)[len(canary) // 2]
return {
"canary_error_rate": canary_error_rate,
"production_error_rate": prod_error_rate,
"error_rate_delta": canary_error_rate - prod_error_rate,
"canary_p50_latency_ms": canary_p50,
"healthy": canary_error_rate <= prod_error_rate * 1.2,
}
# Typical canary progression:
# Day 1: 5% traffic
# Day 2: 20% traffic (if metrics healthy)
# Day 3: 50% traffic
# Day 4: 100% trafficShadow Testing New Models
Run the new model in parallel with production without exposing its output to users. Compare results offline:
import asyncio
import time
class ShadowTester:
def __init__(self, production_model, shadow_model):
self.production = production_model
self.shadow = shadow_model
self.results: list[dict] = []
async def shadow_request(self, request: str) -> str:
prod_task = asyncio.create_task(self._call(self.production, request))
shadow_task = asyncio.create_task(self._call(self.shadow, request))
prod_result, shadow_result = await asyncio.gather(prod_task, shadow_task)
similarity = self._token_overlap(prod_result["text"], shadow_result["text"])
self.results.append({
"request_preview": request[:100],
"production_response": prod_result["text"][:300],
"shadow_response": shadow_result["text"][:300],
"similarity": similarity,
"prod_latency_ms": prod_result["latency_ms"],
"shadow_latency_ms": shadow_result["latency_ms"],
})
# Always return production result to the user
return prod_result["text"]
async def _call(self, model, request: str) -> dict:
start = time.time()
text = model.generate(request)
return {"text": text, "latency_ms": int((time.time() - start) * 1000)}
def _token_overlap(self, text1: str, text2: str) -> float:
tokens1 = set(text1.lower().split())
tokens2 = set(text2.lower().split())
if not tokens1 and not tokens2:
return 1.0
return len(tokens1 & tokens2) / len(tokens1 | tokens2)
def summary(self) -> dict:
if not self.results:
return {}
avg_similarity = sum(r["similarity"] for r in self.results) / len(self.results)
low_agreement = [r for r in self.results if r["similarity"] < 0.5]
return {
"total_comparisons": len(self.results),
"average_similarity": avg_similarity,
"low_agreement_count": len(low_agreement),
"low_agreement_pct": len(low_agreement) / len(self.results) * 100,
}Automated Rollback Triggers
Define metric thresholds that trigger automatic rollback without human intervention:
from datetime import datetime, timedelta
class RollbackPolicy:
def __init__(
self,
error_rate_threshold: float = 0.05,
latency_p95_threshold_ms: float = 3000,
eval_window_minutes: int = 15,
trigger_after_violations: int = 3,
):
self.error_rate_threshold = error_rate_threshold
self.latency_p95_threshold_ms = latency_p95_threshold_ms
self.eval_window_minutes = eval_window_minutes
self.trigger_after = trigger_after_violations
self._violations: list[dict] = []
def check(self, metrics: dict) -> bool:
triggered = False
if metrics.get("error_rate", 0) > self.error_rate_threshold:
triggered = True
self._violations.append({"type": "error_rate", "value": metrics["error_rate"]})
if metrics.get("latency_p95_ms", 0) > self.latency_p95_threshold_ms:
triggered = True
self._violations.append({"type": "latency", "value": metrics["latency_p95_ms"]})
# Only rollback after repeated violations to avoid noise
recent_violations = [
v for v in self._violations
if (datetime.utcnow() - v.get("ts", datetime.utcnow())) < timedelta(minutes=self.eval_window_minutes)
]
return triggered and len(recent_violations) >= self.trigger_afterVersion Pinning for Critical Applications
Allow applications to pin to a specific model version for deterministic behavior:
class VersionedModelClient:
def __init__(self, registry: ModelRegistry, pinned_version: str | None = None):
self.registry = registry
self.pinned_version = pinned_version
def get_model(self, model_name: str):
if self.pinned_version:
versions = self.registry.list_versions(model_name)
match = next(
(v for v in versions if v["version"] == self.pinned_version),
None,
)
if not match:
raise ValueError(f"Pinned version {self.pinned_version} not found")
return match
return self.registry.get_production_model(model_name)Key Takeaways
- Semantic versioning — MAJOR for breaking changes, MINOR for new capabilities, PATCH for bug fixes — makes model update intent explicit to both humans and downstream systems.
- A centralized model registry with stage tracking (development, staging, production, archived) is the prerequisite for safe canary and shadow deployments.
- Canary deployments must use deterministic user-ID-based routing — random routing causes the same user to see different models across requests, creating inconsistent experiences.
- Shadow testing is the safest way to validate a new model: it exposes zero users to the shadow model while generating a large sample of behavioral comparisons.
- Automated rollback policies should trigger on sustained violations over a measurement window, not on single anomalous data points.
- Version pinning is essential for compliance-sensitive applications where the model output must remain reproducible for audit purposes.
- Every model registration should include training data version, cutoff date, and evaluation metrics to make reproducibility and debugging possible months later.
- A/B testing model versions requires tracking business metrics (task completion, user retention) not just model quality metrics — the model that wins on MMLU may lose on user satisfaction.
Advertisement