Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用

0 阅读12分钟

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') # 期望 True
Get-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 SUCCESS
mvn 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 = 8094
spring:
  ai:
    openai:
      base-url: ${LLM_BASE_URL:https://api.deepseek.com}   # 环境变量优先,默认 DeepSeek
      api-key: ${DEEPSEEK_API_KEY}                          # Key 只从环境变量读,文件里永远没有明文
      chat:
        options:
          model: ${LLM_MODEL:deepseek-chat}                 # 模型名,默认 deepseek-chat
          max-tokens: 400                                    # 翻译输出比一问一答略长,教学控成本
          temperature: 0.7                                   # 温度 0.7:翻译要稳但不死板

配置套路和第 1 课一模一样——这就是 yml 自动装配的好处,加新课不改配置习惯,就动了 port 和 max-tokens 两个数。

第二段:ChatClientTemplateDemo.java——本课主菜,完整可运行。

package com.springai.lesson02;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 第 2 课核心:翻译助手控制器。
 * 两个接口:
 *   /translate         —— 参数化模板:模板里写 {question}/{style} 占位,运行时 .param() 填值
 *   /translate-fewshot —— few-shot:system 里塞 3 个"中英对照"示例,让模型模仿风格
 */
@RestController
public class ChatClientTemplateDemo {

    /** ChatClient 实例:和第 1 课一样,Builder 由 starter 自动装配,零手写模型代码 */
    private final ChatClient chatClient;

    public ChatClientTemplateDemo(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    /**
     * 接口一:参数化模板翻译。
     * GET /translate?question=今天天气真不错&style=商务正式
     * 重点看 .user(u -> u.text("...{question}...").param(...)) 这段:
     *   text() 里放模板,占位符 {question}/{style} 不会原样发给模型,填完才发;
     *   param() 按名字填坑,名字必须和模板里的占位符逐字符一致。
     */
    @GetMapping("/translate")
    public String translate(@RequestParam("question") String question,
                            @RequestParam(value = "style", defaultValue = "日常口语") String style) {
        return chatClient.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")
    public String translateFewShot(@RequestParam("question") String question) {
        return chatClient.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——启动类,三行。

package com.springai.lesson02;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 第 2 课启动类。启动后访问:
 *   GET http://localhost:8094/translate?question=今天天气真不错&style=商务正式
 *   GET http://localhost:8094/translate-fewshot?question=我想买两件衬衫
 */
@SpringBootApplication
public class Lesson02Application {

    public static void main(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 和模板占位名对不上(大小写/拼写),两边逐字符核对即可。

五、挑战题:改参数,看看会怎样

  1. ⭐ 不传 style:浏览器里只开 http://localhost:8094/translate?question=你好,style 参数故意不给——看模型用了什么风格回答。答案就在源码 ChatClientTemplateDemo.java 的方法参数上,跑出来才知道默认值真的生效。
  2. ⭐⭐ 不用小括号模式:把 /translate 里的 .user(u -> u.text("...").param(...)) 改回 .user("把下面这句话翻译成英文,译文风格要【商务正式】:今天天气真不错")——再进一步,试试 .user("...{question}...") 带占位符但不加 param()。看 {question} 会不会被替换。这题的答案藏在踩坑节里,跑一遍你这辈子都忘不了。
  3. ⭐⭐ 示例换成法语:把 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 给 23 个示例。展开说:示例是模型的"模仿对象",必须和目标任务同分布;示例常驻输入会涨 token,23 个性价比最高。本课实测:给三个客服示例后,模型自己学会了用 I'd like to... 开头。(指向本课第四节)

八、总结表

坑现象解法
占位名和 param 对不上句子里残留 {question} 原样发给模型两边名字逐字符一致,区分大小写
直接 user(字符串带占位符)变量不替换,坑留在句子里走 lambda 小括号模式 text()+param()
few-shot 示例跑题模型学歪成别的风格示例必须和目标任务同分布
示例堆太多输入 token 涨钱、重点被稀释控制在 2~3 个高质量示例
中文 URL 参数乱码浏览器/curl 直接带中文乱码[uri]::EscapeDataString() 编码
端口占用Port 8094 already in useGet-NetTCPConnection -LocalPort 8094 查占用

九、关于这个系列

本文是「Java 后端实战精通营」系列第 2 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-02/(内含 ChatClientTemplateDemo 双接口——参数化模板 + few-shot,配 application.yml 端口 8094)

系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring 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 直接跑。