news 2026/10/19 8:47:18

Spring AI Prompt模板实战:从字符串拼接到工程化定制对话

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI Prompt模板实战:从字符串拼接到工程化定制对话

很多人接触 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 + ChatModelChatClient
代码可读性中,模板和调用分散高,链式结构清晰
参数传递Map 手动拼装方法级 param 直接绑定
流式响应需要额外处理 Fluxcall / 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/目录下,做风格对比时你会感谢自己这个习惯。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 8:46:53

开源代码审查协议:CLI驱动的本地化LLM自动化审查方案

1. 项目概述&#xff1a;这不是又一个“AI写代码”工具&#xff0c;而是一套可嵌入开发流程的开源代码审查协议open-code-review 这个名字乍看平平无奇&#xff0c;但拆开来看——open&#xff08;开放&#xff09;、code&#xff08;代码&#xff09;、review&#xff08;审查…

作者头像 李华
网站建设 2026/10/19 8:46:46

SpringBoot网上书城毕设实战:需求拆解、库存并发与电子书分发

每年到这个时间点&#xff0c;总会有一批准备毕业设计的同学拿着一串长标题来找我&#xff0c;其中“基于SpringBoot的‘易书’网上书城销售平台”出现频率相当高。这个题目乍一看很长&#xff0c;又是“SpringBoot框架下基于Java的在线图书销售与数字化阅读服务平台”&#xf…

作者头像 李华
网站建设 2026/10/19 8:46:45

网盘直链提取与批量下载:突破限速实现高效文件传输

你有没有遇到过这种场景&#xff1a;从网盘下载一个几个GB的压缩包&#xff0c;网页端老老实实跑了一个多小时&#xff0c;中间手一抖点错了标签页&#xff0c;下载就断了&#xff0c;重新点开又得从头再来。更气的是&#xff0c;明明家里宽带是千兆的&#xff0c;网盘下载速度…

作者头像 李华
网站建设 2026/10/19 8:46:45

AI辅助论文写作:从选题到查重的一整套可落地工作流

1. 写论文真正缺的&#xff0c;不是“神器”&#xff0c;是一套能跑通的工作流很多人一听到“AI写论文”&#xff0c;第一反应是“以后是不是不用自己写了”。我一开始也是这么想的&#xff0c;直到我兴致勃勃地打开一款对话式AI&#xff0c;输了个题目&#xff0c;收到一份看起…

作者头像 李华
网站建设 2026/10/19 8:46:39

网络安全转行怎么选?渗透测试、安全运维、网安工程师三条路径详解

转行网安这件事&#xff0c;我聊过太多次了。每次都有朋友一上来就问“我该学渗透还是搞运维”&#xff0c;其实这个问题本身就问偏了。2026年了&#xff0c;网络安全早就不再是“黑客”的独角戏&#xff0c;而是一个分工明确、各有门槛的行业集群。标题里那句“3类人适配3条路…

作者头像 李华
网站建设 2026/10/19 8:44:22

Z-Image一键尝鲜包:低配置电脑本地AI绘画零门槛部署指南

说实话&#xff0c;看到"低配置专用版""解压即用"这几个字的时候&#xff0c;我第一反应是&#xff1a;这玩意儿终于有人做了。我自己刚入坑本地AI绘画那会儿&#xff0c;光搭环境就折腾了整整两个周末&#xff0c;先是Python版本不对&#xff0c;接着CUDA…

作者头像 李华