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.
<dependency>
<groupId>io.getunleash</groupId>
<artifactId>unleash-client-java</artifactId>
<version>9.2.0</version>
</dependency>
@Configuration
public class UnleashConfig {
@Bean
public Unleash unleash(
@Value("${unleash.url}") String url,
@Value("${unleash.api-token}") String token,
@Value("${spring.application.name}") String appName) {
UnleashConfig config = UnleashConfig.newBuilder()
.appName(appName)
.instanceId(System.getenv().getOrDefault("POD_NAME", "local"))
.unleashAPI(url)
.apiKey(token)
.fetchTogglesInterval(10)
.synchronousFetchOnInitialisation(true)
.build();
return new DefaultUnleash(config);
}
}
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.
@Service
@Slf4j
public class AIFeatureFlags {
private final Unleash unleash;
public String summaryModel(String userId) {
UnleashContext ctx = context(userId);
if (unleash.isEnabled("ai.summary.use-70b-model", ctx)) {
return "llama-3.1-70b-versatile";
}
return "llama-3.1-8b-instant";
}
public String classifyModel(String userId) {
UnleashContext ctx = context(userId);
if (unleash.isEnabled("ai.classify.use-claude-haiku", ctx)) {
return "claude-haiku-4-5-20251001";
}
return "llama-3.1-8b-instant";
}
public String summaryPromptVariant(String userId) {
UnleashContext ctx = context(userId);
Variant variant = unleash.getVariant("ai.summary.prompt-variant", ctx);
return variant.isEnabled() ? variant.getName() : "control";
}
public boolean isSummaryEnabled(String userId) {
return unleash.isEnabled("ai.summary.enabled", context(userId));
}
public boolean isClassifyEnabled(String userId) {
return unleash.isEnabled("ai.classify.enabled", context(userId));
}
private UnleashContext context(String userId) {
return UnleashContext.newBuilder()
.userId(userId)
.addProperty("environment", System.getenv().getOrDefault("SPRING_PROFILES_ACTIVE", "local"))
.build();
}
}
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
@Slf4j
public class DocumentExtractor {
private final ChatClient chatClient;
private final AIFeatureFlags flags;
private final MeterRegistry metrics;
public ExtractionResult extract(String documentText, String userId) {
String model = flags.extractModel(userId);
log.debug("extraction model={} userId={}", model, userId);
Timer.Sample timer = Timer.start(metrics);
ChatResponse response = 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));
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.
@Component
public class SummaryPromptRegistry {
private static final 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"));
}
}
@Service
public class SummaryService {
private final AIFeatureFlags flags;
private final SummaryPromptRegistry prompts;
private final ChatClient chatClient;
private final MeterRegistry metrics;
public SummaryResult summarize(String content, String userId) {
String variant = flags.summaryPromptVariant(userId);
String prompt = prompts.get(variant);
ChatResponse response = chatClient.prompt()
.system(prompt)
.user(content)
.call()
.chatResponse();
metrics.counter("ai.summary.requests", "variant", variant).increment();
return new SummaryResult(
response.getResult().getOutput().getContent(),
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.
@Service
public class SmartSearchService {
private final AIFeatureFlags flags;
private final AISearchService aiSearch;
private final KeywordSearchService keywordSearch;
public SearchResults search(SearchQuery query, String userId) {
if (!flags.isAiSearchEnabled(userId)) {
log.info("ai-search disabled — falling back to keyword search userId={}", userId);
return keywordSearch.search(query);
}
try {
return aiSearch.search(query);
} catch (Exception e) {
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
flowchart TD
A[Search Request] --> B{flags.isAiSearchEnabled?}
B -->|true| C[AI Semantic Search]
B -->|false — flag killed| D[Keyword Fallback]
C -->|Success| E[Return AI Results]
C -->|Exception| F[Log + Alert]
F --> D
D --> G[Return Keyword Results]
style B fill:#1e3a5f,color:#7dd3fc
style D fill:#374151,color:#d1d5db
style F fill:#7f1d1d,color:#fca5a5
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.
@Service
public class AIFeatureFlags {
public boolean isNewExtractorEnabled(String userId, Set<String> userRoles) {
UnleashContext ctx = 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%.
public ExtractionResult extract(Document doc, Authentication auth) {
String userId = 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.
@Aspect
@Component
public class AIFlagMetricsAspect {
private final MeterRegistry metrics;
private final AIFeatureFlags flags;
@Around("@annotation(TrackedAIFeature)")
public Object trackWithFlagContext(ProceedingJoinPoint pjp) throws Throwable {
String userId = Arrays.stream(pjp.getArgs())
.filter(a -> a instanceof String)
.map(a -> (String) a)
.findFirst().orElse("unknown");
String model = flags.summaryModel(userId);
String variant = flags.summaryPromptVariant(userId);
Timer.Sample timer = Timer.start(metrics);
try {
Object result = pjp.proceed();
timer.stop(metrics.timer("ai.feature.duration",
"model", model, "variant", variant, "status", "ok"));
return result;
} catch (Throwable t) {
timer.stop(metrics.timer("ai.feature.duration",
"model", model, "variant", variant, "status", "error"));
throw t;
}
}
}
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:
@Component
public class SimpleAIFlags {
@Value("${ai.summary.enabled:true}")
private boolean summaryEnabled;
@Value("${ai.summary.model:llama-3.1-8b-instant}")
private String summaryModel;
@Value("${ai.summary.rollout-percentage:100}")
private int rolloutPercentage;
public boolean isSummaryEnabled(String userId) {
if (!summaryEnabled) return false;
return 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.