LLM 应用的 Feature Flag 工程实践:Prompt、模型与 AI 行为的生产安全灰度

一次 Prompt 改动,让客服机器人开始「编故事」

去年某电商平台升级客服助手的 system prompt,把原来的「请严格基于订单数据回答」改成了「请友好地帮助用户解决问题」。改动当天下午,用户开始反馈:AI 告诉他们的退款时效比实际政策快了两天。

问题不难找到------但修复花了 47 分钟。这 47 分钟里,Prompt 变更对每一个打进来的用户全量生效,没有任何办法收窄影响范围,只能等工程师手动回滚部署。

这是大多数 LLM 应用团队都经历过的场景:Prompt 改动不像代码改动那样走 PR review + 分支部署流程,但它的影响范围和生产风险完全不亚于一次服务发布。

Stanford HAI 的 AI Index 2026 报告指出,2025 年文档化 AI 事故同比增加了 55%,而与此同时,把自己的 AI 事故响应评为"优秀"的组织从 28% 降到了 18%。问题不在于出事的频率,而在于出了事之后的控制能力。

Feature Flag 是软件工程里处理"部署但不发布"(deploy but not release)问题的标准答案。把它引入 LLM 应用的控制面,不是什么新奇方案,但绝大多数团队都做得非常粗糙------或者根本没做。

这篇文章讲的是怎么系统性地把 Feature Flag 用到 LLM 工程里,包括 flag 类型的设计、代码层的实现模式,以及 5 个很容易踩到的生产陷阱。


为什么 LLM 应用需要 Feature Flag

传统 Feature Flag 解决的是「代码上线了但功能不想立刻暴露给所有用户」的问题。在 LLM 应用里,这个需求的来源更多、更难预测。

难点一:Prompt 变更是隐性发布

Prompt 不在代码仓库里管理(或者管理得很松散)。改一个 system prompt,不会触发 CI/CD 流水线,不会留下 git commit,不会经过代码审查。但它的效果相当于一次全量的功能发布------所有用户的下一次请求都会走新 Prompt。

Pete Hodgson 在 OpenFeature 治理委员会的定义里说:"Feature flag 让我们可以把'潜伏代码'(latent code)发到生产------代码已经部署,但功能被禁用的 flag 挡在后面。"Prompt 的灰度需要一样的机制。

难点二:模型版本升级是高风险变更

你不可能通过 staging 环境完整预测新模型版本的生产行为。模型是非确定的,同一个 Prompt 在 DeepSeek-V2.5 和 DeepSeek-V3 上的风格、格式偏好都可能不同,更别提处理边界输入时的差异。直接全量切换就是在赌博。

难点三:上下文一致性

用户在一次长对话里,中途遇到 flag 变更会感知到行为跳变。如果 A/B 测试的 flag 在会话级别不固定,用户可能在同一个对话里前几轮用的是模型 A 的风格,后几轮变成模型 B 的风格。这是很恶劣的体验问题。

难点四:成本控制

高能力模型(比如 DeepSeek-V3、Qwen-Max)比基础模型贵 5-20 倍。你希望高价模型只对付费用户或高优先级请求开放,免费用户走轻量模型。这本质上就是一个 flag 的定向投放问题。

难点五:功能债和 flag 技术债

LLM 功能迭代快,一个特性从实验到稳定可能只要两周。但 flag 如果不清理,会在代码里越堆越多。GrowthBook 的工程博客里提到,很多 AI 团队 6 个月后有 30% 的 flag 处于"不知道能不能删"的状态。


Feature Flag 的基础类型:LLM 控制面的五种 Flag

在 LLM 应用里,不是所有 flag 都一样。根据控制目标,我把它们分成 5 类:

1. Prompt Flag

最直接的 flag。flag 的值是一个 string 或 enum,决定用哪个 Prompt 变体。

typescript 复制代码
// flag value: "v1" | "v2_friendly" | "v2_strict"
const promptVariant = await flagClient.getStringValue(
  'customer-service-prompt',
  'v1',  // default
  { userId: user.id, tier: user.tier }
);

const systemPrompt = PROMPT_REGISTRY[promptVariant];

Prompt Flag 的 key 指向 Prompt Registry(数据库、配置文件、或 S3),而不是直接在 flag 里存 Prompt 文本。这样 Prompt 内容本身还可以单独更新,flag 只决定用哪个版本。

2. Model Router Flag

flag 决定把请求发到哪个模型。这是成本控制和 A/B 测试的核心工具。

python 复制代码
model_choice = flag_client.get_string_value(
    flag_key='llm-model-tier',
    default='deepseek-chat',
    evaluation_context={
        'user_id': user_id,
        'subscription_tier': subscription_tier,
        'request_type': request_type,
    }
)

response = client.chat.completions.create(
    model=model_choice,
    messages=messages
)

通过 evaluation_context 里的 subscription_tier,付费用户自动路由到 Qwen-Max,免费用户走 mini 模型,代码逻辑不变。

3. Behavior Flag

控制 Agent 的行为属性:工具调用的权限范围、推理步数、输出格式、是否允许搜索外部数据源。

typescript 复制代码
const agentConfig = await flagClient.getObjectValue(
  'agent-behavior-config',
  {
    maxToolCalls: 3,
    allowWebSearch: false,
    outputFormat: 'markdown',
    reasoningDepth: 'standard'
  },
  { tenantId: tenant.id, environment: env }
);

Behavior Flag 的 value 是 JSON 对象,一次 flag 评估决定一组行为参数。

4. Kill Switch

最简单但最重要的 flag------一个 boolean,切到 false 时立刻把 AI 功能关掉,流量转到非 AI 的降级路径。

python 复制代码
ai_enabled = flag_client.get_boolean_value(
    flag_key='ai-assistant-enabled',
    default=True,
    evaluation_context={'region': request.region}
)

if not ai_enabled:
    return fallback_rule_based_response(user_input)

# 正常走 LLM 路径
response = await llm_client.complete(prompt, user_input)

Kill switch 的响应时间目标是 <1 分钟(flag provider 推送新值)+ 下一次请求立刻生效(服务不需要重启)。这是其他手段(热更新配置、Canary 回滚)无法替代的优势。

5. Cost Gate

当 token 用量超过阈值时,自动降级到轻量模型。这不是一个纯 flag,但可以用 flag 来做动态的成本控制策略切换。

typescript 复制代码
// flag value: "premium" | "standard" | "economy"
// 由 cost-control 服务根据当前小时用量动态更新
const costTier = await flagClient.getStringValue(
  'llm-cost-tier',
  'standard',
  { tenantId: tenant.id }
);

const modelMap = {
  premium: 'DeepSeek-V3',
  standard: 'Qwen-Plus',
  economy: 'deepseek-chat'
};

const model = modelMap[costTier];

实战:用 OpenFeature SDK 给 LLM 调用加 Flag

OpenFeature 是 CNCF 维护的 Feature Flag 标准化 SDK 接口,支持切换 provider(LaunchDarkly、GrowthBook、Flagsmith、Unleash 都有适配)而不改应用代码。

以下是一个完整的 Node.js 示例,演示如何在 LLM 调用前做 flag 评估:

typescript 复制代码
import { OpenFeature } from '@openfeature/server-sdk';
import { FlagsmithProvider } from '@openfeature/flagsmith-provider';
import OpenAI from 'openai' // 兼容 OpenAI 协议的 SDK,可接入国产大模型;

// 初始化 OpenFeature provider(只做一次)
await OpenFeature.setProviderAndWait(
  new FlagsmithProvider({ environmentKey: process.env.FLAGSMITH_ENV_KEY! })
);
const flagClient = OpenFeature.getClient('llm-service');

export async function handleUserMessage(
  message: string,
  context: { userId: string; tenantId: string; subscriptionTier: string }
) {
  // 1. 在 LLM 调用前评估所有 flag
  const evalCtx = {
    targetingKey: context.userId,
    tenantId: context.tenantId,
    subscriptionTier: context.subscriptionTier,
  };

  const [modelChoice, promptVariant, aiEnabled] = await Promise.all([
    flagClient.getStringValue('llm-model', 'deepseek-chat', evalCtx),
    flagClient.getStringValue('system-prompt-variant', 'v1', evalCtx),
    flagClient.getBooleanValue('ai-assistant-enabled', true, evalCtx),
  ]);

  // 2. Kill switch:立刻降级
  if (!aiEnabled) {
    return { source: 'rule-based', content: getRuleBasedResponse(message) };
  }

  // 3. 从 Prompt Registry 拿实际 Prompt 内容
  const systemPrompt = await promptRegistry.get(promptVariant);

  // 4. 正常 LLM 调用
  const openai = new OpenAI({ baseURL: 'https://api.deepseek.com/v1', apiKey: process.env.DEEPSEEK_API_KEY });
  const completion = await openai.chat.completions.create  # 国产大模型兼容 OpenAI 协议({
    model: modelChoice,
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: message }
    ]
  });

  return {
    source: 'ai',
    model: modelChoice,
    promptVariant,
    content: completion.choices[0].message.content
  };
}

关键点:所有 flag 评估在 LLM 调用之前并发完成,不在 LLM 内部做判断,LLM 看到的 Prompt 和 model 参数都是已经评估好的。

Python 版本

python 复制代码
import asyncio
from openfeature import api
from openfeature.provider.flagsmith import FlagsmithProvider
from openai import AsyncOpenAI  # 使用兼容 OpenAI 协议的国产大模型接口

api.set_provider(FlagsmithProvider(environment_key=os.environ['FLAGSMITH_ENV_KEY']))
client = api.get_client("llm-service")
openai = AsyncOpenAI(base_url='https://api.deepseek.com/v1', api_key=os.environ['DEEPSEEK_API_KEY'])

async def handle_user_message(message: str, user_id: str, subscription_tier: str):
    ctx = {"targetingKey": user_id, "subscriptionTier": subscription_tier}

    # 并发评估所有 flag
    model_choice, prompt_variant, ai_enabled = await asyncio.gather(
        asyncio.to_thread(client.get_string_value, "llm-model", "deepseek-chat", ctx),
        asyncio.to_thread(client.get_string_value, "system-prompt-variant", "v1", ctx),
        asyncio.to_thread(client.get_boolean_value, "ai-assistant-enabled", True, ctx),
    )

    if not ai_enabled:
        return {"source": "rule-based", "content": get_rule_based_response(message)}

    system_prompt = await prompt_registry.get(prompt_variant)

    response = await openai.chat.completions.create  # 国产大模型兼容 OpenAI 协议(
        model=model_choice,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": message}
        ]
    )
    return {
        "source": "ai",
        "model": model_choice,
        "content": response.choices[0].message.content
    }

5 个生产陷阱

这是最有价值的部分。下面这些错误模式在实际项目里反复出现。

Trap 1:在 LLM 内部判断 flag(最危险)

python 复制代码
# 错误:把 flag 判断交给 LLM
system_prompt = """
你是客服助手。
如果用户是 VIP 用户,使用更礼貌的语气。  # ← 这不是 flag,这是给 LLM 的指令
"""

这个错误看起来无害,实际上非常危险。LLM 对"VIP 用户"的判断是概率性的,无法保证一致性,也无法追踪。你以为在做定向行为,实际上在做随机行为。

正确做法:用真实的 flag 评估决定 Prompt 内容,不要在 Prompt 里描述"什么情况下怎么做"然后寄希望于 LLM 自己判断。

Trap 2:Flag 粒度太粗

typescript 复制代码
// 错误:整个 AI 功能只有一个 flag
const aiEnabled = await flagClient.getBooleanValue('ai-enabled', true, ctx);

当一个 flag 控制整个 AI 功能时,一旦某个细粒度的 Prompt 实验出了问题,你只能选择全关(影响所有用户)或全开(继续暴露问题)。

正确做法:按功能子集拆分 flag------ai-search-enabledai-draft-enabledai-summary-enabled 分开控制。实验 flag 和 Kill Switch flag 不是同一个。

Trap 3:会话级一致性被破坏

python 复制代码
# 错误:每次请求重新评估 flag,没有 session 固定
async def handle_message(session_id: str, message: str, user_id: str):
    model = await flag_client.get_string_value('llm-model', 'default', {'userId': user_id})
    # ← 如果 flag 值在会话中途更新,前几轮和后几轮走的是不同模型

用户在对话第 3 轮时遇到模型切换,会感知到风格突变。在涉及上下文记忆的 Agent 里,这会直接导致逻辑混乱。

正确做法:在会话开始时做一次 flag 评估,把结果固定在 session context 里,同一会话内不再重新评估。

python 复制代码
class Session:
    def __init__(self, user_id: str):
        self.user_id = user_id
        # 会话初始化时固定 flag 评估结果
        self.model = flag_client.get_string_value('llm-model', 'default', {'userId': user_id})
        self.prompt_variant = flag_client.get_string_value('prompt-variant', 'v1', {'userId': user_id})

    async def handle_message(self, message: str):
        # 使用固定值,不重新评估
        return await llm_call(self.model, self.prompt_variant, message)

Trap 4:Flag 生命周期没人管

bash 复制代码
# 6 个月后 flag 列表的常见状态
$ flag list
ai-new-prompt-experiment        # 谁的实验?什么时候全量还是删掉?
ai-v3-model-test                # v3 是什么?现在在测?
ai-feature-2025-11-launch       # 2025-11 已经过去了,这个 flag 还有效吗?
ai-emergency-kill-switch        # 和主 kill switch 有什么区别?

废弃 flag 不清理会带来两个问题:一是每次请求都在评估无效 flag(增加延迟),二是没人知道能不能删,最终演变成技术债。

正确做法:

  • 每个 flag 创建时强制填写 owner、有效期(defaulted to 30 天)、目的描述
  • 在 CI 里加一个 lint 步骤检查代码里引用的 flag 是否都在 flag 配置里有对应声明
  • 每 2 周 review 一次待清理 flag 列表

Trap 5:Cost Gate 没做 per-user/per-tenant 隔离

python 复制代码
# 错误:基于全局用量做 cost gate
total_tokens_this_hour = metrics.get('global_token_usage_1h')
if total_tokens_this_hour > THRESHOLD:
    model = 'deepseek-chat'  # 全局降级,所有用户都受影响

全局 cost gate 会出现「一个高消耗租户把所有人拖下水」的问题。正确的做法是 per-tenant 隔离:

python 复制代码
# 正确:per-tenant cost gate
tenant_tokens_this_hour = metrics.get(f'token_usage_1h:{tenant_id}')
tenant_quota = quota_service.get_hourly_quota(tenant_id)

if tenant_tokens_this_hour > tenant_quota * 0.9:
    # 只降级这个 tenant,不影响其他人
    model = 'deepseek-chat'

Kill Switch 的工程设计

Kill switch 是 LLM 应用里最高优先级的 flag,需要单独设计触发路径和降级逻辑。

触发条件

不要只有人工触发一条路径:

触发方式 描述 响应时间目标
人工翻转 工程师在 flag dashboard 直接关掉 <1 分钟(flag 推送)
自动熔断 错误率/异常输出率超阈值自动触发 <30 秒(监控→flag 写入)
成本告警 单小时 token 费用超预算自动触发 <2 分钟(账单 API 轮询)
合规触发 检测到违禁内容输出,自动隔离 <10 秒(在线检测)

降级路径设计

kill switch 关掉之后,流量要去哪里?这个路径在 kill switch 触发之前就要设计好并测试过:

typescript 复制代码
async function handleWithFallback(
  message: string,
  context: EvalContext
): Promise<Response> {
  const aiEnabled = await flagClient.getBooleanValue(
    'ai-assistant-kill-switch',
    true,
    context
  );

  if (!aiEnabled) {
    // 降级路径 1:规则引擎
    const ruleResult = await ruleEngine.match(message);
    if (ruleResult) return { source: 'rules', content: ruleResult };

    // 降级路径 2:FAQ 检索
    const faqResult = await faqSearch.query(message);
    if (faqResult.score > 0.8) return { source: 'faq', content: faqResult.answer };

    // 降级路径 3:人工转接
    return {
      source: 'human-handoff',
      content: '当前 AI 服务暂时不可用,已转接人工客服,请稍候。'
    };
  }

  return await callLLM(message, context);
}

降级路径需要定期演练(混沌工程),不能只在代码里写好但从不触发。

Kill Switch 的监控闭环

scss 复制代码
[LLM 异常检测]
    ↓ (错误率 > 5% / 有害输出检测)
[自动写入 flag provider: ai-enabled = false]
    ↓ (flag 推送 <30s)
[所有实例下一次请求走降级路径]
    ↓
[告警发到 on-call]
    ↓
[人工确认、定位、修复]
    ↓
[手动恢复: ai-enabled = true]
    ↓
[灰度恢复:先 5%,再 50%,再全量]

Flag 生命周期管理

一个 flag 从创建到删除,应该有明确的状态机。

scss 复制代码
created (实验中)
    → rolled_out (全量)
        → deprecated (代码里已清理引用)
            → archived (从 flag provider 删除)
    → abandoned (实验失败,已回滚)
        → archived

最容易被忽略的是 deprecated → archived 这个步骤。很多团队把 flag 从代码里删了,但 flag provider 里还留着,等到有人问"这个 flag 还有效吗"时已经没人记得了。

在 CI 里加一个检查:

bash 复制代码
# ci/check-flags.sh
# 检查 flag provider 里的 flag,是否都在代码里有引用
# 如果某个 flag 在 provider 里存在但代码里没有引用 → 警告,可能是遗忘的废弃 flag

另一个好习惯是给每个 flag 加 metadata:

json 复制代码
{
  "key": "ai-new-summarizer-v2",
  "owner": "ai-platform-team",
  "created_at": "2026-08-01",
  "expires_at": "2026-09-01",
  "purpose": "Test new summarizer prompt vs v1, target: response length -20%",
  "rollout_plan": "5% → 25% → 50% → 100% over 2 weeks",
  "success_metric": "CSAT score + p95 latency",
  "kill_switch": false
}

对比:Feature Flag vs Canary Deploy vs Config Hot Reload

这三种机制经常被混淆,实际上解决不同维度的问题:

维度 Feature Flag Canary Deploy Config Hot Reload
控制粒度 单个功能/行为,可按用户定向 整个服务版本,按流量比例 服务级配置(无用户区分)
响应速度 <1 分钟(flag 推送) 5-15 分钟(重新部署) <1 分钟(配置重载)
用户定向 支持(按 user_id/tenant/属性) 不支持(按机器/流量比例) 不支持
代码变更 不需要 需要(新版本) 不需要
最适合 Prompt 实验、模型 A/B、kill switch 重大架构变更、新模型版本 模型参数、超时配置、阈值
审计记录 flag 变更历史 部署记录 配置变更日志

在实际系统里,三者组合使用:

  • Canary Deploy 做新版本 LLM 服务本体的灰度(代码层面的变更)
  • Config Hot Reload 做参数级别的动态调整(temperature、max_tokens、timeout)
  • Feature Flag 做功能级别的定向控制(对特定用户开启/关闭 AI 功能、Prompt 变体实验)

结论

LLM 应用的 Feature Flag 不是普通工程里 flag 的简单套用,它要处理的控制对象(Prompt、模型、AI 行为)本身就是非确定的,这带来了额外的复杂性。

把本文的核心 checklist 整理如下:

实施清单

  • Flag 评估在 LLM 调用之前,不在 Prompt 里做"条件判断"
  • 按功能粒度拆分 flag,Kill Switch 和实验 flag 分开
  • 会话级 flag 固定:会话开始时评估,不在对话中途重新评估
  • 每个 flag 有 owner + 过期时间 + 目的描述
  • Kill Switch 有降级路径,且降级路径经过测试
  • Cost Gate 做 per-tenant 隔离,不做全局降级
  • 定期清理废弃 flag(每 2 周 review,CI 检查引用)
  • Kill Switch 支持自动触发(不只是人工操作)

LLM 应用的发布速度已经超过了大多数团队的控制面建设速度。DORA 2025 数据显示,AI 工具让开发者合并 PR 的速度提升了 60%------但控制面如果没有跟上,速度就变成了风险放大器。

Feature Flag 不会让 LLM 应用变得可预测,但它让出问题时的「影响收窄」和「快速恢复」变得可能。这才是它在 LLM 工程栈里真正的价值所在。


参考资料

  • GrowthBook: Feature flags in AI-led development (Jun 2026)
  • Flagsmith: Feature Flags for AI Companies
  • Stanford HAI: AI Index Report 2026
  • DORA: State of AI-assisted Software Development 2025
  • OpenFeature: CNCF Feature Flag Standard
相关推荐
weixin_431600441 小时前
前端对接 SSE 的两种常见方式
前端·后端·学习·ai·sse·nest.js
Larcher7 小时前
React Router 不只是页面跳转:从 SPA 路由到权限守卫的完整实践
javascript·后端
Larcher7 小时前
大模型为什么每次回答都不一样?一文搞懂 Temperature、Top K 与 Top P
javascript·后端
LucianaiB9 小时前
毕业老学长给我留的最后一句话是:“AI 写的,别挂我名。” 我直接 神(Seed Evolving) 来!助我!
后端
IvanCodes9 小时前
RAG 实战教程(一):RAG 工作原理与完整流程——分片、索引、召回、重排和生成
人工智能·后端·agent
CodeSheep10 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员
“初生”10 小时前
Codex 接入视觉功能教程(2026)|vision-skill 外挂通义千问 qwen3-vl-flash 图文
ai编程
小满zs11 小时前
Go语言第八章(函数)
后端·go
鱼樱前端11 小时前
用 AI 做内容变收入
前端·人工智能·ai编程