作者:大洪讲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;
    }
}

关键细节说明

  1. produces = MediaType.TEXT_EVENT_STREAM_VALUE:声明这是SSE流式响应,浏览器会自动识别
  2. new SseEmitter(60000L):设置60秒超时,避免长回答连接被断开
  3. onNext回调:每生成一个字/词,就立即推送给前端
  4. [DONE]结束标记:前端收到这个标记就知道生成结束了,停止读取

2.3 配置异步请求超时(必加)

application.yml中添加异步请求超时配置,防止Spring MVC默认超时时间太短导致连接断开:

spring:
  mvc:
    async:
      request-timeout: 60000 # 异步请求超时时间60秒,和SseEmitter保持一致

2.4 后端接口测试

重启后端项目,用Postman测试流式接口:

  1. 请求地址:POST http://localhost:8080/api/chat/stream
  2. 请求头:Content-Type: application/json
  3. 请求体:
    {
        "message": "用100字介绍一下人工智能"
    }
    
  4. 正常情况下,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 核心逻辑说明

  1. 先占位后填充:发送消息后立即添加一个空的AI消息,避免界面跳动
  2. 流式读取:用reader.read()循环读取字节流,边读边解码
  3. 实时渲染:每收到新内容就更新到页面上,实现打字机效果
  4. 结束判断:遇到[DONE]标记就停止读取,完成本次对话

四、完整效果验证

  1. 启动后端:确保Spring Boot项目正常启动,无报错
  2. 启动前端:执行npm run dev,访问http://localhost:3000
  3. 测试短回答:发送“你好”,应该能看到逐字显示的效果
  4. 测试长回答:发送“用300字介绍一下Java”,观察是否持续输出、自动滚动
  5. 测试异常场景:故意断网,看是否有友好的错误提示

✅ 正常效果: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更新完成再滚动

六、本章总结

本章我们完成了核心体验升级:

  1. ✅ 理解了SSE流式输出的原理和优势
  2. ✅ 后端实现了SSE流式聊天接口,基于Spring原生SseEmitter
  3. ✅ 前端用Fetch API + ReadableStream实现了逐字显示的打字机效果
  4. ✅ 完整验证了流式对话效果,体验接近ChatGPT
  5. ✅ 掌握了流式开发中常见问题的排查方法

现在我们的聊天系统已经有了媲美主流AI产品的交互体验,接下来我们就要开始做这个项目最核心的功能——RAG知识库系统,让AI只能用我们上传的内容回答问题。


下章预告

下一章我们将正式进入RAG知识库系统开发,包含文档上传、自动分割、向量化存储、相似度检索、Prompt工程防编造等完整功能,彻底解决大模型“胡说八道”的问题。

关注我,第一时间收到更新通知!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐