CH 02提示词与对话
如果说第 1 章解决的是「能不能说」,这一章解决的是「说得准不准」。大模型应用的产出质量,70% 取决于喂进去的提示词质量。本章要掌握:用模板把提示词工程化、用消息角色把指令分层、让对话记住上文。
教学设计说明(教师参考):版本基准 Spring AI 2.0.1 + Spring Boot 4.1.x + Java 17;密钥一律走 ${DEEPSEEK_API_KEY:} 环境变量占位符,绝不硬编码。本章 3 学时按「角色(2.1) → 模板(2.2/2.3) → 少样本(2.4) → 记忆(2.5) → 多模态(2.6) → 综合(2.7)」推进。易掉队点(补进本卡,逐条盯):
- ① IDEA 建文件路径:学生常把
prompts/system-customer-service.st建错位置(建到 java 包下或漏掉包名)。务必带他们走一遍「resources 上右键 → New → Package 输入 prompts → 再 New → File 输入 system-customer-service.st」。 - ② 「写完先编译」纪律:每写完一个
.st文件或改完一个类,先mvn -q compile确认资源被拷贝、类能编过,再启动应用。跳这步直接跑,报错信息会混着上下文,排查极慢。 - ③ 模板定界符冲突:
{}与 JSON 花括号冲突是全班最高频报错,讲完立刻让每人动手踩一次(2.2 节translate接口)。 - ④ conversationId 漏传导致串味:必须让学生亲眼看到第 2 轮答对、第 3 轮答错(隔离生效)才算过关(2.7 节三个 curl)。
- ⑤ 同名 bean 冲突:两个 ChatClient 共存时,构造器必须
@Qualifier点名,否则启动报NoSuchBeanDefinitionException/BeanDefinitionOverrideException(2.7 节)。 - ⑥ @Valid 不生效:只写
@NotBlank不写@Valid,或 pom 缺spring-boot-starter-validation依赖,校验都是摆设(2.7 节)。
检查方式:用 2.7 节三个 curl 作为课堂验收动作,逐个点人演示,重点确认「换 sessionId 后模型说没提供过订单号」。
2.1消息角色分工:为什么不能把所有话塞进一句话
新手最常见的写法是把所有要求揉成一段话:「你是一个客服,现在用户问 X,请按 Y 格式回答」。能跑,但问题很多:规则和用户输入混在一起,用户一句「忽略上面的要求」就可能把规则冲掉(提示词注入);示例和问题混在一起,模型经常把示例当问题来回答。
知识点 · 消息角色是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:消息(Message)不是一段纯文本,它带一个角色(role)标签,告诉模型「这段话属于对话里的哪一类」。
- 为什么需要:模型在内部对「系统指令 / 用户数据 / 历史回答」的权重处理是不同的。角色就是给模型的分层信号。
- 没有它会怎样:把三类内容塞进同一段文本,等于主动放弃了模型本身就支持的「分层权重」能力,还会让提示词注入变得极易得手、示例被当成问题。
- 类比:像一封正式邮件——「主题/收件人/正文」分栏,邮局和收件人都按栏位理解;若全挤进一段,谁也分不清哪句是要求、哪句是内容。
为什么角色分层是必要的?因为模型在内部对「系统指令 / 用户数据 / 历史回答」的权重处理是不同的。系统消息被当作最高优先级的背景设定,用户消息被当作本轮待处理的数据,助手消息被当作对话历史。把这三类内容塞进同一段文本,等于主动放弃了模型本身就支持的「分层权重」能力,还会让提示词注入变得极易得手。
正确做法是利用消息角色把不同性质的内容分层:
| 消息类型 | Java 类 | 放什么内容 | 典型使用 |
|---|---|---|---|
| 系统消息 最高优先 |
SystemMessage |
身份、能力边界、输出格式、禁止事项。不随对话轮次变化的稳定规则。 | 「你是银行合规助手,仅依据提供的资料回答」 |
| 用户消息 每轮变化 |
UserMessage |
本轮真正的输入:用户问题、待处理的文本、待分析的文档片段。 | 「帮我查一下订单 2024001 的状态」 |
| 助手消息 历史 |
AssistantMessage |
模型此前说过的话。用于构造多轮上下文,或作为少样本示例。 | 把「上一轮回答」放回去,模型才知道聊到哪了 |
| 工具响应 第 7 章 |
ToolResponseMessage |
工具/函数的执行结果,回填给模型做下一步推理。 | 查数据库返回的 JSON |
在 ChatClient 里怎么写
两种写法都常用:第一种用 ChatClient 的角色方法,最简洁;第二种手工构造消息列表,需要精细控制多轮历史时用(例如把数据库里读出的历史消息原样拼回去)。
| 写法一:角色方法(最常用) | 写法二:消息列表(精细控制) |
|---|---|
|
|
怎么选?.system()/.user() 适合「本轮就一句话」的简单场景;一旦涉及多轮历史、少样本示例、或要复用一段 List<Message>(比如从记忆里读出来),就直接用 new Prompt(List.of(...))。两者底层等价,只是前者帮你省了手动 new 消息对象。 |
|
开始动手前:3 步环境自检(跳过这步,后面 90% 的报错都查不出来)
本章所有示例都跑在配套的 springai-pro/lab 工程里。动手之前先花三分钟把下面三件事确认掉——任何一步没过都不要往下走。带着环境问题去调业务代码,报错信息会互相干扰,排查时间翻几倍。
-
确认 JDK 版本(Spring AI 2.0 的硬性底线是 Java 17):
java -version预期输出(主版本号 ≥ 17 即可,17 / 21 / 25 都行):
openjdk version "17.0.11" 2024-04-16 OpenJDK Runtime Environment (build 17.0.11+9) OpenJDK 64-Bit Server VM (build 17.0.11+9, mixed mode)不通过怎么办:显示
1.8.x或11.x→ 编译时必报invalid target release: 17,去装 JDK 17 或 21;装完还要在 IDEA 的 File → Project Structure → Project 里把 SDK 和 Language level 都切到 17。
提示'java' 不是内部或外部命令→ JDK 没装,或装了没进 PATH。 -
确认工程能编译(第一次执行会下载依赖,3~10 分钟属正常):
cd lab mvn -q compile预期输出:命令结束、没有
BUILD FAILURE,终端回到可输入状态。-q是安静模式,正常情况下几乎什么都不打印——「什么都没输出」就是好消息。不通过怎么办:两类最常见错误见本节末尾的排错卡(依赖下载失败 / 找不到符号)。
-
确认密钥与模型配置。打开
src/main/resources/application.yml,确认这两处:spring: ai: openai: api-key: ${DEEPSEEK_API_KEY:} # ← 由环境变量提供 base-url: https://api.deepseek.com # ← 换厂商改这一行 chat: options: model: deepseek-chat # ← 换厂商改这一行再确认环境变量真的存在。在你准备用来启动应用的终端里执行(只打印一行空白 = 变量没设上):
# Git Bash echo $DEEPSEEK_API_KEY # CMD echo %DEEPSEEK_API_KEY%预期输出:打印出你的密钥字符串(形如
sk-xxxxxxxx)。不通过怎么办:打印空白 → 环境变量没设。设完之后必须重开终端、并重启 IDEA——IDEA 是在启动那一刻继承环境变量的,不重启它读不到新值(这是「明明配了却还是 401」的头号原因)。
-d '{"k":"v"}')。这套写法只在 Git Bash / macOS / Linux 下成立。Windows 上另外两个终端必须改:
| 终端 | 写法 |
|---|---|
| Git Bash(推荐) | 照抄教程,一个字都不用改。 |
| CMD | CMD 不认单引号。JSON 外层改双引号,内部引号用 \" 转义:-d "{\"question\":\"你好\"}" |
| PowerShell | PowerShell 里 curl 是 Invoke-WebRequest 的别名,参数完全不兼容。必须写 curl.exe,并改用 CMD 那套双引号写法。 |
一句话:本章的命令一律在 Git Bash 里跑。如果遇到 curl: (6) Could not resolve host: Content-Type、-d 不是有效参数 这类报错,先怀疑终端选错了,而不是接口写错。
动手:搭好本章「实验室」骨架(建议自建)
本章 2.1~2.4 的技巧,配套 lab 没有内置控制器,需要你自己建一个「实验室」把他们串起来联调:一个 PromptSkillsService 放所有 demo 方法,一个 PromptController 暴露实验接口。下面先把角色相关的两个方法建好,后面每节往里追加方法即可。
-
在 IDEA 左侧 Project 视图展开
src/main/java/com/example/springai/ch02,在ch02包上右键 → New → Java Class。 -
在弹出的对话框 Name 处输入
PromptSkillsService(注意大小写,和包名同级),回车。把下面内容整段粘贴进去并保存(Ctrl+S):package com.example.springai.ch02; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Map; @Service public class PromptSkillsService { private final ChatClient chatClient; public PromptSkillsService(ChatClient chatClient) { this.chatClient = chatClient; } // 2.1 写法一:角色方法 public String roleDemo(String question) { return chatClient.prompt() .system("你是一名银行合规助手,只依据用户提供的资料回答;资料中没有的信息必须回答「资料中未提及」。") .user(question) .call() .content(); } // 2.1 写法二:消息列表(支持把历史原样拼回) public String messagesDemo(String question, List<Map<String, String>> history) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage("你是银行合规助手,只依据用户提供的资料回答。")); if (history != null) { for (Map<String, String> m : history) { if ("user".equals(m.get("role"))) { messages.add(new UserMessage(m.get("content"))); } else { messages.add(new AssistantMessage(m.get("content"))); } } } messages.add(new UserMessage(question)); return chatClient.prompt().messages(messages).call().content(); } } -
同样在
ch02包上右键 → New → Java Class,Name 输入PromptController,回车,粘贴下面内容并保存:package com.example.springai.ch02; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/v1/prompt") public class PromptController { private final PromptSkillsService service; public PromptController(PromptSkillsService service) { this.service = service; } public record RoleReq(String question) {} public record MessagesReq(String question, List<Map<String, String>> history) {} @PostMapping("/role") public String role(@RequestBody RoleReq r) { return service.roleDemo(r.question()); } @PostMapping("/messages") public String messages(@RequestBody MessagesReq r) { return service.messagesDemo(r.question(), r.history()); } } -
编译确认骨架能编过(不启动也能先卡掉语法/依赖错误):
mvn -q compile预期输出:命令结束、没有红色
BUILD FAILURE,终端回到可输入状态(-q只输出警告/错误,正常时几乎静默)。看到BUILD SUCCESS或干净退出即算通过。 -
启动应用(任一方式):
# 方式一:Maven 插件 mvn spring-boot:run # 方式二:IDEA 里直接运行 SpringaiLabApplication 的 main 方法看到日志里
Started SpringaiLabApplication即启动成功(端口 8080)。 -
验证「写法一」角色方法——发一条合规问题:
curl -X POST http://localhost:8080/api/v1/prompt/role \ -H "Content-Type: application/json" \ -d '{"question":"这笔转账需要几级审批?"}'接口返回的是一段纯文本(不是 JSON,因为 Controller 直接返回
String)。它每次措辞都不同,不要逐字比对,按下面的结构判据核对:【输出形态样例 · 措辞会变,结构必须一致】 关于这笔转账的审批级别,资料中未提及。 建议您联系柜台或拨打客服热线核实具体审批流程。逐条对照(三条全中才算通过):
- ① 语气是「银行合规助手」的口吻,不像随口聊天;
- ② 出现「资料中未提及」或同义表述 —— 这是你写在
.system()里的硬规则,它出现就证明系统消息生效了; - ③ 没有凭空编造审批级别(比如自己说「需要三级审批」)。
对不上怎么查:②没出现 → 系统消息没传到模型,或模型在自由发挥,把
.system()改成「即使不知道也必须原样回复『资料中未提及』」再试;③没通过 → 提示词约束强度不够,这正是 2.8 节要解决的问题。 -
验证「写法二」消息列表——带一条历史,确认历史被当成上下文:
curl -X POST http://localhost:8080/api/v1/prompt/messages \ -H "Content-Type: application/json" \ -d '{"question":"那物流问题呢?","history":[{"role":"user","content":"我下单三天了还没发货"},{"role":"assistant","content":"物流"}]}'这一轮要验证的核心是:历史消息有没有真的拼进 Prompt。模型看不到你的 Java 代码,只能从「回答像不像知道上文」来判断。
【输出形态样例 · 措辞会变,关键判据不变】 您是问物流方面的问题吗?关于您下单三天还未发货的情况,我可以帮您进一步查询物流状态。逐条对照(三条全中才算通过):
- ① 回答里出现「物流」二字 —— 它承接的是
history里那条assistant回复; - ② 没有失忆表现(不会反问「您想咨询什么问题」);
- ③ 对照实验(强烈建议做一次):把同一条命令里的
"history":[…]整段删掉、只留{"question":"那物流问题呢?"}再发一遍,观察回答明显变「懵」。两次结果的差异,就是你亲眼看到的「历史有没有拼进去」的证据——这比读十遍文档都管用。
对不上怎么查:如果它表现得像第一次对话,说明
history没被拼进去。查两点:①MessagesReq的字段名有没有拼错(必须是history);② 请求 JSON 里history是否真的传了 ——null时循环直接跳过、不报错,只会"失忆",这也是很隐蔽的坑,所以判据要盯回答内容而不是盯日志。 - ① 回答里出现「物流」二字 —— 它承接的是
BUILD FAILURE: cannot find symbol ChatClient:依赖没同步。确认 lab 的pom.xml里有spring-ai-starter-model-openai(或对应 starter),并在 IDEA 右侧 Maven 面板点刷新。重开终端后重试mvn -q compile。- 启动报
BeanDefinitionOverrideException/NoSuchBeanDefinitionException:说明容器里有多个ChatClient且注入点没点名。本章后面建第二个 ChatClient(客服专用)时,构造器必须@Qualifier("customerServiceChatClient")指明(见 2.7)。 404 Not Found:路径拼错。接口是/api/v1/prompt/role(注意/prompt不是/prompts),curl 的 URL 要完整带http://和端口。- curl 返回
400:JSON 里中文引号或单引号写成了中文全角符,或字段名拼错(必须是question/history)。从本教程直接复制命令最稳。
SystemMessage、数据进 UserMessage、历史进 AssistantMessage——分层不是形式主义,是给模型的分权信号。
2.2PromptTemplate:把提示词变成可维护的代码
硬编码提示词的问题:需求一变就要改 Java 代码、测试用例全失效、同一条规则在 5 个地方复制粘贴。解决办法是把提示词变成带占位符的模板。为什么要模板化?因为提示词本质上是一种「带参数的文本」,把它当字符串写死,就等于把「配置」混进了「代码」——后期根本没法让产品/算法同学维护,也没法做 A/B 测试。
知识点 · PromptTemplate 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:一个「带
{占位符}的模板文本 + 一组参数」的渲染器,调用时把参数填进占位符,产出最终要发给模型的字符串。 - 为什么需要:提示词里常变的部分(角色、受众、问题、长度限制)应该参数化,不变的部分(规则)保持稳定。
- 没有它会怎样:所有提示词散落在
String字面量里,改一处要翻代码、易漏改、无法复用。 - 类比:像邮件合并——同一封模板,只换「亲爱的 {姓名}」,批量生成不同信件。
四步写法(最基础、最透明)
在
PromptSkillsService中追加下面这个方法(粘贴到类的最后一个}之前):// 2.2 四步写法 public String templateDemo(String role, String audience, String maxWords, String question) { String template = """ 你是一名{role},请针对{audience}回答下面的问题。 回答要求: 1. 先用一句话给出结论; 2. 再分点说明理由,每点不超过 30 字; 3. 总长度不超过 {maxWords} 字。 问题:{question} """; org.springframework.ai.chat.prompt.PromptTemplate promptTemplate = org.springframework.ai.chat.prompt.PromptTemplate.builder() .template(template) .build(); org.springframework.ai.chat.prompt.Prompt prompt = promptTemplate.create(Map.of( "role", role, "audience", audience, "maxWords", maxWords, "question", question)); return chatClient.prompt().messages(prompt.getInstructions()).call().content(); }在
PromptController中追加路由与请求体(粘贴到类内、其他@PostMapping之后):public record TemplateReq(String role, String audience, String maxWords, String question) {} @PostMapping("/template") public String template(@RequestBody TemplateReq r) { return service.templateDemo(r.role(), r.audience(), r.maxWords(), r.question()); }编译并启动(
mvn -q compile通过后再mvn spring-boot:run),发请求:curl -X POST http://localhost:8080/api/v1/prompt/template \ -H "Content-Type: application/json" \ -d '{"role":"资深 Java 架构师","audience":"刚入职的应届生","maxWords":"200","question":"什么是依赖注入?"}'预期输出:一段「先结论、再分点、总长≤200 字」的回答。看到结构符合三条要求即算通过。
IllegalArgumentException: Not enough variable values:模板里有 {xxx} 但没传对应 .param("xxx", ...)。把模板里所有 {...} 列出来,逐个和 Map.of 的 key 核对,常见漏的是 maxWords(注意它是字符串,传 "200" 而非 200)。
用 ChatClient 的链式模板(更简洁,推荐)
上面的四步可以浓缩成 ChatClient 的一行链式调用:用 .user(u -> u.text(...).param(...)) 直接写模板和参数。可读性更好,也更符合第 1 章养成的 fluent 习惯。
在
PromptSkillsService追加:// 2.2 链式模板 public String fluentDemo(String role, String audience, String question) { return chatClient.prompt() .system(s -> s.text("你是{role},面向{audience}作答。") .param("role", role) .param("audience", audience)) .user(u -> u.text("请回答:{question}") .param("question", question)) .call() .content(); }在
PromptController追加:public record FluentReq(String role, String audience, String question) {} @PostMapping("/fluent") public String fluent(@RequestBody FluentReq r) { return service.fluentDemo(r.role(), r.audience(), r.question()); }验证:
curl -X POST http://localhost:8080/api/v1/prompt/fluent \ -H "Content-Type: application/json" \ -d '{"role":"资深客服","audience":"普通用户","question":"怎么申请退款?"}'接口返回纯文本,按下面四条核对:
【输出形态样例 · 措辞会变,特征不变】 您好,关于退款申请,您可以这样操作: 1. 进入「我的订单」,找到对应订单; 2. 点击「申请退款」,选择退款原因; 3. 提交后 1~3 个工作日原路退回。逐条对照:
- ① 是「客服对普通用户」的通俗口吻,没有堆技术术语(说明
{audience}生效); - ② 回答围绕「怎么申请退款」展开,没有跑题;
- ③ 回答里没有字面的
{role}/{audience}/{question}。
这节的意义:和上面「四步写法」比,输出质量应该差不多——差别只在代码更短。如果你发现两者结果明显不同,八成是参数名写错了(
.param("role", …)里的字符串必须和模板占位符同名)。- ① 是「客服对普通用户」的通俗口吻,没有堆技术术语(说明
知识点 · 模板定界符冲突(StTemplateRenderer)
- 是什么:模板引擎用一对「定界符」圈出占位符,默认是
{和}。 - 为什么需要换:当提示词本身含花括号(如要求模型输出 JSON
{"name":"..."}),引擎会把{name}当成占位符,渲染时抛异常或把 JSON 吃掉。 - 没有它会怎样:模型输出里 JSON 的花括号消失、或启动/调用即抛
IllegalArgumentException。 - 类比:像 Excel 公式里的
$与文本里的$撞车——换个不冲突的符号即可。
{} 作为定界符。如果提示词里本身包含花括号
(比如要求模型输出 JSON {"name":"..."}),渲染时会抛 IllegalArgumentException 或把 JSON 吃掉。
两种解法:① 转义:用单引号包裹,
'{'name': '张三'}'(模板引擎约定单引号内的部分不解析)。② 换定界符(推荐,更彻底):
import org.springframework.ai.template.st.StTemplateRenderer;
PromptTemplate t = PromptTemplate.builder()
.renderer(StTemplateRenderer.builder()
.startDelimiterToken('<') // 占位符改用 <xxx>
.endDelimiterToken('>')
.build())
.template("请把 <text> 翻译成 <lang>,输出格式保持 {\"k\":\"v\"} 不变")
.build();
在
PromptSkillsService追加(注意这里换成了< >定界符,模板里的{"k":"v"}因此被原样保留):// 2.2 换定界符(推荐解法) public String translateDemo(String text, String lang) { org.springframework.ai.chat.prompt.PromptTemplate t = org.springframework.ai.chat.prompt.PromptTemplate.builder() .renderer(org.springframework.ai.template.st.StTemplateRenderer.builder() .startDelimiterToken('<') .endDelimiterToken('>') .build()) .template("请把 <text> 翻译成 <lang>,输出格式保持 {\"k\":\"v\"} 不变") .build(); org.springframework.ai.chat.prompt.Prompt prompt = t.create(Map.of("text", text, "lang", lang)); return chatClient.prompt().messages(prompt.getInstructions()).call().content(); }在
PromptController追加:public record TranslateReq(String text, String lang) {} @PostMapping("/translate") public String translate(@RequestBody TranslateReq r) { return service.translateDemo(r.text(), r.lang()); }验证——
{"k":"v"}应原样出现在输出里,不被吃掉:curl -X POST http://localhost:8080/api/v1/prompt/translate \ -H "Content-Type: application/json" \ -d '{"text":"你好世界","lang":"English"}'预期输出:形如「Translate 你好世界 to English, output format keep {"k":"v"} unchanged」的翻译说明——关键是
{"k":"v"}仍在。看到花括号没丢即算通过。
IllegalArgumentException: Not enough variable values:模板里有{xxx}但没传对应参数。列全模板里所有{...}逐个核对。- 模型输出里 JSON 的花括号不见了:说明被模板引擎当占位符吃了。按上面「换定界符」改,或用单引号转义。
- 参数值是中文却显示成乱码:检查模板字符串和
.param的值是否都是 UTF-8(Java 源文件默认 UTF-8 即可,IDE 编码别设成 GBK)。 - 换定界符后还报错:确认
startDelimiterToken与endDelimiterToken配对(<配>),且模板里占位符写作<text>而非{text}。
< >——别让引擎把你的 JSON 当成了占位符。
2.3把提示词外置成文件(工程化必备)
企业项目里提示词通常由产品/算法同学维护,不该埋在 Java 代码里。放进 src/main/resources/prompts/,好处是:改提示词不用重新编译、可以做版本管理、非开发人员也能编辑。
知识点 · ClassPathResource 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:Spring 的一个资源抽象,能从 classpath(编译后就是
resources/目录)按相对路径加载文件。 - 为什么需要:代码里写
new ClassPathResource("prompts/system-customer-service.st"),Spring AI 会自动去resources下找,无需拼绝对路径。 - 没有它会怎样:若用
new File("prompts/...")相对路径,运行时的工作目录不一定是项目根,十有八九FileNotFoundException。 - 类比:像 classpath 是「班级名册」,
ClassPathResource是按名字点名取人,不用你亲自跑到对方座位。
动手:在 IDEA 里建提示词文件(最容易建错位置的一步)
-
在 IDEA 的 Project 视图里,找到
src/main/resources。在resources上右键 → New → Package,输入prompts,回车(注意是 Package,不是 Directory——Package 保证路径规范;这里 resources 下建 package 实际就是建目录prompts/)。 -
在刚建好的
prompts目录上右键 → New → File,输入完整文件名system-customer-service.st(含.st后缀,不能只写system-customer-service),回车。 -
把下面内容整段粘贴进该文件并保存(这就是与配套 lab 完全一致的客服系统提示):
# 你是"智汇"公司的智能客服,工号 AI-001。 # # 【提示词版本管理】(教程 2.3 的工程化建议) # - 本文件纳入 Git 管理,改名时带版本号(system-customer-service-v2.st) # - 每次修改在下面追加一行:日期 / 修改人 / 改了什么 / 为什么改 # - 第 10 章做评测时,需要把「提示词版本」和「效果指标」关联起来 # # 2026-09-28 首次创建,与教程 ch02 2.3 节内容一致 你是"智汇"公司的智能客服,工号 AI-001。 ## 你的职责 协助用户处理订单查询、退换货、发票开具三类问题。 ## 硬性规则 1. 只回答上述三类问题,其他问题礼貌说明并回复"转人工"。 2. 涉及订单信息时必须先调用工具查询,禁止凭记忆编造订单号或金额。 3. 用户情绪激动时,第一句先共情,再处理问题。 4. 任何情况下不得承诺具体赔付金额。 ## 输出风格 简洁、专业、有耐心。每条回复不超过 150 字。 -
先编译验证资源被拷贝(这一步很关键——写在 Java 里加载文件,若文件没进 classpath,运行时才爆,难查):
mvn -q compile然后用系统文件管理器(或 IDEA 的
target/classes/prompts/)确认出现了system-customer-service.st。看到该文件即算通过——说明资源已随编译拷贝进 classpath。 -
同样在
prompts目录上右键 → New → File,输入order-query.st,粘贴下面内容(2.3「写法二」练习用,含占位符):你是订单查询助手。请根据订单号 {orderNo} 查询并回答用户的问题。 如果订单号无法识别,请回复"请提供正确的订单号"。再次
mvn -q compile,确认target/classes/prompts/order-query.st也出现了。
.st 是 Spring AI 约定提示词模板文件的后缀(system template 的简写,也可用作普通模板)。
在代码里加载它——ClassPathResource 会自动去 resources 目录下找文件,写 "prompts/system-customer-service.st" 即可,不用写 src/main/resources 前缀:
在
PromptSkillsService追加(单引号转义'<' '/>'这里不需要,因为本模板没有花括号冲突):// 2.3 文件模板(写法二:单次请求带占位符) public String orderDemo(String orderNo) { return chatClient.prompt() .user(u -> u.text(new org.springframework.core.io.ClassPathResource("prompts/order-query.st")) .param("orderNo", orderNo)) .call() .content(); }在
PromptController追加:public record OrderReq(String orderNo) {} @PostMapping("/order") public String order(@RequestBody OrderReq r) { return service.orderDemo(r.orderNo()); }启动后验证:
curl -X POST http://localhost:8080/api/v1/prompt/order \ -H "Content-Type: application/json" \ -d '{"orderNo":"2024001"}'接口返回纯文本,按下面三条核对:
【输出形态样例 · 措辞会变,关键值不变】 订单号 2024001 的查询请求已收到。 (本示例未接入真实订单系统,如需真实查询请提供具体查询维度。)逐条对照:
- ① 输出里必须出现
2024001—— 证明.param("orderNo", …)把值填进了模板; - ② 出现的不是「请提供订单号」这类索要输入的话(那说明占位符没被替换);
- ③ 没有出现字面的
{orderNo}。
对不上怎么查:③出现字面
{orderNo}→ 说明加载到的模板没走渲染,或参数名拼错。回头确认两处:文件确实在src/main/resources/prompts/order-query.st;.param("orderNo", orderNo)的 key 与模板里{orderNo}完全一致。- ① 输出里必须出现
FileNotFoundError/ 提示词变空:文件建错位置(建到java包下,或漏了prompts这一层)。确认路径是src/main/resources/prompts/system-customer-service.st,且加载时写"prompts/system-customer-service.st"。- 改了
.st内容但模型没变:没重新编译/重启。外置文件的「不用重编译」是指不改 Java 代码;但你要让 Spring 重新加载资源,通常需重启应用(dev 模式下可配热加载)。本教程的「不用重编译」指「不必改 Java 类再编译」,并非「改了文件立即生效」。 target/classes/prompts/里没有.st:说明mvn -q compile没跑或 resources 被pom.xml的<resources>排除了。先跑编译、再看 target。
prompts/ 目录纳入 Git,改名时带上版本号
(system-customer-service-v2.st),并在文件头部用注释记录「谁在什么时候改了什么、为什么改」。
第 10 章做评测时,你会需要把「提示词版本」和「效果指标」关联起来——那时你一定会庆幸当初做了这件事。
resources/prompts/*.st,用 ClassPathResource 按相对路径加载——改话术不用动 Java 代码。
2.4少样本提示(Few-shot):用示例代替解释
有些格式要求,用文字描述十遍都不如给两个例子。少样本提示的本质是把示例放进消息历史,让模型模仿模式。为什么示例比规则管用?因为大模型本质是「下一个 token 预测器」,给它看一个「输入→输出」的成对样本,它就能在内隐空间里拟合出这种映射,这比用自然语言描述「请按此格式」稳定得多。
| 方式一:示例对(AssistantMessage 构造) | 方式二:写在模板里 |
|---|---|
| 把「用户说 → 助手答」交替放进消息列表。角色明确、最不易错,适合示例稍复杂。 | 示例和真实输入写在同一段文本里,更省 Token、写法快,但模型偶尔会把示例当问题。 |
| 怎么选?示例需要清晰角色分隔、或要做多轮少样本 → 方式一;示例极短、追求省 Token → 方式二(记得加一句「以下是示例,请勿回答示例」)。 | |
知识点 · Few-shot 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:在 Prompt 里塞几条「输入→输出」的范例,让模型照着学格式/风格。
- 为什么需要:很多格式(特定标签体系、特定口吻)用语言描述不清,给两个例子模型立刻领悟。
- 没有它会怎样:只能靠长段规则硬描述,模型容易跑偏、输出格式不稳定。
- 类比:像教小孩认字,与其讲「这是撇捺构成的字」,不如直接指「这是「人」,这是「大」」。
方式一:用 AssistantMessage 构造示例对(最标准)
在
PromptSkillsService追加:// 2.4 方式一:示例对 public String fewShotMessages(String question) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage("你是工单分类器。请把用户描述归类到:退款/物流/质量/其他,只输出类别名。")); messages.add(new UserMessage("我下单三天了还没发货")); messages.add(new AssistantMessage("物流")); messages.add(new UserMessage("收到的东西外壳裂了")); messages.add(new AssistantMessage("质量")); messages.add(new UserMessage(question)); return chatClient.prompt().messages(messages).call().content(); }在
PromptController追加:public record FewShotReq(String question) {} @PostMapping("/few-shot/messages") public String fewShotMessages(@RequestBody FewShotReq r) { return service.fewShotMessages(r.question()); }验证(期望返回
退款):curl -X POST http://localhost:8080/api/v1/few-shot/messages \ -H "Content-Type: application/json" \ -d '{"question":"钱什么时候退给我"}'这条命令可以精确断言:
【预期输出】 退款逐条对照:
- ① 输出就是「退款」两个字(允许首尾空格或一个句号,去掉后应完全相等);
- ② 不是一整句解释(出现「根据您的描述,这属于退款问题…」说明系统提示里「只输出类别名」没被遵守);
- ③ 不是
物流/质量/其他。
为什么这条能精确断言:因为分类结果是四选一的枚举值,不是自由文本。凡是「从封闭集合里选一个」的任务,预期结果都能这样写死 —— 第 3 章的结构化输出会把这种可断言性做到极致。
方式二:写在模板里(更省 Token,适合简单任务)
在
PromptSkillsService追加:// 2.4 方式二:模板里的示例 public String fewShotTemplate(String question) { String fewShotTemplate = """ 把用户描述归类到 [退款/物流/质量/其他],只输出类别名。 示例: 输入:我下单三天了还没发货 输出:物流 输入:收到的东西外壳裂了 输出:质量 输入:怎么申请开发票 输出:其他 输入:{userInput} 输出:"""; org.springframework.ai.chat.prompt.PromptTemplate pt = org.springframework.ai.chat.prompt.PromptTemplate.builder().template(fewShotTemplate).build(); org.springframework.ai.chat.prompt.Prompt prompt = pt.create(Map.of("userInput", question)); return chatClient.prompt().messages(prompt.getInstructions()).call().content(); }在
PromptController追加:@PostMapping("/few-shot/template") public String fewShotTemplate(@RequestBody FewShotReq r) { return service.fewShotTemplate(r.question()); }验证(期望返回
其他):curl -X POST http://localhost:8080/api/v1/few-shot/template \ -H "Content-Type: application/json" \ -d '{"question":"怎么申请开发票"}'预期输出:单独一个词
其他。看到分类正确即算通过。
| Few-shot(少样本) | CoT(思维链) | 微调(Fine-tuning) |
|---|---|---|
| 给几个「输入→输出」范例,让模型模仿格式/风格。 | 要求模型「先分步推理再给答案」,提升复杂推理准确率。 | 用成百上千条样本重训模型权重,固化专有任务。 |
| 零训练成本、即时改 | 零训练成本、消耗更多 Token | 训练成本高、上线慢、需 GPU |
| 解决「格式/风格」 | 解决「推理步骤」 | 解决「专有知识/极致成本」 |
思维链(CoT):让模型「先想后答」
知识点 · CoT 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:在提示里显式要求模型「先列出推理步骤,最后再给答案」。
- 为什么需要:复杂算术/逻辑题,直接要答案容易错;分步后每步可被校验、准确率显著上升。
- 没有它会怎样:模型直接吐最终数,中间一错全错,且你无法定位错在哪。
- 类比:像考试「写出过程才给分」,逼模型把脑内推导摊开,而不是蒙一个结果。
在
PromptSkillsService追加:// 2.4 思维链(CoT) public String cotDemo(String problem) { String t = """ 请按以下步骤回答,并保留每一步的计算过程: 步骤1:写出原价与折扣率; 步骤2:计算折后价; 步骤3:减去优惠券; 步骤4:用一行给出最终答案,格式为「最终答案:XXX 元」。 题目:%s """.formatted(problem); return chatClient.prompt().user(t).call().content(); }在
PromptController追加:public record CotReq(String problem) {} @PostMapping("/cot") public String cot(@RequestBody CotReq r) { return service.cotDemo(r.problem()); }验证:
curl -X POST http://localhost:8080/api/v1/cot \ -H "Content-Type: application/json" \ -d '{"problem":"一个订单原价 899 元,打 8 折后又用了 50 元券,最终多少钱?"}'这道题的数字可以手算验证,所以整条输出都能拿来对照:899 × 0.8 = 719.2,再减 50 = 669.2。
【输出形态样例 · 中间措辞会变,四个步骤与最终数字必须一致】 步骤1:原价 899 元,折扣率 8 折(即 0.8)。 步骤2:折后价 = 899 × 0.8 = 719.2 元。 步骤3:减去 50 元优惠券:719.2 − 50 = 669.2 元。 步骤4:最终答案:669.2 元逐条对照:
- ① 四个步骤都在(说明 CoT 的「分步」生效);
- ② 步骤2 的结果是 719.2;
- ③ 最后一行形如
最终答案:669.2 元,数字必须正好是 669.2; - ④ 最后一行只有答案,不掺其他话 —— 这是给第 3 章「把推理过程和结构化结果分开」做铺垫。
对不上怎么查:③算成别的数 → 模型算术出错,正是 CoT 要解决的问题,可把步骤写得更死(如「步骤3:用步骤2的结果减去 50」)再试;④最后一行掺了话 → 在提示词里再强调「最后一行只能有这一句」。
- 模型把示例当问题回答了:方式二没加「以下是示例,请勿回答示例」之类的分隔语。改用方式一(
AssistantMessage角色明确)最稳。 - CoT 输出里推理过程污染了你要解析的 JSON:务必把「推理过程」和「结构化结果」分开(例如要求最后一行才是 JSON),否则中间数字会破坏解析。第 3 章给标准解法。
- 分类接口返回了整句而不是单个类别:系统提示里写清「只输出类别名」,并把示例的输出也写成单个词。
2.5多轮上下文与 ChatMemory 初探
大模型本身没有记忆。它每次调用都是「无状态」的——你以为它在跟你聊天,其实是你每一轮都把前面所有对话重新发了一遍。这一点必须刻在脑子里,否则你永远理解不了为什么「Token 越聊越多、越聊越慢、最后报上下文超限」。
知识点 · ChatMemory 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:一个「按 conversationId 存消息历史」的容器,把每轮的问与答存起来,下一轮自动拼回 Prompt。
- 为什么需要:模型无状态,要「记住上文」只能每轮把历史重发;交给 ChatMemory 自动管理,业务代码只管说「挂上记忆」。
- 没有它会怎样:每轮都是全新对话,模型答不出「我刚才说的订单号」,多轮体验直接崩。
- 类比:像客服的「工单系统」——每次通话都先把历史工单调出来附在前面,坐席才知道聊到哪。
知识点 · MessageWindowChatMemory 是什么 → 为什么需要
- 是什么:内存版滑动窗口记忆,只保留最近 N 条消息,超出自动丢弃。
- 为什么需要:无限累积会让 Token 爆炸、变慢、超上下文。窗口限制是成本与效果的折中。
- 代价:应用重启即失忆(内存特性);超过窗口的旧消息被丢弃。
Spring AI 的做法:用 ChatMemory 管理对话历史,再用一个 Advisor 自动把它注入到每次请求里。这样业务代码里你只要说「把这段记忆挂上」,每轮请求就会自动带上历史。
知识点 · MessageChatMemoryAdvisor 是什么 → 为什么需要 → 没有它会怎样
- 是什么:把 ChatMemory 里的历史作为多条消息塞进 Prompt 的 Advisor(拦截器)。
- 为什么需要:业务代码零侵入——只要
.defaultAdvisors(...)挂上它,每轮自动注入历史。 - 没有它会怎样:你得手工每次 new Prompt 拼历史,代码又臭又长,还容易漏。
- 对照(已删除/废弃):
PromptChatMemoryAdvisor在 2.0 中已删除——它原用于「把历史拼成一段文本塞进系统提示」以省 Token,但信息有损;正解是MessageChatMemoryAdvisor。若你搜到旧教程提它,那是 1.x 的过时写法,本课程不存在这个类。
在
PromptSkillsService追加一个「带记忆」的演示(这里用内存窗口记忆,每次调用必须传conversationId):// 2.5 多轮记忆初探(内存窗口,仅演示) public String memoryDemo(String conversationId, String message) { org.springframework.ai.chat.memory.ChatMemory mem = org.springframework.ai.chat.memory.MessageWindowChatMemory.builder() .maxMessages(20) .build(); ChatClient memoryClient = chatClient.mutate() .defaultAdvisors(org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor .builder(mem).build()) .build(); return memoryClient.prompt() .user(message) .advisors(a -> a.param( org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); }在
PromptController追加:public record MemoryReq(String conversationId, String message) {} @PostMapping("/memory") public String memory(@RequestBody MemoryReq r) { return service.memoryDemo(r.conversationId(), r.message()); }编译确认能编过:
mvn -q compile无
BUILD FAILURE即算通过(完整「多轮+隔离」的验收放在 2.7 节三个 curl,那里才是正式验收动作)。
conversationId。2.0 里这个错误会直接暴露出来。
1.x 时代,MessageChatMemoryAdvisor 在没有 conversationId 时会退化到一个默认值
(ChatMemory.DEFAULT_CONVERSATION_ID,值为 "default"),
结果是所有用户的对话混在一起——张三问的问题,李四看到了答案。2.0 做了一个正确的收紧:
ChatMemory.DEFAULT_CONVERSATION_ID 常量被删除,
.conversationId(String) 这个 builder 方法也被删除,
conversationId 变成每次调用必须提供的参数,缺失或传 null 会立刻抛 IllegalArgumentException。排查步骤:
- 确认每次
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))都传了非 null、且唯一的 id; - 如果报
IllegalArgumentException,看堆栈是不是来自MessageChatMemoryAdvisor的 conversationId 校验——是的话就是漏传了; - 多个用户「串味」时,先在日志里打印每次实际使用的 conversationId,确认不同用户拿到的是不同值。
PromptChatMemoryAdvisor。
它原来用于「把历史拼成一段文本塞进系统提示」以省 Token,现在官方建议统一用
MessageChatMemoryAdvisor。如果你需要控制 Token,正确做法是
限制窗口条数 + 做历史摘要压缩(第 6 章 6.3 节),
而不是换成一个信息有损的 Advisor。
| 组件 | 作用 / 特点 | 适用 |
|---|---|---|
MessageWindowChatMemory | 内存中滑动窗口,只保留最近 N 条消息,超出自动丢弃;应用重启即失忆(内存版特性,排查「记忆失效」时先想到它) | 单机 demo |
MessageChatMemoryAdvisor | 把历史作为多条消息塞进 Prompt(保真度高,Token 消耗大)——上面代码用的就是它 | 推荐 |
| JDBC / Redis 持久化仓库 | 历史存数据库,支持重启不丢、分布式共享(第 6 章引入) | 生产环境 |
ChatMemory + MessageChatMemoryAdvisor 自动注入历史;每次调用必传 conversationId,否则要么报错、要么串味。
2.6多模态消息:让模型「看图」
主流模型都支持图片输入。Spring AI 用统一的 Media 对象表达多模态内容:一张图片就是一个 Media,和文本一起放进去,模型就能「边看边答」。
知识点 · Media 是什么 → 为什么需要 → 没有它会怎样 → 类比
- 是什么:一个把「二进制资源 + MIME 类型」打包的对象,代表一条多模态内容(图/音/视频)。
- 为什么需要:模型接收的不只是文本,图片要带上类型(PNG/JPEG)模型才知道怎么解码。
- 没有它会怎样:你没法把图片塞进 Prompt,多模态能力无从用起。
- 类比:像邮件里的「附件」——光发文件名不行,得连文件内容和「这是图片」的标注一起发。
在
PromptSkillsService追加(这里用远程 URL 图片,最稳,不依赖本地资源):// 2.6 多模态:远程 URL 图片 public String multimodalDemo(String imageUrl) { org.springframework.ai.content.Media media = new org.springframework.ai.content.Media( org.springframework.util.MimeTypeUtils.IMAGE_JPEG, new org.springframework.core.io.UrlResource(java.net.URI.create(imageUrl))); return chatClient.prompt() .user(u -> u.text("请识别这张图片里的所有文字,并整理成表格。") .media(media)) .call() .content(); }在
PromptController追加:public record MultimodalReq(String imageUrl) {} @PostMapping("/multimodal") public String multimodal(@RequestBody MultimodalReq r) { return service.multimodalDemo(r.imageUrl()); }编译并启动。注意:deepseek-chat 不支持图片,本接口需把
application.yml的base-url与model改成视觉模型(如qwen-vl-max/gpt-4o/glm-4v)后再测。mvn -q compile无
BUILD FAILURE即算通过(功能验证需要视觉模型,属扩展练习,不计入本章必做验收 —— 但如果你想真跑通,走下面两条路之一)。
- 换支持视觉的在线模型(最省事):把
application.yml的base-url与model换成视觉模型,例如智谱glm-4v(base-url: https://open.bigmodel.cn/api/paas/v4)、阿里通义qwen-vl-max。密钥换成对应厂商的。改完必须重启应用。然后把第 3 步 curl 里的imageUrl换成一张可公网访问的图片地址(本地file://路径模型读不到)。 - 用本机 Ollama 跑本地视觉模型(不花钱,但要显卡):
ollama pull llava之后,把base-url指向http://localhost:11434/v1、model写llava、api-key随便填一个非空字符串(Ollama 不校验)。
- 查该厂商文档的「模型能力」表,看有没有 vision / 多模态 标记;
- 发送图片后若返回「我无法查看图片」「I cannot see images」之类,就是模型不支持 —— 此时不是你的代码错了,换模型即可;
- 若返回
400 Bad Request且提示 content 格式错误,通常是模型支持视觉、但Media的 MIME 类型与实际图片不符(例如把 PNG 声明成了IMAGE_JPEG),改对类型再试。
用 message 对象手工构造(需要同时放文本和图片时)
UserMessage msg = UserMessage.builder()
.text("这张发票的金额和开票日期分别是多少?")
.media(new Media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("images/invoice.png")))
.build();
String answer = chatModel.call(new Prompt(List.of(msg)))
.getResult().getOutput().getText();
deepseek-chat 不支持图片,需要换 qwen-vl-max、gpt-4o、glm-4v 等(改 yml 的 base-url 和 model 即可)。② 图片体积:单张图最好压缩到 1 MB 以内,且长边不超过 2000px,否则 Token 消耗和延迟都会飙升。
③ 成本:视觉模型的图片按 Token 计价,一张 1024×1024 的图约等于几百到上千 Token,批量处理前先算钱。
- 换视觉模型后仍返回「我无法处理图片」:
application.yml里model没改成视觉模型,或base-url还是 DeepSeek 的。确认改对后重启应用。 UrlResource解析失败:图片 URL 不可公网访问(需带鉴权/内网)。改用本地ClassPathResource("images/invoice.png")把图放进resources/images/。- 图片太大导致超时/超 Token:按上面「② 图片体积」压缩后再试。
Media(图+类型)塞进 .user().media(...);但先确认模型「长眼睛」(支持视觉)。
2.7完整示例:智能客服对话接口
把本章所有能力串起来:外置系统提示 + 多轮记忆 + 会话隔离。下面三份文件与配套 lab 的 ch02 包逐字一致,是本站的「标准答案」。
PromptSkillsService,按节分段注释);有状态能力独立(记忆/会话相关的
Config 与 Service 单独放);完整业务按三件套(Config + Service + Controller)。
这样找代码、删代码、改代码都不会牵连其他节。
本章实验接口清单(联调时照抄进 Apifox)
| 接口 | 对应节 | 请求体 |
|---|---|---|
POST /api/v1/prompt/role | 2.1 角色方法 | {"question"} |
POST /api/v1/prompt/messages | 2.1 消息列表 | {"question","history":[{role,content}]} |
POST /api/v1/prompt/template | 2.2 四步写法 | {"role","audience","maxWords","question"} |
POST /api/v1/prompt/fluent | 2.2 链式模板 | {"role","audience","question"} |
POST /api/v1/prompt/translate | 2.2 换定界符 | {"text","lang"} |
POST /api/v1/prompt/order | 2.3 文件模板 | {"orderNo"} |
POST /api/v1/prompt/few-shot/messages | 2.4 示例对 | {"question"} → 返回类别名 |
POST /api/v1/prompt/few-shot/template | 2.4 模板示例 | {"question"} → 返回类别名 |
POST /api/v1/prompt/cot | 2.4 思维链 | {"problem"} |
POST /api/v1/customer-service/chat | 2.7 完整客服 | {"userId","sessionId","message"} |
前 9 个是 2.1~2.4 的练习接口(配套 lab 未内置,按上面「实验室」原则自建时照此命名即可);最后一个客服接口的代码在下文逐行给出。
动手:创建客服三件套(标准答案,逐字一致)
在 IDEA 的
ch02包上右键 → New → Java Class,Name 输入CustomerServiceConfig,回车,把下面内容整段粘贴并保存(不要改动任何一行):package com.example.springai.ch02; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.model.ChatModel; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; @Configuration public class CustomerServiceConfig { /** * 客服记忆容器:内存版滑动窗口,最多保留 20 条消息。 * 第 6 章会把它换成 JDBC/Redis 实现,接口不变,改这一处即可。 */ @Bean public ChatMemory customerChatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); } /** * 客服专用 ChatClient。 * 注意用 @Qualifier 或方法名区分,避免和全局那个 ChatClient 冲突—— * 所以这里把 bean 名字显式写成 customerServiceChatClient。 */ @Bean public ChatClient customerServiceChatClient(ChatModel chatModel, ChatMemory customerChatMemory) { return ChatClient.builder(chatModel) // 系统提示从文件加载:改提示词不用重新编译 .defaultSystem(new ClassPathResource("prompts/system-customer-service.st")) // 挂上记忆 Advisor .defaultAdvisors(MessageChatMemoryAdvisor.builder(customerChatMemory).build()) .build(); } }同样在
ch02包上右键 → New → Java Class,Name 输入CustomerServiceService,粘贴下面内容并保存:package com.example.springai.ch02; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; @Service public class CustomerServiceService { private final ChatClient chatClient; // 显式指定 bean 名,避免容器里有多个 ChatClient 时的歧义 public CustomerServiceService( @Qualifier("customerServiceChatClient") ChatClient chatClient) { this.chatClient = chatClient; } /** * 多轮对话。 * * @param userId 用户标识:决定"这是谁的记忆" * @param sessionId 会话标识:同一用户可能有多个并行会话(网页/App/小程序) * @param message 用户本轮输入 */ public String chat(String userId, String sessionId, String message) { // 会话 ID 的拼装规则要在全项目统一,第 6 章会抽成工具类 String conversationId = userId + ":" + sessionId; return chatClient.prompt() .user(u -> u.text("{message}") .param("message", message)) // ★ 必须传,否则所有用户共享同一段记忆(严重事故) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .content(); } }再建
CustomerServiceController,粘贴下面内容并保存:package com.example.springai.ch02; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @RestController @RequestMapping("/api/v1/customer-service") public class CustomerServiceController { private final CustomerServiceService service; public CustomerServiceController(CustomerServiceService service) { this.service = service; } /** * ★ 教程片段里只写了 @RequestBody,但那样 @NotBlank 是"摆设"—— * 必须再补一个 @Valid,Spring MVC 才会真正执行校验(字段为空白时返回 400)。 */ @PostMapping("/chat") public ChatResponse chat(@Valid @RequestBody ChatRequest req) { String reply = service.chat(req.userId(), req.sessionId(), req.message()); return new ChatResponse(req.sessionId(), reply); } /** * 请求体。 * userId 在真实项目里应从登录态(JWT/Session)取,不能由前端传—— * 前端传的话,任何人都能伪造别人的 userId 读取别人的对话历史。 * 这里为了教学简化,先放请求体里。 */ public record ChatRequest( @NotBlank String userId, @NotBlank String sessionId, @NotBlank String message) {} public record ChatResponse(String sessionId, String reply) {} }关键依赖检查(很多人漏这步,导致 @Valid 不生效):打开
pom.xml,确认有spring-boot-starter-validation依赖。没有就加上:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>只有存在这个 starter,
@Valid+@NotBlank才会真正执行校验;否则@NotBlank形同虚设,空白字段也能通过。编译确认三件套能编过(此时容器里有两个 ChatClient,靠
@Qualifier区分,不应报 bean 冲突):mvn -q compile无
BUILD FAILURE即算通过。启动应用(
mvn spring-boot:run或运行SpringaiLabApplication),日志出现Started SpringaiLabApplication即成功。
知识点 · @Qualifier 与 @Primary(两个 ChatClient 共存时怎么选)
| 场景 | @Qualifier(点名) | @Primary(默认) |
|---|---|---|
| 容器里有多个同类型 bean,要精确指定用哪一个 | 在注入点写 @Qualifier("customerServiceChatClient"),按 bean 名精确点名 |
在某个 bean 上标 @Primary,未指定时默认注入它 |
| 本章用法 | 客服 Service 构造器用 @Qualifier 点名要「客服专用」那个 |
若全局 ChatClient 标了 @Primary,裸注入 ChatClient 拿到的就是全局那个 |
| 踩坑 | 名字拼错 → NoSuchBeanDefinitionException |
多个都标 @Primary → NoUniqueBeanDefinitionException |
一句话:@Qualifier 是「指名道姓」,@Primary 是「没点名就默认选我」。本教程客服场景用 @Qualifier 点名,避免歧义。
知识点 · @Valid + @NotBlank(入参校验)
- 是什么:
jakarta.validation的注解——@NotBlank声明「字段不可为空白」,@Valid触发对请求体的校验。 - 为什么必须组合:只写
@NotBlank不写@Valid,Spring MVC 不会执行校验,注解是「摆设」;两者一起,字段为空白时返回400。 - 没有它会怎样:空白/缺字段的请求直接进业务逻辑,可能引发空指针或脏数据。
- 前提依赖:必须引入
spring-boot-starter-validation,否则校验根本不生效。
ChatClientConfig 和
CustomerServiceConfig 各定义过一次 customerServiceChatClient,
来回改动后一度变成「两处都没有」——启动直接报
NoSuchBeanDefinitionException;两处同时定义则报
BeanDefinitionOverrideException(Boot 默认禁止 bean 覆盖)。
纪律:每个 bean 只在一个配置类里定义——全局的归 ChatClientConfig,
客服专属的归 CustomerServiceConfig;注入时用
@Qualifier("customerServiceChatClient") 点名(按下类型注入拿到的永远是 @Primary 的全局那个)。
运行验证(本章正式验收动作)
-
第 1 轮:告诉客服一个订单号。
curl -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s1","message":"我的订单号是 2024001,帮我看看发货了吗"}'预期输出(这一轮不做严格断言,只要不是报错就行):
【输出形态样例 · 措辞会变】 您好,已为您查询到订单 2024001 的信息。请稍等,我正在核实发货状态…… (本示例未接入真实订单系统,如需真实物流信息请以官方渠道为准。)逐条对照:① 是客服口吻(称呼「您」、语气专业);② 没有报 401 / 500 这类错误;③ 回复里提到了订单号
2024001。
本轮的作用是「喂数据」——真正要看的是第 2 轮。 -
第 2 轮:故意不说订单号,看它记不记得。
curl -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s1","message":"我刚才说的订单号是多少?"}'预期输出:模型能答出
2024001→ 说明记忆生效、身份隔离正确。 -
第 3 轮:换一个 sessionId,验证会话隔离。
curl -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s2","message":"我刚才说的订单号是多少?"}'预期输出:模型回答「你没有提供过订单号」之类 → 说明隔离生效(
s2是另一段记忆)。 -
额外验证:空字段应被
@Valid拦下,返回 400。curl -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"","sessionId":"s1","message":""}'怎么看状态码:默认 curl 只打印响应体、不显示 400,很多同学因此误以为「返回了 200」。加上
-i才会打印响应头。建议改用下面这条命令:curl -i -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"","sessionId":"s1","message":""}'【预期输出 · 状态码是确定的】 HTTP/1.1 400 Bad Request Content-Type: application/json ... (响应体里通常带 MethodArgumentNotValidException 或具体字段名提示)逐条对照:① 第一行是
HTTP/1.1 400(不是 200);② 响应体提到了校验失败。
对不上怎么查:第一行是200 OK→ 说明@Valid漏了,或pom.xml缺spring-boot-starter-validation,见本节排错卡第 4 条。
- 第 2 轮答不出订单号(记忆没生效):检查
CustomerServiceService.chat里是否漏了.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))这一行;或两次请求的userId+sessionId不一致(注意是u1001:s1全程一致)。 - 第 3 轮也答出了订单号(隔离没生效):说明
conversationId两处用了同一个固定值,或没传(2.0 下不传会直接抛IllegalArgumentException,应用起不来)。确认用的是userId + ":" + sessionId拼装。 - 启动报 bean 冲突:见上面「同名 bean 只能存在一处」;确认客服 Service 构造器有
@Qualifier("customerServiceChatClient")。 - 空字段返回 200 而不是 400:漏了
@Valid,或pom.xml没加spring-boot-starter-validation。补上后再测。 - 报 404:路径是
/api/v1/customer-service/chat,确认customer-service是带连字符、chat小写。
一键冒烟测试:写一个脚本,把 12 个接口全跑一遍
手工敲 curl 容易漏、容易改错参数。把验证动作固化成脚本,是「能跑通」和「可重复验证」之间的分水岭——脚本可以每次改完代码就重跑一遍,不用回忆上次敲了什么。
-
在工程根目录(
lab/,与pom.xml同级)新建smoke-test.sh:IDEA 里右键工程根 → New → File,输入文件名smoke-test.sh。 -
粘贴下面内容并保存。脚本已经把「应用没启动」「密钥没配」这两种情况单独拦出来,免得你对着报错发呆:
#!/usr/bin/env bash # 第 2 章冒烟测试 · 把本章全部接口跑一遍 # 用法:在 Git Bash 里执行 bash smoke-test.sh BASE="http://localhost:8080" PASS=0; FAIL=0 # 统一请求函数:$1=名称 $2=路径 $3=JSON 请求体 req() { local name="$1" path="$2" body="$3" out=$(curl -s -X POST "$BASE$path" -H "Content-Type: application/json" -d "$body" 2>&1) if [ -z "$out" ]; then echo "[X] $name 无响应 —— 应用没启动,或端口不是 8080" FAIL=$((FAIL+1)); return fi case "$out" in *Unauthorized*|*401*) echo "[X] $name 401 —— API 密钥不对,或没配到环境变量" FAIL=$((FAIL+1)); return ;; esac echo "[OK] $name" echo " 返回:$(echo "$out" | head -c 120)" PASS=$((PASS+1)) } echo "===== 第 2 章冒烟测试开始 =====" req "2.1 角色方法" "/api/v1/prompt/role" '{"question":"这笔转账需要几级审批?"}' req "2.1 消息列表" "/api/v1/prompt/messages" '{"question":"那物流问题呢?","history":[{"role":"user","content":"我下单三天了还没发货"},{"role":"assistant","content":"物流"}]}' req "2.2 四步模板" "/api/v1/prompt/template" '{"role":"资深 Java 架构师","audience":"刚入职的应届生","maxWords":"200","question":"什么是依赖注入?"}' req "2.2 链式模板" "/api/v1/prompt/fluent" '{"role":"资深客服","audience":"普通用户","question":"怎么申请退款?"}' req "2.2 换定界符" "/api/v1/prompt/translate" '{"text":"你好世界","lang":"English"}' req "2.3 文件模板" "/api/v1/prompt/order" '{"orderNo":"2024001"}' req "2.4 示例对" "/api/v1/prompt/few-shot/messages" '{"question":"钱什么时候退给我"}' req "2.4 模板示例" "/api/v1/prompt/few-shot/template" '{"question":"怎么申请开发票"}' req "2.4 思维链" "/api/v1/prompt/cot" '{"problem":"一个订单原价 899 元,打 8 折后又用了 50 元券,最终多少钱?"}' echo "--- 多轮记忆与会话隔离(这三条要靠人工看内容)---" req "2.7 第1轮 喂订单号" "/api/v1/customer-service/chat" '{"userId":"u1001","sessionId":"s1","message":"我的订单号是 2024001,帮我看看发货了吗"}' req "2.7 第2轮 应记得" "/api/v1/customer-service/chat" '{"userId":"u1001","sessionId":"s1","message":"我刚才说的订单号是多少?"}' req "2.7 第3轮 应隔离" "/api/v1/customer-service/chat" '{"userId":"u1001","sessionId":"s2","message":"我刚才说的订单号是多少?"}' echo "" echo "===== 结果:成功 $PASS / 失败 $FAIL =====" echo "人工核对:第2轮返回应含 2024001;第3轮返回不应含 2024001。" -
运行它:
cd lab bash smoke-test.sh预期输出(12 个接口逐个打印,最后给统计):
===== 第 2 章冒烟测试开始 ===== [OK] 2.1 角色方法 返回:关于这笔转账的审批级别,资料中未提及。 [OK] 2.1 消息列表 返回:您是在问物流相关的问题吗…… (中间省略) ===== 结果:成功 12 / 失败 0 ===== 人工核对:第2轮返回应含 2024001;第3轮返回不应含 2024001。逐条对照:① 没有任何
[X]开头的行;② 末尾是「成功 12 / 失败 0」。出现[X]时,行尾的提示已经告诉你方向——「无响应」查应用是否启动,「401」查密钥。
bash: smoke-test.sh: No such file or directory:当前目录不对(要在lab/下),或文件名拼错(注意是连字符smoke-test)。- 12 条全是「无响应」:应用没起来。先
mvn spring-boot:run,看到Started SpringaiLabApplication再跑脚本。 - 全是 401:密钥问题,回本节开头的「3 步环境自检」第 3 条。
- 报
$'\r': command not found:脚本被存成了 CRLF 行尾。IDEA 右下角把行尾切成 LF 再保存。
要不要写自动化测试?——先分清「能测什么」
看到「测试」两个字,很多同学的第一反应是写 JUnit。但这里有一个反直觉的事实必须先讲清楚:调真实大模型的接口,不适合写单元测试。
| 要测的东西 | 能不能测 | 为什么 |
|---|---|---|
| 提示词模板渲染 | 能,而且应该测 | 纯函数:给定参数必然得到确定的字符串。零成本、毫秒级、可重复。 |
| conversationId 拼装规则 | 能 | 也是纯逻辑:"u1001" + ":" + "s1" 必然等于 "u1001:s1"。 |
| Controller 入参校验(400) | 能(用 MockMvc) | 不依赖模型,只测 Spring MVC 的校验链路。 |
| 「模型回答得好不好」 | 不能用单测 | ① 每次措辞都不同,无法断言相等;② 每次调用都花钱、耗时数秒;③ 网络抖动会让测试随机失败。这类要靠评测集(第 10 章)。 |
一句话原则:测「你写的代码」,不测「模型生成的文本」。下面这份测试全部满足「不联网、不花钱、秒级跑完」三个条件。
-
在
src/test/java/com/example/springai/ch02下新建PromptTemplateTest.java(目录不存在就一层层建;src/test/java在 IDEA 里通常显示为绿色)。 -
整段粘贴并保存。这里的三条断言都是确定性的字符串/逻辑判断,不涉及任何模型调用:
package com.example.springai.ch02; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.prompt.PromptTemplate; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 只测「确定性逻辑」:不调模型、不联网、不花钱。 * 运行:mvn test */ class PromptTemplateTest { /** 占位符能被正确替换 —— 提示词工程的地基 */ @Test void 占位符应被替换成实际参数() { PromptTemplate t = PromptTemplate.builder() .template("请把 {text} 翻译成 {lang}") .build(); String rendered = t.render(Map.of("text", "你好", "lang", "English")); assertEquals("请把 你好 翻译成 English", rendered); } /** JSON 花括号必须原样保留(对应 2.2 节「换定界符」那个坑) */ @Test void 换定界符后_json花括号应原样保留() { PromptTemplate t = PromptTemplate.builder() .renderer(org.springframework.ai.template.st.StTemplateRenderer.builder() .startDelimiterToken('<') .endDelimiterToken('>') .build()) .template("请把 <text> 翻译成 <lang>,格式保持 {\"k\":\"v\"} 不变") .build(); String rendered = t.render(Map.of("text", "你好", "lang", "English")); assertTrue(rendered.contains("{\"k\":\"v\"}"), "JSON 花括号被模板引擎吃掉了,实际输出:" + rendered); } /** conversationId 的拼装规则必须全项目统一(对应 2.7 节会话隔离) */ @Test void 会话ID应按用户与会话拼接() { String userId = "u1001"; String sessionId = "s1"; String conversationId = userId + ":" + sessionId; assertEquals("u1001:s1", conversationId); assertTrue(conversationId.contains(":"), "必须含分隔符,否则不同组合可能撞车"); } }两点说明:① 方法名用中文完全没问题,JUnit 5 支持,而且中文名在测试报告里更好读;② 上面代码里
<text>在网页上已被转义显示,从这个页面直接复制就是正确的尖括号,不需要手工改。若你的 IDE 提示
render方法不存在(不同小版本 API 可能有细微差异),等价写法是t.create(map).getInstructions().get(0).getText()—— 以 IDE 补全给出的方法为准。 -
运行测试:
cd lab mvn test预期输出(关键看
Tests run那几行和结尾的BUILD SUCCESS):[INFO] Running com.example.springai.ch02.PromptTemplateTest [INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.4 s [INFO] BUILD SUCCESS逐条对照:①
Tests run: 3(三条都跑到了);②Failures: 0, Errors: 0;③ 结尾BUILD SUCCESS。
Failures: 1,且提示「JSON 花括号被模板引擎吃掉了」:说明第二个测试的StTemplateRenderer没配对,或模板里占位符写成了{}。这个失败是好事——它就是 2.2 节那个坑的自动化版本,修复方式与 2.2 节完全一致。package org.junit.jupiter.api does not exist:pom.xml里缺spring-boot-starter-test。lab 的 pom 已内置(<scope>test</scope>),自建工程记得补。- 测试没被执行(
Tests run: 0):文件放错目录(必须在src/test/java/…下),或类/方法上漏了@Test。 - 想跳过测试直接打包:
mvn package -DskipTests(正式提交作业时不要这么做)。
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) 这一行,或者 conversationId 两处用了同一个固定值。
userId:sessionId 隔离。
2.8提示词工程实战模板(可直接套用)
一条高质量的系统提示词,通常包含下面 7 个部分。做第 11 章项目时,按这个骨架写就行:
| 模块 | 作用 | 示例句式 |
|---|---|---|
| 身份 | 让模型知道「我是谁」 | 你是智汇公司的资深客服专员。 |
| 任务 | 明确要做什么 | 你的任务是处理用户的订单查询与售后请求。 |
| 上下文 | 提供背景知识边界 | 你的知识范围仅限于下方「参考资料」中的内容。 |
| 规则 | 约束行为(最重要) | 1. 不得编造订单信息;2. 金额必须原样引用;3. 越界问题回复"转人工"。 |
| 示例 | 锚定输出风格 | 用户:怎么退货?→ 回答:您可以在"我的订单"中…… |
| 输出格式 | 让结果可被程序消费 | 必须输出 JSON,字段为 category / reason / confidence。 |
| 兜底 | 定义失败时怎么办 | 如果信息不足,输出 {"error":"INSUFFICIENT_INFO"},不要猜测。 |
2.9常见坑排错
| 现象 | 原因 | 解决 |
|---|---|---|
渲染模板时抛 IllegalArgumentException: Not enough variable values | 模板里有 {} 但没传对应参数 | 检查所有占位符是否都 .param() 了;JSON 里的花括号要转义或换定界符 |
| 模型把示例当问题回答了 | 示例和真实问题之间没有清晰分隔 | 示例用 AssistantMessage 构造,或在模板里加「以下是示例,请勿回答示例」 |
| 多轮对话串味,A 用户看到 B 用户的答案 | 没传 conversationId 或传的是固定值 | 按 userId:sessionId 生成唯一 conversationId |
| 越聊越慢、越聊越贵 | 历史消息无限累积,每次都全量重发 | 用 MessageWindowChatMemory 限条数;长对话再叠加摘要压缩(第 6 章) |
| 提示词里写了「不要输出 Markdown」,模型还是输出 | 负面指令效果弱 | 改成正向指令:「输出纯文本,用中文顿号分隔」,并给一个示例 |
| 中文标点在提示词模板里被转义 | 模板引擎对引号敏感 | 避免在 {} 附近使用单引号;必要时改用 <> 定界符 |
| SystemMessage 不生效(模型不听) | 部分小模型对 system 角色支持弱 | 把关键规则同时写进第一条 user 消息;或换成能力更强的模型 |
客服接口 400,userId/sessionId/message 全为 null | 请求体用了旧格式(只有 question 字段) | 2.7 的请求体必须三字段齐全:{"userId":"…","sessionId":"…","message":"…"} |
| 记忆「没储存」:第 2 轮就答不出第 1 轮的内容 | 两轮之间应用重启过(内存版记忆重启即失忆),或两轮 userId/sessionId 不一致 | 不重启连续发两轮;两轮严格用同一组 userId + sessionId |
| 空字段也返回 200(@NotBlank 没拦) | 漏了 @Valid,或 pom.xml 缺 spring-boot-starter-validation | 补 @Valid + 加 validation starter 依赖 |
| PowerShell 5.1 跑含中文的 .ps1 测试脚本报语法错误 | UTF-8 无 BOM 的脚本被按 GBK 解析 | 测试脚本写纯 ASCII(测试数据可先用英文);或给文件加 UTF-8 BOM |
启动报 NoSuchBeanDefinitionException 或 BeanDefinitionOverrideException | 同名 bean 两处定义 / 来回删改后一处都没了 | 见 2.7 节「同名 bean 只能存在一处」——每个 bean 只在一个配置类定义 |
2.10自测题
Q1. SystemMessage 和 UserMessage 应该怎么分工?把规则写在 user 里有什么风险?
SystemMessage 放稳定的规则、身份、格式约束;UserMessage 放每轮变化的输入数据。 规则写在 user 里的风险:① 容易被用户后续输入覆盖或冲淡(提示词注入); ② 每次都要重复拼接,浪费 Token;③ 提示词与业务数据混杂,无法复用与版本管理。
Q2. 提示词模板里包含 JSON 花括号时会发生什么?怎么解决?
模板引擎会把 {...} 当成占位符,渲染时抛异常或把 JSON 内容吃掉。
解决:① 用单引号包裹转义;② 通过 StTemplateRenderer 把定界符改成 < >。
Q3. 为什么「多轮对话」会让 Token 消耗快速增长?工程上怎么控制?
因为模型无状态,每轮都要把历史消息重新发送一遍,消耗量随轮次近平方级增长。 控制手段:① 滑动窗口(只保留最近 N 条);② 历史摘要压缩(把早期对话总结成一段);③ 把稳定知识放 RAG 按需检索,而不是常驻上下文。
Q4. 什么时候该用 Few-shot,什么时候直接写规则就够了?
当「格式/风格」难以用语言精确描述时用 Few-shot(例如特定的分类标签体系、特定的文书口吻); 当规则本身清晰可枚举时,直接写规则更省 Token 也更好维护。实践中常用「规则 + 1~2 个示例」的组合。
Q5.【动手题】给客服加一条能力:用户发送图片时,先识别图片中的文字再回答。要求不超过 20 行代码改动。
提示:给 ChatRequest 增加可选的 imageBase64 字段;
在 Service 里判断非空时,用 u.media(new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(bytes)));
并把模型换成支持视觉的(如 qwen-vl-max)。
Q6.【校验题】为什么只写 @NotBlank 不写 @Valid 时校验不生效?还缺什么依赖?
@NotBlank 只是「声明约束」,真正触发校验的是 @Valid(Spring MVC 在绑定时执行校验);两者必须组合。
此外还必须引入 spring-boot-starter-validation 依赖,否则校验器不在 classpath 上,@Valid 形同虚设,空白字段也能通过。
2.11实操作业与本章验收
课堂练习(课上完成,约 40 分钟):
- 把 2.7 节三份文件敲进
lab工程的ch02包(含prompts/system-customer-service.st),启动后用上面三个 curl 验证:第 2 轮答对订单号、第 3 轮答「没提供过」。把三段返回截图。 - 故意把
Service里的.advisors(...)那行注释掉再跑第 2、3 轮,观察「串味」现象,然后恢复——亲眼看到 bug 长什么样,比听十遍讲解都管用。 - 在
system-customer-service.st里把「每条回复不超过 150 字」改成「不超过 30 字」,重启验证输出变短,体会「外置提示词不用重编译」。 - 把 2.2 节的
translate接口跑一遍,确认{"k":"v"}原样保留,亲手踩一次「定界符冲突→换 < > 解决」。 - 把
{"userId":"","sessionId":"s1","message":""}发给客服接口,确认返回 400,验证@Valid+@NotBlank生效。
课后作业(提交到个人 Git 仓库,提交信息 ch02: 完成智能客服):
- 设计一个「工单分类器」:用 2.4 节的 Few-shot 方式,把用户描述归类到 退款/物流/质量/其他,写成一个可运行的 Service 方法并贴出测试返回。
- 写一份你自己的「系统提示词」模板(用 2.8 节的 7 模块骨架),场景自选(如「校园二手交易助手」),不少于 150 字,并说明每条规则防的是什么问题。
- 用 300 字回答:「conversationId 如果写成固定值 'default' 会有什么后果?为什么 2.0 把它从可选改成必填是更安全的设计?」
- 能说明四种消息类型(System/User/Assistant/ToolResponse)各自的用途,并手写出多轮对话的消息列表。
- 能把一段硬编码提示词改造成「外置文件 + 模板参数」的形式,并解释
{}与 JSON 花括号冲突的解法(换 < > 定界符 / 单引号转义)。 - 客服用例的三个 curl 全部符合预期(含会话隔离验证:换 sessionId 后模型说没提供过订单号),且空字段返回 400。
- 能解释为什么模型没有记忆,以及 Token 随轮次近平方级增长的原因与控制手段。
- 能说清
@Qualifier与@Primary的区别,以及@Valid+@NotBlank为何必须组合且依赖 validation starter。
2.12动手清单与自查表(照着核对)
本章零件最多、最容易"写了一半就乱"。下面这张清单把要建的文件、每步怎么验、卡住去哪查三件事固定下来。建议边写边勾。
① 你要建的文件(建一个,勾一个)
| 文件(相对工程根) | 对应小节 | 怎么确认建对了 |
|---|---|---|
src/main/java/com/example/springai/ch02/PromptSkillsService.java | 2.1 / 2.2 / 2.3 / 2.4 | 四个技巧各是一段带注释的方法,类无状态(不存成员变量) |
src/main/java/com/example/springai/ch02/PromptController.java | 2.1 ~ 2.4 | 每个方法一个 @PostMapping,路径都以 /api/v1/prompt/ 开头 |
src/main/java/com/example/springai/ch02/CustomerServiceConfig.java | 2.5 / 2.7 | 两个 bean 只在这里定义:customerChatMemory 与 customerServiceChatClient |
src/main/java/com/example/springai/ch02/CustomerServiceService.java | 2.7 | 会话 id 由 userId + ":" + sessionId 拼出,且每次调用都传 CONVERSATION_ID |
src/main/java/com/example/springai/ch02/CustomerServiceController.java | 2.7 | 参数上有 @Valid,请求体字段有 @NotBlank |
src/main/java/com/example/springai/ch02/MultimodalService.java | 2.6 | 代码能编译;DeepSeek 下调用会提示不支持视觉,属预期 |
src/main/resources/prompts/system-customer-service.st | 2.3 | 文件在 resources/prompts/ 下(不是在 java 目录里),后缀是 .st |
src/main/resources/prompts/order-query.st | 2.3 | 里面写了 {orderNo} 占位符,与 Java 里 .param("orderNo", ...) 名字一致 |
src/main/resources/images/invoice.png | 2.6 | 存在且小于 1MB;长边 ≤ 2000px |
customerServiceChatClient 只能有一个定义点(本章放在 CustomerServiceConfig)。在 ChatClientConfig 里再写一个同名 bean,启动会报 BeanDefinitionOverrideException;反过来两边都删掉,会报 NoSuchBeanDefinitionException。判断标准:全局默认走 ChatClientConfig(@Primary),客服专属走 CustomerServiceConfig,互不重复。
② 分步验收:从零到跑通(每步都有预期输出)
-
编译:
mvn -q compile。预期:BUILD SUCCESS。 -
启动,确认日志里出现
Started SpringaiLabApplication。 -
先验 2.1 的两种写法(基础中的基础):
curl -s -X POST http://localhost:8080/api/v1/prompt/role \ -H "Content-Type: application/json" \ -d '{"question":"一句话说明什么是提示词模板"}'预期:一段正常中文回答。
-
验 2.2 的模板渲染(重点看参数真的被填进去了):
curl -s -X POST http://localhost:8080/api/v1/prompt/template \ -H "Content-Type: application/json" \ -d '{"role":"银行合规助手","audience":"普通储户","maxWords":150,"question":"什么是存款保险"}'预期:回答以「银行合规助手」口吻、面向普通储户、长度大约 150 字以内。
看到什么算通过:三个占位符都生效。若回答口吻不对或没提"储户" → 参数名写错或方法只传了 question。
-
验 2.4 的少样本分类(本章唯一可精确断言的一处):
curl -s -X POST http://localhost:8080/api/v1/prompt/few-shot/messages \ -H "Content-Type: application/json" \ -d '{"question":"我要退款"}'预期:返回体里只有一个分类词,必须等于
退款(不是一句话、不是解释)。对不上怎么查:模型把示例当问题回答了 → 回去检查提示词里有没有写「以下是示例,请勿回答示例」。
-
验 2.7 的记忆与隔离(本章正式验收动作,三轮必须连做、中间不能重启应用):
# 第 1 轮:先告诉它订单号 curl -s -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s1","message":"我的订单号是 2024001,帮我看看"}' # 第 2 轮:同 userId + 同 sessionId(应记得订单号) curl -s -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s1","message":"刚才那个订单号是多少?"}' # 第 3 轮:换成另一个 sessionId(应"失忆") curl -s -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","sessionId":"s2","message":"刚才那个订单号是多少?"}'预期与判据:
- 第 1 轮:客服口吻回答,并围绕
2024001展开。 - 第 2 轮:必须答出
2024001——答不出说明记忆没生效(见下方"对不上怎么查")。 - 第 3 轮:回答「你没提供过订单号」之类——必须答不出,答得出说明会话没隔离。
对不上怎么查:① 三轮之间应用有没有重启过——内存版记忆重启即失忆;② 前两轮的
userId与sessionId是否严格一致(大小写、空格都算);③ 日志里CONVERSATION_ID的值是否每次都传了、且非 null(2.0 漏传会直接抛IllegalArgumentException)。 - 第 1 轮:客服口吻回答,并围绕
-
验参数校验(反例):故意漏掉一个字段:
curl -i -s -X POST http://localhost:8080/api/v1/customer-service/chat \ -H "Content-Type: application/json" \ -d '{"userId":"","sessionId":"s1","message":""}'预期:首行
HTTP/1.1 400。看到什么算通过:必须不是 200。拿到 200 怎么查:说明
@NotBlank是个"摆设"——检查参数上有没有同时写@Valid(只写@NotBlank不生效),以及 pom 里有没有spring-boot-starter-validation。 -
跑一次批量冒烟(把 12 个接口一次过一遍,省得一个个敲):
# 工程根目录下的冒烟脚本(2.7 节已给出内容) bash smoke-test.sh预期:逐条打印
HTTP 状态 + 是否通过,末尾汇总通过数。失败行的提示语会直接告诉你方向("无响应"=应用没起,"401"=密钥问题)。
③ 卡住了去哪查(症状 → 定位)
| 症状 | 先去哪一节 | 一句话提示 |
|---|---|---|
启动报 BeanDefinitionOverrideException / NoSuchBeanDefinitionException | 2.9 排错表 + 本节 pit | 同名 bean 只能定义一处 |
模板渲染抛 Not enough variable values | 2.2 节 | 模板里有占位符,但 .param() 没传对应值 |
| JSON 花括号被吃掉 / 模板报错 | 2.2 节占位符冲突 | 换定界符为 < >,或单引号转义 |
| 模型把示例当问题回答 | 2.4 节方式二 | 提示词里加「以下是示例,请勿回答示例」 |
| 客服接口 400,三个字段全 null | 2.9 排错表 | 请求体旧格式(只有 question),要 userId/sessionId/message 三字段齐 |
| 记忆"没储存",第 2 轮答不出 | 2.9 排错表 | 两轮之间重启过应用;或两轮 id 不一致 |
| 多轮对话串味 | 2.5 节 + 2.9 排错表 | conversationId 唯一性没保证 |
| 空字段也返回 200 | 2.9 排错表 | @Valid + @NotBlank 必须组合;且要有 validation starter |
退款;③ 能讲清「模型没有记忆,记忆是框架每轮把历史重新拼进请求」——这是第 6 章做持久化记忆的前提。