第一次在一堆 StringBuilder 拼接的提示词里挣扎时,我还没意识到 Spring AI 的提示词模板能带来多大改变。直到我把{变量}参数化配合 few-shot 示例用起来,才发现原来一条提示词可以稳定复用,并且根本不需要在每个业务代码里复制粘贴。这篇文章聊聊我实际的落地过程,包括变量解析的几种姿势、few-shot 示例怎么组织才有效,以及这条路上我踩过的一些坑。适合正在用 Spring AI 做 Java 应用集成的人,尤其是提示词越来越多、已经开始觉得维护起来很痛苦的同学。
1. 从手拼提示词到模板化:先搞清楚要解决什么问题
1.1 手拼式的痛点:改一处等于改全部
没用模板之前,我写提示词大概是这样的:
String role = "客服"; String tone = "耐心"; String issue = "用户反馈商品破损"; String policy = "支持七天无理由退货"; String prompt = "你是一个" + role + "客服,请用" + tone + "的语气回答用户:" + issue + "。注意平台政策:" + policy + "。";看着不复杂,但一旦这个 prompt 被用在十几个接口里,问题就暴露了。某天产品说平台政策的措辞要改,我第一反应不是去改一处文案,而是打开代码库全局搜索所有拼过policy的地方。有的接口在 controller 层拼,有的在 service 层拼,还有的在调用第三方 SDK 的封装里拼。每一处都要改,还要担心改了之后换行、空格、标点会不会影响模型理解。更头疼的是,团队里每个人写提示词的风格不一样:有人习惯用“你是一个”,有人用“请扮演”,导致同一个系统里模型被赋予了各种不同的人设,输出风格飘忽不定。
后来我看到一个同事提交的代码,里面有一段提示词生成逻辑,整整 80 行,全是 if-else 分支往 prompt 里插入不同段落。我当时就在想:这已经不是“拼接字符串”的问题了,而是把业务逻辑和模型交互逻辑糊在了一起。任何改动都像在做手术,得小心翼翼避开一堆无关代码。
1.2 模板化解决的其实是"关注点分离"
Spring AI 提示词模板的核心思路特别简单:把提示词中“不变的骨架”和“每次变化的参数”分开。骨架就是模板,参数是业务侧的数据。业务代码不再需要关心提示词长什么样,只需要保证参数传对。模板文件本身可以单独维护,改一行文案不需要动 Java 代码,重新发布也只针对模板。
我拿自己做前端时的经验来类比:以前写 SQL 都是直接嵌在 Java 代码里,后来换成 XML 或注解管理 SQL,变化点被集中到了该变化的地方。提示词也是一样的道理——它本质上是模型交互场景里的“SQL”,同样需要集中管理、参数绑定、可测试。模板化之后,我可以在不重新编译代码的情况下调整人设、补充示例、修改输出格式要求,这让提示词的迭代速度快了一个量级。
2. 深入{变量}解析机制:一条模板如何承载不同参数
2.1 PromptTemplate 的基本用法
回到代码层面,Spring AI 中最基础的提示词模板用法其实非常简单,下面是我最早在项目里用的写法:
String template = "你是一个{role},请用{tone}的语气回复用户关于{issue}的咨询。"; PromptTemplate promptTemplate = new PromptTemplate(template); Map<String, Object> params = Map.of( "role", "电商客服", "tone", "耐心友好", "issue", "用户反馈商品破损" ); Prompt prompt = promptTemplate.create(params); String response = chatModel.call(prompt).getResult().getOutput().getText();模板里用{变量}做占位,参数通过 Map 传入。这里需要注意,Map.of只适合参数少的场景,而且不允许 key 为 null。参数一多,我一般会先构建一个统一的参数上下文,比如把请求对象转成 Map,再传给 PromptTemplate。这样做的好处是:模板和参数之间是纯粹的契约关系,模板列了哪些变量,参数侧就必须提供哪些 key,一目了然。
2.2 新版 ChatClient:在调用链里直接塞参数
如果你用的是 Spring AI 1.x 之后推荐的新版流式 API,参数注入会比上面更顺手。我最近的项目基本都在用ChatClient这种写法:
String answer = chatClient.prompt() .system(s -> s.text("你是一个{role},请严格遵循以下输出格式:{outputFormat}") .param("role", "资深技术顾问") .param("outputFormat", "先给结论,再解释原因")) .user(u -> u.text("请分析这个问题:{question}") .param("question", "为什么提示词模板比字符串拼接更利于维护?")) .call() .content();s.text(...)和u.text(...)里写的就是模板内容,后面的.param(...)负责填充变量。这个写法对动态拼接场景非常友好——用户每次传入的{question}都不同,但模板本身保持稳定。模板只定义一次,参数按请求变,这就是“一条提示词反复用”的核心。
2.3 参数从哪来:Map、对象和资源文件
变量参数化的数据来源不只是 Map 一种,我实际用下来有三种常见做法:
- 直接传 Map:适合参数少、一次性调用的场景。
- 把 Java 对象转成 Map:适合表单提交、批量参数场景,用 Spring 的 BeanMap 或者手动反射都行。
- 从 classpath 加载模板文件:适合模板集中管理的项目,也是我现在的主力方案。
第 3 种值得展开说说。模板统一放到src/main/resources/prompts/目录下,例如support-answer.tpl:
你是一个{role},请结合平台规则回复用户问题。 平台规则:{policy} 用户问题:{question} 回复要求:{requirement}Java 侧用@Value读进来:
@Value("classpath:/prompts/support-answer.tpl") private String supportTemplate;需要生成回复时,直接用这个字符串变量加参数创建 Prompt。模板文件一旦修改,重启服务就生效,Java 代码完全不用动。这看起来只是文件位置变了,但实际影响很大:产品或者运营可以自己维护模板,不用再等研发发版。
2.4 变量缺失时会怎样
我最初踩过一个小坑:模板里写了{policy},代码里 Map 少传了这个 key。结果不同版本表现不一样——有的直接抛异常,有的把{policy}原样输出给模型,模型就把它当普通文本处理了,输出内容里还带着花括号。为了不依赖这种不确定行为,我养成一个习惯:在模板文件第一行用注释把必填变量列出来。
# 必填变量:{role}、{policy}、{question};可选变量:{requirement}同时在代码里做一层参数校验,缺了必填项就提前报错。后来我还给模板加了单元测试:用固定参数跑一遍渲染,断言输出里参数替换正确。这样每次改模板文件后跑一次测试,就能挡住绝大部分低级错误。
3. few-shot 示例的正确组织姿势
3.1 few-shot 不是"多加几个例子"那么简单
提示词里的示例不只是让模型“抄作业”,它是在帮模型建立输出格式的锚点。同一个任务,有 few-shot 和没有 few-shot 的差异,经常比换一个模型还明显。我印象最深的对比是情绪分类场景:只用一句话描述任务,模型偶尔会输出“这句话表达了正面情绪”这种解释性文字;加了 3 个示例之后,模型就老老实实只输出标签,格式再也没有漂过。
关键点在于:few-shot 示例本身要结构一致、边界清晰。如果示例一会儿输出一句话,一会儿输出一段话,模型反而更困惑。我一般会在每个示例里同时固定“输入是什么”“输出应包含几个字段”“字段顺序是什么”,让模型从示例里学到的不是内容,而是格式和判断逻辑。
3.2 静态示例:直接写进模板文件
最简单的 few-shot 是静态的,把示例固定放在模板中。以情绪分类为例:
你是一个文本情绪分析器。请判断输入文本的情绪类别,只输出"正面""负面""中性"之一。 示例1: 输入:客服态度很好,问题很快就解决了。 情绪:正面 示例2: 输入:等了好久都没人理,体验很差。 情绪:负面 示例3: 输入:产品功能一般,没有特别惊喜。 情绪:中性 输入:{comment} 情绪:模板里唯一变量是{comment},示例固定不变。每次请求传入不同的评论,模型都按同样的 few-shot 样板输出。这个方式的优点是稳定、省心;缺点是示例无法根据输入动态调整,而且如果某个类别的示例过多,会白白占用 token 成本。
3.3 动态示例:示例本身也可以参数化
有些场景下,更好的做法是把示例也作为可变参数传入:
String template = """ 你是内容审核助手,请根据以下{count}个示例判断输入文本是否合规。 {examples} 输入文本:{input} """;{examples}是在 Java 侧按业务规则拼装的。我实际做过一个电商评论审核系统,需要按商品类别展示不同示例:数码产品的负面评论常是“收到就坏”,服装类目的负面评论则更多是“尺码不准”。如果模板里写死一组示例,跨类目审核时准确率就会下滑。把示例根据商品类目动态生成,同样的模板就能服务完全不同的商品线。这个“模板骨架相同、示例随业务变化”的组合方式,是我认为 few-shot 配合变量的最佳实践之一。
3.4 示例数量与位置的实操建议
我实测下来的经验是:示例一般控制在 3~5 个,既能定住格式,又不至于把上下文撑满。示例顺序也有讲究——把和待输入最像、最关键的示例放在最后,因为模型对靠近输入的示例记忆更深刻。先让模型看一两个常规示例,再在末尾放一个高度相似的示例,效果往往比把相似示例放在开头更稳。
如果模板文件本身很大,我建议把 few-shot 部分拆成独立的子模板片段,用变量方式拼进主模板。这样既方便不同场景复用同一组示例,也避免一个文件里堆太多内容导致难以维护。
4. 一条提示词反复用的落地套路:从模板集中管理到场景组合
4.1 把提示词当"配置"来管
我现在的做法是:所有提示词模板统一放在src/main/resources/prompts/下,按业务域拆文件:
prompts/ ├── support/ │ ├── answer.tpl │ ├── class-check.tpl │ └── summary.tpl └── common/ ├── role-define.tpl └── examples/ ├── emotion-examples.tpl └── intent-examples.tpl目录结构本身就在表意,谁来维护都能一目了然。模板文件内部可以写注释,说明变量取值范围、常见注意事项。这样即使没有专门的提示词工程师,换个人也能接手维护。相比从前“在代码里找提示词”,管理成本下降得非常明显。
4.2 系统提示词 + 用户提示词的组合复用
要真正做到“一条提示词反复用”,通常要把提示词拆成两层:
- 系统提示词:定义角色、任务、输出格式,多数场景共用。
- 用户提示词:承载当次的输入内容、具体参数。
两层的变量合起来,等于一个完整的组合。比如在 common 里放一个角色包装模板:
你是一个{domain}领域的资深顾问,回答问题时必须给出:结论、原因、操作建议。不同业务场景复用这个角色定义,只是在用户提示词里塞不同的问题。之后想调整角色风格,比如要让语气更简洁,只需要改这一处系统提示词,所有下游场景全部生效。这就是“集中变化点”带来的杠杆效应。
4.3 同一模板在不同场景的三种组合
我拿团队常用的一个“通用抽取模板”举例,它长这样:
请从以下文本中抽取{schema}所要求的字段,并按要求格式输出。 文本:{content} 需要的字段:{schema} 输出格式:{format}这个模板本身没有任何业务含义,它只定义了一个“从文本里抽取字段”的行为。在三个不同场景里,我通过不同参数组合让它分别做了三件事:
| 场景 | 传入 schema | 实际行为 |
|---|---|---|
| 客服工单要素提取 | 用户诉求、紧急程度、责任部门 | 提取工单结构化数据 |
| 商品评论情绪分类 | 情绪、关键词、推荐值 | 批量情绪分析 |
| 候选简历信息抽取 | 姓名、工作年限、技能、期望薪资 | 简历结构化入库 |
业务代码完全不需要改,只需要调整模板中的变量值。这正是提示词模板最大的价值:底层提示词保持稳定,上层参数千变万化,一条提示词被反复复用。
5. 必须避开的坑:花括号冲突、模板体积失控、缺失参数报错
5.1 模板中写 JSON,花括号冲突怎么处理
模板变量用的是{变量},但很多结构化输出任务又要求模型返回 JSON,比如{"category": "positive"}。这就尴尬了——JSON 里到处是花括号,模板引擎会把category当成变量名去解析,轻则报错,重则产出奇怪的替换结果。我最初在这个问题上卡了整整一个下午。
最终的解法分三种:
- 把 JSON 骨架作为变量传入,模板里不放字面花括号。
- 在模板文件里使用对应的转义写法,不同版本转义符略有差异,建议先做个小 demo 验证。
- 最彻底的办法:不在模板里写 JSON,而是用 Java 侧的结构化输出解析器,让模型输出后统一做 JSON 解析。
我推荐第三种,稳定省心。如果确实要走第一种,代码大概长这样:
Map<String, Object> params = Map.of( "schema", "{\"category\": \"positive|negative|neutral\", \"confidence\": 0-1}", "comment", "客服态度很好" );模板里只写:
请根据以下 schema 输出 JSON:{schema} 待处理文本:{comment}这样 JSON 的花括号全部落在 Java 侧,模板引擎根本不会去碰它们。
5.2 模板体积与 token 消耗的关系
模板里塞的示例越多、说明越长,每次调用消耗的 token 就越多。成本是按次累积的,而且 token 太长还会拉高延迟。我曾经把一份“超级全”的模板写到 2500 字,结果单次调用 token 量直线上升,接口响应速度明显变慢。
优化手段我总结了两条:第一,把最常复用的公共说明放进系统提示词,用户提示词里只保留当次变量;第二,few-shot 只保留对当前任务帮助最大的 3~5 条,去掉那些“锦上添花”但实际用不上的示例。模板能短则短,但绝不能为了压 token 牺牲输出格式的稳定性。这两者的平衡,需要拿真实数据去试错,而不是拍脑袋决定。
5.3 缺参数、错参数名的排查姿势
前面提到过缺失参数的行为,这里补充我真实的排查链路。出现奇怪输出时,我会按顺序做三件事:
- 看模板变量名和 Map 的 key 是否严格一致,注意大小写和下划线。
- 看模板里有没有把变量写在示例文本内部——示例内部如果也带了花括号,会和模板引擎打架。
- 看参数是否为 null,null 会被拼进提示词,同样会改变模型行为。
三层查完,绝大多数模板问题都能定位。把这些问题前置到单元测试里是最划算的。我后期每次提交模板文件改动,都会跑一次固定输入的渲染测试,确认输出格式没被意外改变。
6. 模板化之后,我接下来的两个扩展方向
6.1 给模板加版本快照
提示词模板会随着业务演进不断变化,但模型侧的“效果预期”也应该有迹可循。我准备把模板文件纳入版本管理,每次改动都打上标记,记录它当时对应的是哪个模型、哪个参数组合下的效果。日后切换模型,或者需要回滚到某个历史版本时,直接比对模板内容,能省去很多“凭感觉调试”的时间。具体做法也不复杂:模板文件名带上版本号,或者用 Git 标签管理,配合一份简单的效果记录表。
6.2 把提示词效果纳入回归测试
模板改动最怕的不是语法错,而是“改完之后个别场景悄悄变差了”。我计划把一组合法输入和期望输出整理成测试集,每次模板变更后批量跑一遍,对比模型输出是否仍然满足格式要求。不需要做特别复杂的评估,只要能把“这次改动破坏了哪个场景”暴露出来就够了。提示词也是一种代码,该有的回归测试它一样需要。
这套模板化的做法帮我省下的时间,已经远超当初研究它的成本。如果你也在 Spring AI 项目里遇到类似的维护困境,不妨从最频繁变动的业务提示词开始抽第一个模板,花一个下午把参数替换和 few-shot 跑通,后续的收益会慢慢显现。