很多人接触 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 或 ThymeleafSpring AI 的 PromptTemplate 底层默认基于 ST4StringTemplate实现。我一开始也疑惑为什么不是更常见的 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、toneUserMessage用户本次输入和显式请求用户的问题、需求描述destination、daysAssistantMessage模型历史输出用于多轮上下文上一轮回答、追问上下文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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.0.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.ai/groupId artifactIdspring-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某个海滨小城days3preferences轻松不赶路喜欢海鲜模型返回的大意是“根据你 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); MapString, 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 按参数组合做 keypublic String renderCached(String template, MapString, 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/目录下做风格对比时你会感谢自己这个习惯。
阅读完成 · 觉得有帮助?