用Spring Boot搭建一个可版本化的Prompt模板管理器:加载、缓存、灰度与回滚
文章摘要
企业AI项目中的Prompt不能长期散落在Java代码里。随着场景增加,需要解决模板版本、环境差异、审批、缓存、灰度、回滚和调用追踪。本文实现一个轻量级Spring Boot Prompt模板管理器:使用数据库保存模板元数据,通过Resource与StringTemplate渲染变量,提供版本发布、缓存和灰度选择能力,并将promptId与version写入AI调用日志。
一、为什么Prompt不能写死在代码中
常见写法:
StringsystemPrompt=""" 你是企业客服助手。 请准确回答用户问题。 """;项目早期很方便,后期会遇到:
- Prompt散落在几十个类;
- 不知道线上使用哪个版本;
- 修改Prompt必须重新发布;
- 产品人员无法参与审核;
- A/B测试困难;
- 模型切换后无法快速回滚;
- 历史Trace无法还原;
- 测试环境与生产环境不一致。
Prompt应该成为一种受管理资产。
二、目标能力
本文实现:
Prompt ID 版本 状态 模板内容 变量定义 发布 缓存 灰度 回滚 调用追踪状态:
DRAFT REVIEWING PUBLISHED ARCHIVED三、数据表设计
CREATETABLEai_prompt_template(idBIGINTPRIMARYKEYAUTO_INCREMENT,prompt_keyVARCHAR(100)NOTNULL,versionINTNOTNULL,nameVARCHAR(200)NOTNULL,template_typeVARCHAR(30)NOTNULL,contentTEXTNOTNULL,variable_schemaTEXT,statusVARCHAR(30)NOTNULL,traffic_percentINTNOTNULLDEFAULT100,created_byVARCHAR(100)NOTNULL,approved_byVARCHAR(100),created_atTIMESTAMPNOTNULL,published_atTIMESTAMP,UNIQUEKEYuk_prompt_version(prompt_key,version));关键字段:
prompt_key:业务唯一标识;version:递增版本;template_type:SYSTEM、USER等;variable_schema:变量定义;traffic_percent:灰度比例;status:生命周期。
四、领域对象
publicenumPromptStatus{DRAFT,REVIEWING,PUBLISHED,ARCHIVED}publicrecordPromptTemplateDefinition(StringpromptKey,intversion,Stringname,Stringcontent,PromptStatusstatus,inttrafficPercent,Set<String>requiredVariables){}渲染结果:
publicrecordRenderedPrompt(StringpromptKey,intversion,Stringcontent,StringcontentHash){}五、Repository接口
publicinterfacePromptTemplateRepository{List<PromptTemplateDefinition>findPublished(StringpromptKey);Optional<PromptTemplateDefinition>findByKeyAndVersion(StringpromptKey,intversion);voidsave(PromptTemplateDefinitiondefinition);}真实项目可以使用Spring Data JPA、JdbcClient、MyBatis、MongoDB或配置中心。
六、版本选择器
一个Prompt可以存在:
v3:90%流量 v4:10%流量稳定灰度需要使用确定性哈希,否则同一用户每次可能进入不同版本。
@ComponentpublicclassPromptVersionSelector{publicPromptTemplateDefinitionselect(StringsubjectId,List<PromptTemplateDefinition>versions){if(versions.isEmpty()){thrownewIllegalStateException("没有已发布Prompt");}intbucket=Math.floorMod(subjectId.hashCode(),100);intaccumulated=0;for(PromptTemplateDefinitiondefinition:versions){accumulated+=definition.trafficPercent();if(bucket<accumulated){returndefinition;}}returnversions.getLast();}}subjectId可以使用userId、tenantId或conversationId。
不要使用随机数,否则用户体验不稳定。
七、模板渲染器
@ComponentpublicclassEnterprisePromptRenderer{privatefinalTemplateRendererrenderer=StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build();publicStringrender(PromptTemplateDefinitiondefinition,Map<String,Object>variables){validateVariables(definition,variables);returnrenderer.apply(definition.content(),variables);}privatevoidvalidateVariables(PromptTemplateDefinitiondefinition,Map<String,Object>variables){Set<String>missing=newHashSet<>(definition.requiredVariables());missing.removeAll(variables.keySet());if(!missing.isEmpty()){thrownewIllegalArgumentException("缺少Prompt变量:"+missing);}}}变量使用:
<customerName> <question> <context>避免与JSON花括号冲突。
八、Prompt Service
@ServicepublicclassPromptTemplateService{privatefinalPromptTemplateRepositoryrepository;privatefinalPromptVersionSelectorselector;privatefinalEnterprisePromptRendererrenderer;publicPromptTemplateService(PromptTemplateRepositoryrepository,PromptVersionSelectorselector,EnterprisePromptRendererrenderer){this.repository=repository;this.selector=selector;this.renderer=renderer;}publicRenderedPromptrender(StringpromptKey,StringsubjectId,Map<String,Object>variables){List<PromptTemplateDefinition>versions=repository.findPublished(promptKey);PromptTemplateDefinitionselected=selector.select(subjectId,versions);Stringcontent=renderer.render(selected,variables);returnnewRenderedPrompt(selected.promptKey(),selected.version(),content,sha256(content));}privateStringsha256(Stringvalue){// 示例省略MessageDigest异常处理returnInteger.toHexString(value.hashCode());}}生产环境应使用真正SHA-256,不要使用hashCode()作为审计哈希。
九、增加缓存
Prompt读取频率高、更新频率低,适合缓存。
@Cacheable(cacheNames="promptTemplates",key="#promptKey")publicList<PromptTemplateDefinition>findPublishedTemplates(StringpromptKey){returnrepository.findPublished(promptKey);}发布或回滚后清除:
@CacheEvict(cacheNames="promptTemplates",key="#promptKey")publicvoidevict(StringpromptKey){}可使用Caffeine、Redis或Spring Cache。
缓存必须带版本更新机制,避免数据库已发布但实例仍使用旧Prompt。
十、接入Spring AI
@ServicepublicclassCustomerAiService{privatefinalChatClientchatClient;privatefinalPromptTemplateServicepromptService;publicCustomerAiService(ChatClientchatClient,PromptTemplateServicepromptService){this.chatClient=chatClient;this.promptService=promptService;}publicStringanswer(StringuserId,Stringquestion){RenderedPromptprompt=promptService.render("customer-answer",userId,Map.of("question",question));returnchatClient.prompt().advisors(spec->spec.param("promptKey",prompt.promptKey()).param("promptVersion",prompt.version())).user(prompt.content()).call().content();}}每次调用记录:
prompt_key prompt_version prompt_hash model request_id user_id十一、发布流程
推荐生命周期:
创建DRAFT → 自动校验 → REVIEWING → 人工审批 → PUBLISHED → 灰度 → 全量 → ARCHIVED自动校验包括:
- 必填变量;
- 未关闭占位符;
- 超长Prompt;
- 禁止词;
- JSON示例是否合法;
- 输出格式;
- 基础回归测试。
十二、Prompt回归测试
测试数据:
{"caseId":"P-001","variables":{"question":"如何申请退款?"},"expectedKeywords":["退款","订单"],"forbiddenKeywords":["百分之百成功","无需审核"]}发布前对比当前生产版本和候选版本。
指标:
- 任务成功率;
- 事实准确率;
- 输出格式成功率;
- 平均Token;
- 平均延迟;
- 禁止表达命中;
- 模型评分;
- 人工评分。
十三、回滚设计
发布记录:
prompt_key from_version to_version operator reason timestamp回滚:
@Transactionalpublicvoidrollback(StringpromptKey,inttargetVersion){archiveCurrent(promptKey);publishVersion(promptKey,targetVersion,100);evict(promptKey);}不要删除错误版本,应该归档,保留审计。
十四、多环境管理
不要让测试环境和生产环境直接共用发布状态。
增加:
environment取值:
DEV TEST STAGING PROD发布链路:
DEV验证 → TEST自动化评测 → STAGING影子流量 → PROD灰度十五、权限
| 角色 | 权限 |
|---|---|
| 编辑者 | 创建和修改草稿 |
| 审核者 | 审核内容 |
| 发布者 | 发布和回滚 |
| 查看者 | 查看历史与指标 |
| 管理员 | 权限和环境管理 |
生产Prompt不能由同一人编辑后直接发布,关键业务应实行审批分离。
十六、还可以继续扩展什么
- Prompt对比界面;
- 在线测试;
- 模型A/B;
- 自动优化;
- 变量Schema编辑器;
- 多语言;
- Prompt依赖片段;
- RAG模板;
- Advisor模板;
- Secret引用;
- Git同步;
- CI/CD发布。
总结
一个可用的Prompt模板管理器,不只是把字符串放进数据库。
它必须同时解决:
版本 发布 变量 缓存 灰度 回滚 评测 权限 审计Prompt一旦影响真实业务,就应该像代码和配置一样接受工程治理。