从零搭建生产级AI智能客服系统(三):SSE流式聊天,实现ChatGPT同款打字机效果
作者:大洪讲AI
更新时间:2026年6月
本章目标:将同步聊天接口升级为SSE流式输出,实现逐字显示的打字机效果,彻底解决“转圈等待”的糟糕体验
前置条件:第二章项目初始化100%完成,豆包API调用正常
前言
上一章我们实现了最基础的同步聊天接口,虽然能正常对话,但体验很差:用户发送消息后,要等十几秒甚至几十秒,等AI把整段话全部生成完,才能一次性看到结果,全程只有一个加载动画,用户非常焦虑。
这一章我们就来解决这个核心体验问题,用SSE流式输出实现ChatGPT同款的打字机效果:AI一边生成,前端一边显示,用户发完消息几乎瞬间就能看到第一个字,全程有持续的内容反馈,体验提升非常明显。
一、为什么选择SSE实现流式输出?
1.1 同步接口的痛点
- 等待焦虑:用户发完消息只能干等,不知道什么时候能出结果
- 体验割裂:长回答等待时间极长,用户容易以为系统卡死了
- 资源浪费:服务端要把全部内容生成完才能返回,内存占用高
1.2 常见流式方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| SSE(Server-Sent Events) | 轻量、基于HTTP、原生支持、开发简单 | 只能服务端推送给客户端,单向通信 | 聊天、日志推送、实时通知 |
| WebSocket | 全双工通信,双向实时 | 协议复杂、维护成本高、需要心跳保活 | 在线游戏、协同编辑、实时通话 |
| 轮询 | 实现最简单 | 频繁请求浪费资源、延迟高、体验差 | 低频次、非实时场景 |
结论:AI聊天场景只有服务端向客户端推送生成内容,完全不需要双向通信,SSE是最优解,轻量、稳定、开发成本极低。
二、后端实现:SSE流式接口开发
2.1 核心技术点
SseEmitter:Spring MVC 原生支持的SSE发射器,专门用来做服务端流式推送StreamingChatLanguageModel:LangChain4j 提供的流式大模型接口,支持逐token回调- 流式回调三要素:
onNext(收到新token)、onComplete(生成结束)、onError(发生异常)
2.2 新增流式聊天接口
修改src/main/java/org/example/controller/ChatController.java,在原有同步接口的基础上,新增流式接口:
package org.example.controller;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.StreamingResponseHandler;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.output.Response;
import org.example.model.vo.AjaxResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import lombok.Data;
import java.io.IOException;
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@Autowired
@Qualifier("doubaoChatModel")
private ChatLanguageModel chatModel;
// 注入流式大模型
@Autowired
@Qualifier("doubaoStreamingChatModel")
private StreamingChatLanguageModel streamingChatModel;
@Data
public static class ChatRequest {
private String message;
}
/**
* 同步聊天接口(保留,用于测试)
*/
@PostMapping("/simple")
public AjaxResult<String> simpleChat(@RequestBody ChatRequest request) {
String response = chatModel.generate(request.getMessage());
return AjaxResult.success(response);
}
/**
* SSE流式聊天接口(核心)
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(@RequestBody ChatRequest request) {
// 1. 创建SSE发射器,设置超时时间为60秒
SseEmitter emitter = new SseEmitter(60000L);
// 2. 调用流式大模型生成回答
streamingChatModel.generate(request.getMessage(), new StreamingResponseHandler<AiMessage>() {
@Override
public void onNext(String token) {
try {
// 3. 每收到一个token,就推送给前端
emitter.send(token, MediaType.TEXT_PLAIN);
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onComplete(Response<AiMessage> response) {
try {
// 4. 生成结束,发送结束标记
emitter.send("[DONE]", MediaType.TEXT_PLAIN);
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
@Override
public void onError(Throwable error) {
// 5. 发生异常,结束连接
emitter.completeWithError(error);
}
});
return emitter;
}
}
关键细节说明:
produces = MediaType.TEXT_EVENT_STREAM_VALUE:声明这是SSE流式响应,浏览器会自动识别new SseEmitter(60000L):设置60秒超时,避免长回答连接被断开onNext回调:每生成一个字/词,就立即推送给前端[DONE]结束标记:前端收到这个标记就知道生成结束了,停止读取
2.3 配置异步请求超时(必加)
在application.yml中添加异步请求超时配置,防止Spring MVC默认超时时间太短导致连接断开:
spring:
mvc:
async:
request-timeout: 60000 # 异步请求超时时间60秒,和SseEmitter保持一致
2.4 后端接口测试
重启后端项目,用Postman测试流式接口:
- 请求地址:
POST http://localhost:8080/api/chat/stream - 请求头:
Content-Type: application/json - 请求体:
{ "message": "用100字介绍一下人工智能" } - 正常情况下,Postman的响应区会逐字显示内容,最后出现
[DONE]标记
✅ 后端流式接口开发完成!
三、前端改造:实现打字机显示效果
3.1 为什么不用Axios?
Axios本质上是对XMLHttpRequest的封装,不支持流式读取响应体,只能等全部接收完才能拿到结果,完全发挥不出SSE的优势。
我们用原生Fetch API + ReadableStream来读取流式响应,这是目前前端处理SSE流式数据的标准方案。
3.2 改造聊天页面,实现逐字显示
修改src/App.vue,把原来的同步请求改成流式读取:
<template>
<div class="app">
<div class="chat-container">
<div class="chat-header">
<h1>AI智能客服</h1>
</div>
<div class="chat-content" ref="chatContentRef">
<div
v-for="(msg, index) in messageList"
:key="index"
:class="['message', msg.role]"
>
<div class="message-content">{{ msg.content }}</div>
</div>
</div>
<div class="chat-input">
<el-input
v-model="inputMessage"
placeholder="请输入您的问题..."
@keyup.enter="handleSend"
:disabled="isLoading"
></el-input>
<el-button
type="primary"
@click="handleSend"
:loading="isLoading"
>
发送
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const messageList = ref([])
const inputMessage = ref('')
const isLoading = ref(false)
const chatContentRef = ref(null)
// 滚动到底部
const scrollToBottom = async () => {
await nextTick()
if (chatContentRef.value) {
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
}
}
// 发送消息(流式版本)
const handleSend = async () => {
const content = inputMessage.value.trim()
if (!content || isLoading.value) return
inputMessage.value = ''
isLoading.value = true
// 添加用户消息
messageList.value.push({ role: 'user', content })
await scrollToBottom()
// 添加AI空消息占位
const aiIndex = messageList.value.length
messageList.value.push({ role: 'ai', content: '' })
await scrollToBottom()
try {
// 发起流式请求
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ message: content })
})
if (!response.ok || !response.body) {
throw new Error(`请求失败,状态码:${response.status}`)
}
// 获取读取器和解码器
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 循环读取流数据
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解码当前片段
buffer += decoder.decode(value, { stream: true })
// 直接把收到的所有内容拼接到AI消息里
// 注意:实际内容里包含[DONE]结束标记,需要过滤掉
if (buffer.includes('[DONE]')) {
// 提取结束标记之前的内容
const realContent = buffer.replace('[DONE]', '')
messageList.value[aiIndex].content = realContent
buffer = ''
break
} else {
messageList.value[aiIndex].content = buffer
}
await scrollToBottom()
}
// 处理最后剩余的内容
if (buffer && buffer !== '[DONE]') {
messageList.value[aiIndex].content = buffer.replace('[DONE]', '')
}
// 兜底:如果内容为空,提示用户
if (!messageList.value[aiIndex].content) {
messageList.value[aiIndex].content = '未收到回复,请重试。'
}
} catch (error) {
console.error('流式请求失败:', error)
messageList.value[aiIndex].content = '抱歉,系统出错了,请稍后再试。'
ElMessage.error('请求失败,请检查网络')
} finally {
isLoading.value = false
await scrollToBottom()
}
}
</script>
<style scoped>
/* 样式和第二章完全一致,这里省略,直接复用之前的样式即可 */
.app {
width: 100vw;
height: 100vh;
background-color: #f5f7fa;
display: flex;
justify-content: center;
align-items: center;
}
.chat-container {
width: 800px;
height: 80vh;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.chat-header {
padding: 20px;
border-bottom: 1px solid #e6e6e6;
text-align: center;
}
.chat-header h1 {
margin: 0;
font-size: 24px;
color: #303133;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message {
margin-bottom: 16px;
max-width: 70%;
display: flex;
}
.message.user {
margin-left: auto;
justify-content: flex-end;
}
.message.ai {
margin-right: auto;
justify-content: flex-start;
}
.message-content {
padding: 12px 16px;
border-radius: 8px;
line-height: 1.5;
word-break: break-word;
}
.message.user .message-content {
background-color: #409eff;
color: white;
}
.message.ai .message-content {
background-color: #f5f7fa;
color: #303133;
}
.chat-input {
padding: 20px;
border-top: 1px solid #e6e6e6;
display: flex;
gap: 10px;
}
.chat-input .el-input {
flex: 1;
}
</style>
3.3 核心逻辑说明
- 先占位后填充:发送消息后立即添加一个空的AI消息,避免界面跳动
- 流式读取:用
reader.read()循环读取字节流,边读边解码 - 实时渲染:每收到新内容就更新到页面上,实现打字机效果
- 结束判断:遇到
[DONE]标记就停止读取,完成本次对话
四、完整效果验证
- 启动后端:确保Spring Boot项目正常启动,无报错
- 启动前端:执行
npm run dev,访问http://localhost:3000 - 测试短回答:发送“你好”,应该能看到逐字显示的效果
- 测试长回答:发送“用300字介绍一下Java”,观察是否持续输出、自动滚动
- 测试异常场景:故意断网,看是否有友好的错误提示
✅ 正常效果:AI回答像打字一样逐字出现,流畅自然,和ChatGPT体验完全一致。
五、常见问题排查
问题1:没有逐字效果,还是一次性全部出来
- 排查点1:检查后端接口的
produces是否加了TEXT_EVENT_STREAM_VALUE - 排查点2:检查前端是否用了
fetch而不是axios - 排查点3:检查是否有反向代理(如Nginx)缓存了响应,关闭缓冲即可
问题2:生成到一半连接断开
- 排查点1:检查
SseEmitter的超时时间是否设置得足够长 - 排查点2:检查
spring.mvc.async.request-timeout配置是否生效 - 排查点3:如果部署在服务器上,检查Nginx的
proxy_read_timeout配置
问题3:中文乱码
- 排查点1:确保前端解码器用的是
TextDecoder('utf-8') - 排查点2:确保后端发送时指定了
MediaType.TEXT_PLAIN,Spring会自动用UTF-8编码
问题4:滚动不自动到底部
- 排查点1:检查每次更新内容后是否调用了
scrollToBottom() - 排查点2:确保用了
await nextTick()等待DOM更新完成再滚动
六、本章总结
本章我们完成了核心体验升级:
- ✅ 理解了SSE流式输出的原理和优势
- ✅ 后端实现了SSE流式聊天接口,基于Spring原生SseEmitter
- ✅ 前端用Fetch API + ReadableStream实现了逐字显示的打字机效果
- ✅ 完整验证了流式对话效果,体验接近ChatGPT
- ✅ 掌握了流式开发中常见问题的排查方法
现在我们的聊天系统已经有了媲美主流AI产品的交互体验,接下来我们就要开始做这个项目最核心的功能——RAG知识库系统,让AI只能用我们上传的内容回答问题。
下章预告
下一章我们将正式进入RAG知识库系统开发,包含文档上传、自动分割、向量化存储、相似度检索、Prompt工程防编造等完整功能,彻底解决大模型“胡说八道”的问题。
关注我,第一时间收到更新通知!
更多推荐



所有评论(0)