生产 AI 系统的热更新——不停机更新 Prompt 和模型配置
生产 AI 系统的热更新——不停机更新 Prompt 和模型配置
有一天下午,我们的 AI 客服系统突然开始频繁给用户推荐一款已经下架的产品。
根因很简单:Prompt 模板里有一段关于「推荐热销品」的说明,产品下架了但 Prompt 没有更新。这种问题修起来一行代码都不用改,就是更新一下 Prompt 文字。
但问题是,我们的 Prompt 是硬编码在代码里的,改了 Prompt 就要走代码发布流程:提 PR、Review、合并、构建、部署。整个流程最快也要 2-3 小时,紧急情况可以加急,但依然要走测试流程。
就为了改一行 Prompt,发一次版本,这太不合理了。
更重要的是:Prompt 工程本质上是运营迭代工作,不是软件开发工作。把运营迭代绑定在代码发布流程上,是一个架构决策错误。
一、为什么 Prompt 热更新是刚需
Prompt 的修改频率远高于业务逻辑代码。在一个成熟的 AI 产品里,Prompt 可能每天都在调整:
- 产品发现某个表述方式更容易被用户接受,需要改 Prompt
- 某个特定类型的问题 AI 回答不好,需要加一段专门的处理说明
- 节假日活动,需要让 AI 的语气变得更活泼
- 某个词汇因为外部事件变得敏感,需要从 Prompt 里删除
如果每次修改都要走代码发布,有两个后果:一是效率极低,运营人员和工程师都很累;二是工程师变成了「Prompt 搬运工」,这是对工程能力的浪费。
热更新的目标:让运营和产品人员可以直接修改 Prompt(通过配置后台),修改立刻生效,无需发版,无需工程师介入。
但热更新需要安全边界,不是所有东西都能随意热改。
二、什么可以热更新,什么不行
先把边界搞清楚,这比实现技术本身更重要。
可以热更新的:
- System Prompt 的文本内容(措辞、语气、边界条件描述)
- 少样本示例(Few-shot examples)
- 模型参数(temperature、top_p、max_tokens)
- RAG 检索的 top-k 和相关性阈值
- 功能开关(某个 AI 功能是否启用)
- 响应格式模板(JSON 结构、Markdown 格式要求)
不能热更新的(必须走代码发布):
- 模型版本(从 gpt-4o 切到 gpt-4o-mini,行为差异大,需要全面测试)
- Embedding 模型版本(一旦改了,现有向量库全部失效,需要重建)
- 数据处理逻辑(影响 RAG 的检索结果)
- 安全过滤规则的核心逻辑
- 收费模型的计费规则
把这个边界写进文档,并在配置后台里用技术手段强制:某些字段只读,必须走工单流程才能修改。
三、技术选型:Apollo vs Nacos
热更新需要一个配置中心。主流选择是 Apollo(携程开源)和 Nacos(阿里开源)。
两者的核心能力差不多,选择主要看你们现有技术栈。我们内部用的是 Nacos,所以下面的代码示例基于 Nacos,但概念完全适用于 Apollo。
核心机制是:配置中心维护配置的版本历史,应用订阅配置变更,配置中心推送变更通知,应用更新本地缓存。
四、完整实现
4.1 Prompt 配置的数据结构
在 Nacos 中,以 JSON 格式存储 Prompt 配置:
{
"version": "v1.23.0",
"updatedAt": "2024-12-15T14:30:00Z",
"updatedBy": "pm_xiaoli",
"changeNote": "调整产品推荐逻辑,移除下架产品的推荐",
"prompts": {
"customer_service_system": {
"content": "你是一位专业的电商客服助手...",
"modelParams": {
"temperature": 0.7,
"maxTokens": 1024,
"topP": 0.9
},
"enabled": true
},
"product_recommendation": {
"content": "根据用户的购物偏好,推荐当前在售的产品...",
"modelParams": {
"temperature": 0.8,
"maxTokens": 512
},
"enabled": true
}
},
"ragConfig": {
"customer_service_kb": {
"topK": 5,
"similarityThreshold": 0.72,
"rerankEnabled": true
}
},
"featureFlags": {
"streamingEnabled": true,
"multiTurnEnabled": true,
"proactiveRecommendEnabled": false
}
}4.2 Prompt 配置模型
@Data
public class PromptConfig {
private String version;
private LocalDateTime updatedAt;
private String updatedBy;
private String changeNote;
private Map<String, PromptItem> prompts;
private Map<String, RagConfigItem> ragConfig;
private Map<String, Boolean> featureFlags;
@Data
public static class PromptItem {
private String content;
private ModelParams modelParams;
private boolean enabled;
}
@Data
public static class ModelParams {
private Double temperature;
private Integer maxTokens;
private Double topP;
// 不包含 model 版本,版本不允许热更新
}
@Data
public static class RagConfigItem {
private Integer topK;
private Double similarityThreshold;
private boolean rerankEnabled;
}
}4.3 配置监听器实现
@Service
@Slf4j
public class DynamicPromptService implements InitializingBean {
@Autowired
private ConfigService nacosConfigService;
@Value("${nacos.config.data-id:ai-prompt-config}")
private String dataId;
@Value("${nacos.config.group:AI_GROUP}")
private String group;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private MeterRegistry meterRegistry;
// 当前生效的配置(volatile 保证可见性)
private volatile PromptConfig currentConfig;
// 配置版本历史(用于回滚)
private final LinkedList<PromptConfig> configHistory = new LinkedList<>();
private static final int MAX_HISTORY_SIZE = 10;
@Override
public void afterPropertiesSet() throws Exception {
// 初始加载配置
String configContent = nacosConfigService.getConfig(dataId, group, 5000);
if (configContent != null) {
applyConfig(configContent, "initial-load");
}
// 注册监听器
nacosConfigService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
log.info("Prompt config changed, applying new version...");
try {
applyConfig(configInfo, "nacos-push");
} catch (Exception e) {
log.error("Failed to apply new prompt config: {}", e.getMessage(), e);
meterRegistry.counter("ai.prompt.update.failed").increment();
}
}
@Override
public Executor getExecutor() {
// 在单独的线程池里处理配置更新,不阻塞 Nacos 的推送线程
return Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "prompt-config-updater");
t.setDaemon(true);
return t;
});
}
});
log.info("DynamicPromptService initialized, listening for config changes");
}
private void applyConfig(String configJson, String source) {
try {
PromptConfig newConfig = objectMapper.readValue(configJson, PromptConfig.class);
// 配置校验
validateConfig(newConfig);
// 保存当前配置到历史
if (currentConfig != null) {
saveToHistory(currentConfig);
}
// 应用新配置
PromptConfig oldConfig = this.currentConfig;
this.currentConfig = newConfig;
// 记录变更
log.info("Prompt config updated: version={}, updatedBy={}, note={}",
newConfig.getVersion(), newConfig.getUpdatedBy(), newConfig.getChangeNote());
meterRegistry.counter("ai.prompt.update.success",
"source", source,
"version", newConfig.getVersion()
).increment();
// 触发后置处理(比如预热缓存)
onConfigUpdated(oldConfig, newConfig);
} catch (JsonProcessingException e) {
throw new RuntimeException("Invalid prompt config JSON: " + e.getMessage(), e);
}
}
private void validateConfig(PromptConfig config) {
if (config.getVersion() == null || config.getVersion().isEmpty()) {
throw new IllegalArgumentException("Prompt config version is required");
}
// 验证所有 Prompt 不为空
if (config.getPrompts() != null) {
config.getPrompts().forEach((key, item) -> {
if (item.isEnabled() && (item.getContent() == null || item.getContent().isBlank())) {
throw new IllegalArgumentException("Prompt content cannot be empty for key: " + key);
}
});
}
// 验证参数范围
if (config.getPrompts() != null) {
config.getPrompts().forEach((key, item) -> {
if (item.getModelParams() != null) {
Double temp = item.getModelParams().getTemperature();
if (temp != null && (temp < 0 || temp > 2)) {
throw new IllegalArgumentException("Temperature must be 0-2 for key: " + key);
}
}
});
}
}
private void saveToHistory(PromptConfig config) {
configHistory.addFirst(config);
if (configHistory.size() > MAX_HISTORY_SIZE) {
configHistory.removeLast();
}
}
private void onConfigUpdated(PromptConfig oldConfig, PromptConfig newConfig) {
// 记录具体哪些 Prompt 发生了变化
if (oldConfig != null && newConfig.getPrompts() != null) {
newConfig.getPrompts().forEach((key, newItem) -> {
PromptConfig.PromptItem oldItem = oldConfig.getPrompts() != null
? oldConfig.getPrompts().get(key)
: null;
if (oldItem == null || !oldItem.getContent().equals(newItem.getContent())) {
log.info("Prompt '{}' content changed", key);
}
});
}
}
/**
* 获取指定场景的 Prompt 内容
*/
public String getPrompt(String key) {
PromptConfig config = currentConfig;
if (config == null || config.getPrompts() == null) {
throw new IllegalStateException("Prompt config not loaded");
}
PromptConfig.PromptItem item = config.getPrompts().get(key);
if (item == null) {
throw new IllegalArgumentException("Prompt not found for key: " + key);
}
if (!item.isEnabled()) {
throw new PromptDisabledException("Prompt is disabled for key: " + key);
}
return item.getContent();
}
/**
* 获取模型参数
*/
public PromptConfig.ModelParams getModelParams(String key) {
PromptConfig config = currentConfig;
if (config == null) return new PromptConfig.ModelParams();
PromptConfig.PromptItem item = config.getPrompts() != null
? config.getPrompts().get(key)
: null;
return item != null && item.getModelParams() != null
? item.getModelParams()
: new PromptConfig.ModelParams();
}
/**
* 回滚到上一个版本
*/
public void rollback() {
if (configHistory.isEmpty()) {
throw new IllegalStateException("No previous config version to rollback to");
}
PromptConfig previousConfig = configHistory.removeFirst();
log.warn("Rolling back prompt config from {} to {}",
currentConfig != null ? currentConfig.getVersion() : "null",
previousConfig.getVersion());
this.currentConfig = previousConfig;
meterRegistry.counter("ai.prompt.rollback.total").increment();
log.info("Prompt config rolled back to version: {}", previousConfig.getVersion());
}
/**
* 获取功能开关状态
*/
public boolean isFeatureEnabled(String featureKey) {
PromptConfig config = currentConfig;
if (config == null || config.getFeatureFlags() == null) return false;
return Boolean.TRUE.equals(config.getFeatureFlags().get(featureKey));
}
public String getCurrentVersion() {
return currentConfig != null ? currentConfig.getVersion() : "not-loaded";
}
}4.4 在业务服务中使用
@Service
@Slf4j
public class AiCustomerService {
@Autowired
private DynamicPromptService promptService;
@Autowired
private OpenAiClient openAiClient;
public AiResponse handleQuery(String userQuery) {
// 检查功能开关
if (!promptService.isFeatureEnabled("streamingEnabled")) {
// 流式功能被关闭,使用非流式模式
return handleQueryNonStream(userQuery);
}
// 获取动态 Prompt(每次调用都会拿到最新的配置)
String systemPrompt = promptService.getPrompt("customer_service_system");
PromptConfig.ModelParams params = promptService.getModelParams("customer_service_system");
// 构建请求
ChatRequest chatRequest = ChatRequest.builder()
.systemPrompt(systemPrompt)
.userMessage(userQuery)
.temperature(params.getTemperature() != null ? params.getTemperature() : 0.7)
.maxTokens(params.getMaxTokens() != null ? params.getMaxTokens() : 1024)
.build();
log.debug("Using prompt config version: {}", promptService.getCurrentVersion());
return openAiClient.chat(chatRequest);
}
}4.5 版本回滚 API(供运营后台调用)
@RestController
@RequestMapping("/internal/prompt")
@PreAuthorize("hasRole('AI_ADMIN')")
@Slf4j
public class PromptConfigController {
@Autowired
private DynamicPromptService promptService;
@GetMapping("/current")
public ResponseEntity<Map<String, Object>> getCurrentConfig() {
return ResponseEntity.ok(Map.of(
"version", promptService.getCurrentVersion(),
"timestamp", LocalDateTime.now()
));
}
@PostMapping("/rollback")
public ResponseEntity<String> rollback(
@RequestHeader("X-Operator") String operator,
@RequestHeader("X-Rollback-Reason") String reason) {
log.warn("Prompt config rollback requested by {}: {}", operator, reason);
try {
promptService.rollback();
return ResponseEntity.ok("Rolled back to version: " + promptService.getCurrentVersion());
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}五、灰度发布支持
对于重要的 Prompt 改动,不应该直接全量生效,应该支持灰度:让 10% 的用户先体验新 Prompt,观察效果后再全量。
@Service
public class GrayReleasePromptService {
@Autowired
private DynamicPromptService promptService;
/**
* 支持灰度的 Prompt 获取
* 根据 userId 的哈希值决定走哪个版本的 Prompt
*/
public String getPromptWithGrayRelease(String key, String userId) {
// 检查是否有灰度配置
GrayConfig grayConfig = promptService.getGrayConfig(key);
if (grayConfig == null || !grayConfig.isEnabled()) {
return promptService.getPrompt(key);
}
// 根据用户 ID 的哈希值决定走不走灰度
int hash = Math.abs(userId.hashCode()) % 100;
if (hash < grayConfig.getGrayPercent()) {
// 走灰度版本
log.debug("User {} in gray release group for prompt {}", userId, key);
return grayConfig.getGrayContent();
} else {
// 走稳定版本
return promptService.getPrompt(key);
}
}
}六、Prompt 变更的安全审计
热更新不意味着无监控。Prompt 变更要留下审计日志,方便出问题时追溯:
@Component
@Slf4j
public class PromptAuditLogger {
@Autowired
private AuditLogRepository auditLogRepo;
@EventListener
public void onPromptConfigChanged(PromptConfigChangedEvent event) {
AuditLog log = AuditLog.builder()
.eventType("PROMPT_CONFIG_CHANGED")
.version(event.getNewConfig().getVersion())
.operator(event.getNewConfig().getUpdatedBy())
.changeNote(event.getNewConfig().getChangeNote())
.previousVersion(event.getOldConfig() != null
? event.getOldConfig().getVersion()
: "none")
.timestamp(LocalDateTime.now())
.build();
auditLogRepo.save(log);
log.info("AUDIT: Prompt config changed - operator={}, version={}, note={}",
log.getOperator(), log.getVersion(), log.getChangeNote());
}
}七、常见问题和踩坑记录
坑1:配置更新时正在进行的请求用哪个版本?
应该是发起请求时的版本,不是请求完成时的版本。getPrompt() 每次都读取 currentConfig(volatile),是当前调用时刻的快照。这是正确行为——一次对话开始时确定用哪个 Prompt,中途切换版本不会影响这次对话。
坑2:多实例的配置同步延迟
Nacos 推送到所有实例有一个短暂的延迟(通常在毫秒级到秒级)。在这个窗口期,不同实例可能在跑不同版本的 Prompt。这通常不是问题,但如果对强一致性有要求,可以在 Prompt 里加上版本标识,方便对比分析。
坑3:Prompt 更新但缓存没刷新
如果系统里有 Prompt 的额外缓存层(比如 Redis 里缓存了组装好的完整 Prompt),Nacos 推送后需要同时清理这些缓存。
坑4:测试环境和生产环境的 Nacos 命名空间隔离
强烈建议用不同的命名空间隔离,避免测试环境的误操作影响生产 Prompt。
总结
Prompt 热更新的核心价值是「解耦运营迭代和代码发布」,这不只是一个技术优化,而是整个 AI 产品开发流程的效率升级。
关键实现要点:
- 配置中心(Nacos/Apollo)+ 监听器是基础架构
- 配置数据结构设计要包含版本、操作人、变更说明——这是可回溯性的基础
- 安全边界明确:Prompt 文本可热更,模型版本不能热更
- 必须有回滚能力,而且要验证过(出了事能快速回到上个版本)
- 灰度能力是加分项,重要改动先灰度再全量
把这套机制建好,Prompt 迭代的效率可以提升 5 倍以上,工程师也可以从「Prompt 搬运工」的角色里解放出来。
