AI
    September 1, 202639 min read

    Feature Flags for AI: The Deployment Pattern That Saved Us From 3 Production Disasters

    Why LLM features need flags more than anything else you've ever shipped — and the four flag patterns that let us swap models, A/B test prompts, and kill broken AI features in under 60 seconds.

    Share

    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.

    <!-- pom.xml -->
    <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)   // poll every 10 seconds
                    .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;
    
        // ── Model selection ──────────────────────────────────────────────────────
    
        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";
        }
    
        // ── Prompt selection ─────────────────────────────────────────────────────
    
        public String summaryPromptVariant(String userId) {
            UnleashContext ctx = context(userId);
            // A/B test: "control" vs "variant-a" vs "variant-b"
            Variant variant = unleash.getVariant("ai.summary.prompt-variant", ctx);
            return variant.isEnabled() ? variant.getName() : "control";
        }
    
        // ── Kill switches ────────────────────────────────────────────────────────
    
        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));
        }
    
        // ── Helpers ──────────────────────────────────────────────────────────────
    
        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));
    
            // 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.

    @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);    // "control" | "variant-a" | "variant-b"
            String prompt  = prompts.get(variant);
    
            ChatResponse response = 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();
    
            return new SummaryResult(
                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.

    @Service
    public class SmartSearchService {
    
        private final AIFeatureFlags     flags;
        private final AISearchService    aiSearch;       // semantic vector search
        private final KeywordSearchService keywordSearch; // traditional Elasticsearch
    
        public 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:

    1. Normal — AI search for everyone
    2. Partial kill — AI search for 0% of users (flag disabled), keyword search for all. No service degradation, just capability downgrade.
    3. 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 {
    
        // Internal users get new AI features first
        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:

    1. UserWithId — list specific internal user IDs: always enabled for the team
    2. 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 increases
    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 {
            // Extract userId from first String argument (by convention)
            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:

    // Minimal flag store — a database table, or even a config map
    @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;
            // Deterministic hash-based assignment so the same user always gets the same experience
            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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    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.

    No spam. Unsubscribe any time.

    Comments