ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue前后端分离的AI客服:上下文感知、SSE流式与多级缓存实战

SpringBoot+Vue前后端分离的AI客服:上下文感知、SSE流式与多级缓存实战 简介这份压缩包是一套基于SpringBoot与Vue.js前后端分离架构的AI智能客服系统完整实现面向Java全栈开发者、人工智能应用开发人员以及需要快速搭建智能客服场景的技术人员。系统集成DeepSeek开源大模型API支持上下文感知对话与流式输出并采用多级缓存查询策略优先访问会话缓存再匹配数据库可有效降低数据库压力、提升响应速度。资源共63个文件压缩包大小23.76MB文件类型以43个Java源码为主覆盖后端业务逻辑另有SQL脚本用于数据库初始化properties配置、bat启动脚本、HTML页面、MD说明文档、mp4运行录屏及docx附赠文档等便于部署、运行与二次开发。目前已有124人学习下载。包内还包含压测报告、前端auth.html/chat.html页面、后端src模块、Ollama相关配置等配合说明文件与README可帮助读者理解系统架构、数据流向和接口设计快速跑通项目并在此基础上扩展功能。1. 为什么一个接了大模型API的客服系统最后卡在缓存和流式上照着网上的教程把聊天框和大模型接口连起来最快半天就能跑通但你会发现字是一个一个蹦出来的多轮问答却越来越容易“失忆”。基于 SpringBoot 与 Vue.js 前后端分离架构构建的 AI 智能客服系统再接入 DeepSeek 开源大模型 API才能真正把“对话”做成“服务”上下文感知对话保证多轮不串台流式输出让首字几百毫秒就出现在屏幕上多级缓存查询策略优先访问会话缓存再匹配才扛得住用户集中提问的场景。这套方案适合三类人拿它当毕设想讲清楚架构的在校生、想在业务里快速落地智能客服的开发者、以及想搞明白 SSE 流式和三级缓存到底怎么配合的运维兼研发。下面按我实际做过的路径从架构拆到代码最后给你一份能直接抄的排查清单。2. 前后端分离的边界与“上下文感知”到底在感知什么2.1 为什么必须前后端分离SpringBoot 只出 APIVue.js 只负责渲染智能客服系统的本质是一个“对话中台”。SpringBoot 这一侧要管的事非常多会话创建、用户鉴权、上下文组装、缓存读写、调用模型、流式转发。Vue.js 这一侧其实就两件事把用户输入发出去把模型返回的字符流渲染成文字。把这两件事放在同一个工程里不是不行但后来你会发现改一版前端界面要重新部署后端加一个模型供应商又要动前端打包非常难受。前后端分离最大的好处是开发期可以并行前端用 mock 数据先把对话界面画出来后端用 Postman 把流式接口调通两边最后用约定好的接口文档对接。还有一个容易被忽视的好处将来换模型或者给会话加路由后端不改接口前端不重新打包只改配置就行。我见过不少项目在分离和“伪分离”之间摇摆——所谓伪分离就是后端返回一个完整的 HTML 片段给前端渲染这样其实还是服务端渲染的思路。对话中台的接口规划一般就三张表接口方法作用关键参数/api/session/createPOST创建会话返回 sessionId无/api/chat/streamPOST流式对话入口content、sessionId/api/session/{id}/historyGET拉取历史消息回填聊天框无后端工程里我会单独拆出一个 CacheService原因是缓存逻辑涉及 Caffeine Redis 两级还要对数据库兜底如果全写在 ChatService 里业务代码会变得非常臃肿。目录结构大致是这样src/main/java/com/example/aisupport/ ├── config # DeepSeek 配置、Caffeine 配置 ├── controller # SessionController、ChatController ├── service # ChatService、CacheService ├── mapper # ConversationMapperMyBatis 或 JPA 仓库 └── dto # ChatRequest、ChatResponse、Message开发期联调不需要上 NginxVite 自带代理就能把请求转发到后端 8080这个配置几乎每个前后端分离项目都会用到// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }生产期有两种部署方式一种是 Vue 打包出的 dist 目录放进 SpringBoot 的 src/main/resources/static 里打成单包部署适合小团队另一种是前后端各自部署用网关转发。前者省事但每次前端发版要重新打后端包后者灵活但不建议在没容器编排的团队里用。我的习惯是先按前后端分离开发上线时根据团队运维能力选一种。2.2 上下文感知的核心会话 ID、消息数组与 token 预算上下文感知不是玄学本质是给模型发“最近发生了什么”。以 DeepSeek API 目前的接口格式为例它沿用 chat/completions 风格请求体里带一个 messages 数组模型根据数组里的历史消息继续往下写。所以“上下文感知对话”实现起来就是三件事。第一给每个用户一个会话 ID。后端在 /api/session/create 里返回一个 UUID前端存到 localStorage之后每次请求放在请求头里。会话 ID 是上下文感知的地基没有它多轮对话就是无源之水。第二把历史消息按 user、assistant 交替的顺序组装成一个 messages 数组就像这样{ model: deepseek-chat, messages: [ {role: system, content: 你是客服助手回答要简洁专业不要编造订单信息}, {role: user, content: 我想申请开发票}, {role: assistant, content: 请提供您的订单编号}, {role: user, content: 订单号是 20250415-001} ] }这里的参数有个细节system 消息是用来固定人设的别让它出现在多轮对话的裁剪范围之外否则模型会慢慢“忘掉”自己是个客服。第三控制 token 预算。上下文越长花费越高响应越慢这是多轮对话最大的隐性成本。常见做法是保留最近 N 轮超出就把更早的消息丢掉我一般先用 10 轮做窗口测试时再根据业务调整。为什么选 SSE 而不是 WebSocket客服问答是典型的一问一答数据流向是从模型单向推到前端SSE 足够且实现简单得多。WebSocket 适合双向频繁通信的场景比如在线协作文档用在客服上属于杀鸡用牛刀还多一套连接管理的复杂度。3. DeepSeek API 如何调用从鉴权配置到流式输出的完整链路3.1 最小可调通的配置yml、配置类与一次普通调用先把配置放在一个独立的前缀下方便后面扩展多家模型供应商。注意 api-key 别写死在配置文件里用环境变量注入这是第三方 API 使用里最基本的纪律否则代码一旦上传到仓库key 就裸奔了deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com model: deepseek-chat temperature: 0.3 max-tokens: 2048 timeout: 60s每个参数都有讲究。temperature 在客服场景建议 0.30.5别用默认的 1.0客服回答跑题比写文案跑题更致命。max-tokens 是成本控制的直接手段限制单次回复长度也和后面的上下文裁剪配合。timeout 必须覆盖流式响应的总时长设太短会在长回答中途报超时设太长又会在模型异常时拖着连接不释放。对应一个配置类用 SpringBoot 的 ConfigurationProperties 自动绑定后面在服务里直接注入就行Bean ConfigurationProperties(prefix deepseek) public DeepSeekProperties deepSeekProperties() { return new DeepSeekProperties(); }先把非流式调用跑通再改流式这是最快路径。用 Java 11 自带的 HttpClient 就够了不引入额外重量级依赖public String chatOnce(ListMessage messages) { // 组装请求体model 和 messages 是必填temperature 和 max_tokens 按配置填 String body buildBody(messages, false); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(baseUrl /chat/completions)) .timeout(Duration.ofSeconds(60)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .POST(BodyPublishers.ofString(body)) .build(); HttpResponseString response httpClient.send(request, BodyHandlers.ofString()); // 响应里取 choices[0].message.content就是模型回复的完整文本 return parseContent(response.body()); }Authorization: Bearer是这套接口统一要求的鉴权方式key 从配置类里读不要每次硬编码。非流式接口返回的是完整文本前端必须先等全部内容生成才能显示体验很差所以这只用来验证网络通不通、key 有没有权限。验证通过后直接跳到下一节改流式。3.2 改成流式输出SseEmitter 逐条解析增量SpringBoot 里做 SSE最直接的工具是 SseEmitter。Controller 先创建 emitter设置超时时间然后把实际请求丢给线程池处理别在 Tomcat 的工作线程里阻塞等模型返回PostMapping(/stream) public SseEmitter stream(RequestBody ChatRequest req) { // 2 分钟超时覆盖一次完整回答的生成时间 SseEmitter emitter new SseEmitter(120_000L); chatService.streamAnswer(req, emitter); return emitter; }Service 里的核心逻辑是先取上下文再拼上当前用户消息发流式请求每拿到一个增量块就通过 emitter 推给前端。流式响应体是 InputStream逐行读解析data:前缀的内容public void streamAnswer(ChatRequest req, SseEmitter emitter) { try { // 多级缓存读上下文后面第 5 章细讲 ListMessage context cacheService.getContext(req.getSessionId()); context.add(new Message(user, req.getContent())); HttpRequest request buildStreamRequest(context); HttpResponseInputStream response httpClient.send(request, BodyHandlers.ofInputStream()); StringBuilder fullAnswer new StringBuilder(); try (BufferedReader reader new BufferedReader( new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { if (!line.startsWith(data:)) continue; String data line.substring(5).trim(); if ([DONE].equals(data)) break; // 增量内容在 choices[0].delta.content 里 String delta parseDelta(data); if (delta ! null !delta.isEmpty()) { fullAnswer.append(delta); emitter.send(SseEmitter.event().data(delta)); } } } // 整轮对话写回缓存和数据库下一轮才能记住 cacheService.saveRound(req.getSessionId(), req.getContent(), fullAnswer.toString()); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }这段代码有两个关键点。一是[DONE]标志DeepSeek API 流式响应结束时最后一行 data 的内容是[DONE]必须在这里 break 并正常 complete。二是 emitter.send 不要包装成 JSON 对象直接发纯文本增量前端拼接更省事减少一次 JSON 解析。SseEmitter.event().data(delta)默认会带上data:前缀前端按 SSE 协议解析即可。3.3 上下文裁剪别让历史消息吃掉你的 token 预算很多人把上下文感知理解成“把所有历史都发给模型”这是多轮对话成本爆炸的根源。DeepSeek 这类模型的上下文窗口再大也不该把一周前的寒暄都带进每次请求。裁剪策略放在组装上下文之后、发送请求之前public ListMessage trimContext(ListMessage context, int maxRounds) { if (context.size() maxRounds * 2 1) return context; // 首条 system 消息保留其余从中间裁掉只留最近 maxRounds 轮 ListMessage head List.of(context.get(0)); ListMessage tail context.subList(context.size() - maxRounds * 2, context.size()); ListMessage merged new ArrayList(head); merged.addAll(tail); return merged; }maxRounds是保留的对话轮数客服场景 1020 轮是常见值。设 10 轮意味着最多带 20 条消息加 1 条 system按每条消息平均 50 token 估算上下文开销在 1000 token 左右加上本次回复的 max-tokens单次请求成本可控。裁剪还有一个副作用模型注意力会更集中在最近的问题上回答质量反而比几十轮历史全塞进去更好。4. Vue.js 端如何接收流式响应SSE 解析与会话管理4.1 用 fetch ReadableStream 解析 SSE而不是等整个响应Vue 端接收流式响应不需要引入第三方 SSE 库浏览器原生 fetch 返回的 body 就是 ReadableStream自己按行解析就够了。下面这段是 Vue 3 组合式 API 的写法核心是“边读边拼”script setup import { ref } from vue const messages ref([]) const sessionId ref(localStorage.getItem(sessionId) || ) const input ref() let controller null // 用来中止请求 async function send() { const content input.value if (!content) return messages.value.push({ role: user, content }) messages.value.push({ role: assistant, content: }) input.value controller new AbortController() const resp await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, X-Session-Id: sessionId.value }, body: JSON.stringify({ content }), signal: controller.signal }) const reader resp.body.getReader() const decoder new TextDecoder() let buffer while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) let index // 按换行切分处理一个 SSE 事件可能被拆在多个 chunk 里的情况 while ((index buffer.indexOf(\n)) 0) { const line buffer.slice(0, index).trim() buffer buffer.slice(index 1) if (!line.startsWith(data:)) continue const payload line.slice(5).trim() if (payload [DONE]) { reader.cancel(); break } const chunk JSON.parse(payload) const delta chunk.choices?.[0]?.delta?.content if (delta) { messages.value[messages.value.length - 1].content delta } } } } /script这段代码里 buffer 的设计很关键。流式网络包不保证按行到达可能一个 SSE 事件被拆成两个 chunk也可能两个事件拼在同一个 chunk 里。用一个 buffer 累积文本每次按\n切出完整的行处理剩下的留在 buffer 里等下一个 chunk这样解析才稳定。TextDecoder的stream: true参数保证中文字符不会被截断成乱码。4.2 会话与渲染的细节中止生成、自动滚动、空状态流式渲染还有个容易被忽略的问题用户想中止生成时怎么办前端调用controller.abort()会触发 fetch 中断但后端可能还在调模型接口。所以后端在 emiter 超时或前端断开时要能感知并主动取消模型请求。最简单的做法是在HttpClient请求上设置合理的超时同时监听 emiter 的onCompletion回调清理资源。渲染性能方面messages 用ref包着Vue 的响应式系统会逐条更新 assistant 消息内容。这里有个坑频繁修改数组最后一个元素的 content 会触发该组件的重新渲染如果聊天框里还嵌了 Markdown 渲染、代码高亮每来一个 token 就重新渲染一次卡顿是必然的。我的做法是流式过程中只更新纯文本等流结束再统一走一遍 Markdown 渲染把高亮计算延后到最终文本。自动滚动用 watch 监听 messages 长度变化滚动到底部。还有一个容易被忽略的边界会话过期。后端如果 24 小时没有新消息就销毁会话前端在收到对应的业务错误码后要自动调 /api/session/create 重建会话并把旧会话的历史在界面上保留但标记为“已结束”。这些细节不做用户聊着聊着突然发现上下文丢了体感就是“系统失忆了”。5. 多级缓存查询策略优先会话缓存再匹配的常见问题与排查5.1 三级读路径Caffeine → Redis → 数据库先说明为什么要三级而不是只上 Redis。客服场景的访问模式非常集中同一个用户短时间内连续提问上下文会被反复读取。Redis 够快但每次都要走一次网络本地内存比它再快一个数量级。三级缓存的结构是本地 Caffeine 做热点加速Redis 做多实例共享数据库做最终兜底。Caffeine 的配置要限定大小和过期时间防止内存泄漏Bean public CacheString, Context localCache() { return Caffeine.newBuilder() .maximumSize(256) // 最多缓存 256 个会话 .expireAfterWrite(Duration.ofMinutes(30)) .build(); }读路径的核心逻辑是“逐级查询、命中即返回、未命中回填下一级”public Context getContext(String sessionId) { // 第一级进程内缓存命中直接返回微秒级 Context ctx localCache.getIfPresent(sessionId); if (ctx ! null) { return ctx; } // 第二级Redis 会话缓存key 带业务前缀避免污染 String json redisTemplate.opsForValue().get(ctx: sessionId); if (json ! null) { Context c JsonUtils.parse(json, Context.class); localCache.put(sessionId, c); // 回填本地 return c; } // 第三级数据库兜底查询最近 N 条消息重建上下文 ListMessage dbMessages conversationMapper.listBySession(sessionId); if (dbMessages ! null !dbMessages.isEmpty()) { Context c new Context(sessionId, dbMessages); redisTemplate.opsForValue().set(ctx: sessionId, JsonUtils.toJson(c), 1, TimeUnit.HOURS); localCache.put(sessionId, c); return c; } // 新会话返回只含 system 消息的初始上下文 return new Context(sessionId, new ArrayList(buildSystemMessage())); }三级参数都不一样按经验给一组参考值层级载体命中耗时建议参数本地缓存Caffeine1msmaximumSize 256过期 30 分钟会话缓存Redis15msTTL 1 小时兜底MySQL1050ms无用哪一层要心里有数本地缓存命中是理想情况Redis 命中是正常情况数据库兜底是保底。如果监控发现数据库兜底比例超过 30%说明缓存回填策略有问题或者 Redis 频繁被清掉要优先排查。5.2 写路径与一致性先落库再删缓存顺序不能反读完路径之后写路径是真正的翻车高发区。一轮对话结束后需要把 user 消息和 assistant 回复都存起来。很多人图省事直接更新 Redis 里的完整上下文这个做法在并发场景下会出问题两个请求同时读旧上下文各自追加消息再写回后写的会覆盖先写的导致消息错序。正确做法是“先落库、再删缓存”让读路径下次自动回填public void saveRound(String sessionId, String userMsg, String assistantMsg) { // 先写数据库 conversationMapper.insert(sessionId, user, userMsg); conversationMapper.insert(sessionId, assistant, assistantMsg); // 再删缓存下次读取时回填最新数据 redisTemplate.delete(ctx: sessionId); localCache.invalidate(sessionId); }为什么是“删缓存”而不是“更新缓存”删掉之后下一次读取会从数据库重建完整上下文天然就是最新的。更新缓存则需要把完整消息列表重新算一遍还要处理并发覆盖复杂度高得多。缺点是删缓存后第一次读取会打到数据库所以要用互斥锁防止缓存击穿private final ConcurrentHashMapString, Object lockMap new ConcurrentHashMap(); public Context getContextSafe(String sessionId) { Context ctx getFromCache(sessionId); if (ctx ! null) return ctx; Object lock lockMap.computeIfAbsent(sessionId, k - new Object()); synchronized (lock) { ctx getFromCache(sessionId); // 双检避免重复查库 if (ctx null) { ctx loadFromDbAndRebuild(sessionId); } lockMap.remove(sessionId); return ctx; } }lockMap.remove放在 synchronized 块内部是防止内存泄漏的关键。如果放在外部并发情况下可能有线程还在等待锁另一个线程已经移除了锁对象导致后续请求拿到不同的锁实例互斥失效。这个坑很小但踩过的人都知道有多难受。5.3 排查清单四个让多级缓存“翻车”的现场现象一流式输出到一半断流首字迟迟出不来。原因前端明明用 fetch 在读流后端也正常推了但数据被网关缓冲了。很多部署架构里前端请求要经过 Nginx 转发到 SpringBootNginx 默认会缓冲响应SSE 数据被攒到一定量才往下发表现就是首字卡顿、中途断流。解决在 Nginx 转发配置里把这个路径的缓冲关掉同时把 read 超时时间调大让后端每推一个 chunk 就立刻到前端。现象二对话到第 5 轮突然报 token 超限。原因上下文没有裁剪历史消息全量发给模型。这是第三章节里 trimContext 没生效的典型表现常见于把裁剪逻辑写在了缓存之后、却忘记在组装请求前调用。解决在调用模型的入口处统一裁剪并把 maxRounds 参数配置化测试时开到 20 轮验证边界上线调回 10 轮。现象三多实例部署时上下文一会对一会不对。原因本地缓存只存在于单个进程用户在 A 实例聊完下一个请求被网关转发到 B 实例B 的本地缓存里没有但 Redis 又可能恰好被删了于是上下文丢失。解决本地缓存只做热点加速一致性交给 Redis写路径删除 Redis 缓存时同步把本机 localCache 也 invalidate本地缓存过期时间控制在 30 分钟以内宁可多打一次 Redis也不能背不一致的锅。现象四SpringBoot 3.x 启动报 ClassNotFoundException: javax.servlet.*。原因SpringBoot 3 迁移到了 jakarta 命名空间旧项目里依赖的 servlet API 包名不对。解决检查所有实现类把 javax.servlet 替换成 jakarta.servlet同时确认第三方依赖没有把 SpringBoot 2 的旧依赖带进来。这个问题和缓存本身无关但我在好几个 SpringBoot Vue 项目里都遇到过前端说“后端起不来”排查半天发现是包名。6. 上线前压测与成本控制把 Demo 变成能扛住上线的服务代码调通只是开始上线前一定要做两件事压测和成本核算。压测不一定要上 JMeter先用并发请求打同一个会话接口就够了。我一般会写一个简单的循环脚本模拟 50 个并发用户同时向同一个 sessionId 提问然后观察两个指标数据库的查询次数有没有明显下降以及首字的平均响应时间。缓存生效时数据库 QPS 应该远低于接口 QPS因为绝大多数请求都命中在本地缓存或 Redis 上。如果数据库 QPS 和接口 QPS 几乎一样说明缓存回填逻辑有问题大概率是没有逐级回填。成本控制方面三条参数经验可以直接抄max-token 客服回复控制在 5001000不需要让模型写小作文temperature 固定 0.30.5既保证回答稳定又不至于死板上下文窗口 10 轮超出即裁剪。这三条同时决定单次调用的 token 消耗一次回答控制在 1000 token 以内按 DeepSeek 这类大模型的 API 定价单个用户即使每天问 100 次成本也很有限。真正让账单失控的从来不是单次消耗而是没裁剪的上下文和没设置 max-token 的默认值。最后说一个我的教训第一次做这类系统时我图省事把全部历史消息都发给模型觉得上下文窗口够大没关系结果测试阶段没感觉上线第三周账单翻了一倍而且回答质量并没有变好。后来把裁剪和三级缓存一起加上数据库连接数降了一半首字时间从 1.8 秒压到 0.7 秒。这套方案的每一层设计都是有代价的但踩过坑之后你会发现上下文裁剪和多级缓存不是优化项是必选项。希望我的这些经验能帮到你让你在落地时少走一段弯路。本文还有配套的精品资源点击获取
返回列表