Production Readiness for AI Applications: Model Versioning, Inference Monitoring, and Rollback Strategies for LLM Deployments
A devops guide to the new requirements of running LLM applications in production, covering model and prompt versioning, inference-specific monitoring, rollback strategies, deployment patterns, and CI/CD gates built on eval suites.
Your traditional devops playbook covers 80% of what you need for an AI application. The remaining 20% is where production incidents live.
An LLM deployment breaks in ways a database or web service does not. A rollback is not just “redeploy the last image.” A canary is not just “send 5% of traffic to the new pod.” A monitor is not just “alert if p99 latency exceeds 500ms.” The model itself is a stateful, non-deterministic artifact, and the prompt is code. Neither fits cleanly into the mental models most devops engineers have spent years building.
This guide covers the practices that bridge the gap: how to version models and prompts, what to monitor beyond standard APM, how to roll back when an LLM starts producing bad output, and how to build a CI/CD pipeline that actually catches regressions before they hit users.
The Core Problem: AI Deployments Have Two Artifacts to Track
A traditional deployment has one versioned artifact: the container image. An LLM deployment has at least three:
- The model version (which base model or fine-tuned checkpoint you are calling)
- The prompt version (system prompt, few-shot examples, output format instructions)
- The application code (the TypeScript or Python layer that orchestrates calls, handles context, routes output)
Each can change independently. A model provider silently updates a model (it happens). A developer edits the system prompt. A dependency bumps the tokenizer. Any of these can degrade output quality without touching a single line of application code. Your versioning and monitoring strategy needs to treat all three as first-class artifacts.
Model and Prompt Versioning
Git-Based Prompt Versioning
Prompts are code. Store them in your repository, review them in pull requests, and deploy them through your normal release process.
// lib/prompts/classification.ts
export const CLASSIFICATION_PROMPT_V2 = {
version: "2.1.0",
commitHash: process.env.GIT_COMMIT_SHA ?? "unknown",
deployedAt: new Date().toISOString(),
system: `You are a support ticket classifier. Categorize each ticket into exactly one of: billing, technical, account, other.
Rules:
- Return only valid JSON: { "category": string, "confidence": number }
- Confidence must be between 0 and 1
- If a ticket could belong to multiple categories, choose the most specific one`,
userTemplate: (ticket: string) =>
`Classify this support ticket:\n\n${ticket}`,
} as const;
export type PromptConfig = typeof CLASSIFICATION_PROMPT_V2;
The version and commitHash fields end up in your logs and traces. When you are debugging a production incident three weeks later, you will want to know exactly which prompt was active.
Registry-Based Model Versioning
Vendor models need a different approach. You cannot pin a container digest to an OpenAI model. What you can do is treat the model identifier as a versioned configuration value, separated from code.
// config/models.ts
export interface ModelConfig {
provider: "openai" | "anthropic" | "google";
modelId: string;
maxTokens: number;
temperature: number;
version: string;
deprecatesAt?: string;
}
export const MODEL_REGISTRY: Record<string, ModelConfig> = {
"classification-v1": {
provider: "openai",
modelId: "gpt-4o-2024-11-20",
maxTokens: 512,
temperature: 0.0,
version: "1.0.0",
},
"classification-v2": {
provider: "openai",
modelId: "gpt-4o-2025-01-15",
maxTokens: 512,
temperature: 0.0,
version: "2.0.0",
},
};
// Active model is determined by feature flag, not hardcoded
export function getActiveModel(feature: string): ModelConfig {
const key = process.env[`MODEL_${feature.toUpperCase()}`] ?? "classification-v1";
const config = MODEL_REGISTRY[key];
if (!config) throw new Error(`Unknown model config: ${key}`);
return config;
}
Now you can swap models via environment variable without a code deploy, and the registry gives you a paper trail of every model you have ever used.
Inference Monitoring Beyond APM
Standard APM tells you latency, error rate, and throughput. For LLMs, that is a starting point, not a finish line.
What to Instrument
// lib/llm-client.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
interface InferenceMetrics {
promptTokens: number;
completionTokens: number;
totalTokens: number;
latencyMs: number;
modelId: string;
promptVersion: string;
featureContext: string;
qualityScore?: number;
cacheHit: boolean;
}
export async function callLLM(
prompt: PromptConfig,
userInput: string,
feature: string
): Promise<{ content: string; metrics: InferenceMetrics }> {
const tracer = trace.getTracer("llm-client");
const span = tracer.startSpan("llm.inference");
const start = Date.now();
try {
const model = getActiveModel(feature);
const response = await openai.chat.completions.create({
model: model.modelId,
messages: [
{ role: "system", content: prompt.system },
{ role: "user", content: prompt.userTemplate(userInput) },
],
max_tokens: model.maxTokens,
temperature: model.temperature,
});
const latencyMs = Date.now() - start;
const usage = response.usage!;
const metrics: InferenceMetrics = {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
latencyMs,
modelId: model.modelId,
promptVersion: prompt.version,
featureContext: feature,
cacheHit: false,
};
span.setAttributes({
"llm.model_id": metrics.modelId,
"llm.prompt_version": metrics.promptVersion,
"llm.prompt_tokens": metrics.promptTokens,
"llm.completion_tokens": metrics.completionTokens,
"llm.latency_ms": metrics.latencyMs,
"llm.feature": metrics.featureContext,
});
return { content: response.choices[0].message.content ?? "", metrics };
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR });
throw error;
} finally {
span.end();
}
}
Metrics That Actually Matter
Beyond latency and error rate, track these at the feature level:
| Metric | Why It Matters | Alert Threshold |
|---|---|---|
p99_latency_ms | Tail latency drives user experience at scale | Feature-specific; start at 3x p50 |
tokens_per_request | Token bloat raises cost without signal | Alert on 20%+ increase week-over-week |
cost_per_request | Unit economics; catches prompt regressions early | Alert on 15%+ increase after deploys |
refusal_rate | Over-instructed models refuse valid requests | Alert if > 2% for any feature |
parse_failure_rate | Structured output breaking; often after model updates | Alert on any increase post-deploy |
quality_score_p50 | Downstream business metric; hard to define but essential | Feature-defined; monitor trend not threshold |
The parse_failure_rate metric is one of the first signals that a model update broke your prompt. If you expect JSON and the model starts returning prose, your error rate may stay low (no exception thrown) while your application silently degrades.
Deployment Patterns for AI
Shadow Mode
Before sending any real traffic to a new model or prompt, run it in shadow mode: process every request with both the current and candidate version, log both outputs, and compare them offline.
// middleware/shadow-llm.ts
export async function shadowEval(
request: ClassificationRequest,
feature: string
): Promise<void> {
// Fire and forget; don't block the primary response
setImmediate(async () => {
try {
const shadowModel = getShadowModel(feature);
if (!shadowModel) return;
const { content: shadowOutput, metrics } = await callLLMWithModel(
shadowModel,
request
);
await shadowResultStore.write({
requestId: request.id,
feature,
shadowOutput,
shadowModelId: shadowModel.modelId,
timestamp: new Date().toISOString(),
});
} catch {
// Shadow failures must never surface to the user
}
});
}
Shadow mode gives you real-world input distribution without real-world risk. Run it for at least 48 hours before promoting a new model to canary.
Canary Deployment for LLMs
A canary for LLMs needs to measure output quality, not just availability. Use feature flags to control the traffic split, and automate rollback on quality degradation rather than just error rate.
// lib/canary-router.ts
export async function routeWithCanary(
userId: string,
feature: string
): Promise<ModelConfig> {
const canaryConfig = await featureFlags.getCanaryConfig(feature);
if (!canaryConfig?.active) {
return getActiveModel(feature);
}
// Deterministic bucketing: same user always gets same variant
const bucket = murmurhash(userId + feature) % 100;
const useCanary = bucket < canaryConfig.trafficPercent;
if (useCanary) {
metrics.increment("canary.request", { feature });
return MODEL_REGISTRY[canaryConfig.candidateModelKey];
}
return getActiveModel(feature);
}
The key detail is deterministic bucketing. Random routing means the same user gets different behavior on consecutive requests, which produces noisy quality comparisons and confuses users.
A/B Testing Prompts
A/B testing prompts follows the same pattern, but you measure a business outcome rather than a system metric. For a support classifier, that might be “rate of tickets escalated to a human after AI classification” as a proxy for classification quality.
Tie every inference record back to a downstream outcome event within a defined window (typically 24 hours), and compute the quality metric per prompt variant.
CI/CD Gates: Eval Suites Before Promotion
The hardest problem in LLM devops is defining “the new version is safe to deploy.” Traditional tests are deterministic; LLM output is not. The solution is an eval suite: a curated set of labeled examples that you run against every candidate version and compare against a baseline.
// evals/classification-eval.ts
interface EvalCase {
id: string;
input: string;
expectedCategory: "billing" | "technical" | "account" | "other";
severity: "critical" | "standard";
notes?: string;
}
const EVAL_SUITE: EvalCase[] = [
{
id: "billing-001",
input: "I was charged twice this month for my subscription",
expectedCategory: "billing",
severity: "critical",
},
{
id: "technical-001",
input: "The API returns 500 when I call the /export endpoint",
expectedCategory: "technical",
severity: "critical",
},
{
id: "edge-001",
input: "Can you delete my account and also refund my last invoice",
expectedCategory: "billing",
severity: "standard",
notes: "Mixed intent; billing is the actionable category",
},
// ... 50+ more cases covering the full input distribution
];
export async function runEvalSuite(
prompt: PromptConfig,
model: ModelConfig
): Promise<EvalResults> {
const results = await Promise.all(
EVAL_SUITE.map(async (evalCase) => {
const { content } = await callLLMWithConfig(prompt, model, evalCase.input);
let predicted: string;
try {
const parsed = JSON.parse(content);
predicted = parsed.category;
} catch {
predicted = "parse_error";
}
return {
id: evalCase.id,
expected: evalCase.expectedCategory,
predicted,
correct: predicted === evalCase.expectedCategory,
severity: evalCase.severity,
};
})
);
const criticalFailures = results.filter(
(r) => r.severity === "critical" && !r.correct
);
const accuracy = results.filter((r) => r.correct).length / results.length;
const parseFailures = results.filter((r) => r.predicted === "parse_error").length;
return { results, accuracy, criticalFailures, parseFailures };
}
// CI gate: fail the pipeline if any critical case fails or accuracy drops
export function assertEvalPasses(
results: EvalResults,
baseline: EvalResults
): void {
if (results.criticalFailures.length > 0) {
throw new Error(
`Eval failed: ${results.criticalFailures.length} critical case(s) failed`
);
}
if (results.accuracy < baseline.accuracy - 0.02) {
throw new Error(
`Eval failed: accuracy ${results.accuracy.toFixed(3)} is more than 2pp below baseline ${baseline.accuracy.toFixed(3)}`
);
}
if (results.parseFailures > 0) {
throw new Error(
`Eval failed: ${results.parseFailures} parse failure(s) — structured output format broken`
);
}
}
The eval suite runs in CI on every change to a prompt file, model configuration, or the orchestration code. It does not run on every application code commit; that would make it too slow and too noisy.
Rollback Strategies
Prompt Rollback
Prompt rollback is the fastest and safest rollback available. Because prompts are stored in the registry (or as environment variables loaded at startup), you can revert a prompt change without redeploying the application.
The critical step: store the previous prompt version in your registry before every promotion, and wire the rollback to your on-call tooling so it can be executed in under two minutes.
// ops/rollback.ts
export async function rollbackPrompt(
feature: string,
targetVersion: string
): Promise<void> {
const current = await promptRegistry.getCurrent(feature);
await promptRegistry.archive(feature, current);
await promptRegistry.activate(feature, targetVersion);
await featureFlags.invalidateCache(feature);
await ops.notify(
`Prompt rollback: ${feature} reverted from ${current.version} to ${targetVersion}`
);
}
Model Rollback
Model rollback uses the same mechanism but takes longer to take effect because the model identifier change may require a deploy if it is baked into environment variables rather than loaded from a live config store.
Design for live config: store the active model key in a config store (Redis, AWS Parameter Store, LaunchDarkly) that your application polls, not in a build-time environment variable. This lets you swap models in under 30 seconds without a deploy.
Fallback Chains
A fallback chain handles provider outages and rate limits, but it can also handle quality degradation gracefully. If your primary model exceeds a quality threshold alert, the fallback chain can route to a known-good model automatically.
// lib/fallback-chain.ts
const FALLBACK_CHAIN: ModelConfig[] = [
MODEL_REGISTRY["classification-v2"], // Primary
MODEL_REGISTRY["classification-v1"], // Stable fallback
MODEL_REGISTRY["classification-legacy"], // Last resort
];
export async function callWithFallback(
prompt: PromptConfig,
input: string,
feature: string
): Promise<string> {
for (const model of FALLBACK_CHAIN) {
if (await circuitBreaker.isOpen(model.modelId)) continue;
try {
const { content, metrics } = await callLLMWithModel(model, prompt, input);
metrics.featureContext = feature;
await telemetry.record(metrics);
return content;
} catch (error) {
circuitBreaker.recordFailure(model.modelId);
continue;
}
}
throw new Error(`All models in fallback chain failed for feature: ${feature}`);
}
Incident Response for LLM Degradation
When an LLM starts producing bad output in production, the incident response flow differs from a typical service incident:
-
Identify the scope. Is this one feature or all features? One user cohort or all users? Narrowing the blast radius tells you whether to suspect the prompt, the model, or the application code.
-
Check recent changes. Did a prompt version change in the last 24 hours? Did the model provider announce a silent update? Did a dependency that affects tokenization or context construction change?
-
Pull a sample. Export 20-50 recent inference records, including the exact prompt sent (not the template, but the fully rendered prompt) and the raw model response. Look for patterns: are outputs too short, refusing valid requests, returning malformed JSON, or just wrong?
-
Rollback before root-causing. Restore the last known good prompt or model configuration. Get users back to a good state, then investigate.
-
Add a regression eval case. Whatever input exposed the problem, add it to your eval suite before closing the incident. The eval suite grows through incidents. A suite that was built only from happy-path examples will not catch production failure modes.
Cost Monitoring and Alerting
Cost spikes are often the first signal of a prompt regression. A prompt that introduces a verbose few-shot example, or a code path that accidentally includes an entire document in context, will show up as a token usage spike before it shows up in quality scores.
// monitoring/cost-alerts.ts
export async function checkCostAnomaly(
feature: string,
windowHours = 1
): Promise<CostAnomaly | null> {
const current = await metrics.getTokenUsage(feature, windowHours);
const baseline = await metrics.getTokenUsageBaseline(feature, windowHours * 24);
const ratio = current.avgTokensPerRequest / baseline.avgTokensPerRequest;
if (ratio > 1.3) {
return {
feature,
currentAvg: current.avgTokensPerRequest,
baselineAvg: baseline.avgTokensPerRequest,
increasePercent: Math.round((ratio - 1) * 100),
severity: ratio > 2.0 ? "critical" : "warning",
};
}
return null;
}
Set this as a scheduled check running every 15 minutes, not just on deploys. Model providers can update models without notice, and the token count change can be immediate.
Deployment Patterns vs. Risk Tradeoffs
| Pattern | When to Use | Risk | Rollback Speed |
|---|---|---|---|
| Direct deploy | Internal tools, low traffic, frequent iteration | High | Slow (redeploy) |
| Shadow mode | Before any prod exposure; validate on real inputs | None to users | N/A |
| Canary (5%) | After shadow validation; validate at scale | Low | Fast (flag flip) |
| A/B test | Comparing two stable variants; business metric comparison | Low | Fast (flag flip) |
| Blue/green | Major prompt or model rewrites | Medium | Fast (DNS/LB flip) |
| Feature flag only | Config changes with live config store | Low | Instant (flag flip) |
The pattern you use should depend on how much you know about the candidate version, not on how confident you feel. Shadow mode is cheap. Run it first, always.
Production Considerations
Prompt injection surface. Every user-supplied value that goes into a prompt is an injection surface. Validate and sanitize before interpolation. Log the rendered prompt, not just the template, so you can audit what was actually sent.
Non-determinism in evals. Run eval cases at temperature 0 for stable CI results. For models that do not support temperature 0 cleanly, run each case three times and fail on two or more failures.
Model version drift. OpenAI, Anthropic, and Google all reserve the right to update a model version without changing the model identifier. Pin to dated model versions (e.g., gpt-4o-2024-11-20) rather than aliases (e.g., gpt-4o). Monitor for output distribution shifts even when you have not changed your configuration.
Context length creep. As your application adds features, it often adds context to the prompt. Track prompt token counts over time and alert when they approach the model’s context window. Running at 90% of context capacity creates unpredictable truncation behavior.
Structured output contracts. If your application parses model output (JSON, XML, function calls), treat the output schema as a contract and version it explicitly. A prompt change that affects output format without updating the parser creates silent data corruption, not a visible error.
The gap between “working AI prototype” and “production AI application” is almost entirely infrastructure: versioning, monitoring, rollback, and the discipline to gate deploys on eval results rather than manual inspection. The model is the easiest part. The scaffolding around it is where production readiness actually lives.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.