很多人接触 Spring AI,都是先跑通一个最简单的对话接口——把用户的问题原样丢给模型,拿到回答就完事。但一旦真要在业务里做定制对话,事情就没这么简单了:同一个咨询机器人,不同渠道要不同口吻,不同业务线要不同背景知识,还要在每轮对话里塞进用户画像、历史记录、当前上下文。这些如果全靠程序里一个个字符串去拼,维护成本会非常恐怖。这篇“Spring with AI”系列的第三篇,就专门聊聊 Spring AI 里解决这个问题的一环:Prompt 模板。
本文适合两类读者:一是已经用 Spring AI 跑通过基础对话、想把对话能力工程化的开发者;二是被“到处拼 Prompt、改一处崩三处”折磨过的同学。我会从模板机制、选型理由讲起,再用一个旅行顾问 Demo 完整走一遍,最后聊模板的工程化落地和几个我踩过的坑。全程有代码、有配置、有测试思路,可以直接照着抄。
1. 为什么定制对话最先要解决的是 Prompt 复用
1.1 字符串拼接失控后的典型翻车现场
我之前接手过一个咨询系统,最初大家写对话逻辑都很随意,Service 里到处是这样的代码:
String prompt = "你是一个专业的客服助手,请用友好的语气回答用户问题。用户的问题是:" + userQuestion;第二条业务线复制一下改成“你是一个金融顾问……”,第三条再复制改成“……请把结果输出成 JSON”。最初一切正常,直到有一天产品经理说要统一品牌口吻,把所有的“亲”改成“您”。我全局搜了一遍,改了七八处,还是漏了两处;更麻烦的是,有些用户输入里带引号和换行,直接拼进 Prompt 后,模型要么理解错乱,要么把引号原样复制进回答,导致后续解析 JSON 直接失败。
这种问题的本质,是把“提示词的内容”和“提示词的组装方式”搅在了一起。Prompt 里哪些部分是固定的角色约束,哪些部分是每次变化的动态参数,在 String.format 和字符串拼接的表达方式下完全看不出来。改的人只能靠肉眼去分辨,出错几乎只是时间问题。
1.2 模板真正解决的三个层面
Prompt 模板不是新概念,它本质上就是“把对话指令做成可填写的表单”。我后来梳理了一下,模板至少要解决三个层面的问题:
第一是系统人设复用。角色定义、语气风格、安全边界、输出规则,这些内容在同一个应用里往往要被多条对话线共用。写成模板文件后,人设部分只维护一份,改一次全局生效。
第二是动态内容注入。用户问题、业务参数、上下文摘要、历史记录,这些每轮都在变的内容只需要通过模板变量传入。参数和模板分离后,即使输入里带点特殊符号,也可以先在代码层做清洗,而不是让原始输入直接冲击指令文本。
第三是输出格式契约。很多业务场景要求模型返回结构化结果,比如 JSON。把人设和格式说明放进模板,把动态值填进指定位置,比在代码里拼一长串“请严格返回以下格式……”要可靠得多。
1.3 相比手写 String.format,模板到底强在哪
很多人第一反应是“我用 String.format 不也能做占位符替换吗”。确实,如果只是替换三五个变量,String.format 完全够用。但 Prompt 模板的场景比这复杂:
- 模板支持条件渲染。例如“如果用户是会员,追加专属优惠说明;如果不是,跳过这一段”,这种逻辑在 String.format 里得写在代码中,在模板引擎里可以用 if 语法直接表达。
- 模板支持循环迭代。比如把历史消息列表、候选商品列表批量生成到 Prompt 里,String.format 写起来冗长且容易出格式错位。
- 模板可以独立存文件。文案人员可以直接编辑模板文件,不需要碰代码、不需要重新编译;代码里只保留参数组装逻辑,职责清晰得多。
Spring AI 里的模板能力基于成熟的模板引擎实现,并非自己做了一套复杂的语法,所以学习成本也不高。下一节就来看看它底层选了什么引擎、消息结构又是怎么划分的。
2. 模板引擎、消息角色与 API 取舍:Spring AI 背后的设计思路
2.1 为什么选 StringTemplate 而不是 Freemarker 或 Thymeleaf
Spring AI 的 PromptTemplate 底层默认基于 ST4(StringTemplate)实现。我一开始也疑惑,为什么不是更常见的 Freemarker 或者 Thymeleaf?后来翻了翻设计思路才明白:ST4 本来就是为“代码生成、文本生成”这类场景设计的,语法极简,专注做占位符替换,不像 Thymeleaf 那样带有浓厚的 HTML/XML 语义。
这一点在对话场景里特别重要。模型回答里经常出现 HTML、Markdown、JSON 片段,如果你用 Thymeleaf 这类模板,模板里的<div>、{{}}很容易和标签语法打架;而 ST4 的占位符语法足够简单,和正文内容冲突的概率低很多。
关于占位符写法,我要特别提醒一句:Spring AI 版本迭代很快,早期模板用{变量名},后来某些调整中又出现过<变量名>的写法。不同资料里你会看到两种风格都不一样,判断方法很简单——写一个含变量的模板跑一次渲染,或者直接看你依赖里模板引擎的定界符配置。我项目里锁定在 Spring AI 1.0.x,后面的代码统一用{变量名}写法。
2.2 SystemMessage、UserMessage、AssistantMessage 的职责边界
定制对话的另一个核心概念是“消息角色”。Spring AI 的 Prompt 由多条消息组成,最常见的是这三种:
| 消息类型 | 职责 | 典型内容 | 模板参数示例 |
|---|---|---|---|
| SystemMessage | 系统人设、全局约束、输出规则 | “你是旅游顾问,必须用中文回答” | role、tone |
| UserMessage | 用户本次输入和显式请求 | 用户的问题、需求描述 | destination、days |
| AssistantMessage | 模型历史输出,用于多轮上下文 | 上一轮回答、追问上下文 | history |
在模板化定制对话时,原则很简单:凡是“应用希望模型始终遵守的”,尽量放 SystemMessage;凡是“用户这次带来的变化信息”,放 UserMessage;多轮对话时再把 AssistantMessage 按时间顺序拼接进去。如果把动态的用户输入塞进系统消息,每次请求都在重建全局规则,模板的复用价值就大打折扣。
2.3 PromptTemplate 旧 API 和 ChatClient 新 API 的取舍
Spring AI 早期版本主要用PromptTemplate配合ChatModel来工作,典型的写法是手动创建 PromptTemplate、传入参数 Map、生成 Prompt,再交给 ChatModel 调用。这个 API 在 1.x 里依然存在,但官方已经明显把重心移到了ChatClient上。
以 Spring AI 1.0.x 为例,我更推荐直接使用 ChatClient,因为它把系统消息、用户消息、参数、返回内容都组织在一个链式调用里,可读性比手动组装 PromptTemplate 好很多。下表是我在项目里的对比结论:
| 维度 | PromptTemplate + ChatModel | ChatClient |
|---|---|---|
| 代码可读性 | 中,模板和调用分散 | 高,链式结构清晰 |
| 参数传递 | Map 手动拼装 | 方法级 param 直接绑定 |
| 流式响应 | 需要额外处理 Flux | call / stream 都支持 |
| 多轮消息组装 | 手动构造消息列表 | 内置消息状态管理 |
| 是否推荐新项目 | 一般,适合离线批量渲染 | 推荐,常规对话首选 |
如果你只是想在本地批量生成一批带参数的 Prompt 文本、不发起模型调用,PromptTemplate依然是个轻量选择;但凡是涉及真实对话的服务,建议统一走 ChatClient。下面进入实战环节。
3. 实战:把旅行顾问对话模板化需要几步
3.1 项目准备:依赖版本与网关配置
我用一个虚构的“旅行顾问对话 Demo”来演示。先建一个普通的 Spring Boot 3.4 项目,引入 Spring AI 相关依赖。以 Spring AI 1.0.x 为例,Maven 依赖大致是这样:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>1.0.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> </dependencies>如果依赖拉不下来,先确认一下 Spring 的仓库是否已配置;GA 版本发布后 Maven 中央仓库一般可用,但个别里程碑版本还要走仓库。这个细节卡过不少同学,我直接放在最前面。
配置方面,我在本地连的是一个兼容 OpenAI 协议的内部模型网关,所以配置长这样:
spring: ai: openai: api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL:https://api.example.com/v1} chat: options: model: ${LLM_MODEL:chat-model}前缀是 Spring AI 官方按 OpenAI 协议命名的,不代表连的是官方服务;很多团队配合内部网关时都会沿用这套配置,只是把 base-url 换掉。模型名走环境变量注入,避免硬编码。
3.2 模板文件设计:人设和请求彻底分离
我把“系统人设”单独放到一个模板文件里,位置在src/main/resources/prompts/travel-advisor-system.st,内容大概是:
你是{role}旅游顾问,拥有十年从业经验,擅长根据客户的{preferences}设计行程。 交流时请使用简洁清晰的中文,优先提供可执行的交通、住宿和每日安排建议。 如果客户提供的信息不足,先主动询问,不要编造景点和价格。“用户请求”则放在另一个模板文件travel-advisor-user.st:
请帮我规划一次{days}天的{destination}旅行,重点包含交通、住宿和每日行程安排。 我的偏好:{preferences}这样设计有两个好处:一是产品运营可以直接改文案文件,不触碰代码;二是系统人设模板和用户请求模板可以分别做版本管理,后续做 A/B 测试也很方便。
3.3 核心代码:ChatClient 怎么把参数填进模板
接下来写一个旅行顾问的服务类。构造时通过ChatClient.Builder构建客户端,并在需要时从 classpath 加载模板文件:
@Service public class TravelAdvisorService { private final ChatClient chatClient; private final String systemTemplate; private final String userTemplate; public TravelAdvisorService( ChatClient.Builder builder, @Value("classpath:prompts/travel-advisor-system.st") Resource systemResource, @Value("classpath:prompts/travel-advisor-user.st") Resource userResource) throws IOException { this.chatClient = builder.build(); this.systemTemplate = new String(systemResource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); this.userTemplate = new String(userResource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); } public String recommend(String role, String destination, int days, String preferences) { return chatClient.prompt() .system(s -> s .text(systemTemplate) .param("role", role) .param("preferences", preferences)) .user(u -> u .text(userTemplate) .param("days", String.valueOf(days)) .param("destination", destination) .param("preferences", preferences)) .call() .content(); } }注意几个细节。第一,systemTemplate和userTemplate在构造时读入内存,避免每次请求都做一次文件 IO。第二,占位符名称必须与param名称一一对应,如果模板里写了{destination}但代码忘了传这个参数,渲染阶段不会报错,但模型拿到的文本里会残留变量名,这种问题很难排查。第三,我故意在系统消息里也传了preferences,因为人设需要知道客户偏好来调整推荐风格,而用户消息里的preferences则是具体的行程偏好,两边需要保持一致。
3.4 跑起来看效果:一组真实的输入输出
我写了一个简单的入口来验证效果:
@RestController public class TravelAdvisorController { private final TravelAdvisorService travelAdvisorService; public TravelAdvisorController(TravelAdvisorService travelAdvisorService) { this.travelAdvisorService = travelAdvisorService; } @GetMapping("/recommend") public String recommend( @RequestParam String role, @RequestParam String destination, @RequestParam int days, @RequestParam String preferences) { return travelAdvisorService.recommend(role, destination, days, preferences); } }调用一次:
curl "http://localhost:8080/recommend?role=亲子&destination=某个海滨小城&days=3&preferences=轻松不赶路,喜欢海鲜"模型返回的大意是:“根据你 3 天 2 晚的海滨小城需求,推荐住在老城区靠近海鲜市场的位置,第一天下午抵达后可以逛海边栈道,第二天上午赶海、下午去渔村体验,第三天睡到自然醒后采购特产返程……”这个效果不算惊艳,但整个过程里我没有在业务代码里拼过任何一段完整的 Prompt,模板内容和参数完全解耦,后面改口吻只需要动模板文件。
4. 从 Demo 到生产:模板存储、版本与契约测试
4.1 模板该放 classpath 还是配置中心
Demo 里我把模板放在 classpath,这适合模板基本稳定、改动频率低的小项目。但做到生产环境,我见到过几种不同的做法,各有利弊:
- 放 classpath:最简单,随应用发布,模板与代码同版本,回滚方便。缺点是改文案要重新发版。
- 放数据库或配置中心:运营人员可以随时改文案,不重新发布。缺点是需要额外交互设计,还要防手滑改坏。
- 放独立 Git 仓库:适合文案团队参与度高的场景,模板评审、发布流程更规范,但应用要额外做拉取逻辑。
我的习惯是:模板文件放 classpath,通过配置中心控制“启用哪个版本的模板”,而不是让配置中心直接存模板全文。这样可以兼顾审计和灵活性,线上出问题能快速切换旧版本。
4.2 多版本管理与灰度回退
模板不是普通静态资源,它直接影响模型输出的风格和质量。一次人设措辞的调整,可能让回答风格骤变,甚至让安全边界失效。所以模板改动必须当作代码变更来管理。
我目前的做法是给每个模板文件标注版本号,可以写在文件名里,比如travel-advisor-system-v3.st,并在代码里用一个枚举或配置项指向当前生效版本。每次调整模板,先在小流量上观察几轮输出,重点对比三项指标:回答风格是否一致、是否出现与模板无关的内容、输出格式是否正确。如果发现回退,把配置项指回上一版即可,不需要回滚整个应用发布。
4.3 用契约测试锁住模板的“关键信息”
模板最怕的不是写错,而是改的时候被“顺带改坏”。比如运营同学调整语气时,不小心删掉了“不要编造景点”这句安全约束。这种问题只靠人工 review 很难发现,最好用契约测试把它固定下来。
我写了一个简单的测试,用固定参数渲染模板,然后断言关键片段必须存在:
@SpringBootTest class TravelAdvisorTemplateTest { @Autowired TemplateRenderer templateRenderer; @Test void systemTemplateShouldKeepSafetyRule() throws IOException { Resource resource = new ClassPathResource("prompts/travel-advisor-system.st"); String template = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); Map<String, Object> params = Map.of( "role", "亲子", "preferences", "轻松不赶路"); String rendered = templateRenderer.render(template, params); assertThat(rendered).contains("亲子旅游顾问"); assertThat(rendered).contains("不要编造景点和价格"); } }这里的TemplateRenderer是 Spring AI 提供的渲染接口。如果项目里用的不是 Spring AI 1.0.x,也可以直接调用 PromptTemplate 的 render 方法,思路是一样的:不发起模型请求,只验证模板渲染结果。有了这套测试,后面每次改模板都能得到即时反馈,改坏了立刻红一片,比上线后才发现问题舒服太多。
5. 实战中踩过的坑和一眼能用的优化建议
5.1 占位符定界符、JSON 花括号和特殊符号
先说最坑的定界符问题。Spring AI 底层用 ST4 做渲染,不同版本对定界符的处理有过调整,最直接的后果是:同一份模板在版本 A 里正常渲染,切到版本 B 后变量名原样输出。这不是玄学,是版本行为差异。我的建议是:项目一开始就锁定 Spring AI 的具体小版本,并在代码注释里写清楚“模板占位符语法以当前版本为准”,避免团队其他人换版本时踩坑。
另一个高频问题是模板里出现 JSON 示例。如果你写:
请返回JSON:{"city": "{destination}"}这时候 ST4 会把{city}也当作占位符尝试解析,轻则渲染出空值,重则直接抛异常。我后来养成的习惯是:模板里不写完整 JSON 结构,只渲染 JSON 里的值部分;或者把 JSON 结构说明放到系统人设里用纯文本描述,不出现真实花括号。如果确实需要模板带示例结构,可以临时把示例里的花括号用其他符号替代,渲染完再替换回来,但这种方式不太优雅,能避免尽量避免。
参数值本身带花括号或反斜杠也会触发类似问题,特别是用户输入里有代码片段时。我在服务入口会先做一轮参数清理,把模板元字符相关的内容过滤或转义,再传给模板。别小看这一步,它帮我挡掉了很多没必要的调试时间。
5.2 Token 膨胀:模板越长,每轮对话越贵
模板化之后很容易出现另一个问题:系统人设越写越长,各种约束、示例、背景信息全往里塞。模板不会变短,但每一轮对话都会带着全部模板内容去请求模型,Token 消耗是重复计算的。
我见过有人为追求“回答精准”,把系统人设写成了两千字的小册子,结果每次请求的 Token 一大半都花在重复人设上,成本直接翻了几倍。合理的做法是:系统人设只保留“角色 + 核心约束 + 关键安全边界”,一般控制在几百字内;那些需要时才会用到的详细背景,放到动态参数里按需注入,而不是常驻模板。
另外动态参数也要设长度上限。比如用户偏好描述,用户传进来 5000 字,模板里就原样带 5000 字去请求模型,既不经济,还可能因为上下文过长影响模型对关键信息的关注度。我在实现里会先对用户输入做摘要或截断,再注入模板。
5.3 预加载模板与渲染结果缓存
ChatClient 每次调用都会对模板做解析渲染,如果模板文件是每次从磁盘读取的,在高并发场景下文件 IO 会成为瓶颈。前面代码里我已经把模板内容在构造时读入内存,这算第一层优化。
更进一步的优化是对“参数完全相同的”渲染结果做缓存。比如后台运营会频繁用同一组参数预览某条对话线的效果,连模型请求都是重复的。这种场景下,直接把 Prompt 渲染结果做短时缓存,命中后复用字符串,能省掉模板渲染和一部分网络开销。我用过最简单的实现就是 Caffeine 按参数组合做 key:
public String renderCached(String template, Map<String, Object> params) { String cacheKey = template + "|" + params.toString(); return cache.get(cacheKey, key -> templateRenderer.render(template, params)); }如果是离线批量生成 Prompt 的场景,可以直接用 PromptTemplate 渲染,不要建 ChatModel 请求,成本几乎为零。
5.4 验收清单:模板改动上线前先过一遍
最后分享一个我一直在用的清单。每次改完模板准备上线前,我会按这个顺序自查:
- 模板在锁定版本下能正常渲染,占位符全部被替换,没有残留变量名。
- 契约测试通过,关键安全约束和角色声明没有被误删。
- 动态参数有长度上限和特殊字符处理,不会因为用户输入破坏模板结构。
- 系统人设总长度没有明显膨胀,单轮 Token 成本可接受。
- 线上小流量对比通过,回答风格和输出格式与旧版一致或符合预期。
这五条不一定覆盖所有场景,但基本能挡住 90% 的线上问题。
我做模板化改造后的实际体会是,改动一处模板、全链路生效,加上契约测试兜底之后,团队改 Prompt 不再需要反复提醒“小心别改坏”。如果你也在把 Spring AI 的对话能力往工程化方向推,建议先别急着堆复杂功能,把人设、参数、模板结构理清楚,后面的多轮对话、函数调用、知识库接入都会顺很多。一个小技巧收尾:每次改系统模板之前,手动保留一份上一版文本,放在项目里的docs/prompt-history/目录下,做风格对比时你会感谢自己这个习惯。