Feature Flags for AI: The Deployment Pattern That Saved Us From 3 Production Disasters
Why LLM features need flags more than anything you've shipped — the four flag patterns that let us swap models, A/B test prompts, and kill broken features fast.
Three months after we shipped our first AI feature to production, it broke. Not dramatically — no 500s, no alerts. Just wrong. The model we'd integrated started producing confidently incorrect summaries on a specific document type, and we didn't know until a customer flagged it four days later.
The fix was straightforward. The deployment of the fix was not. We had no way to roll it out to 5% of users first. No way to instantly revert to the previous behavior. No kill switch. We pushed the fix to all users at once and watched the dashboards, anxious for the next 48 hours.
That was the last time we shipped an AI feature without feature flags.
Here's everything we learned about flagging AI features specifically — the patterns that work, the anti-patterns that burn you, and the Spring Boot implementation that runs our current system.
Why AI Features Need Flags More Than Anything Else You've Shipped
Traditional features fail in binary ways: they work or they error. AI features fail in a third way — they work incorrectly, silently, at a rate that only becomes statistically visible after thousands of requests.
That changes the deployment calculus entirely.
You can't unit test your way to confidence. A prompt change that scores 94% on your eval suite can still fail on 2% of production inputs you didn't evaluate. At 100K requests/day, that's 2,000 bad responses a day.
Model providers change behavior under you. Your LLM provider updates the underlying model. Behavior shifts. You didn't deploy anything — but your feature changed. You need a rollback that doesn't involve redeploying your application.
Prompt changes are code. A new system prompt is not less risky than a new database query. It needs the same gradual rollout, the same observability, the same rollback capability.
Cost spikes hit instantly. A new prompt that accidentally uses 3× the tokens will hit your monthly budget within hours of a full rollout. A 1% rollout catches it before it matters.
flowchart TD
A[AI Feature Change\nmodel / prompt / config] --> B{Feature Flag\nEvaluation}
B -->|0% rollout| C[Old Behavior\nfor all users]
B -->|5% rollout| D[New Behavior\nfor 5% of users]
B -->|100% rollout| E[New Behavior\nfor all users]
D -->|Metrics look good| F[Increase to 25%]
D -->|Metrics look bad| G[Instant rollback\nto 0%]
F -->|Metrics look good| H[Increase to 100%]
F -->|Metrics look bad| G
G -->|Takes effect in| I[< 60 seconds\nno redeploy]
style G fill:#7f1d1d,color:#fca5a5
style I fill:#065f46,color:#6ee7b7
A Taxonomy of AI Feature Flags
Not all AI flags are the same. Mixing them up leads to either over-engineering or missing the ones that matter.
Flag Type
What it controls
When to use it
Model toggle
Which LLM model handles a task
Model migrations, cost optimizations, provider failover
Prompt toggle
Which system prompt / prompt template is active
Any prompt change, A/B testing, prompt experiments
Rollout percentage
What fraction of users see new AI behavior
Gradual migration, de-risking changes
Kill switch
Entire AI feature on/off
Emergency disable, degraded mode
User segment
New AI behavior for specific users/tenants
Beta users, internal testing, enterprise pilot
You need all five. Teams that only implement kill switches miss the gradual rollout pattern that prevents most incidents. Teams that only implement rollout percentages have no emergency lever.
The Implementation: Spring Boot + Unleash
We use Unleash for flag management — open source, self-hostable, strong Java SDK. The patterns below translate directly to LaunchDarkly, Split, Flagsmith, or even a simple database-backed flag store if you're starting out.
The fetchTogglesInterval(10) is important: flag changes propagate to all running pods within 10 seconds without a redeploy. That's your 60-second kill switch window.
The AI Flag Service
All AI feature flag checks go through a single service — never scattered inline unleash.isEnabled() calls throughout the codebase.
Every flag check passes a UnleashContext with the user ID. This is what makes percentage rollouts and user-segment targeting work — Unleash uses the user ID to hash-consistently assign the same user to the same variant across requests.
Pattern 1: Model Migration Flag
You're running llama-3.1-8b-instant and want to migrate to claude-haiku-4-5 for better extraction quality. The naive approach: update the constant, deploy, pray.
The flag approach:
@Service@Slf4jpublicclassDocumentExtractor {
privatefinal ChatClient chatClient;
privatefinal AIFeatureFlags flags;
privatefinal MeterRegistry metrics;
public ExtractionResult extract(String documentText, String userId) {
Stringmodel= flags.extractModel(userId);
log.debug("extraction model={} userId={}", model, userId);
Timer.Sampletimer= Timer.start(metrics);
ChatResponseresponse= chatClient
.prompt()
.system(EXTRACTION_SYSTEM_PROMPT)
.user(documentText)
.options(ChatOptionsBuilder.builder().model(model).build())
.call()
.chatResponse();
timer.stop(metrics.timer("ai.extract.latency", "model", model));
// Model is tagged on every metric — you can compare in Grafana
metrics.counter("ai.extract.tokens.input", "model", model)
.increment(response.getMetadata().getUsage().getPromptTokens());
metrics.counter("ai.extract.tokens.output", "model", model)
.increment(response.getMetadata().getUsage().getGenerationTokens());
return parseResponse(response);
}
}
In Unleash, configure ai.extract.use-haiku:
Start at 0% rollout
Ramp to 5% — watch P95 latency and error rate by model in Grafana
Ramp to 25%, 50%, 100% as metrics look good
If anything degrades — flip to 0% in the Unleash UI. No redeploy, propagates in 10 seconds
flowchart LR
subgraph "Week 1"
A[0% rollout\nBaseline metrics]
end
subgraph "Week 2"
B[5% rollout\nWatch latency + cost]
end
subgraph "Week 3"
C[25% rollout\nWatch quality metrics]
end
subgraph "Week 4"
D[100% rollout\nMigration complete]
end
A -->|Metrics OK| B
B -->|Metrics OK| C
C -->|Metrics OK| D
B -->|Issue found| E[Rollback to 0%\nin 10 seconds]
C -->|Issue found| E
style E fill:#7f1d1d,color:#fca5a5
style D fill:#065f46,color:#6ee7b7
What to compare: latency P95 per model, error rate per model, token cost per model, and — critically — your quality metric (RAGAS score, human thumbs up rate, or whatever you use). The migration is complete when the new model is better or equivalent on all of them.
Pattern 2: Prompt A/B Testing
A new system prompt is not less risky than a code change. It deserves the same scientific treatment.
@ComponentpublicclassSummaryPromptRegistry {
privatestaticfinal Map<String, String> PROMPTS = Map.of(
"control", """
Summarize the following document in under 150 words.
Focus on the key decisions, numbers, and outcomes.
""",
"variant-a", """
You are a senior analyst. Summarize the following document for an executive
who has 30 seconds to read it. Lead with the most important insight.
Use bullet points for supporting facts. 150 words maximum.
""",
"variant-b", """
Summarize this document. Structure your response as:
HEADLINE: One sentence capturing the core finding.
KEY FACTS: 3 bullet points, each under 20 words.
CONTEXT: One sentence on why this matters.
"""
);
public String get(String variant) {
return PROMPTS.getOrDefault(variant, PROMPTS.get("control"));
}
}
@ServicepublicclassSummaryService {
privatefinal AIFeatureFlags flags;
privatefinal SummaryPromptRegistry prompts;
privatefinal ChatClient chatClient;
privatefinal MeterRegistry metrics;
public SummaryResult summarize(String content, String userId) {
Stringvariant= flags.summaryPromptVariant(userId); // "control" | "variant-a" | "variant-b"Stringprompt= prompts.get(variant);
ChatResponseresponse= chatClient.prompt()
.system(prompt)
.user(content)
.call()
.chatResponse();
// Tag every quality signal with the variant — A/B analysis in Grafana
metrics.counter("ai.summary.requests", "variant", variant).increment();
returnnewSummaryResult(
response.getResult().getOutput().getContent(),
variant // return variant so callers can record user satisfaction per-variant
);
}
}
In Unleash, configure ai.summary.prompt-variant as a Variants toggle:
control — 50% of users
variant-a — 25% of users
variant-b — 25% of users
After 7 days and sufficient sample size, compare thumbs-up rate per variant in your analytics. Promote the winner to 100% by setting all weight to the winning variant, then eventually replace control with it.
[!NOTE]
Tag every downstream metric — user satisfaction scores, task completion rates, support tickets — with the prompt variant. The quality metric that matters is business outcome, not LLM eval score.
Pattern 3: Graceful Degradation via Kill Switch
When an AI feature breaks, you need to serve something useful while you fix it. A kill switch paired with a fallback path is the pattern.
@ServicepublicclassSmartSearchService {
privatefinal AIFeatureFlags flags;
privatefinal AISearchService aiSearch; // semantic vector searchprivatefinal KeywordSearchService keywordSearch; // traditional Elasticsearchpublic SearchResults search(SearchQuery query, String userId) {
if (!flags.isAiSearchEnabled(userId)) {
// AI search is killed — fall back to keyword search
log.info("ai-search disabled — falling back to keyword search userId={}", userId);
return keywordSearch.search(query);
}
try {
return aiSearch.search(query);
} catch (Exception e) {
// Unexpected failure — fall back and alert, but don't surface the error to users
log.error("ai-search failed unexpectedly — falling back: {}", e.getMessage());
metrics.counter("ai.search.fallback", "reason", "exception").increment();
return keywordSearch.search(query);
}
}
}
The kill switch gives you three modes:
Normal — AI search for everyone
Partial kill — AI search for 0% of users (flag disabled), keyword search for all. No service degradation, just capability downgrade.
Automatic fallback — AI search throws an unexpected error, keyword search runs transparently
The key design rule: the fallback path must exist before you ship the AI feature. "We'll add fallback later" means you have no kill switch on day one — exactly when you're most likely to need it.
Pattern 4: User-Segment Targeting for Beta Rollouts
Before a public rollout, you want your internal team (and willing beta customers) to dog-food the feature.
@ServicepublicclassAIFeatureFlags {
// Internal users get new AI features firstpublicbooleanisNewExtractorEnabled(String userId, Set<String> userRoles) {
UnleashContextctx= UnleashContext.newBuilder()
.userId(userId)
.addProperty("role", String.join(",", userRoles))
.build();
return unleash.isEnabled("ai.extractor.v2", ctx);
}
}
In Unleash, configure ai.extractor.v2 with activation strategies:
UserWithId — list specific internal user IDs: always enabled for the team
GradualRolloutUserId — 5% of everyone else
This means your engineers and internal QA users see the new extractor immediately. External users get it at 5% rollout. You run this for 2 weeks, then ramp external users to 25%, 50%, 100%.
// Usage in a service — no change needed as rollout percentage increasespublic ExtractionResult extract(Document doc, Authentication auth) {
StringuserId= auth.getName();
Set<String> roles = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
if (!flags.isNewExtractorEnabled(userId, roles)) {
return legacyExtractor.extract(doc);
}
return newExtractor.extract(doc);
}
Observability: Linking Flag State to Metrics
Flags are useless without observability. Every request that touches an AI feature should emit metrics tagged with the active flag variant. This is what makes "is variant-A performing better than control?" answerable in Grafana.
In Grafana, you now have a single dashboard panel that shows latency by model and variant — so model migration and prompt A/B testing are both visible without extra instrumentation.
The four queries you always want:
# Error rate by variant
rate(ai_feature_duration_seconds_count{status="error"}[5m])
/ rate(ai_feature_duration_seconds_count[5m])
by (variant)
# P95 latency by model
histogram_quantile(0.95,
rate(ai_feature_duration_seconds_bucket[5m]))
by (model, le)
# Cost by model (tokens × price)
rate(ai_tokens_input_total[1h]) by (model)
# Rollout progress (% of requests per variant)
rate(ai_feature_duration_seconds_count[5m]) by (variant)
/ ignoring(variant) group_left
sum(rate(ai_feature_duration_seconds_count[5m]))
The Three Disasters We Avoided
Disaster 1 — Model degradation on a specific doc type. Our summarization model started hallucinating numbers in financial documents after a provider-side update. Kill switch disabled AI summarization for affected document categories within 45 seconds of the on-call alert. Users saw the keyword-based fallback. No customer data corrupted.
Disaster 2 — Prompt regression. A new prompt that performed well in offline evals caused a 12% drop in thumbs-up rate in production. We caught it at 5% rollout (affecting ~800 users) instead of rolling it to all 16,000. Rolled back to control variant in the Unleash UI.
Disaster 3 — Cost spike. A batch job that used a new prompt sent to 100% of traffic consumed 4× the expected tokens due to an accidental context window issue. The cost alert fired at $180 (2× rolling average). Kill switch → model flag reverted to the cheaper model → cost normalized in under 2 minutes.
In all three cases: no redeploy, no hotfix PR, no incident spanning multiple engineers. One person, one flag change, one minute.
Anti-Patterns
Checking flags at the wrong layer. Putting flag checks inside the LLM integration class couples your infrastructure to your feature logic. Put flags in the service layer, let the LLM client be a dumb executor.
One flag for the whole AI system. A single ai.enabled kill switch means you can't surgically kill summarization while keeping classification running. One flag per feature, or per feature + model combination.
No fallback for the kill switch. A kill switch that returns an error instead of a fallback response is just a way to 500 your users more deterministically. The fallback must exist before the flag.
Forgetting to tag metrics with flag state. If your metrics don't tell you which variant produced which latency, you're flying blind. Tag everything.
Long-lived feature branches. Don't keep AI behavior in a Git branch while waiting for the flag rollout to complete. Merge to main, deploy behind a 0% flag. The code is in production; the behavior is off. This is trunk-based development for AI.
Starting Simple: The Minimum Viable AI Flag
If you're not ready for Unleash or LaunchDarkly, this works:
// Minimal flag store — a database table, or even a config map@ComponentpublicclassSimpleAIFlags {
@Value("${ai.summary.enabled:true}")privateboolean summaryEnabled;
@Value("${ai.summary.model:llama-3.1-8b-instant}")private String summaryModel;
@Value("${ai.summary.rollout-percentage:100}")privateint rolloutPercentage;
publicbooleanisSummaryEnabled(String userId) {
if (!summaryEnabled) returnfalse;
// Deterministic hash-based assignment so the same user always gets the same experiencereturn Math.abs(userId.hashCode() % 100) < rolloutPercentage;
}
public String getSummaryModel() { return summaryModel; }
}
With Spring Boot Actuator, you can update @Value properties at runtime via /actuator/env + /actuator/refresh (with @RefreshScope). That's your 60-second kill switch without any external system.
Start here. Add Unleash when you have more than 3 AI features and more than one person managing flag changes.
Key Takeaways
AI features fail silently. Flags let you catch silent failures at 5% before they affect 100% of users.
Prompt changes are deployments. Treat them with the same rigor: staging, gradual rollout, observability, rollback.
Build the fallback first. A kill switch that errors instead of degrading gracefully is worse than no kill switch.
Tag every metric with flag state. Without this, you can't answer "is the new model better?" from production data.
Model providers change under you. Your flag infrastructure needs to handle changes you didn't trigger, not just changes you did.
The kill switch window matters. 60 seconds to production effect (flag change → all pods updated) is the target. Anything above 5 minutes is too slow for an active incident.
The engineering investment is modest — a few days to wire up Unleash and build the flag service. The return is the ability to ship AI features confidently and fix them without waking up your team at 3AM.
The next part of this series covers AI observability specifically: the metrics, traces, and dashboards that make "what exactly did the model return for user X?" answerable in under 30 seconds. If you're already doing feature flags and want to pair them with tighter observability, that's where to look next.
Keep reading — it's free
Why LLM features need flags more than anything you've shipped — the four flag patterns that let us swap models, A/B test prompts, and kill broken features fast.
In the rest of this article
Pattern 1: Model Migration Flag
Pattern 2: Prompt A/B Testing
Pattern 3: Graceful Degradation via Kill Switch
Pattern 4: User-Segment Targeting for Beta Rollouts
Observability: Linking Flag State to Metrics
The Three Disasters We Avoided
A free account unlocks the rest of this article, the full prompt library, quiz answer reviews and completion certificates. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.
Join engineers building production AI systems
Free: LLM Production Readiness Checklist (PDF)
50 checks across observability, rate limiting, cost optimization, failure handling, and security — for teams shipping AI features to production.