Spring AI 的配置外部化——Prompt 不该写死在代码里
Spring AI 的配置外部化——Prompt 不该写死在代码里
去年有一次,我们产品经理跑来找我说:「用户反馈 AI 助手的语气太生硬,能不能改得亲切一点?」
我说:行,我改一下 prompt,下周版本发布。
她说:下周?能不能今天?
我说:今天不行,得走发布流程。
她脸上写着「这代码咋这么不灵活」。
这件事之后我认真想了一下,prompt 作为一种配置,确实不应该硬编码在代码里。它是产品策略的一部分,不是实现逻辑的一部分,理论上应该像其他业务配置一样,可以不发布代码就能修改。
折腾了两周,搭了一套基于数据库的 Prompt 管理体系,结合 Nacos 做热更新,现在产品经理真的可以在后台页面直接改 prompt,改完立刻生效,我负责审核,不需要走代码发布流程。
这篇文章就写这套方案的完整实现。
Prompt 外部化的核心需求
在开始写代码之前,先把需求整理清楚:
- 版本管理:Prompt 要有版本,能回滚,能对比不同版本的差异
- 权限控制:不是所有人都能改 Prompt,需要审批流
- 环境隔离:测试环境和生产环境的 Prompt 可以不同
- 热更新:修改后无需重启服务,立刻生效
- 模板变量:Prompt 里要支持占位符,如
{userName}、{context} - A/B 测试支持(加分项):同一个场景可以有多个 Prompt 版本,按比例灰度
数据库表设计
CREATE TABLE prompt_template (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
template_key VARCHAR(100) NOT NULL COMMENT '模板标识符,代码里用这个 key 引用',
template_name VARCHAR(200) NOT NULL COMMENT '显示名称',
scene VARCHAR(100) NOT NULL COMMENT '业务场景,如 customer-service / code-review',
prompt_type VARCHAR(20) NOT NULL COMMENT 'SYSTEM / USER / FEW_SHOT',
content TEXT NOT NULL COMMENT 'Prompt 内容,支持 {variable} 占位符',
variables JSON COMMENT '变量定义,如 ["userName", "context"]',
version INT NOT NULL DEFAULT 1 COMMENT '版本号',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/PENDING_REVIEW/ACTIVE/ARCHIVED',
env VARCHAR(20) NOT NULL DEFAULT 'ALL' COMMENT 'DEV/TEST/PROD/ALL',
description VARCHAR(500) COMMENT '说明,给产品经理看的',
created_by VARCHAR(100),
updated_by VARCHAR(100),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key_version_env (template_key, version, env),
INDEX idx_key_active (template_key, status, env)
);
CREATE TABLE prompt_template_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
template_id BIGINT NOT NULL COMMENT '关联的模板 ID',
template_key VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
version INT NOT NULL,
operated_by VARCHAR(100),
operation VARCHAR(50) COMMENT 'CREATED/UPDATED/ACTIVATED/ARCHIVED',
operated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);PromptTemplateManager 核心实现
@Service
public class PromptTemplateManager {
private static final Logger log = LoggerFactory.getLogger(PromptTemplateManager.class);
private final PromptTemplateMapper templateMapper;
// 本地缓存:key -> 最新 ACTIVE 模板
// 使用 Caffeine 做二级缓存,减少数据库查询
private final Cache<String, PromptTemplate> localCache;
@Value("${spring.profiles.active:prod}")
private String activeEnv;
public PromptTemplateManager(PromptTemplateMapper templateMapper) {
this.templateMapper = templateMapper;
this.localCache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(5)) // 5 分钟过期,保证最终一致性
.build();
}
/**
* 获取 Prompt 内容,支持变量替换
*
* @param templateKey 模板 key
* @param variables 变量 Map,如 {"userName": "老张", "context": "..."}
*/
public String getPrompt(String templateKey, Map<String, Object> variables) {
PromptTemplate template = getTemplate(templateKey);
if (template == null) {
throw new PromptNotFoundException("找不到 Prompt 模板: " + templateKey);
}
return renderTemplate(template.getContent(), variables);
}
/**
* 获取 Prompt 内容(无变量)
*/
public String getPrompt(String templateKey) {
return getPrompt(templateKey, Collections.emptyMap());
}
/**
* 获取模板对象(带缓存)
*/
public PromptTemplate getTemplate(String templateKey) {
return localCache.get(templateKey, key -> {
PromptTemplate template = templateMapper.findActiveTemplate(key, activeEnv);
if (template == null) {
// 尝试找 ALL 环境的模板
template = templateMapper.findActiveTemplate(key, "ALL");
}
return template;
});
}
/**
* 主动刷新缓存(Nacos 变更通知时调用)
*/
public void refreshCache(String templateKey) {
localCache.invalidate(templateKey);
log.info("Prompt 模板缓存已刷新: {}", templateKey);
}
/**
* 刷新所有缓存
*/
public void refreshAllCache() {
localCache.invalidateAll();
log.info("所有 Prompt 模板缓存已刷新");
}
/**
* 模板变量渲染
* 支持 {variableName} 格式的占位符
*/
private String renderTemplate(String template, Map<String, Object> variables) {
if (variables == null || variables.isEmpty()) {
return template;
}
String rendered = template;
for (Map.Entry<String, Object> entry : variables.entrySet()) {
String placeholder = "{" + entry.getKey() + "}";
String value = entry.getValue() != null ? entry.getValue().toString() : "";
rendered = rendered.replace(placeholder, value);
}
// 检查是否还有未替换的占位符
if (rendered.contains("{") && rendered.contains("}")) {
// 用正则找出未替换的变量
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{(\\w+)\\}");
java.util.regex.Matcher matcher = pattern.matcher(rendered);
List<String> unresolved = new ArrayList<>();
while (matcher.find()) {
unresolved.add(matcher.group(1));
}
if (!unresolved.isEmpty()) {
log.warn("Prompt 模板 '{}' 中有未替换的变量: {}",
rendered.substring(0, Math.min(50, rendered.length())), unresolved);
}
}
return rendered;
}
}在 ChatClient 中使用外部化 Prompt
@Service
public class CustomerServiceChatService {
private final ChatClient chatClient;
private final PromptTemplateManager promptManager;
public CustomerServiceChatService(ChatModel chatModel,
PromptTemplateManager promptManager) {
this.chatClient = ChatClient.builder(chatModel).build();
this.promptManager = promptManager;
}
public String handleCustomerQuery(String userId, String userName, String query) {
// 从数据库获取系统 Prompt,支持变量替换
String systemPrompt = promptManager.getPrompt(
"customer-service.system",
Map.of(
"companyName", "老张科技",
"assistantName", "小智"
)
);
// 获取用户消息模板(如果有的话)
String userTemplate = promptManager.getPrompt(
"customer-service.user-prefix",
Map.of(
"userName", userName,
"userId", userId
)
);
String fullUserMessage = userTemplate + "\n\n" + query;
return chatClient.prompt()
.system(systemPrompt)
.user(fullUserMessage)
.call()
.content();
}
}数据库里对应的 Prompt 模板:
-- template_key: customer-service.system
-- content:
你是{companyName}的智能客服助手{assistantName}。
你的职责是帮助用户解决问题,回答要简洁、专业、亲切。
如果遇到无法解决的问题,引导用户联系人工客服。
禁止提供任何违规内容,禁止泄露公司内部信息。
-- template_key: customer-service.user-prefix
-- content:
用户信息:
- 用户名:{userName}
- 用户ID:{userId}
用户问题如下:结合 Nacos 的热更新
这是让产品经理改完 prompt 立刻生效的关键。思路是:
- Prompt 存在数据库里(权威数据源)
- Nacos 作为变更通知的渠道(不用于存储 prompt 内容)
- 产品经理在管理后台修改 prompt → 写入数据库 → 触发 Nacos 配置变更通知 → 服务收到通知 → 刷新本地缓存
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>@Component
@RefreshScope // 支持配置刷新
public class PromptCacheRefresher {
private final PromptTemplateManager promptManager;
private static final Logger log = LoggerFactory.getLogger(PromptCacheRefresher.class);
public PromptCacheRefresher(PromptTemplateManager promptManager) {
this.promptManager = promptManager;
}
/**
* 监听 Nacos 配置变更事件
* 当 prompt-templates.json 这个配置项变更时触发
*/
@NacosConfigListener(dataId = "prompt-cache-refresh", groupId = "AI_CONFIG")
public void onPromptRefreshEvent(String config) {
log.info("收到 Prompt 缓存刷新通知: {}", config);
try {
// config 格式:{"action":"refresh","keys":["customer-service.system"]}
// 或者 {"action":"refresh_all"}
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(config);
String action = node.get("action").asText();
if ("refresh_all".equals(action)) {
promptManager.refreshAllCache();
log.info("全量刷新 Prompt 缓存完成");
} else if ("refresh".equals(action)) {
JsonNode keys = node.get("keys");
if (keys != null && keys.isArray()) {
keys.forEach(keyNode -> {
String key = keyNode.asText();
promptManager.refreshCache(key);
log.info("Prompt 缓存刷新: {}", key);
});
}
}
} catch (Exception e) {
log.error("处理 Prompt 刷新通知失败: {}", config, e);
}
}
}管理后台触发刷新的逻辑:
@Service
public class PromptAdminService {
private final PromptTemplateMapper templateMapper;
private final NacosConfigService nacosConfigService;
private final String nacosGroup = "AI_CONFIG";
/**
* 激活新版本 Prompt
*/
@Transactional
public void activatePrompt(Long templateId, String operator) {
PromptTemplate template = templateMapper.findById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
// 1. 将旧的 ACTIVE 版本归档
templateMapper.archiveOldActiveVersion(template.getTemplateKey(), template.getEnv());
// 2. 激活新版本
template.setStatus("ACTIVE");
template.setUpdatedBy(operator);
templateMapper.update(template);
// 3. 记录历史
insertHistory(template, "ACTIVATED", operator);
// 4. 通知所有服务节点刷新缓存
notifyRefresh(template.getTemplateKey());
log.info("Prompt 已激活: key={}, version={}, operator={}",
template.getTemplateKey(), template.getVersion(), operator);
}
private void notifyRefresh(String templateKey) {
try {
String refreshConfig = String.format(
"{\"action\":\"refresh\",\"keys\":[\"%s\"],\"timestamp\":%d}",
templateKey, System.currentTimeMillis()
);
nacosConfigService.publishConfig(
"prompt-cache-refresh",
nacosGroup,
refreshConfig
);
} catch (NacosException e) {
log.error("发布 Nacos 刷新通知失败,将依赖缓存自然过期", e);
// 降级:依赖 Caffeine 的 5 分钟自然过期,不强依赖 Nacos
}
}
}如果没有 Nacos——用 Apollo 或轮询
如果你们用的是 Apollo,思路类似:
@Component
public class ApolloPromptRefresher {
private final PromptTemplateManager promptManager;
// 监听 Apollo 的配置变更
@ApolloConfigChangeListener(namespaces = "AI_PROMPT_CONFIG")
public void onChange(ConfigChangeEvent changeEvent) {
// 有任何配置变更,就刷新全部缓存
// 更精细的做法是解析变更的 key,只刷新对应的模板
promptManager.refreshAllCache();
}
}如果连 Apollo/Nacos 都没有,最简单的方式是定时轮询数据库:
@Component
public class PromptCacheScheduler {
private final PromptTemplateManager promptManager;
// 每 2 分钟检查一次是否有更新
@Scheduled(fixedDelay = 120000)
public void checkAndRefreshCache() {
// 检查最近 3 分钟内有没有 prompt 被更新
LocalDateTime threshold = LocalDateTime.now().minusMinutes(3);
List<String> updatedKeys = templateMapper.findRecentlyUpdatedKeys(threshold);
if (!updatedKeys.isEmpty()) {
updatedKeys.forEach(promptManager::refreshCache);
log.info("发现 {} 个 Prompt 模板更新,已刷新缓存", updatedKeys.size());
}
}
}Prompt 的版本对比和 A/B 测试
这是可选功能,但用起来挺有价值的。
版本对比:
@GetMapping("/prompt/diff")
public ResponseEntity<PromptDiffVO> diffVersions(
@RequestParam String templateKey,
@RequestParam int versionA,
@RequestParam int versionB) {
PromptTemplate a = templateMapper.findByKeyAndVersion(templateKey, versionA);
PromptTemplate b = templateMapper.findByKeyAndVersion(templateKey, versionB);
// 用 DiffUtils 计算差异
List<String> aLines = Arrays.asList(a.getContent().split("\n"));
List<String> bLines = Arrays.asList(b.getContent().split("\n"));
Patch<String> patch = DiffUtils.diff(aLines, bLines);
PromptDiffVO diff = new PromptDiffVO();
diff.setTemplateKey(templateKey);
diff.setVersionA(versionA);
diff.setVersionB(versionB);
diff.setDiffs(patch.getDeltas().stream()
.map(delta -> delta.toString())
.collect(Collectors.toList()));
return ResponseEntity.ok(diff);
}简单 A/B 测试(按用户 ID 哈希):
public String getPromptWithABTest(String templateKey, String userId,
Map<String, Object> variables) {
// 看看有没有 A/B 测试配置
ABTestConfig abConfig = abTestMapper.findActiveTest(templateKey);
if (abConfig == null) {
return getPrompt(templateKey, variables);
}
// 按用户 ID 哈希决定用哪个版本
int hash = Math.abs(userId.hashCode() % 100);
String versionToUse;
if (hash < abConfig.getVersionAPercent()) {
versionToUse = abConfig.getVersionAKey();
} else {
versionToUse = abConfig.getVersionBKey();
}
// 记录 A/B 测试分组,用于后续效果分析
abTestLogger.log(userId, templateKey, versionToUse);
return getPrompt(versionToUse, variables);
}实际收益
这套方案上线 4 个月,几个真实数据:
- 产品经理自助修改 Prompt 的次数:平均每月 15 次,其中约 12 次不需要我介入
- 因 Prompt 调整触发的发布次数:从每月 8~10 次降到了 0 次(全部走配置中心)
- Prompt 相关问题的回滚时间:从「等下次发布」到「5 分钟内」
当然也有一个小插曲:产品经理有一次把 system prompt 里一段关键约束删掉了(「禁止生成不良内容」那句),导致线上出了几个小时的问题。
这让我意识到要加一个「审批」环节,不是所有人的修改都能直接 ACTIVE,特别是 system prompt 里涉及安全约束的部分,需要研发 review 之后才能发布。
现在的流程是:产品经理修改 → 状态变为 PENDING_REVIEW → 钉钉通知我 → 我 review 通过 → ACTIVE。这个流程最多半小时,比走代码发布快多了,双方都能接受。
