AI 应用的 Feature Flag——功能开关在 AI 场景的特殊用法
AI 应用的 Feature Flag——功能开关在 AI 场景的特殊用法
有一天下午,产品经理来找我,说用户反馈 AI 写的内容太保守了,语气太刻板,希望活泼一点。
我说好,需要调整 Temperature 参数,从 0.3 改到 0.7。
他说:"那今晚能上吗?"
我看了下当前的发版排期,下次发版是下周三。我说,要等下周三。
他说:"就改一个参数,能不能今天就生效?"
我当时的第一反应是:这不就得发版吗?
然后我想起来我们有 Feature Flag 系统,然后想了一下:能做,但不是直接用普通功能开关,需要扩展一下。
这篇文章就讲 AI 场景下 Feature Flag 的特殊用法——不只是开关,而是参数级别的动态控制。
普通 Feature Flag 解决不了 AI 的问题
传统 Feature Flag 的用途:
- 功能 A 是否开启(true/false)
- 对哪些用户开启(用户白名单)
- A/B 测试(50% 用户看版本 A,50% 看版本 B)
这些是布尔值或者简单枚举的控制,满足"开/关"语义。
AI 场景需要的控制粒度更细:
- 模型版本控制:现在用 gpt-4o,想对部分用户试试 gpt-4o-mini,不行就切回来
- Prompt 版本控制:Prompt 改了一版,想先给 10% 用户试试效果
- 超参数控制:Temperature、TopP、MaxTokens 这些参数想动态调整,不发版
- 检索策略控制:RAG 系统里,想在不发版的情况下切换检索策略(BM25 vs 向量检索 vs 混合)
- 功能行为控制:AI 是否引用来源、是否展示思考链、是否启用函数调用
这些需求的共同特点是:值不是布尔,而是枚举或数值;调整频率高;需要针对不同用户/租户有不同值。
传统的 Feature Flag 系统(LaunchDarkly 等)虽然支持多值 Flag,但对 AI 场景没有特殊优化,用起来也稍显繁琐。
设计一个适合 AI 场景的 Flag 系统
我们的方案:在 LaunchDarkly 的能力上做封装,专门为 AI 参数控制设计一层 DSL。
如果你不用 LaunchDarkly,也可以用自研的简单实现(我后面也会给出自研版本)。
核心数据模型
// AI Flag 的统一配置结构
@Data
public class AiFeatureFlag {
private String flagKey; // 唯一标识
private String description; // 描述
private AiFlagType type; // 类型:MODEL_VERSION, PROMPT_VERSION, PARAMETER, STRATEGY
// 默认值(所有未命中规则的请求使用)
private AiFlagValue defaultValue;
// 规则列表(按顺序匹配,第一个匹配的规则生效)
private List<AiFlagRule> rules;
// 是否启用(全局开关)
private boolean enabled;
@Data
public static class AiFlagValue {
private String modelVersion; // 模型版本(type=MODEL_VERSION 时使用)
private String promptVersion; // Prompt 版本 ID
private Map<String, Object> parameters; // AI 超参数 key-value
private String strategyName; // 检索/处理策略名称
}
@Data
public static class AiFlagRule {
private String name;
private AiFlagCondition condition; // 匹配条件
private AiFlagValue value; // 命中时的值
private int weight; // 用于 A/B 测试的权重(0-100)
}
@Data
public static class AiFlagCondition {
private List<String> userIds; // 用户白名单
private List<String> tenantIds; // 租户白名单
private List<String> userGroups; // 用户分组
private String rolloutPercentage; // 灰度百分比 "10%" 表示 10%
}
}Flag 评估引擎
@Service
@Slf4j
public class AiFlagEvaluator {
@Autowired
private AiFlagRepository flagRepository;
/**
* 评估 Flag,返回当前上下文应该使用的值
*/
public AiFeatureFlag.AiFlagValue evaluate(String flagKey, AiEvaluationContext context) {
AiFeatureFlag flag = flagRepository.getFlag(flagKey);
if (flag == null || !flag.isEnabled()) {
log.debug("Flag {} not found or disabled, using default", flagKey);
return getHardcodedDefault(flagKey);
}
// 按规则顺序匹配
for (AiFeatureFlag.AiFlagRule rule : flag.getRules()) {
if (matchesCondition(rule.getCondition(), context)) {
if (rule.getWeight() > 0) {
// A/B 测试模式:按权重随机
if (isInRollout(context.getUserId(), flagKey + ":" + rule.getName(), rule.getWeight())) {
log.debug("Flag {} matched rule {} for user {}", flagKey, rule.getName(), context.getUserId());
return rule.getValue();
}
} else {
return rule.getValue();
}
}
}
// 没有规则命中,使用默认值
return flag.getDefaultValue();
}
private boolean matchesCondition(AiFeatureFlag.AiFlagCondition condition, AiEvaluationContext context) {
if (condition == null) {
return true; // 没有条件,总是匹配
}
// 检查用户白名单
if (condition.getUserIds() != null && !condition.getUserIds().isEmpty()) {
if (condition.getUserIds().contains(context.getUserId())) {
return true;
}
}
// 检查租户白名单
if (condition.getTenantIds() != null && !condition.getTenantIds().isEmpty()) {
if (condition.getTenantIds().contains(context.getTenantId())) {
return true;
}
}
// 检查用户分组
if (condition.getUserGroups() != null && !condition.getUserGroups().isEmpty()) {
if (condition.getUserGroups().contains(context.getUserGroup())) {
return true;
}
}
return false;
}
/**
* 基于用户ID + salt 的稳定哈希,确保同一用户每次评估结果一致
*/
private boolean isInRollout(String userId, String salt, int percentage) {
int hash = Math.abs((userId + salt).hashCode()) % 100;
return hash < percentage;
}
private AiFeatureFlag.AiFlagValue getHardcodedDefault(String flagKey) {
// Flag 不存在时的兜底默认值
AiFeatureFlag.AiFlagValue defaultValue = new AiFeatureFlag.AiFlagValue();
Map<String, Object> defaultParams = new HashMap<>();
defaultParams.put("temperature", 0.7);
defaultParams.put("maxTokens", 2048);
defaultValue.setParameters(defaultParams);
defaultValue.setModelVersion("gpt-4o");
return defaultValue;
}
}和 AI 调用的集成
@Service
@Slf4j
public class ContentGenerationService {
@Autowired
private AiFlagEvaluator flagEvaluator;
@Autowired
private PromptVersionService promptVersionService;
@Autowired
private UnifiedModelService modelService;
public ContentGenerationResult generate(ContentGenerationRequest request) {
// 构建 Flag 评估上下文
AiEvaluationContext context = AiEvaluationContext.builder()
.userId(request.getUserId())
.tenantId(request.getTenantId())
.userGroup(userGroupService.getGroup(request.getUserId()))
.build();
// 评估模型版本 Flag
AiFeatureFlag.AiFlagValue modelFlag = flagEvaluator.evaluate("content_gen_model", context);
String modelVersion = modelFlag.getModelVersion() != null
? modelFlag.getModelVersion()
: "gpt-4o";
// 评估 Prompt 版本 Flag
AiFeatureFlag.AiFlagValue promptFlag = flagEvaluator.evaluate("content_gen_prompt", context);
String promptVersion = promptFlag.getPromptVersion() != null
? promptFlag.getPromptVersion()
: "default";
// 评估超参数 Flag
AiFeatureFlag.AiFlagValue paramFlag = flagEvaluator.evaluate("content_gen_params", context);
Map<String, Object> params = paramFlag.getParameters() != null
? paramFlag.getParameters()
: Collections.emptyMap();
double temperature = (double) params.getOrDefault("temperature", 0.7);
int maxTokens = (int) params.getOrDefault("maxTokens", 2048);
// 加载对应版本的 Prompt
String promptTemplate = promptVersionService.getPrompt(promptVersion);
String finalPrompt = renderPrompt(promptTemplate, request);
// 构建 AI 请求
UnifiedChatRequest chatRequest = UnifiedChatRequest.builder()
.systemPrompt(finalPrompt)
.messages(buildMessages(request))
.modelConfig(UnifiedChatRequest.UnifiedModelConfig.builder()
.temperature(temperature)
.maxTokens(maxTokens)
.build())
.build();
log.info("Generating content for user {} with model={}, promptVersion={}, temp={}",
request.getUserId(), modelVersion, promptVersion, temperature);
UnifiedChatResponse response = modelService.chatWithModel(modelVersion, chatRequest);
return ContentGenerationResult.builder()
.content(response.getContent())
.modelUsed(modelVersion)
.promptVersion(promptVersion)
.build();
}
}自研 Flag 存储(不依赖 LaunchDarkly)
如果你不想引入 LaunchDarkly,可以用数据库 + Redis 缓存自己实现一个简单的存储:
@Service
@Slf4j
public class AiFlagRepository {
@Autowired
private AiFlagMapper flagMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
private static final String CACHE_PREFIX = "ai_flag:";
private static final long CACHE_TTL_SECONDS = 60; // 1 分钟缓存,修改后最多 1 分钟生效
public AiFeatureFlag getFlag(String flagKey) {
// 先查缓存
String cacheKey = CACHE_PREFIX + flagKey;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
try {
return objectMapper.readValue(cached, AiFeatureFlag.class);
} catch (JsonProcessingException e) {
log.warn("Failed to deserialize cached flag: {}", flagKey);
}
}
// 查数据库
AiFlagPO flagPO = flagMapper.selectByKey(flagKey);
if (flagPO == null) {
return null;
}
AiFeatureFlag flag = convertToDomain(flagPO);
// 写入缓存
try {
redisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(flag),
Duration.ofSeconds(CACHE_TTL_SECONDS));
} catch (JsonProcessingException e) {
log.warn("Failed to cache flag: {}", flagKey);
}
return flag;
}
public void updateFlag(AiFeatureFlag flag) {
// 更新数据库
flagMapper.updateByKey(convertToPO(flag));
// 清除缓存(让下次读取时重新加载)
redisTemplate.delete(CACHE_PREFIX + flag.getFlagKey());
log.info("Flag {} updated and cache invalidated", flag.getFlagKey());
}
}Flag 管理 API
@RestController
@RequestMapping("/admin/ai-flags")
@PreAuthorize("hasRole('AI_ADMIN')")
public class AiFlagAdminController {
@Autowired
private AiFlagRepository flagRepository;
@Autowired
private AiFlagChangeHistoryService historyService;
/**
* 更新 AI 参数 Flag(核心接口:不发版修改 AI 参数)
*/
@PutMapping("/{flagKey}/parameters")
public ResponseEntity<Void> updateParameters(
@PathVariable String flagKey,
@RequestBody UpdateParametersRequest request,
@AuthenticationPrincipal AdminUser adminUser) {
AiFeatureFlag flag = flagRepository.getFlag(flagKey);
if (flag == null) {
return ResponseEntity.notFound().build();
}
// 记录变更历史(谁在什么时候改了什么)
historyService.record(FlagChangeRecord.builder()
.flagKey(flagKey)
.changeType(FlagChangeType.PARAMETER_UPDATE)
.operator(adminUser.getUsername())
.before(objectMapper.writeValueAsString(flag.getDefaultValue().getParameters()))
.after(objectMapper.writeValueAsString(request.getParameters()))
.reason(request.getReason())
.timestamp(System.currentTimeMillis())
.build());
// 更新默认值参数
flag.getDefaultValue().setParameters(request.getParameters());
flagRepository.updateFlag(flag);
log.info("Parameters updated for flag {} by {}: {}",
flagKey, adminUser.getUsername(), request.getParameters());
return ResponseEntity.ok().build();
}
/**
* 针对特定租户设置参数覆盖
*/
@PostMapping("/{flagKey}/tenant-overrides")
public ResponseEntity<Void> addTenantOverride(
@PathVariable String flagKey,
@RequestBody TenantOverrideRequest request) {
AiFeatureFlag flag = flagRepository.getFlag(flagKey);
// 添加一条新规则:针对该租户使用指定参数
AiFeatureFlag.AiFlagRule tenantRule = new AiFeatureFlag.AiFlagRule();
tenantRule.setName("tenant_" + request.getTenantId());
AiFeatureFlag.AiFlagCondition condition = new AiFeatureFlag.AiFlagCondition();
condition.setTenantIds(List.of(request.getTenantId()));
tenantRule.setCondition(condition);
AiFeatureFlag.AiFlagValue value = new AiFeatureFlag.AiFlagValue();
value.setParameters(request.getParameters());
tenantRule.setValue(value);
flag.getRules().add(0, tenantRule); // 插到最前面,优先级最高
flagRepository.updateFlag(flag);
return ResponseEntity.ok().build();
}
}真实场景:不发版把 Temperature 从 0.7 调到 0.3
回到文章开头那个场景,产品经理说要今天生效。
有了这套 Flag 系统,操作是这样的:
- 打开管理后台的 AI Flag 页面
- 找到
content_gen_params这个 Flag - 修改
temperature值从 0.7 到 0.3 - 点保存,填写变更原因:"产品反馈语气太保守,提高活泼度"
- 完成
因为 Redis 缓存 TTL 是 60 秒,最多 1 分钟后所有请求都会使用新的 Temperature 值。
产品经理晚上测了一下,觉得 0.3 还不够活泼,要调到 0.5。一分钟搞定。
如果效果不好要回滚,看变更历史,找到上一次的值,再改回来,一样一分钟。
这就是 Feature Flag 给 AI 系统带来的真正价值:把 AI 行为的调整从"发版问题"变成"配置问题",响应速度从天级降到分钟级。
注意事项
变更历史必须记录
不记录变更历史,你两周后根本想不起来某个参数是什么时候改的,为什么改的。当出问题需要排查时,你需要知道"这个时间点,系统使用的 Temperature 是多少"。
权限要严格控制
能修改 AI 参数的人要有限制。Temperature 影响所有用户的体验,随意修改风险不低。我们的权限是:AI 工程师可以修改,产品经理需要申请,普通开发没有权限。
参数值要做校验
Temperature 的合法范围是 0-2(OpenAI)或 0-1(大多数模型),MaxTokens 有上限。参数值入库前要做校验,防止填了一个非法值导致 AI 调用全部报错。
不是所有 AI 行为都适合用 Flag 控制
一些深层的逻辑变化(比如 RAG 检索策略的重大重构、Agent 的工具集变化)不适合用参数 Flag 控制,还是需要发版。Flag 适合控制的是"同一套逻辑的参数调整",不适合"替换一套逻辑"。
最后
产品经理当天下午 5 点就看到了调整后的效果,给我发了个点赞。
Feature Flag 在传统软件里是为了解耦部署和发布,在 AI 系统里还多了一层价值:让 AI 行为的调整不需要工程师介入,产品和运营可以直接操作。
这对于需要快速迭代 AI 产品的团队来说,是个实实在在的效率提升。
