Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官

0 阅读15分钟

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 一致。

五项连起来看:记得住、答得准、查得到、拒得了、吐得快。这就是能上线的样子。

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

  1. ⭐ 加一个工具:仿照 CustomerTools 加一个 queryRefund(@Tool 查退款进度,内存 Map 放 2 条),重启后问"退款单 2003 到账了吗",看模型是不是自主调用。答案就在 CustomerTools.java 里照抄一个方法。
  2. ⭐⭐ 调相似度门槛:把 similarityThreshold(0.5) 改成 0.2 和 0.8,各问一道机票兜底题,观察兜底还触不触发——体会"检索阈值和兜底规则怎么配合"。
  3. ⭐⭐ 改窗口大小:把 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启动报 NoUniqueBeanDefinitionExceptionyml 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 聊天页)

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

篇主题
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 + 工具 + 记忆 + 流式 + 兜底,十课收官

十课走到这就毕业了。回头看,主线其实就一句话——从"一个对话"长成"一个完整的 AI 应用":第 1 课的 ChatClient 链式调用起步,到工具调用让模型查得到数据、流式让回答不再干等、向量检索和 RAG 让它照你公司的资料答、Advisor 编排把这些零件串成链,最后攒成这个能写进简历的企业客服。你手里这个工程,就是那个"基于大模型的企业智能客服"项目原型。

下一步建议:去 LangChain4j 课的第 10 课对照着看一遍——它做的是同一个客服业务,只是换成 AiServices 动态代理实现。两门课对着看,你就能在面试上讲清"两大 Java AI 框架的设计取舍",这是这套系列给你的最硬一张牌。

跑完有任何报错,把终端输出发评论区,一起排查。浏览器打开聊天页,你问它一道订机票的题,它真的老实转人工了吗?


标签建议:SpringAI、企业智能客服、RAG实战 摘要建议(≤256 字):Spring AI 十课收官项目,把 RAG、@Tool、多轮记忆、SSE 流式、兜底话术五件套攒成一个能跑的企业智能客服。浏览器打开就能聊:记得住你名字、查得到订单库存、答得准公司制度、逐字打字机输出、答不上转人工。逐行拆解总装配置与双接口,五项验收实测全过,附挑战题和面试模板,源码在 gitee lesson-10 可 clone 直接跑。