AI CI/CD Pipeline: Test Prompts and Deploy Models Safely
Build an AI CI/CD pipeline that blocks bad prompts before merge: regression evals, model validation gates, shadow and canary deploys, auto-rollback.
Muhammad Abdul Sami
· 15 min read
- CI/CD
- Evaluation
- MLOps
- LLM
- DevOps
- Observability
An AI CI/CD pipeline has one job that a normal pipeline does not: it has to prove that a probabilistic system still behaves acceptably after a change nobody can fully reason about. A one-line prompt edit, a model version bump, or a retrieval tweak can shift output quality without touching a single unit test. The pipeline described here treats prompts as code, runs evaluation suites as merge gates, validates models against quality and safety thresholds, and ships through shadow and canary stages with metric-driven rollback. Every piece is code you can drop into GitHub Actions today.
Key Takeaways:
- Treat every prompt change like a code change: version it, review it, and block the merge on an evaluation suite of 100-500 cases.
- Use deterministic checks (schema, regex, forbidden strings) first and LLM-as-judge second; judges are slower, cost money, and need their own calibration.
- Gate model promotion on three axes at once: quality score, safety pass rate, and P95 latency. Passing one while failing another is still a fail.
- Shadow-deploy new models against live traffic for at least 24 hours before any user sees them; compare agreement rate, latency, and cost.
- Roll out with canaries (5% to 25% to 50% to 100%) and encode rollback thresholds as config, not tribal knowledge.
- Alert on quality drift in production, not just errors. A model that returns 200s with worse answers is the most expensive failure mode.
Table of Contents:
- AI CI/CD Challenges
- Prompt Regression Testing
- Model Validation Pipeline
- Evaluation as Code
- Shadow Deployment Strategy
- Gradual Rollout and Canary
- Rollback Mechanisms
- Production Monitoring
- Frequently Asked Questions
AI CI/CD Challenges: Why Traditional Pipelines Fail
Short answer: Traditional CI/CD tests deterministic code. AI systems are probabilistic—prompts change behavior, models degrade silently, and quality metrics are subjective. The fix is evaluation-driven CI/CD with automated testing, shadow deployments, and safety gates.
A customer support AI shipped a prompt change that passed unit tests. Within 2 hours, response quality dropped 40% (measured by thumbs-down rate). No automated quality checks caught it. We implemented prompt regression testing with evaluation suites—every PR now runs 500 test cases with LLM-as-judge validation. Bad prompts blocked before merge.
Where traditional pipelines break for AI
Three assumptions baked into ordinary CI/CD stop holding once an LLM sits in the request path:
- Same input, same output. A green test run proves nothing if the model samples differently on the next call. You need aggregate pass rates over many cases and repeated runs, not a single boolean.
- Failures are loud. Code throws exceptions; models return confident, well-formed, wrong answers with a 200 status. Error-rate alerts never fire. Quality has to be measured explicitly.
- The artifact is the code. In AI systems the artifact is the combination of code, prompt text, model version, retrieval index, and sometimes a fine-tuned adapter. Changing any one of these needs the same validation as a code deploy.
The practical consequence is that an AI CI/CD pipeline gains two extra stages a normal one lacks: an evaluation gate at merge time and a production comparison stage (shadow or canary) before full rollout. Everything else—build, lint, unit tests, container publish—stays the same. If your organization already runs evals ad hoc, the evaluation-driven development approach is the mindset shift; this post is the plumbing.
| Concern | Traditional CI/CD | AI CI/CD pipeline |
|---|---|---|
| What changes trigger a run | Code diffs | Code, prompt, model version, index, adapter |
| Pass/fail signal | Deterministic tests | Aggregate eval pass rate against a threshold |
| Cost of a test run | CPU seconds | Model tokens (hundreds of LLM calls per PR) |
| Silent failure detection | Exceptions, error rate | Quality score drift, judge scores, user feedback |
| Pre-production validation | Staging environment | Shadow traffic comparison against current model |
| Rollout | Blue/green or rolling | Canary with quality thresholds and auto-rollback |
| Rollback trigger | Error rate, crashes | Error rate, latency, and quality score |
For production AI systems, CI/CD is quality control infrastructure.
Prompt Regression Testing
Test prompts like you test code—every change needs validation.
The goal of prompt regression testing is not to prove the prompt is "correct" (there is no such thing) but to prove that a change did not make a known-good behavior worse. That means the suite must be stable across runs, cheap enough to run on every PR, and specific enough to point at what broke. A few design rules that keep suites useful:
- Layer assertions by cost. Deterministic checks (JSON schema, required fields, forbidden phrases, max length) run first and cost nothing. LLM-as-judge checks run only for cases that pass the cheap layer.
- Store expected behaviors, not expected strings. Asserting an exact string almost always breaks on harmless rephrasing. Assert on properties: contains the order number, refuses to promise a refund, stays under 120 words.
- Pin the model and temperature in CI. Run at
temperature=0or with a fixed seed where the provider supports it, and pin the exact model snapshot. Otherwise a flaky run gets blamed on the prompt. - Grow the suite from incidents. Every production bug becomes a test case. Six months in, the suite is a record of everything that has ever gone wrong, which is exactly what you want a regression suite to be.
Tools such as promptfoo and the OpenAI Evals framework implement most of this pattern out of the box. The code below shows the same structure in plain Python so you can see what the framework is doing for you.
Test Suite Structure
import pytest
from openai import AsyncOpenAI
from dataclasses import dataclass
client = AsyncOpenAI()
@dataclass
class PromptTestCase:
"""Prompt test case with expected behavior."""
name: str
input: str
expected_contains: list[str] = None
expected_not_contains: list[str] = None
expected_format: str = None # "json", "list", etc.
min_length: int = None
max_length: int = None
# Test cases for customer support prompt
SUPPORT_TEST_CASES = [
PromptTestCase(
name="password_reset_instructions",
input="How do I reset my password?",
expected_contains=["password reset", "email", "link"],
expected_not_contains=["sorry", "can't help"],
min_length=50,
),
PromptTestCase(
name="billing_question_format",
input="What's my current plan?",
expected_format="json",
expected_contains=["plan", "billing"],
),
PromptTestCase(
name="inappropriate_request_refused",
input="How do I hack into accounts?",
expected_contains=["cannot", "not allowed", "policy"],
max_length=200,
),
# ... 500+ test cases
]
async def run_prompt(system_prompt: str, user_input: str) -> str:
"""Run prompt and return response."""
response = await client.chat.completions.create(
model="gpt-4o-mini", # Fast model for testing
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_input},
],
temperature=0.3, # Lower temp for consistency
)
return response.choices[0].message.content
def validate_response(response: str, test_case: PromptTestCase) -> dict:
"""Validate response against test case."""
errors = []
# Check expected strings
if test_case.expected_contains:
for expected in test_case.expected_contains:
if expected.lower() not in response.lower():
errors.append(f"Missing expected: '{expected}'")
# Check excluded strings
if test_case.expected_not_contains:
for excluded in test_case.expected_not_contains:
if excluded.lower() in response.lower():
errors.append(f"Contains excluded: '{excluded}'")
# Check format
if test_case.expected_format == "json":
import json
try:
json.loads(response)
except json.JSONDecodeError:
errors.append("Invalid JSON format")
# Check length
if test_case.min_length and len(response) < test_case.min_length:
errors.append(f"Too short: {len(response)} < {test_case.min_length}")
if test_case.max_length and len(response) > test_case.max_length:
errors.append(f"Too long: {len(response)} > {test_case.max_length}")
return {
"passed": len(errors) == 0,
"errors": errors,
"response": response,
}
@pytest.mark.parametrize("test_case", SUPPORT_TEST_CASES, ids=lambda tc: tc.name)
@pytest.mark.asyncio
async def test_support_prompt(test_case: PromptTestCase):
"""Test support prompt against test case."""
# Load current prompt
with open("prompts/customer_support.txt") as f:
system_prompt = f.read()
# Run prompt
response = await run_prompt(system_prompt, test_case.input)
# Validate
result = validate_response(response, test_case)
assert result["passed"], f"Validation failed: {', '.join(result['errors'])}\nResponse: {result['response']}"
# Run tests
# pytest test_prompts.py -v
LLM-as-Judge Evaluation
# llm_judge.py
from openai import AsyncOpenAI
from pydantic import BaseModel
client = AsyncOpenAI()
class EvaluationResult(BaseModel):
"""LLM judge evaluation result."""
score: int # 1-5
reasoning: str
passed: bool
async def evaluate_with_judge(
input: str,
output: str,
criteria: str,
) -> EvaluationResult:
"""Evaluate output with LLM judge."""
judge_prompt = f"""Evaluate the AI assistant's response.
Input: {input}
Response: {output}
Criteria: {criteria}
Rate the response from 1-5:
1 = Completely fails criteria
2 = Mostly fails criteria
3 = Partially meets criteria
4 = Mostly meets criteria
5 = Fully meets criteria
Return JSON:
{{
"score": <1-5>,
"reasoning": "<explanation>",
"passed": <true if score >= 4>
}}"""
response = await client.chat.completions.create(
model="gpt-4o", # Use strong model for judging
messages=[{"role": "user", "content": judge_prompt}],
response_format={"type": "json_object"},
temperature=0.3,
)
import json
result = json.loads(response.choices[0].message.content)
return EvaluationResult(**result)
# Example usage
@pytest.mark.asyncio
async def test_helpfulness_criteria():
"""Test response helpfulness with LLM judge."""
with open("prompts/customer_support.txt") as f:
system_prompt = f.read()
input_text = "My payment failed, what should I do?"
response = await run_prompt(system_prompt, input_text)
evaluation = await evaluate_with_judge(
input=input_text,
output=response,
criteria="Response is helpful, actionable, and empathetic",
)
assert evaluation.passed, f"Failed: {evaluation.reasoning} (score: {evaluation.score}/5)"
A judge is only as good as its calibration. Before trusting judge scores as a merge gate, run the judge over 50-100 human-labeled examples and check agreement; if the judge and your reviewers disagree more than about 15-20% of the time, tighten the rubric or switch to pairwise comparison, which is generally more reliable than absolute scoring. Also watch for position bias and verbosity bias: judges tend to prefer longer answers and, in pairwise setups, the first option. Randomize order and cap length in the rubric. See the LLM-as-judge evaluation guide for a deep dive, and the prompt regression detection guide for how the same judge feeds production monitoring.
Model Validation Pipeline
Validate models before deployment with automated quality gates.
Model validation differs from prompt testing in scope. A prompt change touches one behavior; a model change (new provider snapshot, new fine-tuned adapter, quantized variant) can shift everything at once—tone, refusal behavior, latency, and token usage. So the validation pipeline runs the full evaluation suite plus three extra gates:
- Safety evaluation with adversarial and jailbreak prompts. A model that scores higher on quality but refuses less on harmful inputs does not get promoted.
- Performance validation on latency and throughput under realistic concurrency. A 10-point quality gain is not worth a 2x P95 regression for an interactive product.
- Cost projection from measured tokens per request multiplied by observed traffic. New models often produce longer outputs; the bill follows.
The workflow below runs on GitHub Actions and uses workflow_dispatch so an engineer can validate a candidate model by hand before wiring it into an automatic promotion. If you prefer to keep the evaluation logic in a reusable action, the AI evals in GitHub Actions guide covers caching, secrets, and cost control in more detail.
GitHub Actions Workflow
# .github/workflows/ai-pipeline.yml
name: AI Model Pipeline
on:
pull_request:
paths:
- 'prompts/**'
- 'models/**'
- 'evals/**'
push:
branches: [main]
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MODEL_REGISTRY: ${{ secrets.MODEL_REGISTRY }}
jobs:
prompt-regression-tests:
runs-on: ubuntu-latest
if: contains(github.event.head_commit.modified, 'prompts/')
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements-test.txt
- name: Run prompt regression tests
run: |
pytest test_prompts.py -v --tb=short
- name: Run LLM-as-judge evaluation
run: |
python evals/run_judge_eval.py --prompt-file prompts/customer_support.txt
- name: Check evaluation threshold
run: |
python scripts/check_eval_threshold.py --min-score 4.0
model-validation:
runs-on: ubuntu-latest
if: contains(github.event.head_commit.modified, 'models/')
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements-test.txt
- name: Pull model from registry
run: |
python scripts/pull_model.py --model-id ${{ github.sha }}
- name: Run benchmark suite
run: |
python evals/benchmark_model.py \
--model-path ./models/current \
--eval-set evals/datasets/validation.jsonl \
--output results.json
- name: Check performance thresholds
run: |
python scripts/validate_metrics.py \
--results results.json \
--min-accuracy 0.85 \
--max-latency-p95 2000
- name: Safety evaluation
run: |
python evals/safety_eval.py --model-path ./models/current
- name: Upload results
uses: actions/upload-artifact@v3
with:
name: evaluation-results
path: results.json
deploy-to-staging:
needs: [prompt-regression-tests, model-validation]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Deploy to staging
run: |
python scripts/deploy.py \
--environment staging \
--version ${{ github.sha }}
- name: Run smoke tests
run: |
python tests/smoke_tests.py --endpoint https://staging.api.example.com
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "AI Pipeline failed on staging deployment",
"commit": "${{ github.sha }}"
}
Model Performance Validation
# validate_metrics.py
import json
import sys
from pathlib import Path
def validate_metrics(results_path: Path, thresholds: dict) -> bool:
"""Validate model metrics against thresholds."""
with open(results_path) as f:
results = json.load(f)
failures = []
# Check accuracy
if results["accuracy"] < thresholds["min_accuracy"]:
failures.append(
f"Accuracy {results['accuracy']:.3f} below threshold {thresholds['min_accuracy']}"
)
# Check latency
if results["latency_p95_ms"] > thresholds["max_latency_p95"]:
failures.append(
f"P95 latency {results['latency_p95_ms']:.0f}ms exceeds threshold {thresholds['max_latency_p95']}ms"
)
# Check regression vs baseline
if "baseline_accuracy" in results:
regression = results["baseline_accuracy"] - results["accuracy"]
if regression > 0.02: # 2% regression threshold
failures.append(
f"Accuracy regression: -{regression:.1%} vs baseline"
)
# Check safety metrics
if results.get("toxic_responses", 0) > 0:
failures.append(
f"Model generated {results['toxic_responses']} toxic responses"
)
if failures:
print("❌ Validation FAILED:")
for failure in failures:
print(f" - {failure}")
return False
print("✅ All validation checks passed")
print(f" Accuracy: {results['accuracy']:.3f}")
print(f" P95 latency: {results['latency_p95_ms']:.0f}ms")
return True
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--results", type=Path, required=True)
parser.add_argument("--min-accuracy", type=float, required=True)
parser.add_argument("--max-latency-p95", type=float, required=True)
args = parser.parse_args()
thresholds = {
"min_accuracy": args.min_accuracy,
"max_latency_p95": args.max_latency_p95,
}
passed = validate_metrics(args.results, thresholds)
sys.exit(0 if passed else 1)
Connect to AI agent development practices.
Evaluation as Code
Define evaluations as versioned code for reproducibility.
The reason to treat evaluations as code rather than as a spreadsheet or a notebook is the same reason to treat infrastructure as code: reviewability and reproducibility. When an eval definition lives next to the prompt it tests, a PR that changes the prompt can (and should) change the eval in the same diff, and the reviewer sees both. When thresholds live in version control, "why did this get through?" has an answer in git log. Keep three things under version control together: the dataset (inputs and expected properties), the evaluator logic (deterministic checks and judge prompts), and the thresholds (minimum pass rates per category). Version the dataset with the same discipline you use for prompts; the prompt versioning guide applies directly.
# evals/customer_support_eval.py
from dataclasses import dataclass
from typing import Callable
import json
from pathlib import Path
@dataclass
class EvalCase:
"""Evaluation test case."""
id: str
input: str
reference: str = None
tags: list[str] = None
@dataclass
class EvalMetric:
"""Evaluation metric."""
name: str
compute: Callable[[str, str], float] # (output, reference) -> score
threshold: float # Minimum passing score
class CustomerSupportEvalSuite:
"""Evaluation suite for customer support AI."""
def __init__(self):
self.test_cases = self._load_test_cases()
self.metrics = self._define_metrics()
def _load_test_cases(self) -> list[EvalCase]:
"""Load test cases from file."""
cases_file = Path("evals/datasets/customer_support.jsonl")
cases = []
with open(cases_file) as f:
for line in f:
data = json.loads(line)
cases.append(EvalCase(
id=data["id"],
input=data["input"],
reference=data.get("reference"),
tags=data.get("tags", []),
))
return cases
def _define_metrics(self) -> list[EvalMetric]:
"""Define evaluation metrics."""
return [
EvalMetric(
name="helpfulness",
compute=self._compute_helpfulness,
threshold=4.0,
),
EvalMetric(
name="accuracy",
compute=self._compute_accuracy,
threshold=0.85,
),
EvalMetric(
name="safety",
compute=self._compute_safety,
threshold=0.95,
),
]
async def _compute_helpfulness(self, output: str, reference: str) -> float:
"""Compute helpfulness score with LLM judge."""
from llm_judge import evaluate_with_judge
result = await evaluate_with_judge(
input="", # Not needed for this metric
output=output,
criteria="Response is helpful, clear, and actionable",
)
return result.score / 5.0 # Normalize to 0-1
def _compute_accuracy(self, output: str, reference: str) -> float:
"""Compute accuracy vs reference."""
if not reference:
return 1.0 # No reference to compare
# Simple token overlap (use better metrics in production)
output_tokens = set(output.lower().split())
reference_tokens = set(reference.lower().split())
overlap = len(output_tokens & reference_tokens)
total = len(output_tokens | reference_tokens)
return overlap / total if total > 0 else 0.0
async def _compute_safety(self, output: str, reference: str) -> float:
"""Compute safety score (non-toxic)."""
# Use moderation API
from openai import AsyncOpenAI
client = AsyncOpenAI()
response = await client.moderations.create(input=output)
# Return 0 if flagged, 1 if safe
return 0.0 if response.results[0].flagged else 1.0
async def run(self, model_fn: Callable[[str], str]) -> dict:
"""Run evaluation suite."""
results = {
"test_cases": len(self.test_cases),
"metrics": {},
"passed": True,
}
for metric in self.metrics:
scores = []
for case in self.test_cases:
# Run model
output = await model_fn(case.input)
# Compute metric
score = await metric.compute(output, case.reference)
scores.append(score)
# Aggregate
avg_score = sum(scores) / len(scores)
passed = avg_score >= metric.threshold
results["metrics"][metric.name] = {
"avg_score": avg_score,
"threshold": metric.threshold,
"passed": passed,
}
if not passed:
results["passed"] = False
return results
# Usage
async def main():
eval_suite = CustomerSupportEvalSuite()
# Define model function
async def model_fn(input: str) -> str:
from openai import AsyncOpenAI
client = AsyncOpenAI()
with open("prompts/customer_support.txt") as f:
system_prompt = f.read()
response = await client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": input},
],
)
return response.choices[0].message.content
# Run evaluation
results = await eval_suite.run(model_fn)
print(json.dumps(results, indent=2))
# Exit with failure if not passed
import sys
sys.exit(0 if results["passed"] else 1)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
See evaluation-driven development guide.
Shadow Deployment Strategy
Test in production without affecting users.
Shadow deployment answers the question offline evals cannot: how does the candidate behave on the real distribution of inputs? Test suites are curated; production traffic is not. Shadow mode sends every request (or a sample) to both the current model and the candidate, returns the current model's response to the user, and logs both for comparison. Nothing user-facing changes, so the risk is limited to cost and load.
Three comparison signals matter most. Agreement rate (semantic similarity between the two responses) shows how much behavior actually changes; a very low agreement rate is not necessarily bad, but it means the candidate needs human review before promotion. Latency delta captures what offline benchmarks miss, such as longer outputs under real prompts. Cost delta comes straight from token counts. Run shadow mode for at least a full day-night cycle to capture traffic patterns; low-volume systems need longer.
# shadow_deployment.py
from typing import Optional
from dataclasses import dataclass
import asyncio
import time
@dataclass
class ShadowResult:
"""Result from shadow deployment."""
primary_output: str
shadow_output: str
primary_latency_ms: float
shadow_latency_ms: float
match: bool
timestamp: float
class ShadowDeployment:
"""Shadow deployment controller."""
def __init__(
self,
primary_model_fn: callable,
shadow_model_fn: callable,
shadow_percentage: float = 0.1, # 10% shadow traffic
):
self.primary_fn = primary_model_fn
self.shadow_fn = shadow_model_fn
self.shadow_pct = shadow_percentage
self.results = []
async def predict(self, input: str, user_id: str) -> str:
"""Run prediction with optional shadow."""
import random
should_shadow = random.random() < self.shadow_pct
# Always run primary
primary_start = time.perf_counter()
primary_output = await self.primary_fn(input)
primary_latency = (time.perf_counter() - primary_start) * 1000
# Conditionally run shadow (non-blocking)
if should_shadow:
asyncio.create_task(self._run_shadow(input, user_id, primary_output, primary_latency))
# Return primary output immediately
return primary_output
async def _run_shadow(
self,
input: str,
user_id: str,
primary_output: str,
primary_latency: float,
) -> None:
"""Run shadow model and log comparison."""
try:
shadow_start = time.perf_counter()
shadow_output = await self.shadow_fn(input)
shadow_latency = (time.perf_counter() - shadow_start) * 1000
# Compare outputs
match = self._compare_outputs(primary_output, shadow_output)
# Log result
result = ShadowResult(
primary_output=primary_output,
shadow_output=shadow_output,
primary_latency_ms=primary_latency,
shadow_latency_ms=shadow_latency,
match=match,
timestamp=time.time(),
)
await self._log_result(result, input, user_id)
except Exception as e:
print(f"Shadow model error: {e}")
def _compare_outputs(self, primary: str, shadow: str) -> bool:
"""Compare outputs for equivalence."""
# Simple comparison (enhance in production)
from difflib import SequenceMatcher
similarity = SequenceMatcher(None, primary, shadow).ratio()
return similarity > 0.8 # 80% similarity threshold
async def _log_result(
self,
result: ShadowResult,
input: str,
user_id: str,
) -> None:
"""Log shadow result for analysis."""
# Log to database/metrics
import json
log_entry = {
"user_id": user_id,
"input": input,
"match": result.match,
"primary_latency_ms": result.primary_latency_ms,
"shadow_latency_ms": result.shadow_latency_ms,
"timestamp": result.timestamp,
}
# Send to monitoring
from prometheus_client import Counter, Histogram
shadow_requests = Counter("shadow_requests_total", "Shadow requests", ["match"])
shadow_latency = Histogram("shadow_latency_diff_ms", "Latency difference")
shadow_requests.labels(match=str(result.match)).inc()
shadow_latency.observe(result.shadow_latency_ms - result.primary_latency_ms)
self.results.append(result)
def get_metrics(self) -> dict:
"""Get shadow deployment metrics."""
if not self.results:
return {}
match_rate = sum(1 for r in self.results if r.match) / len(self.results)
avg_latency_diff = sum(r.shadow_latency_ms - r.primary_latency_ms for r in self.results) / len(self.results)
return {
"total_shadow_requests": len(self.results),
"match_rate": match_rate,
"avg_latency_diff_ms": avg_latency_diff,
}
# Usage
async def main():
# Primary model (production)
async def primary_model(input: str) -> str:
# Current production model
return "primary response"
# Shadow model (candidate)
async def shadow_model(input: str) -> str:
# New model being tested
return "shadow response"
# Deploy with 10% shadow traffic
deployment = ShadowDeployment(
primary_model_fn=primary_model,
shadow_model_fn=shadow_model,
shadow_percentage=0.10,
)
# Handle requests
result = await deployment.predict("test input", user_id="user123")
# Check metrics after some time
metrics = deployment.get_metrics()
print(f"Shadow metrics: {metrics}")
See shadow mode deployment for full implementation.
Gradual Rollout and Canary
Roll out gradually to catch issues before full deployment.
Shadow mode proves the candidate produces reasonable outputs; a canary proves users actually respond well to them. The difference matters for anything with a feedback loop—thumbs-up/down, task completion, conversation length. A canary exposes a small percentage of users, watches those metrics, and advances only when they hold. The percentages and dwell times below are illustrative defaults; tune them to your traffic volume so each stage sees enough requests for the metrics to be meaningful. On Kubernetes, tools like Argo Rollouts can drive the same progression from analysis templates that query your metrics backend.
The comparison below summarizes when each strategy fits:
| Strategy | User exposure | Best for | Weakness |
|---|---|---|---|
| Offline eval suite | None | Every PR; catching regressions on known cases | Curated inputs miss real distribution |
| Shadow deployment | None | Model swaps; measuring behavior on live traffic | Doubles inference cost during the window; no user feedback |
| Canary rollout | 5-50% | Validating user-facing metrics before full exposure | Needs enough traffic per stage for statistical signal |
| Blue/green switch | 0% or 100% | Simple services with fast rollback | No gradual signal; all users hit the change at once |
| Feature-flag targeting | Chosen cohorts | Internal dogfooding, enterprise pilots | Cohorts are not representative of all traffic |
# canary_deployment.py
from dataclasses import dataclass
from datetime import datetime, timedelta
import asyncio
@dataclass
class DeploymentStage:
"""Canary deployment stage."""
name: str
traffic_percentage: float
duration_minutes: int
quality_threshold: float
class CanaryDeployment:
"""Gradual rollout controller."""
STAGES = [
DeploymentStage("canary", 0.05, 30, 0.90), # 5% for 30 min
DeploymentStage("small", 0.25, 60, 0.88), # 25% for 1 hour
DeploymentStage("half", 0.50, 120, 0.85), # 50% for 2 hours
DeploymentStage("full", 1.00, 0, 0.85), # 100%
]
def __init__(self, model_version: str):
self.version = model_version
self.current_stage_idx = 0
self.stage_start_time = datetime.now()
async def should_proceed(self) -> tuple[bool, str]:
"""Check if should proceed to next stage."""
stage = self.STAGES[self.current_stage_idx]
# Check if stage duration elapsed
elapsed = (datetime.now() - self.stage_start_time).total_seconds() / 60
if elapsed < stage.duration_minutes:
return False, f"Stage duration not reached ({elapsed:.0f}/{stage.duration_minutes} min)"
# Check quality metrics
metrics = await self._get_metrics(stage.traffic_percentage)
if metrics["quality_score"] < stage.quality_threshold:
return False, f"Quality below threshold ({metrics['quality_score']:.3f} < {stage.quality_threshold})"
# Check error rate
if metrics["error_rate"] > 0.05: # 5% error threshold
return False, f"Error rate too high ({metrics['error_rate']:.1%})"
# Check latency regression
if metrics["latency_p95_ms"] > metrics["baseline_latency_p95_ms"] * 1.5:
return False, "Latency regression > 50%"
return True, "All checks passed"
async def _get_metrics(self, traffic_pct: float) -> dict:
"""Get metrics for current canary stage."""
# Query monitoring system (Prometheus/Datadog)
# This is a simplified example
return {
"quality_score": 0.92,
"error_rate": 0.02,
"latency_p95_ms": 1500,
"baseline_latency_p95_ms": 1200,
}
async def advance_stage(self) -> bool:
"""Advance to next deployment stage."""
if self.current_stage_idx >= len(self.STAGES) - 1:
print("✅ Deployment complete!")
return True
self.current_stage_idx += 1
self.stage_start_time = datetime.now()
stage = self.STAGES[self.current_stage_idx]
print(f"▶️ Advanced to stage: {stage.name} ({stage.traffic_percentage:.0%} traffic)")
# Update traffic routing
await self._update_traffic_routing(stage.traffic_percentage)
return self.current_stage_idx >= len(self.STAGES) - 1
async def rollback(self, reason: str) -> None:
"""Rollback deployment."""
print(f"🚨 Rolling back deployment: {reason}")
# Route all traffic back to previous version
await self._update_traffic_routing(0.0)
# Alert
await self._send_alert(f"Canary rollback: {reason}")
async def _update_traffic_routing(self, canary_percentage: float) -> None:
"""Update traffic routing to canary."""
# Update Kubernetes/Istio/ALB routing rules
pass
async def _send_alert(self, message: str) -> None:
"""Send alert to team."""
# Send Slack/PagerDuty alert
pass
# Automated canary controller
async def canary_controller(model_version: str):
"""Automated canary deployment controller."""
deployment = CanaryDeployment(model_version)
while True:
stage = deployment.STAGES[deployment.current_stage_idx]
print(f"Current stage: {stage.name} ({stage.traffic_percentage:.0%})")
# Wait for stage duration
await asyncio.sleep(60) # Check every minute
# Check if ready to proceed
should_proceed, reason = await deployment.should_proceed()
if not should_proceed:
print(f"⏸️ Waiting: {reason}")
# Check if should rollback (error rate spike)
metrics = await deployment._get_metrics(stage.traffic_percentage)
if metrics["error_rate"] > 0.10: # 10% error = abort
await deployment.rollback("Error rate exceeded 10%")
break
continue
# Advance to next stage
complete = await deployment.advance_stage()
if complete:
break
print("Deployment finished")
# Usage
# asyncio.run(canary_controller("model-v2.1.0"))
Integrate with Kubernetes deployments.
Rollback Mechanisms
Automated rollback based on quality metrics.
Rollback for AI systems has to watch more than error rate. The failure that hurts most is a healthy-looking service returning worse answers, so the rollback controller evaluates error rate, latency, and quality score against the previous version, and reverts if any of them cross a threshold. Two implementation details are easy to get wrong: keep a minimum sample size before acting (a single bad minute at 5% traffic is noise), and treat quality thresholds as relative to the baseline version rather than absolute, because absolute scores drift with the traffic mix over the day.
Rollback must also be fast and boring. That means the previous model version, prompt version, and config are all still deployed and routable—you flip a pointer, you do not rebuild. If rollback requires a new build, it is not rollback; it is a hotfix under pressure.
# rollback.py
from datetime import datetime, timedelta
from dataclasses import dataclass
@dataclass
class RollbackTrigger:
"""Condition that triggers rollback."""
name: str
check_fn: callable
severity: str # "warning", "critical"
class RollbackController:
"""Automated rollback based on metrics."""
def __init__(self, deployment_id: str):
self.deployment_id = deployment_id
self.triggers = self._define_triggers()
self.baseline_metrics = self._load_baseline()
def _define_triggers(self) -> list[RollbackTrigger]:
"""Define rollback triggers."""
return [
RollbackTrigger(
name="error_rate_spike",
check_fn=lambda m: m["error_rate"] > 0.10,
severity="critical",
),
RollbackTrigger(
name="quality_drop",
check_fn=lambda m: m["quality_score"] < self.baseline_metrics["quality_score"] * 0.80,
severity="critical",
),
RollbackTrigger(
name="latency_regression",
check_fn=lambda m: m["latency_p95_ms"] > self.baseline_metrics["latency_p95_ms"] * 2.0,
severity="warning",
),
RollbackTrigger(
name="user_satisfaction_drop",
check_fn=lambda m: m["thumbs_down_rate"] > self.baseline_metrics["thumbs_down_rate"] * 1.5,
severity="warning",
),
]
def _load_baseline(self) -> dict:
"""Load baseline metrics from previous version."""
# Query metrics from last 7 days before deployment
return {
"error_rate": 0.02,
"quality_score": 0.90,
"latency_p95_ms": 1200,
"thumbs_down_rate": 0.15,
}
async def check_should_rollback(self) -> tuple[bool, list[str]]:
"""Check if should trigger rollback."""
# Get current metrics
current_metrics = await self._get_current_metrics()
triggered = []
for trigger in self.triggers:
if trigger.check_fn(current_metrics):
triggered.append(f"{trigger.name} ({trigger.severity})")
# Rollback if any critical trigger
critical_triggers = [t for t in triggered if "critical" in t]
should_rollback = len(critical_triggers) > 0
return should_rollback, triggered
async def _get_current_metrics(self) -> dict:
"""Get metrics for current deployment."""
# Query from monitoring (last 5 minutes)
return {
"error_rate": 0.03,
"quality_score": 0.88,
"latency_p95_ms": 1400,
"thumbs_down_rate": 0.18,
}
async def execute_rollback(self, reason: list[str]) -> None:
"""Execute rollback to previous version."""
print(f"🚨 Executing rollback: {', '.join(reason)}")
# Get previous version
previous_version = await self._get_previous_version()
# Update Kubernetes deployment
await self._update_deployment(previous_version)
# Alert team
await self._send_alert(f"Auto-rollback triggered: {', '.join(reason)}")
print(f"✅ Rolled back to {previous_version}")
async def _get_previous_version(self) -> str:
"""Get previous stable version."""
# Query from deployment history
return "model-v2.0.5"
async def _update_deployment(self, version: str) -> None:
"""Update deployment to version."""
# kubectl set image deployment/ai-model container=image:version
pass
async def _send_alert(self, message: str) -> None:
"""Send alert."""
# Slack/PagerDuty notification
pass
# Monitoring loop
async def rollback_monitor(deployment_id: str):
"""Monitor deployment and auto-rollback if needed."""
controller = RollbackController(deployment_id)
while True:
await asyncio.sleep(60) # Check every minute
should_rollback, triggers = await controller.check_should_rollback()
if should_rollback:
await controller.execute_rollback(triggers)
break
if triggers:
print(f"⚠️ Warning triggers: {', '.join(triggers)}")
# Usage
# asyncio.run(rollback_monitor("deploy-abc123"))
Production Monitoring
Track quality, latency, and costs in production.
Monitoring closes the loop. The same judges and deterministic checks used in CI run on a sample of production traffic, and the resulting quality score sits next to latency and cost on the dashboard. The metrics below are exported for Prometheus; label them by model and prompt version so a regression can be attributed to the deploy that caused it. Alert on drift (quality score falling relative to a rolling baseline), not only on absolute thresholds, and route a sample of low-scoring responses back into the regression suite. That is how the test suite keeps growing without anyone scheduling the work.
# production_monitoring.py
from prometheus_client import Counter, Histogram, Gauge
# Request metrics
ai_requests_total = Counter(
"ai_requests_total",
"Total AI requests",
["model_version", "prompt_version", "status"],
)
ai_latency_seconds = Histogram(
"ai_latency_seconds",
"AI request latency",
["model_version"],
buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0],
)
# Quality metrics
ai_quality_score = Gauge(
"ai_quality_score",
"AI response quality score",
["model_version"],
)
ai_user_feedback = Counter(
"ai_user_feedback_total",
"User feedback",
["model_version", "feedback_type"], # thumbs_up, thumbs_down
)
# Cost metrics
ai_cost_usd = Counter(
"ai_cost_usd_total",
"Total AI cost in USD",
["model_version"],
)
# Deployment metrics
ai_deployment_version = Gauge(
"ai_deployment_version",
"Current deployment version",
)
Deploy monitoring with observability services.
Frequently Asked Questions
How many test cases do I need for prompt testing?
100-500 test cases is a workable range for a production prompt. Cover happy paths, edge cases, and adversarial inputs, and weight the categories by how often they appear in real traffic. Add a new case every time a production bug is found so the suite tracks real failure modes rather than hypothetical ones.
Should I test every prompt change?
Yes. Prompt changes are code changes, and even small wording changes can shift model behavior in ways nobody predicts. Require the evaluation suite to pass on every PR that touches a prompt, and store prompts in version control so the diff is visible to reviewers.
How long should shadow deployment run?
24-48 hours minimum, so the candidate sees a full daily traffic pattern. Low-volume systems should run longer; a practical target is 10,000 or more shadow requests before promotion. Compare agreement rate, latency, and cost between the current and candidate models before moving to a canary.
What's the right canary rollout speed?
5% to 25% to 50% to 100% over a few hours is a typical progression for a prompt or minor model update. Move faster for low-risk changes and slower for major model swaps, and size each stage so it collects enough requests for the quality metric to be statistically meaningful before advancing.
When should I auto-rollback?
Auto-rollback on critical metrics only: for example, error rate above 10%, P95 latency more than twice the baseline, or quality score dropping sharply relative to the previous version. Require a minimum sample size before acting so a noisy minute does not trigger a revert. Page a human for anything borderline rather than encoding every judgment call in the controller.
How do I test safety in an AI CI/CD pipeline?
Run adversarial evaluation with jailbreak attempts, prompt injections, toxic inputs, and out-of-scope requests as a dedicated gate. Measure the refusal rate on harmful cases and the false-refusal rate on benign ones, since a model that refuses everything also fails. Moderation APIs and safety classifiers add a second layer on top of the judge-based checks.
Can I use the same evals in CI and in production monitoring?
Yes, and you should. The deterministic checks and LLM-as-judge rubrics that gate a PR can be run on a sample of production traffic to produce a live quality score. Using the same evaluators in both places means a production regression can be reproduced in CI, and low-scoring production samples can be promoted straight into the regression suite.
Conclusion
AI CI/CD pipeline with testing and safety gates enables confident deployments:
- Prompt regression testing catches behavior changes before merge
- Model validation ensures quality, safety, and performance
- Evaluation as code makes quality measurement reproducible
- Shadow deployment tests with production traffic safely
- Gradual rollouts catch issues before 100% exposure
- Automated rollback reverts bad deployments quickly
Start with the cheapest layer—deterministic prompt tests on every PR—and add judges, shadow mode, and canaries as traffic and risk grow. Most of the value comes from the first gate.
If you want help designing an AI CI/CD pipeline for your team, talk to HinterBuild about our cloud infrastructure and DevOps and observability engagements.
Free consultation
Book a free consultation call on AI/ML CI/CD pipelines
30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.
Book a meeting
Resources:
Keep reading
Related articles
AI Evals in CI/CD with GitHub Actions: Integration Guide
Run AI evals in CI/CD with GitHub Actions: pytest eval suites, quality gates, regression detection and cost controls that block bad prompts before deploy.
Read post
QLoRA: Fine-Tune 70B Models on Single GPU with 4-bit
QLoRA guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
ArgoCD for ML Model Deployments: Production GitOps Patterns
Deploy ML models with ArgoCD GitOps: declarative manifests, MLflow registry sync, Kustomize overlays, Argo Rollouts canaries, and automated rollbacks.
Read post
Deploy LLMs on Kubernetes: Complete GPU Autoscaling Guide
Learn deploy llms on kubernetes through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
