AI Model Versioning — Managing Model Updates Without Breaking Your Application

Sanjeev SharmaSanjeev Sharma
7 min read

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 regressions

Model 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% traffic

Shadow 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_after

Version 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro