CH 02提示词与对话
如果说第 1 章解决的是「能不能说」,这一章解决的是「说得准不准」。大模型应用的产出质量,70% 取决于喂进去的提示词质量。本章要掌握:用模板把提示词工程化、用消息角色把指令分层、让对话记住上文。
2.1消息角色分工:为什么不能把所有话塞进一句话
新手最常见的写法是把所有要求揉成一段话:「你是一个客服,现在用户问 X,请按 Y 格式回答」。能跑,但问题很多:规则和用户输入混在一起,用户一句「忽略上面的要求」就可能把规则冲掉(提示词注入);示例和问题混在一起,模型经常把示例当问题来回答。
正确做法是利用消息角色把不同性质的内容分层:
| 消息类型 | Java 类 | 放什么内容 | 典型使用 |
|---|---|---|---|
| 系统消息 最高优先 |
SystemMessage |
身份、能力边界、输出格式、禁止事项。不随对话轮次变化的稳定规则。 | 「你是银行合规助手,仅依据提供的资料回答」 |
| 用户消息 每轮变化 |
UserMessage |
本轮真正的输入:用户问题、待处理的文本、待分析的文档片段。 | 「帮我查一下订单 2024001 的状态」 |
| 助手消息 历史 |
AssistantMessage |
模型此前说过的话。用于构造多轮上下文,或作为少样本示例。 | 把「上一轮回答」放回去,模型才知道聊到哪了 |
| 工具响应 第 7 章 |
ToolResponseMessage |
工具/函数的执行结果,回填给模型做下一步推理。 | 查数据库返回的 JSON |
在 ChatClient 里怎么写
// 写法一:直接用 ChatClient 的角色方法(最常用)
String answer = chatClient.prompt()
.system("你是一名银行合规助手,只依据用户提供的资料回答;资料中没有的信息必须回答「资料中未提及」。")
.user("这笔转账需要几级审批?")
.call()
.content();
// 写法二:显式构造消息列表(需要精细控制时,例如手工拼多轮历史)
Prompt prompt = new Prompt(List.of(
new SystemMessage("你是银行合规助手……"),
new UserMessage("上一轮的问题"),
new AssistantMessage("上一轮的答案"),
new UserMessage("这一轮的问题")
));
ChatResponse response = chatModel.call(prompt);
2.2PromptTemplate:把提示词变成可维护的代码
硬编码提示词的问题:需求一变就要改 Java 代码、测试用例全失效、同一条规则在 5 个地方复制粘贴。解决办法是把提示词变成带占位符的模板。
// ① 定义模板:{占位符} 会被参数替换
String template = """
你是一名{role},请针对{audience}回答下面的问题。
回答要求:
1. 先用一句话给出结论;
2. 再分点说明理由,每点不超过 30 字;
3. 总长度不超过 {maxWords} 字。
问题:{question}
""";
// ② 创建模板对象(推荐 builder,功能更全)
PromptTemplate promptTemplate = PromptTemplate.builder()
.template(template)
.build();
// ③ 渲染:把参数填进去
Prompt prompt = promptTemplate.create(Map.of(
"role", "资深 Java 架构师",
"audience", "刚入职的应届生",
"maxWords", "200",
"question", "什么是依赖注入?"
));
// ④ 送进模型
String answer = chatModel.call(prompt).getResult().getOutput().getText();
用 ChatClient 的链式模板(更简洁,推荐)
// .user() / .system() 可以直接接收 lambda,在 lambda 里写模板和参数
String answer = chatClient.prompt()
.system(s -> s.text("你是{role},面向{audience}作答。")
.param("role", "资深客服")
.param("audience", "普通用户"))
.user(u -> u.text("请回答:{question}")
.param("question", userQuestion))
.call()
.content();
{} 作为定界符。如果提示词里本身包含花括号
(比如要求模型输出 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();
2.3把提示词外置成文件(工程化必备)
企业项目里提示词通常由产品/算法同学维护,不该埋在 Java 代码里。放进 src/main/resources/prompts/:
文件内容示例(system-customer-service.st):
你是"智汇"公司的智能客服,工号 AI-001。
## 你的职责
协助用户处理订单查询、退换货、发票开具三类问题。
## 硬性规则
1. 只回答上述三类问题,其他问题礼貌说明并回复"转人工"。
2. 涉及订单信息时必须先调用工具查询,禁止凭记忆编造订单号或金额。
3. 用户情绪激动时,第一句先共情,再处理问题。
4. 任何情况下不得承诺具体赔付金额。
## 输出风格
简洁、专业、有耐心。每条回复不超过 150 字。
在代码里加载它:
import org.springframework.core.io.ClassPathResource;
// 写法一:构建 ChatClient 时作为默认系统提示
ChatClient chatClient = builder
.defaultSystem(new ClassPathResource("prompts/system-customer-service.st"))
.build();
// 写法二:单次请求里用,且需要带占位符参数
String answer = chatClient.prompt()
.user(u -> u.text(new ClassPathResource("prompts/order-query.st"))
.param("orderNo", "2024001"))
.call()
.content();
prompts/ 目录纳入 Git,改名时带上版本号
(system-customer-service-v2.st),并在文件头部用注释记录「谁在什么时候改了什么、为什么改」。
第 10 章做评测时,你会需要把「提示词版本」和「效果指标」关联起来——那时你一定会庆幸当初做了这件事。
2.4少样本提示(Few-shot):用示例代替解释
有些格式要求,用文字描述十遍都不如给两个例子。少样本提示的本质是把示例放进消息历史,让模型模仿模式。
方式一:用 AssistantMessage 构造示例对(最标准)
List<Message> messages = new ArrayList<>();
// 系统提示:交代任务
messages.add(new SystemMessage("你是工单分类器。请把用户描述归类到:退款/物流/质量/其他,只输出类别名。"));
// 示例 1:用户说 → 助手答
messages.add(new UserMessage("我下单三天了还没发货"));
messages.add(new AssistantMessage("物流"));
// 示例 2
messages.add(new UserMessage("收到的东西外壳裂了"));
messages.add(new AssistantMessage("质量"));
// 真正要处理的问题
messages.add(new UserMessage("钱什么时候退给我"));
String category = chatModel.call(new Prompt(messages))
.getResult().getOutput().getText().trim();
// 期望:退款
方式二:写在模板里(更省 Token,适合简单任务)
String fewShotTemplate = """
把用户描述归类到 [退款/物流/质量/其他],只输出类别名。
示例:
输入:我下单三天了还没发货 输出:物流
输入:收到的东西外壳裂了 输出:质量
输入:怎么申请开发票 输出:其他
输入:{userInput}
输出:""";
思维链(CoT):让模型「先想后答」
// 反例:直接要答案,复杂推理容易错
"一个订单原价 899 元,打 8 折后又用了 50 元券,最终多少钱?"
// 正例:显式要求分步推理
"""
请按以下步骤回答,并保留每一步的计算过程:
步骤1:写出原价与折扣率;
步骤2:计算折后价;
步骤3:减去优惠券;
步骤4:用一行给出最终答案,格式为「最终答案:XXX 元」。
题目:一个订单原价 899 元,打 8 折后又用了 50 元券,最终多少钱?
"""
// 注意:如果最终结果要给程序解析,务必把"推理过程"和"结构化结果"分开,
// 否则模型输出的中间数字会污染你的 JSON 解析(第 3 章会给出标准解法)。
2.5多轮上下文与 ChatMemory 初探
大模型本身没有记忆。它每次调用都是「无状态」的——你以为它在跟你聊天,其实是你每一轮都把前面所有对话重新发了一遍。这一点必须刻在脑子里,否则你永远理解不了为什么「Token 越聊越多、越聊越慢、最后报上下文超限」。
Spring AI 的做法是用 ChatMemory 管理对话历史,再用一个 Advisor 自动把它注入到每次请求里:
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
// ① 创建记忆容器:只保留最近 20 条消息,超出的自动丢弃
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
// ② 包装成 Advisor,挂到 ChatClient 上
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
// ③ 调用时必须传 conversationId,用于区分"谁在跟谁说话"
String reply = chatClient.prompt()
.user("我刚才说的订单号是多少?")
.advisors(a -> a.param(
ChatMemory.CONVERSATION_ID, "user-1001-session-a")) // ★ 关键
.call()
.content();
conversationId。2.0 里这个错误会直接暴露出来。
1.x 时代,MessageChatMemoryAdvisor 在没有 conversationId 时会退化到一个默认值
(ChatMemory.DEFAULT_CONVERSATION_ID,值为 "default"),
结果是所有用户的对话混在一起——张三问的问题,李四看到了答案。2.0 做了一个正确的收紧:
ChatMemory.DEFAULT_CONVERSATION_ID 常量被删除,
.conversationId(String) 这个 builder 方法也被删除,
conversationId 变成每次调用必须提供的参数,缺失或传 null 会立刻抛 IllegalArgumentException。虽然它把「忘记传」从隐蔽的数据事故变成了显式的启动期错误,值得庆幸; 但请记住这条纪律:只要用了 ChatMemory,就必须传 conversationId。第 6 章会把会话隔离讲透。
PromptChatMemoryAdvisor。
它原来用于「把历史拼成一段文本塞进系统提示」以省 Token,现在官方建议统一用
MessageChatMemoryAdvisor。如果你需要控制 Token,正确做法是
限制窗口条数 + 做历史摘要压缩(第 6 章 6.3 节),
而不是换成一个信息有损的 Advisor。
| ChatMemory 实现 | 特点 | 适用 |
|---|---|---|
MessageWindowChatMemory | 内存中滑动窗口,只保留最近 N 条 | 单机 demo |
MessageChatMemoryAdvisor | 把历史作为多条消息塞进 Prompt(保真度高,Token 消耗大) | 推荐 |
| JDBC / Redis 持久化仓库 | 历史存数据库,支持重启不丢、分布式共享 | 生产环境(第 6 章) |
| JDBC / Redis 持久化仓库 | 历史存数据库,支持重启不丢、分布式共享 | 生产环境(第 6 章) |
2.6多模态消息:让模型「看图」
主流模型都支持图片输入。Spring AI 用统一的 Media 对象表达多模态内容:
import org.springframework.ai.content.Media;
import org.springframework.util.MimeTypeUtils;
import org.springframework.core.io.ClassPathResource;
// 方式一:本地 / classpath 图片
String desc = chatClient.prompt()
.user(u -> u.text("请识别这张图片里的所有文字,并整理成表格。")
.media(new Media(MimeTypeUtils.IMAGE_PNG,
new ClassPathResource("images/invoice.png"))))
.call()
.content();
// 方式二:远程 URL 图片
new Media(MimeTypeUtils.IMAGE_JPEG, URI.create("https://example.com/a.jpg"))
// 方式三:Base64 字符串(前端上传的场景)
new Media(MimeTypeUtils.IMAGE_PNG, new ByteArrayResource(bytes))
用 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 等。② 图片体积:单张图最好压缩到 1 MB 以内,且长边不超过 2000px,否则 Token 消耗和延迟都会飙升。
③ 成本:视觉模型的图片按 Token 计价,一张 1024×1024 的图约等于几百到上千 Token,批量处理前先算钱。
2.7完整示例:智能客服对话接口
把本章所有能力串起来:外置系统提示 + 模板化用户消息 + 多轮记忆 + 会话隔离。
① 客服专用的 ChatClient 配置
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();
}
}
② 业务服务:会话隔离 + 参数化提问
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();
}
}
③ HTTP 接口
package com.example.springai.ch02;
import org.springframework.web.bind.annotation.*;
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;
}
@PostMapping("/chat")
public ChatResponse chat(@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) {}
}
运行验证
# 第 1 轮:告诉客服一个订单号
curl -X POST http://localhost:8080/api/v1/customer-service/chat \
-H "Content-Type: application/json" \
-d '{"userId":"u1001","sessionId":"s1","message":"我的订单号是 2024001,帮我看看发货了吗"}'
# 第 2 轮:故意不说订单号,看它记不记得
curl -X POST http://localhost:8080/api/v1/customer-service/chat \
-H "Content-Type: application/json" \
-d '{"userId":"u1001","sessionId":"s1","message":"我刚才说的订单号是多少?"}'
# 期望:模型能答出 2024001 → 说明记忆生效
# 换一个 sessionId,验证会话隔离
curl -X POST http://localhost:8080/api/v1/customer-service/chat \
-H "Content-Type: application/json" \
-d '{"userId":"u1001","sessionId":"s2","message":"我刚才说的订单号是多少?"}'
# 期望:模型回答"你没有提供过订单号" → 说明隔离生效
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 消息;或换成能力更强的模型 |
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)。
- 能说明四种消息类型各自的用途,并手写出多轮对话的消息列表。
- 能把一段硬编码提示词改造成「外置文件 + 模板参数」的形式。
- 客服用例的三个 curl 全部符合预期(含会话隔离验证)。
- 能解释为什么模型没有记忆,以及 Token 增长的原因。