SSE 流式输出 Markdown 实时渲染问题解决方案

SSE 流式输出 Markdown 实时渲染问题解决方案

在开发 AI 对话功能时,我遇到了一个棘手的问题:使用 fetch + ReadableStream 实现 SSE 流式输出后,AI 返回的 Markdown 格式内容(标题、换行、列表等)全部挤在一起,无法正确渲染。

之前采用之前的原生 EventSource 实时渲染正常,就是因为它自动处理了所有 SSE 规范。但是原生 EventSource 存在一些问题,他没办法携带请求头,意味着我们需要在路径拼接token传递给后端人员,但是这样在网站开发规范中将token暴露是相当危险且不正确的做法,于是本文将该方法改进记录使用 fetch + ReadableStream出现问题的排查过程和最终的解决方案。

背景

MindCampus 项目的 AI 对话模块采用以下技术栈:

  • 后端:Spring Boot 3.x + Spring AI Alibaba + DashScope(通义千问)
  • 前端:Vue 3 + Uni-app + ua-markdown 组件
  • 通信方式:SSE (Server-Sent Events) 流式传输

AI 返回的内容包含丰富的 Markdown 格式,如:

markdown 复制代码
## 你好!

我是 AI 助手,很高兴为你服务。

### 我可以帮你:
1. 解答问题
2. 提供建议
3. 倾听心声

但实际渲染出来却是这样的:

复制代码
## 你好!我是 AI 助手,很高兴为你服务。### 我可以帮你:1. 解答问题2. 提供建议3. 倾听心声

所有内容挤成一团,换行符完全丢失。

问题分析

后端 SSE 实现

首先查看后端的 SSE 流式输出代码:

java 复制代码
// AiChatStreamServiceImpl.java
public Flux<ServerSentEvent<String>> generateStreamResponse(String message, Long sessionId, Long userId) {
    // ...
    return dashScopeChatClient.prompt()
            .user(message)
            .system(SYSTEM_PROMPT)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
            .stream()
            .content()
            .takeWhile(data -> isStreaming.get())
            .map(content -> {
                aiResponse.append(content);
                return ServerSentEvent.<String>builder()
                        .data(content)  // 每个 token 作为独立的 SSE 事件发送
                        .build();
            })
            .concatWith(Flux.just(ServerSentEvent.<String>builder()
                    .data("\u0003")  // ETX 结束标记
                    .build()));
}

后端代码看起来没问题,每个 AI 返回的 token 都通过 ServerSentEvent 发送。

前端原始代码

问题出在前端的 SSE 解析逻辑:

第一个问题:H5 端的 fetch + ReadableStream 实现方式问题

查看非 H5 端(小程序)的代码:

javascript 复制代码
// api/ai.js - 原始的小程序端代码(有问题)
requestTask.onChunkReceived((res) => {
  const decoder = new TextDecoder('utf-8')
  const text = decoder.decode(res.data)

  const lines = text.split('\n')
  for (const line of lines) {
    if (line.startsWith('data:')) {
      const data = line.substring(5).trim()  // 问题1:使用 trim() 移除空白
      if (data && data !== ':heartbeat') {   // 问题2:空字符串被过滤
        fullContent += data
        onMessage(data, fullContent)
      }
    }
  }
})

这里有两个严重问题:

  1. 使用 .trim() 移除了首尾空白,包括换行符
  2. 空字符串被 if (data && ...) 过滤掉

SSE 格式的秘密

要理解问题的根源,需要了解 SSE 格式规范。根据 SSE 规范

当数据包含换行符时,会被拆分为多个 data: 行,客户端应该用 \n 将它们重新连接。

例如,当 AI 返回 "你好\n我是AI" 时:

复制代码
# 后端发送的 SSE 格式
data:你好
data:
data:我是AI

注意中间那个空的 data: 行!它代表一个换行符。

但我们的代码把空的 data: 行过滤掉了,所以换行符丢失了!

SSE 事件的边界

另一个关键点是 SSE 事件之间用空行分隔

复制代码
data:第一个事件的数据
                        ← 空行,表示第一个事件结束
data:第二个事件的数据
                        ← 空行,表示第二个事件结束

同一个事件可以有多个 data: 行,它们应该用 \n 连接:

复制代码
data:Hello
data:World
                        ← 空行,事件结束
# 客户端应该得到 "Hello\nWorld"

解决方案

核心思路

  1. 不使用 .trim(),保留空白字符
  2. 收集同一 SSE 事件的所有 data:
  3. 遇到空行时,将收集的行用 \n 连接
  4. 使用 buffer 处理跨 chunk 的不完整行

H5 端完整实现

javascript 复制代码
// api/ai.js - 修复后的 H5 端代码
fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': token ? 'Bearer ' + token : '',
    'Accept': 'text/event-stream',
    'Cache-Control': 'no-cache'
  },
  signal: abortController.signal
})
.then(response => {
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`)
  }

  const reader = response.body.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = ''              // 处理跨 chunk 的不完整行
  let eventDataLines = []      // 收集同一 SSE 事件的所有 data 行

  function processChunk({ done, value }) {
    if (done) {
      // 处理最后可能残留的事件数据
      if (eventDataLines.length > 0) {
        const content = eventDataLines.join('\n')
        if (content && !content.includes(END_MARKER)) {
          fullContent += content
          onMessage(content, fullContent)
        }
      }
      onComplete(fullContent)
      return
    }

    // 解码并追加到 buffer
    const text = decoder.decode(value, { stream: true })
    buffer += text

    // 按行分割
    const lines = buffer.split('\n')
    buffer = lines.pop() || ''  // 保留最后一行(可能不完整)

    for (const line of lines) {
      // 移除 Windows 换行符的 \r
      const cleanLine = line.endsWith('\r') ? line.slice(0, -1) : line

      if (cleanLine.startsWith('data:')) {
        // 关键:不使用 trim(),保留空白
        const data = cleanLine.substring(5)

        // 检查结束标记
        if (data === END_MARKER || data.includes(END_MARKER)) {
          // 先处理之前收集的数据
          if (eventDataLines.length > 0) {
            const content = eventDataLines.join('\n')
            fullContent += content
            onMessage(content, fullContent)
            eventDataLines = []
          }
          onComplete(fullContent)
          return
        }

        // 收集 data 行(包括空字符串,因为空字符串表示换行!)
        eventDataLines.push(data)

      } else if (cleanLine === '') {
        // 空行 = SSE 事件结束
        if (eventDataLines.length > 0) {
          // 将同一事件的多个 data 行用换行符连接
          const content = eventDataLines.join('\n')
          eventDataLines = []

          if (content !== ':heartbeat') {
            fullContent += content
            onMessage(content, fullContent)
          }
        }
      }
    }

    // 继续读取
    return reader.read().then(processChunk)
  }

  return reader.read().then(processChunk)
})
.catch(error => {
  if (error.name === 'AbortError') {
    console.log('请求被中断')
    onComplete(fullContent)
  } else {
    console.error('Fetch 错误:', error)
    onError(error)
  }
})

小程序端实现

javascript 复制代码
// api/ai.js - 修复后的小程序端代码
let chunkBuffer = ''           // 处理跨 chunk 的不完整行
let chunkEventDataLines = []   // 收集同一 SSE 事件的所有 data 行

requestTask.onChunkReceived((res) => {
  try {
    const decoder = new TextDecoder('utf-8')
    const text = decoder.decode(res.data)
    chunkBuffer += text

    const lines = chunkBuffer.split('\n')
    chunkBuffer = lines.pop() || ''

    for (const line of lines) {
      const cleanLine = line.endsWith('\r') ? line.slice(0, -1) : line

      if (cleanLine.startsWith('data:')) {
        const data = cleanLine.substring(5)  // 不使用 trim()

        if (data === END_MARKER || data.includes(END_MARKER)) {
          if (chunkEventDataLines.length > 0) {
            const content = chunkEventDataLines.join('\n')
            fullContent += content
            onMessage(content, fullContent)
            chunkEventDataLines = []
          }
          onComplete(fullContent)
          return
        }

        chunkEventDataLines.push(data)

      } else if (cleanLine === '') {
        if (chunkEventDataLines.length > 0) {
          const content = chunkEventDataLines.join('\n')
          chunkEventDataLines = []

          if (content !== ':heartbeat') {
            fullContent += content
            onMessage(content, fullContent)
          }
        }
      }
    }
  } catch (error) {
    console.error('解析分块数据错误:', error)
  }
})

数据流转示例

让我们用一个具体例子来理解修复后的处理流程:

场景:AI 返回 "你好\n我是AI"

后端 SSE 输出:

复制代码
data:你好
data:

data:我是AI

前端处理过程:

步骤 读取的行 eventDataLines 操作 fullContent
1 data:你好 ["你好"] 收集 ""
2 data: ["你好", ""] 收集(空字符串也收集!) ""
3 `` (空行) [] 连接:"你好" + "\n" + "" = "你好\n" "你好\n"
4 data:我是AI ["我是AI"] 收集 "你好\n"
5 `` (空行) [] 连接:"我是AI" "你好\n我是AI"

最终 fullContent = "你好\n我是AI",换行符被正确保留!

关键点总结

1. 不要使用 .trim()

javascript 复制代码
// 错误
const data = line.substring(5).trim()

// 正确
const data = line.substring(5)

2. 不要过滤空字符串

javascript 复制代码
// 错误
if (data && data !== ':heartbeat') { ... }

// 正确
eventDataLines.push(data)  // 空字符串也要收集

3. 正确处理 SSE 事件边界

javascript 复制代码
// 空行表示一个 SSE 事件结束
if (cleanLine === '') {
  // 将收集的 data 行用换行符连接
  const content = eventDataLines.join('\n')
  eventDataLines = []
  fullContent += content
}

4. 使用 buffer 处理跨 chunk 数据

javascript 复制代码
buffer += text
const lines = buffer.split('\n')
buffer = lines.pop() || ''  // 最后一行可能不完整,留到下次处理

前端 Markdown 渲染

修复 SSE 解析后,前端的 ua-markdown 组件就能正确渲染 Markdown 了:

vue 复制代码
<template>
  <view class="message-body">
    <!-- AI消息使用 markdown 解析 -->
    <ua-markdown
      v-if="msg.role === 'assistant' && msg.content"
      :source="msg.content"
      :showLine="false"
    />
    <!-- 用户消息直接显示文本 -->
    <text v-else-if="msg.role === 'user'">{{ msg.content }}</text>
  </view>
</template>

ua-markdown 组件基于 markdown-it 库,支持:

  • 标题 (h1-h6)
  • 段落和换行
  • 代码块(带语法高亮)
  • 列表 (ul, ol)
  • 引用
  • 表格
  • 链接

写在最后

这个问题的根源在于对 SSE 格式规范理解不够深入。SSE 看似简单,但其中关于多行数据和事件边界的处理容易被忽略。

希望这篇文章能帮助遇到类似问题的开发者。记住:

在 SSE 中,空的 data: 行不是无效数据,而是换行符的表示!


本文记录于 MindCampus 毕设项目开发过程中,2024年12月,如果对你有帮助不妨留一个赞

相关推荐
咩咩啃树皮1 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚2 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白3 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司4 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求
爱笑的源码基地5 小时前
高并发 Redis 缓存门诊HIS系统源码,含财务统计药房进销存
java·程序·门诊系统·诊所系统·云诊所源码
wuqingshun3141595 小时前
TCP超时重传机制是为了解决什么问题?
java
慧一居士6 小时前
Element Plus 按需引入的配置使用说明和完整示例
前端·vue.js
莫逸风7 小时前
【AgentScope 2.0】 0. 学习指南
java·llm·agent·agentscope
z123456789868 小时前
2026最新两款AI编程工具深度对比实测
java·数据库·ai编程
yaoxin5211238 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python