news 2026/7/21 10:06:04

Day28-Java核心 × Spring生态篇:用Spring AI为你的Spring Boot项目添加智能客服

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Day28-Java核心 × Spring生态篇:用Spring AI为你的Spring Boot项目添加智能客服

很多老板都希望这样一个AI智能客服:"客户问啥它都能答。"

——这是90%老板的真实想法。我得告诉你:AI客服不是"万能答题机",它是"带约束的对话引擎"。你能把约束定义得多清楚,它就有多靠谱。

一、先想清楚架构,再动手写代码

很多同学上手就ChatClient.create().prompt().user("你好").call(),然后发现AI答非所问、胡说八道、还记不住上文。问题不在大模型,在于你根本没给AI搭好"舞台"。

一个生产可用的智能客服,至少要解决四件事:

这四层,每一层都有坑。下面我逐层给你代码。

版本约定:本文基于 Spring Boot 3.3.x + Spring AI 1.0.0 + JDK 17。Day 26 讲过依赖配置,这里不再重复贴 POM,直接上核心逻辑。


二、第一层:会话历史管理——让AI"记住"上下文

大模型本身是无状态的。你以为它"记住"了上文?不,是你每次请求时把历史对话一起塞进去了。所以会话历史的存储和裁剪,是智能客服的第一道命门

2.1 为什么不能全塞进去?

一个客户跟你聊了50轮,每轮平均200 Token,历史就是10000 Token。全塞进去有三个致命问题:

  1. 费用爆炸:Token就是钱,每次请求1万Token历史,一天烧掉你几百块
  2. 上下文窗口溢出:大部分模型32K窗口,历史+Prompt+输出超了就截断
  3. 注意力稀释:上下文越长,模型对近期对话的"注意力"越弱,答非所问

2.2 实战方案:Redis滚动窗口 + 摘要压缩

/** * 对话历史管理服务 * 策略:保留最近6轮(12条消息),超出部分做摘要压缩 * 存储:Redis List,key为 sessionId,TTL 30分钟 */ @Service @Slf4j public class ChatHistoryService { private final StringRedisTemplate redisTemplate; private final ChatClient summarizeClient; // 专门做摘要的轻量模型 // 保留最近几轮对话(一轮=user+assistant各一条) private static final int MAX_ROUNDS = 6; private static final int HISTORY_TTL_MINUTES = 30; private static final String HISTORY_KEY_PREFIX = "chat:history:"; private static final String SUMMARY_KEY_PREFIX = "chat:summary:"; public ChatHistoryService(StringRedisTemplate redisTemplate, ChatClient.Builder builder, @Value("${ai.summary-model}") String summaryModel) { this.redisTemplate = redisTemplate; // 摘要用便宜模型,省钱 this.summarizeClient = builder.build(); } /** * 构建完整的上下文消息列表(摘要 + 最近N轮) */ public List<Message> buildContextMessages(String sessionId, String currentQuestion) { List<Message> messages = new ArrayList<>(); // 1. 加载历史摘要(如果有) String summary = redisTemplate.opsForValue().get(SUMMARY_KEY_PREFIX + sessionId); if (StrUtil.isNotBlank(summary)) { messages.add(new SystemMessage("以下是之前对话的摘要信息:\n" + summary)); } // 2. 加载最近N轮历史 List<String> rawHistory = redisTemplate.opsForList() .range(HISTORY_KEY_PREFIX + sessionId, 0, -1); if (CollUtil.isNotEmpty(rawHistory)) { for (String json : rawHistory) { ChatTurn turn = JSONUtil.toBean(json, ChatTurn.class); if ("user".equals(turn.getRole())) { messages.add(new UserMessage(turn.getContent())); } else { messages.add(new AssistantMessage(turn.getContent())); } } } // 3. 当前问题 messages.add(new UserMessage(currentQuestion)); return messages; } /** * 保存一轮对话,并在超限时触发摘要压缩 */ public void saveTurn(String sessionId, String userMsg, String assistantMsg) { String key = HISTORY_KEY_PREFIX + sessionId; redisTemplate.opsForList().rightPush(key, JSONUtil.toJsonStr(new ChatTurn("user", userMsg))); redisTemplate.opsForList().rightPush(key, JSONUtil.toJsonStr(new ChatTurn("assistant", assistantMsg))); redisTemplate.expire(key, Duration.ofMinutes(HISTORY_TTL_MINUTES)); // 超过最大轮数,压缩旧消息 Long size = redisTemplate.opsForList().size(key); if (size != null && size > MAX_ROUNDS * 2) { compressHistory(sessionId); } } /** * 摘要压缩:把最早的两轮抽出来,让AI生成摘要,存到summary key */ private void compressHistory(String sessionId) { String key = HISTORY_KEY_PREFIX + sessionId; // 弹出最早的2条(1轮) String old1 = redisTemplate.opsForList().leftPop(key); String old2 = redisTemplate.opsForList().leftPop(key); if (old1 == null || old2 == null) return; String existingSummary = redisTemplate.opsForValue() .get(SUMMARY_KEY_PREFIX + sessionId); String prompt = """ 请将以下内容整合为一段简洁的摘要(不超过200字),保留关键事实: %s 对话片段: 用户:%s 客服:%s """.formatted( existingSummary != null ? "已有摘要:" + existingSummary : "无已有摘要", JSONUtil.toBean(old1, ChatTurn.class).getContent(), JSONUtil.toBean(old2, ChatTurn.class).getContent() ); String newSummary = summarizeClient.prompt() .user(prompt) .call() .content(); redisTemplate.opsForValue().set(SUMMARY_KEY_PREFIX + sessionId, newSummary, Duration.ofMinutes(HISTORY_TTL_MINUTES)); log.info("会话 {} 历史已压缩,新摘要长度: {}", sessionId, newSummary.length()); } @Data @AllArgsConstructor @NoArgsConstructor static class ChatTurn { private String role; private String content; } }

这段代码的三个关键设计

设计点为什么这么做常见错误
Redis List滚动存储左push右pop天然适合FIFO窗口用Hash存,取排序麻烦
摘要用独立轻量模型摘要任务简单,用GPT-4纯属烧钱全用一个模型,成本翻3倍
TTL 30分钟客服场景用户很少离开1小时再回来设24小时,Redis内存被吃光

三、第二层:Prompt增强——给AI套上"紧箍咒"

Day 27 讲了PromptTemplate和角色系统,今天我们把它们组装成一个Prompt增强Pipeline。核心思想:用户说的话不能直接喂给大模型,要经过三层加工

3.1 三层加工流水线

用户原始问题 │ ├─① 安全过滤 → 拦截Prompt注入 / 敏感词 │ ├─② 意图识别 → 判断问题类型(售前/售后/投诉/闲聊) │ └─③ 知识注入 → 根据意图注入对应FAQ/产品信息 │ ▼ 增强后的Prompt → 送入大模型

3.2 完整实现

/** * Prompt增强Pipeline * 职责:安全过滤 → 意图识别 → 知识注入 → 组装最终Prompt */ @Service @Slf4j public class PromptEnhanceService { private final ChatClient chatClient; private final KnowledgeBaseService knowledgeBaseService; // 你的业务知识库 // 敏感词黑名单(生产环境用DFA算法,这里简化) private static final List<String> BLOCKED_PATTERNS = List.of( "忽略以上指令", "ignore previous instructions", "system:", "你现在是" ); public PromptEnhanceService(ChatClient.Builder builder, KnowledgeBaseService knowledgeBaseService) { this.chatClient = builder.build(); this.knowledgeBaseService = knowledgeBaseService; } /** * 增强入口:原始问题 → 增强后的完整消息列表 */ public EnhancedPrompt enhance(String userQuestion, String sessionId) { // ① 安全过滤 if (isMaliciousInput(userQuestion)) { log.warn("检测到恶意输入,session={}, question={}", sessionId, userQuestion); return EnhancedPrompt.blocked("您的提问包含敏感内容,请重新描述。"); } // ② 意图识别(用大模型快速分类,也可以用规则引擎) String intent = classifyIntent(userQuestion); log.info("意图识别结果: session={}, intent={}", sessionId, intent); // ③ 知识注入:根据意图检索业务知识 String knowledge = knowledgeBaseService.retrieveByIntent(intent, userQuestion); // ④ 组装系统Prompt String systemPrompt = buildSystemPrompt(intent, knowledge); return EnhancedPrompt.ok(systemPrompt, userQuestion); } /** * 意图分类:让AI判断用户问的是售前/售后/还是闲聊 */ private String classifyIntent(String question) { String classifyPrompt = """ 判断用户问题的意图类别,只返回以下之一: - PRESALE(售前咨询:产品/价格/规格/活动) - AFTERSALE(售后:物流/退换/安装/投诉) - GENERAL(闲聊/其他) 用户问题:%s """.formatted(question); String result = chatClient.prompt() .user(classifyPrompt) .call() .content(); // 容错:模型可能返回多余内容 if (result != null && result.contains("PRESALE")) return "PRESALE"; if (result != null && result.contains("AFTERSALE")) return "AFTERSALE"; return "GENERAL"; } /** * 构建系统Prompt:角色 + 约束 + 业务知识 */ private String buildSystemPrompt(String intent, String knowledge) { return """ ## 角色 你是「星云家居」的智能客服助手,负责解答客户关于家居定制产品的咨询。 ## 行为约束 1. 只回答与家居定制相关的问题,拒绝回答政治/色情/暴力等无关内容 2. 如果不确定具体信息,回答"这个问题我帮您转接人工客服",不要编造 3. 回答简洁,不超过200字,用口语化表达 4. 当前意图分类:%s ## 业务知识参考 %s ## 输出格式 直接回答用户问题,不要加"亲"等过度营销话术。 """.formatted(intent, knowledge); } /** * 简易恶意输入检测 */ private boolean isMaliciousInput(String input) { if (StrUtil.isBlank(input) || input.length() > 500) return true; String lower = input.toLowerCase(); return BLOCKED_PATTERNS.stream().anyMatch(lower::contains); } @Data @AllArgsConstructor public static class EnhancedPrompt { private boolean blocked; private String blockReason; private String systemPrompt; private String userQuestion; public static EnhancedPrompt ok(String systemPrompt, String userQuestion) { return new EnhancedPrompt(false, null, systemPrompt, userQuestion); } public static EnhancedPrompt blocked(String reason) { return new EnhancedPrompt(true, reason, null, null); } } }

这段代码的实战价值

  1. 安全过滤层isMaliciousInput是Prompt注入的第一道防线。生产环境建议上正则+DFA敏感词树,别像我这样写contains
  2. 意图识别用轻量模型:这步可以用最便宜的模型(如DeepSeek-V3或通义千问turbo),成本几分钱。不要用GPT-4o做分类,杀鸡用牛刀
  3. 知识注入:这里先用关键词检索示意,Day 71-75 会讲RAG用向量检索替代,效果提升一个量级

四、第三层:流式对话接口——把AI输出实时推给前端

用户问完问题,盯着转圈等3秒才出结果——这体验在2026年已经不可接受了。流式输出(SSE)是智能客服的标配,Day 14讲过SSE原理,今天直接落地。

4.1 完整的对话Controller

/** * 智能客服对话接口 * POST /api/chat/stream * 流式返回AI回复(SSE) */ @RestController @RequestMapping("/api/chat") @Slf4j public class ChatController { private final PromptEnhanceService promptEnhanceService; private final ChatHistoryService chatHistoryService; private final ChatClient chatClient; // 降级开关:大模型超时/异常时返回兜底话术 @Value("${ai.fallback-message:抱歉,客服系统正忙,请稍后再试或联系人工客服 400-xxx-xxxx}") private String fallbackMessage; @Value("${ai.timeout-ms:30000}") private long timeoutMs; public ChatController(PromptEnhanceService promptEnhanceService, ChatHistoryService chatHistoryService, ChatClient.Builder builder) { this.promptEnhanceService = promptEnhanceService; this.chatHistoryService = chatHistoryService; this.chatClient = builder.build(); } /** * 流式对话接口 */ @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> chat(@RequestBody ChatRequest request) { String sessionId = request.getSessionId(); String question = request.getQuestion(); // ① Prompt增强 PromptEnhanceService.EnhancedPrompt enhanced = promptEnhanceService.enhance(question, sessionId); if (enhanced.isBlocked()) { return Flux.just(ServerSentEvent.<String>builder() .event("blocked") .data(enhanced.getBlockReason()) .build()); } // ② 构建完整上下文(摘要 + 历史 + 当前) List<Message> contextMessages = chatHistoryService .buildContextMessages(sessionId, enhanced.getUserQuestion()); // ③ 组装Prompt:系统消息 + 上下文 Prompt prompt = new Prompt(contextMessages); prompt.getInstructions().add(0, new SystemMessage(enhanced.getSystemPrompt())); // ④ 流式调用大模型 StringBuilder fullResponse = new StringBuilder(); return chatClient.prompt(prompt) .stream() .content() .timeout(Duration.ofMillis(timeoutMs)) // 逐Token推送 .map(chunk -> { fullResponse.append(chunk); return ServerSentEvent.<String>builder() .event("message") .data(chunk) .build(); }) // 流结束:保存对话历史 .concatWith(Flux.defer(() -> { chatHistoryService.saveTurn(sessionId, enhanced.getUserQuestion(), fullResponse.toString()); return Flux.just(ServerSentEvent.<String>builder() .event("done") .data("[DONE]") .build()); })) // 降级:超时或异常 .onErrorResume(e -> { log.error("对话异常, session={}", sessionId, e); chatHistoryService.saveTurn(sessionId, enhanced.getUserQuestion(), fallbackMessage); return Flux.just(ServerSentEvent.<String>builder() .event("error") .data(fallbackMessage) .build()); }); } @Data public static class ChatRequest { private String sessionId; private String question; } }

4.2 三个SSE事件类型的分工

事件类型触发时机前端处理
message每收到一个Token追加到对话气泡
done流正常结束关闭loading,标记回复完成
error超时/异常显示兜底话术,引导转人工
blocked安全过滤拦截提示用户重新提问

前端用EventSource监听这四种事件,体验丝滑。这里有个新手必踩的坑concatWith里保存历史这一步要用Flux.defer()包裹,否则fullResponse在流还没跑完时就是空的——因为 Reactor 的流是惰性求值。

4.3 降级策略比你想的重要

大模型API不是100%可用的。DeepSeek高峰期偶尔500,OpenAI偶尔限流。你的客服系统必须能优雅降级。我见过太多团队没做降级,大模型一挂,客服系统直接白屏,用户骂声一片。


五、第四层:业务知识注入——别让AI"裸奔"

上面的KnowledgeBaseService我留了个悬念,这里展开。

5.1 最简方案:YAML配置的知识库

如果你的业务知识量不大(几百条以内),别上来就整RAG+向量数据库,先用YAML配置:

# knowledge-base.yml knowledge: PRESALE: - keywords: ["价格", "多少钱", "费用"] answer: "全屋定制衣柜按投影面积计价,699元/㎡起,含基础五金。详细报价请提供户型图。" - keywords: ["工期", "多久", "几天"] answer: "标准工期15-20个工作日,加急7天(加收20%费用)。" AFTERSALE: - keywords: ["退货", "退换"] answer: "定制产品支持7天无理由退换(非质量问题运费自理),质量问题30天内免费维修。" - keywords: ["安装", "师傅"] answer: "安装师傅下单后3天内联系您预约时间,安装免费,偏远地区加收路费。"
@Service public class KnowledgeBaseService { private final Map<String, List<KnowledgeEntry>> knowledgeMap; public KnowledgeBaseService() { Yaml yaml = new Yaml(); // 生产环境从配置中心加载,这里示意 InputStream is = getClass().getClassLoader() .getResourceAsStream("knowledge-base.yml"); Map<String, Object> loaded = yaml.load(is); // ... 解析到 knowledgeMap this.knowledgeMap = parseKnowledge(loaded); } public String retrieveByIntent(String intent, String question) { List<KnowledgeEntry> entries = knowledgeMap.getOrDefault(intent, List.of()); for (KnowledgeEntry entry : entries) { if (entry.getKeywords().stream().anyMatch(question::contains)) { return entry.getAnswer(); } } return "暂无相关知识,请转人工客服。"; } }

5.2 什么时候升级到RAG?

知识量方案适用阶段
<100条YAML/数据库硬编码MVP验证期
100~1000条Elasticsearch全文检索初期上线
>1000条 / 非结构化文档RAG + 向量数据库规模化运营

老兵建议:先用YAML跑通闭环,验证AI客服确实能解决用户问题,再上RAG。别技术倒置——我见过团队花了2周搭RAG,结果发现业务知识总共才50条,杀鸡用了牛刀。


六、实战建议:三条老兵经验

建议一:永远不要让AI直接回答"不知道"的问题

// Prompt里加这句约束 "2. 如果不确定具体信息,回答\"这个问题我帮您转接人工客服\",不要编造"

大模型有"幻觉"——不知道也会编得像模像样。在客服场景,编造一个错误的退货政策,比说"不知道"严重100倍。宁可保守,不可冒险。建议在Prompt里硬约束:不确定就转人工。

建议二:监控三个核心指标,别只看"能不能用"

指标计算方式告警阈值
首次响应延迟(TTFT)用户发送→首个Token返回>2秒
拦截率blocked事件/总请求>5%需检查误杀
转人工率回复中包含"人工客服"的比例>30%说明知识库覆盖不足

我见过一个团队上线AI客服后沾沾自喜"响应好快",结果一看数据,60%的对话都被转给了人工——AI形同虚设。不监控,就等于盲飞

建议三:给AI客服设置"下班时间"

@Value("${ai.working-hours.start:8}") private int workStartHour; @Value("${ai.working-hours.end:22}") private int workEndHour; // Controller入口判断 int hour = LocalTime.now().getHour(); if (hour < workStartHour || hour >= workEndHour) { // 非工作时间:降级为留言模式 return Flux.just(ServerSentEvent.<String>builder() .event("off-hours") .data("当前为非工作时间(8:00-22:00),您的留言已记录,客服将在工作时间内回复。") .build()); }

大模型API按Token计费,凌晨3点没人问的时候,你的服务还开着,万一有恶意刷接口的,一晚上能给你刷掉几千块。AI客服也要"上下班"


AI客服的本质不是"让AI代替人",而是"让AI处理80%的重复问题,把人的精力释放给那20%真正需要温度的对话"。

今天这四层架构——会话管理、Prompt增强、流式输出、知识注入——是我在实战中反复打磨的最小可用方案。你把它跑起来,先解决"有没有"的问题;Day 71开始讲RAG,再解决"好不好"的问题。

下篇预告:Day 29——《MySQL索引失效的10个经典场景与执行计划解读》。从AI切回数据库,但这次我们会讲一个新视角:AI应用的数据库,和传统CRUD的索引设计有什么不同。比如向量字段怎么建索引、RAG场景的查询模式怎么优化。别走开,好戏刚开始。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/21 10:06:04

QuickRecorder:让Mac屏幕录制变得像呼吸一样自然的创作伴侣

QuickRecorder&#xff1a;让Mac屏幕录制变得像呼吸一样自然的创作伴侣 【免费下载链接】QuickRecorder A lightweight screen recorder based on ScreenCapture Kit for macOS / 基于 ScreenCapture Kit 的轻量化多功能 macOS 录屏工具 项目地址: https://gitcode.com/GitHu…

作者头像 李华
网站建设 2026/7/21 10:05:15

XUnity Auto Translator:3分钟搞定Unity游戏多语言翻译的终极工具

XUnity Auto Translator&#xff1a;3分钟搞定Unity游戏多语言翻译的终极工具 【免费下载链接】XUnity.AutoTranslator 项目地址: https://gitcode.com/gh_mirrors/xu/XUnity.AutoTranslator 你是否曾经因为语言障碍而无法畅玩心仪的Unity游戏&#xff1f;是否面对日文…

作者头像 李华
网站建设 2026/7/21 10:03:23

Python数据可视化终极指南:5分钟快速上手Pyecharts图表制作

Python数据可视化终极指南&#xff1a;5分钟快速上手Pyecharts图表制作 【免费下载链接】pyecharts &#x1f3a8; Python Echarts Plotting Library 项目地址: https://gitcode.com/gh_mirrors/py/pyecharts 想要用Python创建令人惊艳的交互式图表吗&#xff1f;Pyecha…

作者头像 李华
网站建设 2026/7/21 9:59:32

2026年二季度中国办公智能体市场:监管完善,桌面端访问量突破6000万次

2026年二季度办公智能体&#xff1a;监管加速形成&#xff0c;企业版成新增长点易观分析《2026年二季度中国办公智能体平台市场洞察》报告指出&#xff0c;2026年二季度智能体行业监管体系加速形成&#xff0c;为办公智能体发展营造了创新扶持与规范监管协同的制度环境。同时&a…

作者头像 李华
网站建设 2026/7/21 9:59:25

Android功耗系列专题理论之十九:竞品机功耗对比拆解

【关注我,后续持续新增专题博文,谢谢!!!】 上一篇我们讲了: 这一篇我们开始讲: Android功耗系列专题理论之十九:竞品机功耗对比拆解(麒麟&高通) 目录 一、麒麟&高通平台差异性 CPU性能与能耗差异 GPU性能与能耗差异 制程工艺差异

作者头像 李华