Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官
作者:鱼宵 | Spring AI 实战精通营 · 第 10 篇
走到这课,你手里已经攒了一堆零件:第 1 课的 ChatClient、第 4 课的 @Tool、第 5 课的流式、第 7 课的向量库、第 8 课的 RAG、第 9 课的 Advisor 编排。但零件散着不算本事,面试时人家问"你做的那个 AI 项目完整长啥样",你得能掏出一个能跑、能演示、能写进简历的工程。
这节课就是把上面所有零件攒成一个——企业智能客服。员工问它"年假怎么休、CRM 怎么收费"(RAG),问"订单 2001 什么状态、云文档还剩多少席位"(工具),它还能记住你上句话(记忆),回答一个字一个字蹦出来(流式),答不上来就老实说"请联系人工 400"(兜底)。代码全在仓库 lesson-10/,clone 下来浏览器一开就能聊。
一、核心原理:五个缺陷,五件装备
先把这门课的主线一句话记住:LLM 是个"强但有缺陷的实习生",每个缺陷配一件装备。
| LLM 的天生缺陷 | 配的装备 | 对应本课环节 |
|---|---|---|
| 不知道你们公司的事 | RAG 检索增强 | 先捞 FAQ 再答 |
| 查不到你数据库 | 工具调用 @Tool | 模型自己查订单/库存 |
| 转头就忘(无状态) | 多轮记忆 | 按 sessionId 带历史 |
| 要憋几秒才给整段 | 流式输出 SSE | 逐字推打字机 |
| 爱一本正经地编 | 兜底话术 | 答不上就转人工 |
整工程的分层一眼看清:浏览器聊天页 → 最薄的 Controller → 总装好的客服 ChatClient(Advisor 责任链)→ 工具和向量库两条支线。面试时能默画出这张图、说清"哪一环治哪个缺陷",这项目就算讲透了。
1. Advisor 责任链:快递分拣流水线
Advisor 就是夹在提问和模型之间的加工器,请求发出去前加工消息、回答回来后加工响应。本课两道顾问:
- MessageChatMemoryAdvisor:发请求前把 sessionId 的历史消息拼进来,收回答后再存回去。
- QuestionAnswerAdvisor:发请求前先检索向量库 top3 资料,拼进提示词。
类比快递分拣流水线:你的包裹(问题)进流水线,先过"合单机(记忆贴历史)",再过"贴广告机(RAG 贴资料)",终点才是快递车(模型)。你站在起点放包裹就行,中间全自动。
2. 为什么用本地 ONNX 嵌入?
DeepSeek 不提供 embedding 接口,而 RAG 向量化必须本地算(all-MiniLM-L6-v2,384 维)。便宜、离线、FAQ 内容不出本机——这是合规理由。
3. 工具描述写得全不全,直接决定命中率
@Tool 描述是写给模型看的说明书。模型看不到你的库存表,用户说"云文档企业版还有多少席位",工具要的是编码 DOC-ENT——这层"名字→编码"映射只能写进 description。本课实测踩过坑:描述写得太笼统,模型不知道该传啥编码,干脆不敢调、直接走兜底。补全目录后立刻调对。
二、动手:浏览器打开就能聊
环境:JDK 17 + Maven 3.9+,
DEEPSEEK_API_KEY。本课端口 8102。
第 1 步:检查环境。
java -version # True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY') # True
Get-NetTCPConnection -LocalPort 8102 -State Listen -ErrorAction SilentlyContinue # 无输出=空闲
第 2 步:编译 + 启动。(知识库是相对路径读的,必须在 lesson-10 目录下启动)
cd spring-ai-journey\lesson-10
$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
mvn spring-boot:run
首次启动要加载 ONNX 模型(约 30 秒~1 分钟),日志停在 tokenizers 初始化是正常的,不是卡死。
第 3 步:浏览器打开聊天页。
访问 http://localhost:8102/ ,输入框试三类问题:知识题 CRM怎么收费、工具题 订单2001什么状态、兜底题 帮我订张去北京的机票。
三、关键代码:总装一段,控制器两个接口
第一段:application.yml——注意那个排除配置。
server:
port: 8102 # 本课端口:lesson-10 = 8102
spring:
ai:
openai:
base-url: ${LLM_BASE_URL:https://api.deepseek.com}
api-key: ${DEEPSEEK_API_KEY}
chat:
options:
model: ${LLM_MODEL:deepseek-chat}
max-tokens: 400
temperature: 0.2 # 客服照本宣科,比第 8/9 课的 0.0 略活一点
# 本课特有坑:openai 和 transformers 双 starter 会各注册一个 EmbeddingModel Bean,
# 启动报 NoUniqueBeanDefinitionException。DeepSeek 本就不提供 embedding,直接排除它。
autoconfigure:
exclude:
- org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration
第二段:CustomerChatClientConfig.java——总装(本课最关键一段)。
package com.springai.lesson10.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 客服 ChatClient 总装:人设 + 记忆顾问 + RAG 顾问,一次配好处处复用。
*/
@Configuration
public class CustomerChatClientConfig {
// 系统提示词:人设 + 兜底规则,"治幻觉"第一道闸
private static final String SYSTEM_PROMPT = """
你是「星辰科技」的企业内部智能客服。回答必须遵守:
1. 制度、产品、售后类问题,优先根据公司资料回答,数字和时间必须与资料完全一致;
2. 涉及订单、库存,必须调用工具查真实数据,不要凭印象;
3. 如果资料和工具都答不上,禁止编造,统一回复:
"这个问题我暂时答不上来,请联系人工客服 400-800-8888(工作日 9:00-18:00)。"
4. 回答简洁,一般不超过 150 字。
""";
// 窗口记忆:只留最近 20 条(约 10 轮),更早的自动丢,省 token 钱
@Bean
public ChatMemory chatMemory() {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(20)
.build();
}
@Bean
public ChatClient customerChatClient(ChatClient.Builder builder,
ChatMemory chatMemory,
VectorStore vectorStore) {
// 顾问一:多轮记忆顾问(拼历史 / 存新问答)
MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();
// 顾问二:RAG 顾问,检索 top3、相似度门槛 0.5(低于它视为库里没这题,走兜底)
QuestionAnswerAdvisor ragAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(3).similarityThreshold(0.5).build())
.build();
return builder
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(memoryAdvisor, ragAdvisor) // 先恢复记忆,再检索资料
.build();
}
}
第三段:ChatController.java——一个阻塞接口,一个流式接口。
package com.springai.lesson10.web;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 两个接口:
* GET /chat?sessionId=s1&message=你好 阻塞:拿完整回答 + 引用
* GET /chat/stream?sessionId=s1&message=你好 SSE 流式:逐字推
*/
@RestController
public class ChatController {
private final ChatClient chatClient;
private final com.springai.lesson10.tool.CustomerTools customerTools;
private final VectorStore vectorStore;
public ChatController(ChatClient customerChatClient,
com.springai.lesson10.tool.CustomerTools customerTools,
VectorStore vectorStore) {
this.chatClient = customerChatClient;
this.customerTools = customerTools;
this.vectorStore = vectorStore;
}
@GetMapping("/chat")
public Map<String, Object> chat(@RequestParam String sessionId, @RequestParam String message) {
String answer = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId)) // 按会话隔离记忆
.tools(customerTools) // 工具交给模型,它自己决定调不调
.user(message)
.call().content();
// 引用依据:再检索一次 top3 返回前端,回答可溯源
var citations = vectorStore.similaritySearch(
SearchRequest.builder().query(message).topK(3).similarityThreshold(0.5).build())
.stream().map(doc -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("snippet", doc.getText());
item.put("distance", doc.getMetadata().get("distance")); // 相似度分
return item;
}).toList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("answer", answer);
result.put("citations", citations);
return result;
}
// produces=text/event-stream:Spring 自动把 Flux 每个元素包成 SSE 帧推给浏览器
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String sessionId, @RequestParam String message) {
return chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.tools(customerTools)
.user(message)
.stream().content(); // 直接返回 Flux<String>,打字机效果靠它
}
}
第四段:CustomerTools.java——两个工具,description 写全产品目录。
package com.springai.lesson10.tool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 客服工具集:模拟订单系统 + 库存系统(生产换真实 DB/RPC)。
*/
@Component
public class CustomerTools {
private static final Logger log = LoggerFactory.getLogger(CustomerTools.class);
private static final Map<String, String> ORDER_DB = Map.of(
"2001", "客户张三,订单已支付,星辰CRM专业版 5 账号,1194 元/月,收货地郑州"
// 2002/2003/2004 略,见仓库源码
);
private static final Map<String, String> STOCK_DB = Map.of(
"DOC-ENT", "星辰云文档企业版:定制席位余量仅剩 3,建议尽快确认需求"
// 其余编码见仓库源码
);
@Tool(description = "根据订单号查询订单详情(客户、状态、商品、金额、物流)。用户提到订单号、发货、物流时使用。")
public String queryOrder(@ToolParam(description = "订单号,例如 2001") String orderId) {
log.info("===== 工具被调用 → queryOrder(orderId={}) =====", orderId);
return ORDER_DB.getOrDefault(orderId, "未找到订单 " + orderId);
}
// 关键:产品编码目录写进 description——模型靠它把"云文档企业版"映射成 DOC-ENT
@Tool(description = "查询某产品库存余量。产品编码目录:CRM-PRO=星辰CRM专业版、CRM-BASIC=基础版、DOC-ENT=云文档企业版、CRM-TRIAL=试用账号。用户问某产品有没有货、剩多少席位时,按产品名选对应编码调用。")
public String queryStock(@ToolParam(description = "商品编码,例如 CRM-PRO / DOC-ENT") String productCode) {
log.info("===== 工具被调用 → queryStock(productCode={}) =====", productCode);
return STOCK_DB.getOrDefault(productCode, "未找到商品 " + productCode);
}
}
第五段:KnowledgeLoader.java——按文档结构切 12 条 FAQ。
package com.springai.lesson10.rag;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 启动建库:读 company-faq.md → 按 "### " 切成 12 条 → 本地嵌入 → 入向量库。
*/
@Component
public class KnowledgeLoader implements ApplicationRunner {
private final VectorStore vectorStore;
public KnowledgeLoader(VectorStore vectorStore) { this.vectorStore = vectorStore; }
@Override
public void run(ApplicationArguments args) throws Exception {
Path faqPath = Path.of("docs", "company-faq.md");
if (!Files.exists(faqPath)) {
throw new IllegalStateException("找不到知识库文件 " + faqPath.toAbsolutePath()
+ ",请确认在 lesson-10 目录下启动");
}
String markdown = Files.readString(faqPath, StandardCharsets.UTF_8); // ① 加载
// ② 按 "### " 切(前瞻表达式保留标题),制度类资料按结构切比按字数硬切更准
String[] parts = markdown.split("(?=### )");
List<Document> documents = new ArrayList<>();
for (String part : parts) {
if (!part.trim().startsWith("### ")) continue;
documents.add(new Document(part.trim(), Map.of("source", "星辰科技公司FAQ")));
}
vectorStore.add(documents); // ③ 嵌入入库(本地 ONNX)
}
}
第六段:聊天页 index.html 关键部分——EventSource 接 SSE。
<script>
// 每个标签页一个 sessionId:多轮记忆按它隔离
const sessionId = 'web-' + Math.random().toString(36).slice(2, 10);
function send() {
const text = document.getElementById('msg').value.trim();
// EventSource 只支持 GET,正好匹配 /chat/stream
const es = new EventSource('/chat/stream?sessionId=' + sessionId + '&message=' + encodeURIComponent(text));
es.onmessage = function (e) {
bubble.textContent += e.data; // 每来一小段拼上,打字机效果
};
es.onerror = function () { es.close(); }; // 流结束主动关,避免自动重连重复提问
}
</script>
整页是单 HTML(原生 JS,没框架),完整文件在 src/main/resources/static/index.html。
第七段:主类。
package com.springai.lesson10;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 启动后浏览器开 http://localhost:8102/ 即可聊天。
* 五大能力:RAG 带依据 + @Tool 查单查库存 + 多轮记忆 + SSE 流式 + 兜底话术。
*/
@SpringBootApplication
public class Lesson10Application {
public static void main(String[] args) {
SpringApplication.run(Lesson10Application.class, args);
}
}
四、实测输出:五项验收全过
以下是 2026-10-05 本机真实运行(DeepSeek,端口 8102)。启动日志:
Tomcat initialized with port 8102 (http)
========== [知识库] 加载完成:共入库 12 条 FAQ,已嵌入向量存入内存向量库 ==========
Tomcat started on port 8102 (http) with context path '/'
验收① 多轮记忆(sessionId=mem-1):先说"我叫王豪杰,喜欢 Java",连问三轮,模型全程记得。
R2 问:我刚才说我叫什么名字? 答:你刚才说你叫王豪杰,今天第一天入职星辰科技。
R3 问:我最喜欢的编程语言是什么? 答:你刚才说你最喜欢的编程语言是 Java。
验收② 知识题带依据:
问: 星辰CRM是怎么收费的?
答: 基础版 99 元/账号/月,专业版 199 元/账号/月,按年付享 8 折,新客户首月免费试用。
引用(top1, distance 0.206): ### 8. 星辰 CRM 怎么收费?……按年付享 8 折……
数字(99/199/8折)和引用片段一字不差,distance 0.206 说明检索很准。
验收③ 工具题查真实数据:
问: 订单2001什么状态? 答: 客户张三,状态「已支付」,星辰CRM专业版 5 账号,1194 元/月,收货地郑州。
问: 云文档企业版还有多少席位? 答: 可售席位余量 3 个,建议尽快确认。
(日志: ===== 工具被调用 → queryOrder(orderId=2001) =====
===== 工具被调用 → queryStock(productCode=DOC-ENT) =====)
张三/1194元/席位3个全来自内存 Map;模型还自己把"云文档企业版"映射成了 DOC-ENT——这就是 description 写全产品目录的功劳。
验收④ 未知问题走兜底:
问: 帮我订一张明天飞北京的机票。
答: 这个问题我暂时答不上来,请联系人工客服 400-800-8888(工作日 9:00-18:00)。
知识库没有、工具也管不着,它没硬编一个航班。
验收⑤ 流式逐字返回:
问: 年假能休几天?
SSE data 帧数: 48
前 8 个片段(按到达顺序): 入职 | 满 | | 1 | | 年 | 享 |
拼接后: 入职满 1 年享 5 天年假,每多 1 年增加 1 天,上限 15 天……
同一段回答拆成 48 个小片段陆续推过来——打字机效果,数字和 FAQ#2 一致。
五项连起来看:记得住、答得准、查得到、拒得了、吐得快。这就是能上线的样子。
五、挑战题:改个参数,看看会怎样
- ⭐ 加一个工具:仿照
CustomerTools加一个queryRefund(@Tool 查退款进度,内存 Map 放 2 条),重启后问"退款单 2003 到账了吗",看模型是不是自主调用。答案就在CustomerTools.java里照抄一个方法。 - ⭐⭐ 调相似度门槛:把
similarityThreshold(0.5)改成0.2和0.8,各问一道机票兜底题,观察兜底还触不触发——体会"检索阈值和兜底规则怎么配合"。 - ⭐⭐ 改窗口大小:把
maxMessages(20)改成2,连聊 5 轮后再问"我叫什么",看模型是不是"忘了"——直观感受窗口记忆的边界。
六、生产环境进阶:从 Demo 到上线的三步
1. 换掉两个内存件。 内存向量库换 Redis / PgVector,内存记忆换 JDBC 实现——都是"只换依赖和配置,业务代码不动"。
2. 加安全审计两道工序。 链上再加一道敏感词过滤 Advisor(SafeGuardAdvisor),再加日志审计——客服场景被用户诱导套内部数据是常见攻击面。
3. 补上工程化三件事。 可观测(Micrometer + Tracing)、RAG 效果评测、token 成本预算与缓存。这三件才是"Demo 到上线"的真正鸿沟,简历上能聊出来就是加分项。
七、面试回答模板
面试官:一个能上线的 RAG 应用,完整工程链路长什么样?
一句话:加载→切分→嵌入→入库(启动时一次做完),检索→增强生成(每次提问实时做)。展开:本课 KnowledgeLoader 启动时按
###切 12 条 FAQ、本地 ONNX 嵌入入内存库;提问时 QuestionAnswerAdvisor 自动检索 top3 拼进提示词。切分按文档结构,别一刀切字数。(指向本课 KnowledgeLoader / lesson-10)
追问:记忆、工具、流式、RAG 这五件事怎么在一个项目里同时装起来?
靠 Advisor 责任链:记忆顾问拼历史、RAG 顾问拼资料做成 defaultAdvisors,工具按轮
.tools()传入,流式用.stream().content()返回 Flux。业务 Controller 极薄,编排全在 Config。(指向本课 CustomerChatClientConfig + ChatController)
追问:Spring AI 的 Advisor 责任链,和 LangChain4j 的 AiServices 动态代理比,设计取舍在哪?
Spring AI 是 yml 自动装配 + Builder 挂 Advisor 列表,和 Spring 生态无缝、少写胶水;LangChain4j 是定义接口、运行时代理生成实现,更轻量、厂商覆盖广。团队是 Spring 栈选前者,要轻量多模型选后者。两者核心概念(记忆/RAG/工具/流式)同构,学透一套另一套一周上手。(指向本课第二节 + LangChain4j 课第 10 课)
追问:生产环境你用哪几招治大模型幻觉?
四道闸:SystemPrompt 写死边界 + RAG 只喂真资料 + 工具查真实数据 + 相似度阈值兜底话术。敢说不知道比乱答值钱,验收④就是现场。(指向本课第四节验收④)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| 双 starter 两个 EmbeddingModel | 启动报 NoUniqueBeanDefinitionException | yml exclude OpenAiEmbeddingAutoConfiguration |
| 用了不存在的 InMemoryChatMemory | 旧代码编译不过 | MessageWindowChatMemory + InMemoryChatMemoryRepository |
| 忘传 CONVERSATION_ID | 多用户记忆串台 | 请求里 .advisors(a->a.param(...)) |
| 相对路径读不到知识库 | 启动报找不到 company-faq.md | 必须在 lesson-10 目录下启动 |
| 工具 description 写太简 | 模型不敢调工具走兜底 | 产品名→编码映射写进 @Tool description |
| PS 抓 SSE 乱码 | 流解析失败 | 按 data: 行匹配,别按 event: 匹配 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 10 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…
- 本文对应源码位置:
lesson-10/(内含CustomerChatClientConfig总装五件套 +ChatController阻塞/流式双接口 +CustomerTools查单查库存 +KnowledgeLoader建库 +static/index.html聊天页)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 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 + 工具 + 记忆 + 流式 + 兜底,十课收官 |
十课走到这就毕业了。回头看,主线其实就一句话——从"一个对话"长成"一个完整的 AI 应用":第 1 课的 ChatClient 链式调用起步,到工具调用让模型查得到数据、流式让回答不再干等、向量检索和 RAG 让它照你公司的资料答、Advisor 编排把这些零件串成链,最后攒成这个能写进简历的企业客服。你手里这个工程,就是那个"基于大模型的企业智能客服"项目原型。
下一步建议:去 LangChain4j 课的第 10 课对照着看一遍——它做的是同一个客服业务,只是换成 AiServices 动态代理实现。两门课对着看,你就能在面试上讲清"两大 Java AI 框架的设计取舍",这是这套系列给你的最硬一张牌。
跑完有任何报错,把终端输出发评论区,一起排查。浏览器打开聊天页,你问它一道订机票的题,它真的老实转人工了吗?
标签建议:SpringAI、企业智能客服、RAG实战 摘要建议(≤256 字):Spring AI 十课收官项目,把 RAG、@Tool、多轮记忆、SSE 流式、兜底话术五件套攒成一个能跑的企业智能客服。浏览器打开就能聊:记得住你名字、查得到订单库存、答得准公司制度、逐字打字机输出、答不上转人工。逐行拆解总装配置与双接口,五项验收实测全过,附挑战题和面试模板,源码在 gitee lesson-10 可 clone 直接跑。