Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
作者:鱼宵 | Spring AI 实战精通营 · 第 2 篇
上周拿第 1 课那个翻译接口改需求:产品说"同一句话,要能翻商务版、口语版、文艺版"。我的第一反应是再写两个接口,把system()里的提示词复制三遍、改两个字。写到第三个风格时我自己都烦了——提示词骨架一个字没动,就"风格"两个字不一样,凭什么复制三遍?
这就是第 2 课要收拾的事:把写死的提示词改造成带{变量}的模板,骨架写死一次、参数按次填;顺手再玩个 few-shot——不讲规则,直接甩 3 个"输入→输出"示例,让模型自己照着样例说话。代码全在仓库lesson-02/目录里,clone 下来照着命令重跑一遍,十分钟你手里就有两个翻译接口。
一、核心原理:提示词不是写死的字符串,是带坑的表格
1. 一次请求 = 一张对话记录表
先把底层概念掰清楚:Message(消息)是发给模型的最小单位,每条消息就两个属性——谁说的(role)+ 说了什么(content)。模型不是"读一整段话",而是捧着一本对话记录本,一行一行往下看:
| 角色 | 内容 |
|---|---|
| system | “你是专业中英翻译助手,只输出译文” ← 员工守则 |
| user | “把【今天天气真不错】翻译成商务正式风格的英文” ← 这一次的问题 |
| assistant | “The weather is quite pleasant today.” ← 模型回话,下一轮进表 |
system()必须放在user()前面——顺序就是记录本上的先后,模型按行读,写反了它就先答题再读守则。
2. 参数化模板:合同为什么不用每次重写
公司的劳动合同不会来一个人重写一份,都是一份模板写死"甲方:、乙方:、岗位:____",新人入职就把三个空填上。提示词模板一个思路:
模板: "把下面这句话翻译成英文,译文风格要【{style}】:{question}" 填值: style=商务正式, question=今天天气真不错 成品: "把下面这句话翻译成英文,译文风格要【商务正式】:今天天气真不错"对照两种写法,一眼看出差别:
| 写法 | 代码 | 问题 |
|---|---|---|
| 错误(字符串拼接) | "风格是" + style + ",句子是" + question | 又臭又长,引号转义全靠自己擦屁股 |
| 正确(模板+填坑) | .user(u -> u.text("...{style}...").param("style", style)) | 骨架复用,按名字填值 |
这里埋了本课最大的一个坑:直接.user("...{question}...")传字符串,占位符不会被替换——模板引擎只在 lambda 小括号模式(u -> u.text(...).param(...))里才启动。你老老实实把模板写进text()、把值交给param(),坑才会被填上。两边名字还得逐字符一致,{Question}和"question"大小写对不上,坑就原样留在句子里发给模型,模型当场懵。
3. few-shot:教新人不写 SOP,甩 3 份优秀聊天记录
few-shot(少样本提示):不写一堆规则,直接在 system 里塞 2~3 个"输入→输出"对照示例,模型自己归纳规律并模仿。给 0 个示例叫 zero-shot(第 1 课就是),给几个示例就是 few-shot。
| 对比 | zero-shot(讲规则) | few-shot(给样例) |
|---|---|---|
| 写法 | system 里写"你是客服翻译,语气简短客气" | system 里塞 3 条"用户:xxx / 翻译:yyy" |
| 适合 | 规则说得清的任务 | 风格类、格式类任务(你说不清"客服口吻"是几个字) |
| 成本 | 输入短 | 示例常驻每次请求,token 略涨 |
类比带新人:教客服新人,与其写 500 字 SOP,不如甩 3 份优秀聊天记录让他照着聊——他自己就悟到"要短、要客气"。大模型吃这套,第四节实测给你看证据。
二、动手:十分钟跑通两个翻译接口
环境:Windows + JDK 17 + Maven 3.9+,会
@RestController就行。
第 1 步:30 秒检查环境。
java-version# 期望 True[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY')# 期望 TrueGet-NetTCPConnection-LocalPort 8094-State Listen-ErrorAction SilentlyContinue# 无输出=端口空闲第 2 步:编译 + 启动。
cd spring-ai-journey\lesson-02$env:JAVA_HOME="C:\Program Files\Java\jdk-17"# Maven 必须跑在 JDK 17 上mvn clean install-DskipTests# 结尾看到 BUILD SUCCESSmvn spring-boot:run# 看到 Tomcat started on port 8094 即成功第 3 步:调两个接口(中文参数先 URL 编码)。
[Console]::OutputEncoding=[System.Text.Encoding]::GetEncoding(936)# 防控制台中文乱码# 接口一:参数化模板,question + style 两个变量$q=[uri]::EscapeDataString('今天天气真不错')$s=[uri]::EscapeDataString('商务正式')Invoke-RestMethod"http://localhost:8094/translate?question=$q&style=$s"# 接口二:few-shot,只传 question,风格靠 system 里的示例带出来$q2=[uri]::EscapeDataString('我想买两件衬衫')Invoke-RestMethod"http://localhost:8094/translate-fewshot?question=$q2"浏览器直接开http://localhost:8094/translate?question=你好&style=商务正式也行,浏览器会自动编码中文。
三、关键代码:两个接口,逐行拆解
工程还是标准 Spring Boot 项目,真正要看的就两处:yml 配置和那个控制器。
第一段:application.yml——只改端口和 token 上限。
server:port:8094# 端口按课程分配表:spring-ai 系列 lesson-02 = 8094spring:ai:openai:base-url:${LLM_BASE_URL:https://api.deepseek.com}# 环境变量优先,默认 DeepSeekapi-key:${DEEPSEEK_API_KEY}# Key 只从环境变量读,文件里永远没有明文chat:options:model:${LLM_MODEL:deepseek-chat}# 模型名,默认 deepseek-chatmax-tokens:400# 翻译输出比一问一答略长,教学控成本temperature:0.7# 温度 0.7:翻译要稳但不死板配置套路和第 1 课一模一样——这就是 yml 自动装配的好处,加新课不改配置习惯,就动了port和max-tokens两个数。
第二段:ChatClientTemplateDemo.java——本课主菜,完整可运行。
packagecom.springai.lesson02;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RequestParam;importorg.springframework.web.bind.annotation.RestController;/** * 第 2 课核心:翻译助手控制器。 * 两个接口: * /translate —— 参数化模板:模板里写 {question}/{style} 占位,运行时 .param() 填值 * /translate-fewshot —— few-shot:system 里塞 3 个"中英对照"示例,让模型模仿风格 */@RestControllerpublicclassChatClientTemplateDemo{/** ChatClient 实例:和第 1 课一样,Builder 由 starter 自动装配,零手写模型代码 */privatefinalChatClientchatClient;publicChatClientTemplateDemo(ChatClient.Builderbuilder){this.chatClient=builder.build();}/** * 接口一:参数化模板翻译。 * GET /translate?question=今天天气真不错&style=商务正式 * 重点看 .user(u -> u.text("...{question}...").param(...)) 这段: * text() 里放模板,占位符 {question}/{style} 不会原样发给模型,填完才发; * param() 按名字填坑,名字必须和模板里的占位符逐字符一致。 */@GetMapping("/translate")publicStringtranslate(@RequestParam("question")Stringquestion,@RequestParam(value="style",defaultValue="日常口语")Stringstyle){returnchatClient.prompt()// system:人设——只给译文,别啰嗦.system("你是专业中英翻译助手。只输出英文译文本身,不要解释、不要加引号、不要多余的话。")// user:带变量的模板,运行时填 question 和 style.user(u->u.text("把下面这句话翻译成英文,译文风格要【{style}】:\n{question}").param("style",style).param("question",question)).call().content();}/** * 接口二:few-shot(给示例让模型模仿)。 * GET /translate-fewshot?question=我想买两件衬衫 * system 里写死 3 个"中文→英文"对照示例(客服场景的简短客气口吻), * user 只放真正要翻的那句话,尾巴故意留个"翻译:"让模型顺着示例续写。 */@GetMapping("/translate-fewshot")publicStringtranslateFewShot(@RequestParam("question")Stringquestion){returnchatClient.prompt()// system:人设 + 3 个示例(示例就是模型的"模仿对象").system("你是外贸客服的中英翻译。下面是翻译示例,请严格模仿示例的简短客气语气,只输出英文译文:\n"+"示例1:\n用户:早上好\n翻译:Good morning!\n"+"示例2:\n用户:这件商品包邮吗?\n翻译:Is shipping free for this item?\n"+"示例3:\n用户:麻烦帮我退一下货,谢谢\n翻译:I'd like to return this, please. Thank you.")// user:只留 {question} 一个坑,尾巴的"翻译:"引导模型续写.user(u->u.text("用户:{question}\n翻译:").param("question",question)).call().content();}}这段代码讲了三件事:u -> u.text(...)进了 user 的小括号模式(第 1 课是直接传字符串,本课要填变量所以得进 lambda);{style}、{question}是占位符不是发给模型的原文;defaultValue = "日常口语"让 style 不传也能跑。
第三段:Lesson02Application.java——启动类,三行。
packagecom.springai.lesson02;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * 第 2 课启动类。启动后访问: * GET http://localhost:8094/translate?question=今天天气真不错&style=商务正式 * GET http://localhost:8094/translate-fewshot?question=我想买两件衬衫 */@SpringBootApplicationpublicclassLesson02Application{publicstaticvoidmain(String[]args){SpringApplication.run(Lesson02Application.class,args);}}四、实测输出:同一个模板,换个填法就换个风格
以下是 2026-10-05 本机真实运行(DeepSeek,端口 8094,HTTP 200)。先调 /translate,question 不变,style 换两次:
# style=商务正式 HTTP 200 The weather is quite pleasant today. # style=古代诗人李白风格 HTTP 200 The weather today is truly fine.模板里的{style}真的被填进去了——商务版用了 “quite pleasant” 这种书面词,李白版明显凝练。风格有影响但不会天翻地覆(temperature 0.7 下模型偏稳妥),面试时别吹成"换个词就脱胎换骨"。
再调 /translate-fewshot,只传 question,看模型怎么模仿示例:
# question=我想买两件衬衫 HTTP 200 I'd like to buy two shirts. # question=请问什么时候发货? HTTP 200 When will this be shipped?看第一句——I'd like to buy two shirts.用了I'd like to...开头,这正是 system 里示例 3(I'd like to return this, please)的句式。你没写"要用 I’d like to 开头"这条规则,模型自己从三个示例里学去了。这就是 few-shot 的力量:风格类任务,甩样例比讲规则准。
排查提示:回答里如果残留
{question}字样,说明 param 的 key 和模板占位名对不上(大小写/拼写),两边逐字符核对即可。
五、挑战题:改参数,看看会怎样
- ⭐不传 style:浏览器里只开
http://localhost:8094/translate?question=你好,style 参数故意不给——看模型用了什么风格回答。答案就在源码ChatClientTemplateDemo.java的方法参数上,跑出来才知道默认值真的生效。 - ⭐⭐不用小括号模式:把
/translate里的.user(u -> u.text("...").param(...))改回.user("把下面这句话翻译成英文,译文风格要【商务正式】:今天天气真不错")——再进一步,试试.user("...{question}...")带占位符但不加 param()。看{question}会不会被替换。这题的答案藏在踩坑节里,跑一遍你这辈子都忘不了。 - ⭐⭐示例换成法语:把 system 里 3 个示例改成"中译法"(用户:早上好 / 翻译:Bonjour! 这种),user 还是中文——验证模型会不会照猫画虎吐出法语。看你改完示例后它"学歪"还是"学对"。
六、生产环境进阶:三个加分项
1. 提示词和代码分离。模板骨架抽到application.yml或常量类里,Controller 只负责填变量。提示词改起来不用动代码、不用重新编译,产品提需求改一段 yml 就行。
2. few-shot 示例算 token 账。示例是常驻输入,每次调用都陪着你的问题一起发给模型——示例太多既加钱又可能稀释重点。2~3 个高质量示例性价比最高,别贪多。
3. 占位符命名即契约。{style}在模板里写一次、param("style", ...)在代码里填一次,两边靠字符串名字绑死。建议占位符名和业务参数名保持一致,加个常量类集中管理,别散落在 Controller 里手写。
七、面试回答模板
面试官:一次 ChatClient 请求,到底发给模型几段东西?
一句话:不是一整段字符串,是一条消息列表(Message List)。展开说:每条消息 = 角色(system/user/assistant)+ 内容;
system()是员工守则放在最前,user()是这一轮问题,call()把整本记录本发给模型。顺序就是记录本上的先后,system 必须在 user 前面。(指向本课第一节)
追问:提示词模板的变量怎么填?为什么我直接 user(“…{x}…”) 不替换?
一句话:填变量必须走 lambda 小括号模式
user(u -> u.text("...{x}...").param("x", v))。展开说:text()里写模板、param()按名字填坑;直接给字符串不走模板引擎,占位符原样发出去。param 的 key 和占位名逐字符一致,大小写不同就填不上。(指向本课第三节)
追问:few-shot 和 zero-shot 怎么选?
一句话:规则说得清用 zero-shot,风格/格式类说不清规则的任务用 few-shot 给 2~3 个示例。展开说:示例是模型的"模仿对象",必须和目标任务同分布;示例常驻输入会涨 token,2~3 个性价比最高。本课实测:给三个客服示例后,模型自己学会了用
I'd like to...开头。(指向本课第四节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| 占位名和 param 对不上 | 句子里残留{question}原样发给模型 | 两边名字逐字符一致,区分大小写 |
| 直接 user(字符串带占位符) | 变量不替换,坑留在句子里 | 走 lambda 小括号模式 text()+param() |
| few-shot 示例跑题 | 模型学歪成别的风格 | 示例必须和目标任务同分布 |
| 示例堆太多 | 输入 token 涨钱、重点被稀释 | 控制在 2~3 个高质量示例 |
| 中文 URL 参数乱码 | 浏览器/curl 直接带中文乱码 | [uri]::EscapeDataString()编码 |
| 端口占用 | Port 8094 already in use | Get-NetTCPConnection -LocalPort 8094查占用 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 2 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉Spring AI 实战精通营(10 课):https://gitee.com/j67mk2/spring-ai-journey
- 本文对应源码位置:
lesson-02/(内含ChatClientTemplateDemo双接口——参数化模板 + few-shot,配application.yml端口 8094)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
下一篇预告:《Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON》——本课翻出来的是一坨英文译文,人看得懂,但程序没法拿它算钱;下一课上主菜,
.entity(Order.class)一句话让模型直接吐一个 Java 对象,商品清单、数量、总价自动装进 Order。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:SpringAI、提示词工程、few-shot
摘要建议(≤256 字):第 1 课的提示词是写死在代码里的字符串,改个风格就得复制三遍。本文把它改造成带 {变量} 的参数化模板——text() 写模板、param() 按名填值,骨架复用;再塞 3 个输入输出示例玩 few-shot,实测模型自己学会了 I’d like to 开头。逐行拆解 lambda 小括号模式这个新手坑,附 3 道挑战题与面试回答模板,源码在 gitee lesson-02 可 clone 直接跑。