Spring AI动态配置实战:运行时切换模型与参数
Spring AI动态配置实战:运行时切换模型与参数
那次让我们停服2小时的"模型升级"
2025年1月的一个下午,我的老同学李强发来一条微信:
"老张救命,我们想把AI助手的模型从gpt-3.5-turbo换成gpt-4o,结果整个服务要停2个小时!"
我问他为什么停那么久。
他说:"我们的模型名称是写死在配置文件里的,换模型要改代码、提PR、走代码审查、构建、发布……整个流程最快也要2小时。发布期间服务停了,影响了1200个线上用户。"
我问:"你们为什么不用配置中心?"
"之前没想到AI这块也需要动态配置,就写死了。"
这个"没想到",让他们付出了2小时停服的代价。
实际上,AI系统对动态配置的需求,比任何其他系统都要强烈:
- 模型供应商发布新模型,你想马上切换试试效果
- 某个模型API突然涨价,你要立刻降级到便宜模型
- 你想做A/B测试,让10%的用户用新模型,90%的用旧模型
- 某个提示词需要微调,但不想发版
- 某个租户反馈效果差,你想临时给他们调高Temperature
以上所有需求,都需要不重启服务、不发版就能完成。
这篇文章,是我帮李强重构后的完整方案。
先说结论(TL;DR)
| 配置场景 | 推荐方案 | 生效延迟 | 复杂度 |
|---|---|---|---|
| 模型参数调整(Temperature等) | Nacos配置 | <5秒 | 低 |
| 模型切换 | Nacos + 工厂重建 | <5秒 | 中 |
| A/B测试 | 流量分组路由 | <5秒 | 中 |
| 提示词更新 | 数据库 + 本地缓存 | <30秒 | 低 |
| API Key轮换 | Nacos加密配置 | <5秒 | 中 |
| 紧急降级 | 断路器 + Feature Toggle | 即时 | 低 |
架构决策:
- 用Nacos作为配置中心(国内主流,开源免费)
- 用Spring Cloud Config作为配置读取层(标准化)
- 敏感配置(API Key)单独加密存储
- 审计日志记录所有配置变更
整体架构
核心实现一:Nacos配置集成
1.1 依赖配置
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
<version>2023.0.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-context</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
</dependencies>1.2 Bootstrap配置
spring:
application:
name: ai-service
config:
import:
- "nacos:ai-service-config.yaml?refresh=true"
- "nacos:ai-models-config.yaml?refresh=true"
cloud:
nacos:
config:
server-addr: ${NACOS_SERVER_ADDR:localhost:8848}
namespace: ${NACOS_NAMESPACE:dev}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
refresh-enabled: true
config-long-poll-timeout: 30001.3 AI动态配置属性类
package com.laozhang.dynamic.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
/**
* AI动态配置属性
* @RefreshScope + @ConfigurationProperties:Nacos推送变更时,Spring自动刷新Bean
*/
@Data
@Component
@RefreshScope
@ConfigurationProperties(prefix = "ai")
public class AiDynamicProperties {
private LlmConfig defaultLlm = new LlmConfig();
private boolean abTestEnabled = false;
private AbTestConfig abTest = new AbTestConfig();
private FeatureToggles features = new FeatureToggles();
private RateLimitConfig rateLimit = new RateLimitConfig();
@Data
public static class LlmConfig {
private String provider = "openai";
private String model = "gpt-4o-mini";
private double temperature = 0.7;
private int maxTokens = 2048;
private int timeoutSeconds = 30;
private boolean streamEnabled = true;
}
@Data
public static class AbTestConfig {
private double experimentTrafficRatio = 0.0;
private LlmConfig experimentLlm = new LlmConfig();
private String experimentDescription = "";
}
@Data
public static class FeatureToggles {
private boolean ragEnabled = true;
private boolean streamingEnabled = true;
private boolean costTrackingEnabled = true;
private boolean promptCachingEnabled = false;
}
@Data
public static class RateLimitConfig {
private int globalRpm = 1000;
private int perUserRpm = 20;
private int maxConcurrentRequests = 50;
}
}1.4 Nacos中的配置文件
在Nacos控制台创建 ai-service-config.yaml:
ai:
default-llm:
provider: openai
model: gpt-4o-mini
temperature: 0.7
max-tokens: 2048
timeout-seconds: 30
ab-test-enabled: false
ab-test:
experiment-traffic-ratio: 0.0
experiment-description: ""
experiment-llm:
provider: openai
model: gpt-4o
temperature: 0.5
max-tokens: 4096
features:
rag-enabled: true
streaming-enabled: true
cost-tracking-enabled: true
prompt-caching-enabled: false
rate-limit:
global-rpm: 1000
per-user-rpm: 20
max-concurrent-requests: 50核心实现二:动态ChatClient工厂
package com.laozhang.dynamic.factory;
import com.laozhang.dynamic.config.AiDynamicProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicReference;
/**
* 动态ChatClient工厂
* 监听配置刷新事件,自动重建ChatClient实例
* 使用AtomicReference实现无锁的原子切换(线程安全)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicChatClientFactory {
private final AiDynamicProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final ApiKeyProvider apiKeyProvider;
private final AtomicReference<ChatClient> currentClient = new AtomicReference<>();
private final AtomicReference<ChatClient> experimentClient = new AtomicReference<>();
private volatile String lastConfigHash = "";
public ChatClient getDefaultClient() {
ChatClient client = currentClient.get();
if (client == null) {
synchronized (this) {
if (currentClient.get() == null) {
rebuildClients();
}
}
}
return currentClient.get();
}
public ChatClient getExperimentClient() {
ChatClient client = experimentClient.get();
return client != null ? client : getDefaultClient();
}
@EventListener(RefreshScopeRefreshedEvent.class)
public void onConfigRefreshed(RefreshScopeRefreshedEvent event) {
String newConfigHash = computeConfigHash(properties);
if (newConfigHash.equals(lastConfigHash)) {
log.debug("AI配置未发生变化,跳过重建");
return;
}
log.info("检测到AI配置变更,开始重建ChatClient");
log.info("新配置:model={}, temperature={}, maxTokens={}",
properties.getDefaultLlm().getModel(),
properties.getDefaultLlm().getTemperature(),
properties.getDefaultLlm().getMaxTokens());
rebuildClients();
lastConfigHash = newConfigHash;
eventPublisher.publishEvent(new AiConfigChangedEvent(this, properties, newConfigHash));
log.info("ChatClient重建完成");
}
private void rebuildClients() {
ChatClient newDefaultClient = buildClient(properties.getDefaultLlm());
currentClient.getAndSet(newDefaultClient);
if (properties.isAbTestEnabled()) {
ChatClient newExperimentClient = buildClient(properties.getAbTest().getExperimentLlm());
experimentClient.set(newExperimentClient);
log.info("A/B测试已启用:实验组={},流量比例={}",
properties.getAbTest().getExperimentLlm().getModel(),
properties.getAbTest().getExperimentTrafficRatio());
} else {
experimentClient.set(null);
}
}
private ChatClient buildClient(AiDynamicProperties.LlmConfig config) {
return switch (config.getProvider()) {
case "openai" -> buildOpenAiClient(config);
case "alibaba" -> buildAlibabaClient(config);
default -> throw new IllegalArgumentException("不支持的AI提供商: " + config.getProvider());
};
}
private ChatClient buildOpenAiClient(AiDynamicProperties.LlmConfig config) {
String apiKey = apiKeyProvider.getOpenAiApiKey();
OpenAiApi openAiApi = OpenAiApi.builder()
.apiKey(apiKey)
.build();
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(config.getModel())
.temperature(config.getTemperature())
.maxTokens(config.getMaxTokens())
.build();
OpenAiChatModel model = OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(options)
.build();
return ChatClient.builder(model).build();
}
private ChatClient buildAlibabaClient(AiDynamicProperties.LlmConfig config) {
String apiKey = apiKeyProvider.getAlibabaApiKey();
OpenAiApi openAiApi = OpenAiApi.builder()
.apiKey(apiKey)
.baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1")
.build();
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(config.getModel())
.temperature(config.getTemperature())
.maxTokens(config.getMaxTokens())
.build();
OpenAiChatModel model = OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(options)
.build();
return ChatClient.builder(model).build();
}
private String computeConfigHash(AiDynamicProperties props) {
AiDynamicProperties.LlmConfig llm = props.getDefaultLlm();
return String.format("%s_%s_%.2f_%d",
llm.getProvider(), llm.getModel(), llm.getTemperature(), llm.getMaxTokens());
}
}核心实现三:A/B测试流量路由
package com.laozhang.dynamic.ab;
import com.laozhang.dynamic.config.AiDynamicProperties;
import com.laozhang.dynamic.factory.DynamicChatClientFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
/**
* A/B测试流量路由器
* 根据配置的流量比例,将请求路由到不同的ChatClient实例
* 使用userId的确定性哈希,确保同一用户始终在同一组
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AbTestRouter {
private final DynamicChatClientFactory clientFactory;
private final AiDynamicProperties properties;
private final AbTestMetricsRecorder metricsRecorder;
public ChatClient getClientForUser(String userId) {
AiDynamicProperties.AbTestConfig abTest = properties.getAbTest();
if (!properties.isAbTestEnabled()) {
return clientFactory.getDefaultClient();
}
boolean isExperimentGroup = isInExperimentGroup(userId, abTest);
metricsRecorder.recordRequest(
isExperimentGroup ? "experiment" : "control",
abTest.getExperimentDescription());
if (isExperimentGroup) {
log.debug("用户{}分配到实验组(模型={})", userId, abTest.getExperimentLlm().getModel());
return clientFactory.getExperimentClient();
} else {
log.debug("用户{}分配到对照组(模型={})", userId, properties.getDefaultLlm().getModel());
return clientFactory.getDefaultClient();
}
}
/**
* 基于userId的确定性哈希:同一用户每次都分到同一组
*/
private boolean isInExperimentGroup(String userId, AiDynamicProperties.AbTestConfig abTest) {
double ratio = abTest.getExperimentTrafficRatio();
if (ratio <= 0.0) return false;
if (ratio >= 1.0) return true;
int hash = Math.abs(userId.hashCode());
double userBucket = (hash % 1000) / 1000.0;
return userBucket < ratio;
}
}@Component
@RequiredArgsConstructor
public class AbTestMetricsRecorder {
private final MeterRegistry meterRegistry;
public void recordRequest(String group, String experimentName) {
Counter.builder("ai.ab_test.requests")
.tag("group", group)
.tag("experiment", experimentName)
.register(meterRegistry)
.increment();
}
public void recordLatency(String group, long latencyMs) {
Timer.builder("ai.ab_test.latency")
.tag("group", group)
.register(meterRegistry)
.record(Duration.ofMillis(latencyMs));
}
public void recordUserFeedback(String group, boolean positive) {
Counter.builder("ai.ab_test.feedback")
.tag("group", group)
.tag("type", positive ? "positive" : "negative")
.register(meterRegistry)
.increment();
}
}核心实现四:提示词动态管理
package com.laozhang.dynamic.prompt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 提示词动态管理服务
* 提示词存储在数据库中,通过缓存提高性能
* 管理员更新提示词后,缓存自动失效
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PromptTemplateService {
private final PromptTemplateRepository repository;
@Cacheable(value = "prompt-templates", key = "#templateName + '_' + #version")
public String getTemplate(String templateName, String version) {
log.debug("从数据库加载提示词模板: {}, version={}", templateName, version);
Optional<PromptTemplate> template;
if (version != null) {
template = repository.findByNameAndVersion(templateName, version);
} else {
template = repository.findActiveByName(templateName);
}
return template.map(PromptTemplate::getContent)
.orElseThrow(() -> new PromptTemplateNotFoundException(
"提示词模板不存在: " + templateName));
}
@CacheEvict(value = "prompt-templates", allEntries = true)
public PromptTemplate updateTemplate(String name, String content,
String updatedBy, String changeNote) {
Optional<PromptTemplate> current = repository.findActiveByName(name);
String newVersion = generateVersion(current);
PromptTemplate newTemplate = PromptTemplate.builder()
.name(name)
.content(content)
.version(newVersion)
.active(true)
.createdBy(updatedBy)
.changeNote(changeNote)
.createdAt(LocalDateTime.now())
.build();
current.ifPresent(old -> {
old.setActive(false);
repository.save(old);
});
PromptTemplate saved = repository.save(newTemplate);
log.info("提示词模板已更新: name={}, version={}", name, newVersion);
return saved;
}
@CacheEvict(value = "prompt-templates", allEntries = true)
public void rollbackTemplate(String name, String targetVersion, String rollbackBy) {
PromptTemplate targetTemplate = repository.findByNameAndVersion(name, targetVersion)
.orElseThrow(() -> new IllegalArgumentException("目标版本不存在: " + name + " v" + targetVersion));
repository.findActiveByName(name).ifPresent(current -> {
current.setActive(false);
repository.save(current);
});
targetTemplate.setActive(true);
repository.save(targetTemplate);
log.info("提示词模板已回滚: name={}, version={}", name, targetVersion);
}
@Scheduled(fixedDelay = 300_000)
@CacheEvict(value = "prompt-templates", allEntries = true)
public void refreshPromptCache() {
log.debug("定时刷新提示词缓存");
}
private String generateVersion(Optional<PromptTemplate> current) {
if (current.isEmpty()) return "1.0.0";
String[] parts = current.get().getVersion().split("\\.");
int patch = Integer.parseInt(parts[2]) + 1;
return parts[0] + "." + parts[1] + "." + patch;
}
}核心实现五:配置变更审计
package com.laozhang.dynamic.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.laozhang.dynamic.event.AiConfigChangedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 配置变更审计日志
* 记录:谁改了什么、改了哪个配置、从什么改成什么、什么时间改的
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ConfigAuditLogger {
private final ConfigAuditRepository auditRepository;
private final ObjectMapper objectMapper;
@Async
@EventListener
public void onConfigChanged(AiConfigChangedEvent event) {
try {
ConfigAuditLog auditLog = ConfigAuditLog.builder()
.configKey("ai.llm-config")
.configHash(event.getNewConfigHash())
.newValue(objectMapper.writeValueAsString(event.getNewProperties().getDefaultLlm()))
.changedAt(LocalDateTime.now())
.changeSource("nacos-push")
.build();
auditRepository.save(auditLog);
log.info("配置变更审计已记录: hash={}", event.getNewConfigHash());
} catch (Exception e) {
log.error("配置审计写入失败", e);
}
}
public void logManualChange(String configKey, String oldValue, String newValue,
String operator, String reason) {
ConfigAuditLog auditLog = ConfigAuditLog.builder()
.configKey(configKey)
.oldValue(oldValue)
.newValue(newValue)
.operator(operator)
.reason(reason)
.changedAt(LocalDateTime.now())
.changeSource("manual-api")
.build();
auditRepository.save(auditLog);
log.info("手动配置变更已记录: key={}, operator={}", configKey, operator);
}
}@Data
@Builder
@Entity
@Table(name = "config_audit_log",
indexes = {
@Index(name = "idx_config_key_time", columnList = "config_key, changed_at"),
@Index(name = "idx_operator", columnList = "operator")
})
public class ConfigAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "config_key", nullable = false)
private String configKey;
@Column(name = "config_hash")
private String configHash;
@Column(name = "old_value", columnDefinition = "TEXT")
private String oldValue;
@Column(name = "new_value", columnDefinition = "TEXT")
private String newValue;
@Column(name = "operator")
private String operator;
@Column(name = "reason")
private String reason;
@Column(name = "changed_at", nullable = false)
private LocalDateTime changedAt;
@Column(name = "change_source")
private String changeSource;
@Enumerated(EnumType.STRING)
@Column(name = "status")
@Builder.Default
private ChangeStatus status = ChangeStatus.SUCCESS;
public enum ChangeStatus { SUCCESS, FAILED, ROLLED_BACK }
}核心实现六:灰度发布
package com.laozhang.dynamic.grayscale;
import com.laozhang.dynamic.config.AiDynamicProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Set;
/**
* 灰度发布控制器
* 支持:用户白名单、用户比例、用户标签、时间窗口
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GrayscaleController {
private final AiDynamicProperties properties;
private final UserTagService userTagService;
private Set<String> betaWhitelist = Set.of();
public boolean isFeatureEnabled(String featureName, String userId) {
if (betaWhitelist.contains(userId)) {
log.debug("用户{}在白名单,功能{}开放", userId, featureName);
return true;
}
boolean globalEnabled = switch (featureName) {
case "rag" -> properties.getFeatures().isRagEnabled();
case "streaming" -> properties.getFeatures().isStreamingEnabled();
case "prompt-caching" -> properties.getFeatures().isPromptCachingEnabled();
default -> false;
};
if (!globalEnabled) return false;
if (userTagService.isVip(userId)) {
return true;
}
return isInGrayscaleGroup(userId, featureName);
}
private boolean isInGrayscaleGroup(String userId, String featureName) {
int hash = Math.abs((userId + featureName).hashCode());
int bucket = hash % 100;
int grayscalePercent = getGrayscalePercent(featureName);
return bucket < grayscalePercent;
}
private int getGrayscalePercent(String featureName) {
return 100; // 从Nacos配置读取,默认100%全量
}
}核心实现七:配置管理API
package com.laozhang.dynamic.controller;
import com.laozhang.dynamic.audit.ConfigAuditLogger;
import com.laozhang.dynamic.prompt.PromptTemplateService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* AI配置管理API
* 供运维人员和产品经理使用(不需要改代码、不需要发版)
*/
@RestController
@RequestMapping("/admin/ai-config")
@RequiredArgsConstructor
@PreAuthorize("hasAnyRole('ADMIN', 'AI_OPS')")
public class AiConfigManageController {
private final PromptTemplateService promptTemplateService;
private final ConfigAuditLogger auditLogger;
private final NacosConfigPublisher nacosConfigPublisher;
@PutMapping("/llm")
@Operation(summary = "热更新LLM模型参数,立即生效,无需重启")
public ResponseEntity<Void> updateLlmConfig(
@Valid @RequestBody UpdateLlmConfigRequest request,
Authentication auth) {
String operator = auth.getName();
Map<String, Object> newConfig = Map.of(
"ai.default-llm.model", request.getModel(),
"ai.default-llm.temperature", request.getTemperature(),
"ai.default-llm.max-tokens", request.getMaxTokens()
);
nacosConfigPublisher.updateConfig("ai-service-config.yaml", newConfig);
auditLogger.logManualChange("ai.llm-config", "旧配置", newConfig.toString(),
operator, request.getReason());
return ResponseEntity.ok().build();
}
@PutMapping("/ab-test")
@Operation(summary = "配置A/B测试,设置实验组流量比例")
public ResponseEntity<Void> configureAbTest(
@Valid @RequestBody AbTestConfigRequest request,
Authentication auth) {
Map<String, Object> newConfig = Map.of(
"ai.ab-test-enabled", request.isEnabled(),
"ai.ab-test.experiment-traffic-ratio", request.getTrafficRatio(),
"ai.ab-test.experiment-llm.model", request.getExperimentModel(),
"ai.ab-test.experiment-description", request.getDescription()
);
nacosConfigPublisher.updateConfig("ai-service-config.yaml", newConfig);
auditLogger.logManualChange("ai.ab-test", null, newConfig.toString(),
auth.getName(), request.getReason());
return ResponseEntity.ok().build();
}
@PutMapping("/prompts/{templateName}")
@Operation(summary = "热更新提示词模板,30秒内生效")
public ResponseEntity<Map<String, String>> updatePrompt(
@PathVariable String templateName,
@Valid @RequestBody UpdatePromptRequest request,
Authentication auth) {
var updated = promptTemplateService.updateTemplate(
templateName, request.getContent(), auth.getName(), request.getChangeNote());
auditLogger.logManualChange("prompt." + templateName, "旧提示词",
request.getContent().substring(0, Math.min(100, request.getContent().length())),
auth.getName(), request.getChangeNote());
return ResponseEntity.ok(Map.of(
"version", updated.getVersion(),
"message", "提示词已更新,最长30秒后生效"));
}
@PostMapping("/prompts/{templateName}/rollback")
@Operation(summary = "回滚提示词到指定历史版本")
public ResponseEntity<Void> rollbackPrompt(
@PathVariable String templateName,
@RequestParam String version,
Authentication auth) {
promptTemplateService.rollbackTemplate(templateName, version, auth.getName());
return ResponseEntity.ok().build();
}
@GetMapping("/audit-log")
@Operation(summary = "查询配置变更审计日志")
public ResponseEntity<List<ConfigAuditLogDTO>> getAuditLog(
@RequestParam(required = false) String configKey,
@RequestParam(defaultValue = "50") int limit) {
return ResponseEntity.ok(auditLogger.queryLog(configKey, limit));
}
@PostMapping("/emergency-degradation")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "紧急降级:立即关闭AI功能")
public ResponseEntity<Void> emergencyDegradation(
@RequestParam String reason,
Authentication auth) {
Map<String, Object> degradedConfig = Map.of(
"ai.features.rag-enabled", false,
"ai.features.streaming-enabled", false,
"ai.emergency-degradation", true,
"ai.degradation-reason", reason
);
nacosConfigPublisher.updateConfig("ai-service-config.yaml", degradedConfig);
auditLogger.logManualChange("ai.emergency", null, degradedConfig.toString(),
auth.getName(), "紧急降级: " + reason);
return ResponseEntity.ok().build();
}
}核心实现八:敏感配置加密管理
package com.laozhang.dynamic.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.vault.core.VaultTemplate;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
/**
* API Key安全管理
* 集成HashiCorp Vault + 本地缓存(带TTL)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiKeyProvider {
private final VaultTemplate vaultTemplate;
private final com.github.benmanes.caffeine.cache.Cache<String, String> keyCache;
public String getOpenAiApiKey() {
return getSecret("openai/api-key");
}
public String getAlibabaApiKey() {
return getSecret("alibaba/dashscope-api-key");
}
private String getSecret(String secretPath) {
return keyCache.get(secretPath, path -> {
try {
var response = vaultTemplate.read("secret/" + path);
if (response == null || response.getData() == null) {
throw new RuntimeException("Vault中不存在密钥: " + path);
}
return (String) response.getData().get("value");
} catch (Exception e) {
log.error("从Vault加载API Key失败: {}", path, e);
throw new RuntimeException("API Key加载失败", e);
}
});
}
public void invalidateKey(String secretPath) {
keyCache.invalidate(secretPath);
log.info("API Key缓存已失效: {}", secretPath);
}
/**
* AES-256-GCM加密(用于在Nacos中存储加密值)
*/
public static String encryptApiKey(String plainKey, byte[] masterKey) throws Exception {
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(masterKey, "AES"),
new GCMParameterSpec(128, nonce));
byte[] encrypted = cipher.doFinal(plainKey.getBytes());
byte[] result = new byte[nonce.length + encrypted.length];
System.arraycopy(nonce, 0, result, 0, nonce.length);
System.arraycopy(encrypted, 0, result, nonce.length, encrypted.length);
return Base64.getEncoder().encodeToString(result);
}
}生产环境注意事项
关键配置参数
spring:
cloud:
nacos:
config:
fail-fast: true
refresh-enabled: true
resilience4j:
circuitbreaker:
instances:
ai-service:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 3配置变更防护
@Component
public class ConfigChangeGuard {
@EventListener(EnvironmentChangeEvent.class)
public void validateNewConfig(EnvironmentChangeEvent event) {
Set<String> changedKeys = event.getKeys();
if (changedKeys.contains("ai.default-llm.model")) {
String model = env.getProperty("ai.default-llm.model");
if (!SUPPORTED_MODELS.contains(model)) {
log.error("不支持的模型: {},配置变更已记录告警", model);
alertService.sendAlert("AI配置警告:尝试设置不支持的模型 " + model);
}
}
if (changedKeys.contains("ai.default-llm.temperature")) {
String tempStr = env.getProperty("ai.default-llm.temperature");
try {
double temp = Double.parseDouble(tempStr);
if (temp < 0 || temp > 2) {
alertService.sendAlert("AI配置警告:temperature超出范围 " + temp);
}
} catch (NumberFormatException e) {
alertService.sendAlert("AI配置错误:temperature格式不合法 " + tempStr);
}
}
}
}踩坑1:@RefreshScope的单例Bean陷阱
不要在构造函数或@PostConstruct中缓存@RefreshScope Bean的值,因为Bean刷新后,缓存的值仍然是旧的。
踩坑2:配置刷新期间的请求处理
ChatClient重建期间(通常<100ms),用AtomicReference的原子切换解决:老请求用旧ChatClient完成,新请求用新ChatClient,不会有中间状态。
踩坑3:Nacos配置推送失败的处理
Nacos客户端有重试机制,如果服务器长时间不可用,应用继续使用上次收到的配置(本地缓存)。确保日志里有告警,便于排查问题。
常见问题解答
Q1:Nacos推送配置到应用需要多长时间?
A:Nacos使用长轮询机制,配置变更后通常1-3秒内推送到应用。对比重新发版的2小时,是革命性的改进。
Q2:多个应用实例,所有实例都会收到配置推送吗?
A:是的,Nacos会向所有订阅了该配置的实例推送变更,所有实例几乎同时(毫秒级)收到新配置。
Q3:A/B测试的分组,如果用户刷新页面,会不会换组?
A:不会。基于userId的确定性哈希,同一个userId无论调用多少次,计算出的分组桶都是固定的。只有改变experiment-traffic-ratio比例值,才会引起分组变化。
Q4:API Key在Nacos中存储安全吗?
A:明文存储绝对不安全。推荐:1)HashiCorp Vault(最安全);2)KMS加密后存储加密值;3)Kubernetes Secret挂载为环境变量。绝对不要在Nacos中存明文API Key。
Q5:配置回滚怎么做?
A:Nacos控制台有配置历史记录功能,可以一键回滚到任意历史版本。提示词有我们自己的版本表,通过API回滚。每次重要变更前先记录当前值,方便快速回滚。
Q6:如何保证配置变更不破坏正在处理中的请求?
A:用AtomicReference做原子切换。正在执行的请求持有旧的ChatClient引用,用旧配置完成;新来的请求拿到新的ChatClient引用,用新配置处理。切换过程对请求透明,没有失败请求。
总结
李强为一个"写死配置"付出了2小时停服的代价。这篇文章的核心价值是:让AI系统的配置变更像修改Word文档一样简单。
可操作行动清单:
不重启,不发版,配置变更秒级生效——这是AI系统应该有的基础能力。
