1. 从“写死提示词”到“模板化提示词”一次工程化改造先说个我自己的体会。早期做AI应用开发提示词是直接写在Java代码里的String prompt 你是一个资深客服请对以下用户反馈进行分类 userInput;当时觉得没什么字符串拼接嘛能用就行。直到项目里提示词数量超过20条问题开始集中爆发产品经理改需求时要在代码里搜字符串、同一个业务多条提示词之间存在大量重复、不同模型的提示词风格差异没法统一管理最关键的是——每加一个变量就要重新拼接一遍字符串稍不注意就是漏引号、少空格、格式错乱。Spring AI 1.0.0 GA版本发布之后我做的第一件事就是把项目里的提示词全部迁到它提供的模板机制上。这套机制的核心概括起来就一句话用{变量}做参数占位配合 few-shot 示例让一条提示词模板在多个业务场景下反复复用。它能解决的问题非常明确提示词不再散落在业务代码里而是在独立模板文件中维护变量的注入不再靠手动拼接而是走统一的参数渲染机制few-shot 示例被固化在模板中模型每次调用都能获得稳定的输出范式。适合的读者包括正在用 Spring AI 做 LLM 应用开发的工程师、想把提示词从代码中剥离出来做统一管理的团队以及被“一条提示词只敢用一次”的痛点折磨过的开发者。这篇文章就围绕这套机制从设计思路、核心实现到实战踩坑完整拆一遍。2. 为什么是“变量参数化”和“few-shot”需求场景与方案选型2.1 参数化的本质把提示词从“硬编码”变成“可配置”先想一个问题业务里的大多数提示词到底是“完全固定”的多还是“大同小异”的多以我经手的项目为例一个典型的客服反馈分类场景提示词的结构是固定的“你是一个客服分类助手请将以下用户反馈归类为【技术问题】【账单问题】【产品建议】中的一类并返回JSON。” 变的只有“用户反馈”这一部分。如果不用模板每次调用都要重新拼这个字符串。拼几次不觉得痛拼到几百次一定会遇到三类问题格式错误漏了换行、少了引号、空格数不对模型给出的结果就飘了。维护灾难改了提示词的措辞所有拼接处都要跟着改漏改一处就是线上事故。无法复用同一个“分类”能力A接口拼了一套B接口又拼一套两边长得差不多但就是没法共用。参数化模板解决的就是这个问题的根本把“不变的部分”固化成模板把“变化的部分”用{变量}标记出来运行时只注入变量。这正是 Spring AI 中PromptTemplate做的事情。它让我想到了 Spring 框架早年做JdbcTemplate的思路——把数据库操作中易错的样板代码收走开发者只关心参数和结果。Spring AI 的提示词模板也是同样的哲学把你最容易写错、最容易漏改的提示词拼接逻辑收走你只关心业务变量。2.2 few-shot 的价值让模型“照着办”而不是“猜着办”大模型不是规则引擎同一个提示词换一种问法输出就可能不一样。few-shot少样本示例是工程上让模型输出稳定的最朴实手段——给它几个“标准答案示例”让它照着示例的格式和风格来。比如做情感分析你当然可以写请判断下面句子的情感是正面还是负面。模型大概率能完成但输出的格式可能千奇百怪有人回“positive”有人回“正面”有人回“这是一个正面情感”还有人给你一段解释。解析起来非常痛苦。但如果你加上几个示例示例1输入“这个手机电池太差了” → 输出负面 示例2输入“物流速度很快非常满意” → 输出正面模型的输出格式就会明显收敛。这就是 few-shot 在实践中最朴素的价值——把“你期望的输出格式”直接摆给模型看让输出变得可预测。但 few-shot 有个工程问题示例数据如果写死在代码里同样没法复用。Spring AI 的做法是把 few-shot 示例放进模板文件和提示词正文一起维护调用时仍然只注入当前业务的变量。这样“系统指令 示例演示 当前输入”三者合在一条模板里模型看到的就是一个完整的、有上下文的任务。2.3 方案选型为什么是 Spring AI 的 PromptTemplate 而不是手动模板引擎有人会问我用 FreeMarker、Velocity甚至 String.format 也能实现变量替换为什么非要用 Spring AI区别在于两点第一Spring AI 的模板机制不是孤立的“字符串替换”它和Prompt、ChatModel、Message这一整套消息体系是深度绑定的。你渲染出来的不是一段干巴巴的字符串而是一组结构化的消息对象。这为后续接入多轮对话、多角色消息system / user / assistant、结构化输出解析铺平了路。第二Spring AI 提供了完整的模板资源解析能力。模板可以选择放在类路径下的独立文件中通过PromptTemplate从资源目录加载而不是像字符串拼接那样把模板硬编码在 Java 类里。后续改提示词不用重新编译打包运维直接改文件就能生效。另外它还支持“消息模板”的维度——不只是渲染一段文本而是渲染成system和user等多条消息。这就解决了传统模板引擎做不到的一件事把“系统指令”和“用户输入”分开渲染到不同的消息槽位中而这对大模型API调用来说恰恰是更规范的做法。3. 工程准备依赖引入与配置项基座3.1 在 Spring Boot 3.x 中引入 Spring AI当前 Spring AI 1.0.0 GA 已经发布引入方式非常标准。以 Maven 为例在pom.xml中声明parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.3.5/version relativePath/ /parent dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.0.0/version /dependency如果你用的是阿里云、智谱、DeepSeek 等兼容 OpenAI 协议的服务同样可以使用这个 starter只需要在配置里切换base-url。我用的是某家兼容 OpenAI 协议的服务配置长这样spring: ai: openai: api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.2这里注意一点spring-ai-starter-model-openai1.0.0 版本的默认实现中ChatModel会自动被注入到上下文中所以不需要自己手写RestClient调用整个使用流程和 Spring 生态的编程习惯是一脉相承的。3.2 核心对象速览Prompt、PromptTemplate、ChatModel在 Spring AI 1.0.0 中和提示词模板关系最紧密的类有三个PromptTemplate模板入口。负责从字符串或资源文件加载模板渲染之后返回Prompt对象。Prompt对模型请求的整体抽象。内部包含一个或多个PromptMessage。ChatModel模型调用抽象。传入Prompt返回ChatResponse。组合起来的基本调用形态如下PromptTemplate promptTemplate new PromptTemplate(你是一个分类助手请分类{input}); Prompt prompt promptTemplate.create(Map.of(input, 手机屏幕碎了)); ChatResponse response chatModel.call(prompt);这是最朴素的用法。但在多轮消息场景下通常不会直接渲染成一个纯文本消息而是把系统指令和用户输入分开。这就要用到更精细的MessageAPI。PromptTemplate.create方法也能接收MessageSpec之类的参数但真正适用的场景还是下面这种多消息结构。4. 核心实现细节从单模板到多消息 few-shot 体系4.1{变量}在 Spring AI 中有两种解析层次这是很多人第一次使用时容易混淆的地方。Spring AI 的模板机制分两个层次第一层是字符串模板。你提供一个字符串里面有{变量}占位符PromptTemplate渲染时直接替换成值。它的解析策略是优先查找注册的TemplateResolver默认是SimpleTemplateResolver只检查类路径下的prompts目录。第二层是消息模板。这个时候模板文件描述的不只是一段纯文本而是system、user、assistant等多条消息的模板结构需要遵循特定的文件头格式。渲染之后你得到的是多条Message对象而不是一个字符串。我用一个例子帮大家建立直觉。假设我在classpath:/prompts/下建了一个文件classify.st内容如下system: 你是一个客服工单分类助手。 请将用户反馈归类为以下类型之一技术问题、账单问题、产品建议、其他。 只输出分类名称不要输出解释。 user: {userInput}注意文件开头的system:和user:前缀。这种格式就是消息模板的声明方式。PromptTemplate解析时会根据这个前缀把消息拆成系统消息和用户消息两端模板中的{userInput}在渲染时替换成实际的用户输入。调用代码PromptTemplate template new PromptTemplate(classify.st); Prompt prompt template.create(Map.of(userInput, 你们这个app更新以后总是闪退));从prompt.getInstructions()中拿到的就是两条消息一条SystemMessage一条UserMessage。这比单字符串模板更符合大模型 API 的消息结构也让模型更清楚地知道哪些是规则、哪些是待处理的内容。4.2 few-shot 应该放在哪里system 还是 user 还是 assistant这是个值得好好琢磨的问题。我最初的习惯是把 few-shot 示例直接接在 system 指令后面比如system: 你是一个情感分析助手。 输出只能是“正面”或“负面”不要输出其他内容。 示例1输入“这个手机电池太差了” → 输出负面 示例2输入“物流很快非常满意” → 输出正面 user: {userInput}这样写在技术上没有问题模型也能正常理解。但我后来在实际项目中碰到了一个有意思的现象把 few-shot 示例放在assistant消息里效果更稳定。具体做法是示例中的“输入”部分作为user消息示例中的“输出”部分作为assistant消息。最后再放上当前的真实用户输入。模型的注意力机制对消息角色的区分非常敏感真实的问答对结构能让模型更清晰地模仿“你来我往”的对话节奏而不是把示例当成一段说明文字。Spring AI 的模板文件允许配置多个消息块所以可以实现这种结构system: 你是一个电影评论情感分类助手。 只输出“正面”或“负面”。 user: 这部电影的剧情太拖沓了 assistant: 负面 user: 节奏紧凑配乐震撼值得二刷 assistant: 正面 user: {userInput}这里要注意一个细节user:和assistant:交替出现的消息序列在模板引擎渲染时会被严格保留为消息数组。模型调用时看到的是一段完整的对话历史学习到的不仅是“分类规则”还包括“回答的节奏和口吻”。实践下来输出稳定性比把示例一次性塞进 system 更好。4.3 变量不只是占位参数值可以是列表、数字、对象很多教程只讲到Map.of(name, 张三)这种字符串占位替换就结束了。但实际业务里变量类型往往更复杂。比如我要在提示词里带上一段历史聊天记录这是一个ListStringMapString, Object params new HashMap(); params.put(history, List.of(用户我要退货, 客服请问订单号是多少)); params.put(userInput, 订单号是20241201);模板渲染时Spring AI 直接调用字符串的replace逻辑List的toString()输出就会被塞进去。这在简单场景下能跑通但格式不理想。如果你的模板对列表格式有要求更推荐的做法是先在代码里格式化好字符串再传入模板String historyText history.stream().collect(Collectors.joining(\n)); params.put(history, historyText);这里顺便提一个容易踩的坑{变量}占位符在 Spring AI 的PromptTemplate中默认使用String.replace进行替换这意味着如果变量的值里包含$、{、}等特殊字符可能导致替换结果出现意外。早期版本里这是个大坑1.0.0 GA 已经做了规避但我依然建议对用户输入做一层转义或者长度限制防患于未然。4.4 模板的资源加载机制与自定义解析器默认情况下PromptTemplate会从类路径下的prompts目录加载以.st结尾的模板文件。刚才我写的new PromptTemplate(classify.st)实际加载的是classpath:/prompts/classify.st。如果你希望模板不放这个目录可以自定义TemplateResolverBean public PromptTemplateFactory promptTemplateFactory() { return new DefaultPromptTemplateFactory(new ResourceTemplateResolver(/templates/prompts)); }这样解析器会将模板路径映射到classpath:/templates/prompts。这种自定义在做多模块工程时很有用不同模块可以把自己的提示词模板放在各自模块的resources下而不是全部堆在同一个目录里。还有一点容易被忽略PromptTemplate.create()方法每次调用都会重新解析模板。但内部是有缓存机制的——DefaultPromptTemplateFactory在第一次解析时会把模板文件内容读入并构建为内部模板模型后续复用同样的模板路径时直接从缓存取。所以性能上不必担心每次调用都读文件。5. 完整实战客服工单分类器从零到一5.1 业务背景与模板设计现在做一个可以落地的完整案例。业务场景某电商平台需要一个客服工单分类器输入用户反馈输出工单类型和技术等级。输出要求严格JSON格式供下游系统直接消费。先在classpath:/prompts/下创建模板文件ticket-classifier.stsystem: 你是一个电商客服工单分类助手。你的任务是根据用户反馈内容将其归类到给定的类别中并评估紧急程度。 分类标准 - 技术问题涉及App崩溃、闪退、功能异常、报错信息等。 - 账单问题涉及扣费异常、退款进度、发票开具、优惠券使用等。 - 产品建议涉及功能需求、改进意见、体验反馈等。 - 其他不属于以上任何类别。 紧急程度标准 - high涉及资金安全、隐私泄露、服务完全不可用。 - medium功能受影响但存在临时替代方案。 - low一般咨询或建议。 输出格式要求 返回JSON对象格式如下 {category: 技术问题, priority: high, summary: 一句话概括用户问题} 只输出JSON对象不要输出其他任何内容。 user: {userInput} assistant:注意最后一行user:之后紧跟{userInput}再空一行写assistant:。这个assistant:块是空的它的作用是给模型“开路”——让模型在生成时直接从这个空块开始续写而不是重新发挥一套格式。这是我从实际项目中总结出的小技巧在模板末尾预留一个空的 assistant 块能显著减少模型输出多余的前缀解释。5.2 Java 侧调用代码模板设计好后Java 侧非常简单。定义一个服务类Service public class TicketClassifierService { private final ChatModel chatModel; public TicketClassifierService(ChatModel chatModel) { this.chatModel chatModel; } public TicketClassification classify(String userInput) { PromptTemplate template new PromptTemplate(ticket-classifier.st); Prompt prompt template.create(Map.of(userInput, userInput)); ChatResponse response chatModel.call(prompt); String content response.getResult().getOutput().getText(); return parseJson(content); } private TicketClassification parseJson(String content) { // 使用 Jackson 反序列化 ObjectMapper mapper new ObjectMapper(); try { return mapper.readValue(content, TicketClassification.class); } catch (JsonProcessingException e) { throw new RuntimeException(无法解析模型输出: content, e); } } }TicketClassification是一个简单的 DTOpublic record TicketClassification(String category, String priority, String summary) {}整个过程没有一行手动拼提示词的代码。模板在资源文件里维护变量从方法参数注入输出通过 Jackson 解析成对象直接进入下游工单系统。这里我想多说一句模型输出解析这一步建议不要直接mapper.readValue一把梭。因为模型偶尔会抽风输出多余的空格、换行、甚至Markdown代码块标记。我在项目里对输出做了一层清洗先移除首尾三引号和json标记再截取第一个{到最后一个}之间的内容然后再交给 Jackson。这个小处理让解析成功率从92%左右提升到了接近100%。private String extractJson(String modelOutput) { int start modelOutput.indexOf({); int end modelOutput.lastIndexOf(}); if (start 0 || end 0 || end start) { throw new RuntimeException(模型输出中未找到JSON: modelOutput); } return modelOutput.substring(start, end 1); }5.3 实测效果与变量注入验证用日志把渲染后的完整消息打出来看看这是排查模板问题的第一手段Prompt prompt template.create(Map.of(userInput, 退款一直不到账已经三天了钱去哪了)); for (PromptMessage message : prompt.getInstructions()) { log.info(role{}, content{}, message.getMessageType(), message.getText()); }实测输出roleSYSTEM, content你是一个电商客服工单分类助手。... roleUSER, content退款一直不到账已经三天了钱去哪了再调用一次换一个输入roleSYSTEM, content你是一个电商客服工单分类助手。... roleUSER, contentApp打开就闪退完全没法用系统指令部分完全不变用户消息部分准确注入。这就是参数化模板最直观的收益——系统指令只维护一份业务输入每次注入。模型对这组输入的返回结果{category: 技术问题, priority: high, summary: App打开即闪退无法正常使用}没有多余的解释没有格式偏差直接就能进工单系统。6. 常见问题与排查技巧实录6.1 模板加载失败找不到模板文件这个问题新手遇到最多。PromptTemplate默认加载classpath:/prompts/目录所以资源路径别放错。src/main/resources/ └── prompts/ ├── ticket-classifier.st └── classify.st如果你把.st文件放在了src/main/resources/根目录然后new PromptTemplate(ticket-classifier.st)就会报找不到文件。解决方式要么把文件移动到prompts/目录要么自定义TemplateResolver。6.2 变量替换后模板中的引号问题模板中有括号和引号是家常便饭。注意 Java 中加载模板不用管引号转义的问题但如果你在 Java 代码里直接写模板字符串就要小心\的转义。我个人强烈推荐模板一律放资源文件用文本编辑器维护不要放进 Java 字符串里。放资源文件还有个额外好处非开发人员也能直接打开文件改提示词不用碰代码。6.3 同一个{变量}在多个消息块中能否复用可以。比如模板中 system 和 user 消息各用了一次{userInput}渲染后两处都会被替换成同一个值。这在实际项目中常用来让系统指令引用用户输入的摘要信息减少模型对上下文的混淆。6.4message模板的assistant:块是否必须不是必须但非常推荐。如果你不加末尾的空assistant:块模型的输出会从user消息后的标准续写位置开始多数情况下模型还是能正常输出。但加上之后等于明确告诉模型“从这里开始输出”对抑制前缀解释有明显效果尤其是在复杂指令场景下。6.5 模型不遵循 few-shot 示例偶尔会遇到模型对示例视而不见的情况。排查思路按优先级来检查示例是否足够典型太偏门或者太笼统的示例反而干扰模型理解。检查示例数量2到3个就够过多会让模型过度拟合示例的具体措辞。检查示例是否覆盖了输出格式的所有可能性。比如分类场景有4个类别示例最好覆盖至少2到3个类别避免模型只模仿单一输出样式。最后一步再考虑调低温度。温度调低可以让模型沿示例的路径走得更稳但也会减少灵活性。6.6 变量值里包含换行导致模板结构错乱如果你的变量是一个很长的多行文本直接替换进{userInput}没问题。但如果这个变量恰好要被替换进 system 指令中比如“以下是用户的历史工单记录”这种块那就要小心换行导致的消息结构错乱。比较稳妥的做法对注入文本做换行折叠或者限制长度防止模板的可读性和消息边界被破坏。7. 高阶玩法模板继承、缓存与动态组装7.1 模板之间如何复用公共片段实际项目中不同场景的提示词之间往往有公共部分。比如所有客服场景都要先定义“你是一个客服助手”的系统角色再拼接各自的细分指令。Spring AI 的MessageTemplate允许你基于已有模板“继承”并扩展这在官方文档里描述不多但实际可用。做法很简单把公共部分抽到父模板子模板通过嵌套引用的方式复用。不过我更推荐的方式是编程组装——把公共的系统指令在Java中定义为一个常量模板细分指令各建各的组装时调用PromptTemplate的合并方法PromptTemplate baseTemplate new PromptTemplate(base-customer-service.st); PromptTemplate detailTemplate new PromptTemplate(ticket-classifier.st); Prompt basePrompt baseTemplate.create(Map.of()); Prompt detailPrompt detailTemplate.create(Map.of(userInput, userInput)); Prompt combinedPrompt new Prompt(List.of( basePrompt.getInstructions().get(0), detailPrompt.getInstructions().get(0), detailPrompt.getInstructions().get(1) ));这种做法比搞模板语法继承直观得多也更贴合 Spring 生态“组合优于继承”的风格。7.2 让一条模板支持不同的温控策略模型的默认温度可以在配置中设定。但同一套系统做分类任务时希望温度低输出稳定做创意文案时希望温度高内容多样这需求很常见。Spring AI 的Prompt支持按请求覆盖模型参数Prompt prompt template.create(Map.of(userInput, userInput)); prompt.getOptions().setTemperature(0.1);不过要注意PromptTemplate.create()返回的Prompt对象中模型参数是通过OpenAiChatOptions之类的方式传递的。不同模型provider的实现略有差异但基本思路一致——在call之前修改请求级别的模型参数。7.3 把{变量}化为结构化的 OutputConverter仅靠模板 提示词约束模型输出JSON格式解析成功率再高也会偶发格式错误。严谨的工程做法是把提示词模板与结构化输出转换器结合。Spring AI 1.0.0 提供了BeanOutputConverter直接从类型生成“输出格式指令”并嵌入提示词再把模型输出转回 BeanBeanOutputConverterTicketClassification converter new BeanOutputConverter(TicketClassification.class); String formatInstruction converter.getFormat(); PromptTemplate template new PromptTemplate(ticket-classifier-pure.st); Prompt prompt template.create(Map.of( userInput, userInput, format, formatInstruction ));模板里的对应位置写user: {userInput} {format}{format}会被渲染成一段由框架生成的JSON格式指令包含字段名、类型说明和示例。这样模型输出格式的稳定性会大幅提升因为格式约束不是靠自然语言“请返回JSON”这种祈使句而是明确的结构化描述。7.4 模板缓存与热更新权衡PromptTemplate解析后内部有缓存机制第一次加载模板文件后后续调用不重新读文件。这对性能是好事。但注意如果你的场景需要在运行期修改模板并立即生效比如运营临时调整提示词措辞缓存会把新内容挡在外面。取舍建议配置管理平台直接改模板文件热更新放弃框架缓存自己写读取逻辑每次加载前比对文件更新时间。模板改动频率极低比如只在发版时调整用框架默认的缓存省心。我自己的项目属于前者运营经常要调措辞所以我在TemplateResolver上做了自定义扩展根据文件最后修改时间来判断是否需要重新解析。这个优化改起来不难但对运营侧的幸福感提升巨大——以后改提示词不用找开发重新发版了。8. 从其他方案迁移到 Spring AI 模板体系8.1 从字符串拼接迁移如果你现在代码里是 String.format 或者手动拼接的提示词迁移路径很简单把拼接模板抽出来写到prompts/目录下的.st文件。把拼接的变量整理成MapString, Object。把调用处替换为new PromptTemplate(xxx.st).create(params)。这个过程中顺便可以做几件事删掉重复的提示词定义、统一措辞风格、为每条提示词补充 few-shot 示例。8.2 从其他模板引擎迁移如果之前用的是 FreeMarker、Velocity迁移时要特别注意一个差异Spring AI 模板的占位符是{变量}跟随的是 Mustache 风格FreeMarker 是${变量}。所以模板内容要整体替换占位符语法不要只换调用代码。另外如果模板中使用了条件判断、循环等复杂逻辑Spring AI 的提示词模板目前并不支持这些——它的设计哲学是“提示词模板保持简单复杂逻辑交给Java代码”。这其实是好事因为提示词模板一旦出现复杂逻辑就很难被非技术同事维护了。8.3 迁移后的测试策略提示词模板迁移后最怕的是模型输出风格变了影响下游解析。我建议建立一套“模板回归测试”准备一组固定的测试输入分别用旧代码和新模板生成结果人工对比输出格式的偏差度。如果格式有出入优先调整模板中的 few-shot 示例而不是改代码兼容新旧两种格式。这听起来像废话但真能坚持做的团队不多。9. 我对这套模板机制的整体评价与维护建议如果把 Spring AI 的提示词模板机制放到整个 LLM 工程化的框架里看它解决的是“提示词如何作为代码资产被管理”的问题。它没有把提示词变成一门新的DSL也没有把问题复杂化——它只是把提示词的渲染、消息组装、参数注入这些会造成混乱的地方收拢起来提供一个规范化的出口。那些“提示词工程是不是玄学”的争论本质上是因为提示词散落在代码各处没法系统化验证和迭代。而当提示词模板化之后每一次修改都有明确的文件变更记录每一组 few-shot 示例都能被回归测试覆盖提示词工程也就从一个“靠感觉调词”的事情变成了“有版本、有测试、可复用”的工程实践。我维护项目里几十条模板文件的一些习惯顺手分享在这里文件名以业务场景命名比如ticket-classifier.st、sentiment-analysis.st不要用prompt1.st这种。模板顶部统一写清楚输入变量有哪些用注释标注比如{- userInput: 用户反馈文本 }不然过两个月自己都忘了要传什么参数。每个模板至少带一组 few-shot 示例不要偷懒。加了示例和没加示例的输出质量差距实测比调温度参数明显得多。模板修改走Git评审和代码改动一样对待不要觉得“只是改个提示词”就不走流程。最后再分享一个小细节为了保险起见所有{变量}的命名我统一用小驼峰渲染参数时用相同的key注入。这样模板里一眼就能看出哪些是动态内容哪些是固定指令维护成本最低。这个习惯看起来不起眼但当模板数量超过20条、团队里多个人维护统一批提示词时规范的变量命名能省下大量沟通成本。