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月,如果对你有帮助不妨留一个赞

相关推荐
AM越.17 小时前
Java设计模式详解--装饰器设计模式(含uml图)
java·设计模式·uml
光影少年18 小时前
Vue2 Diff和Vue 3 Diff实现及底层原理
前端·javascript·vue.js
59803541518 小时前
【java工具类】小数、整数转中文大写
android·java·开发语言
傻啦嘿哟18 小时前
隧道代理“请求监控”实战:动态调整采集策略的完整指南
前端·javascript·vue.js
JIngJaneIL18 小时前
基于java + vue个人博客系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
C_心欲无痕18 小时前
vue3 - readonly创建只读的响应式对象
前端·javascript·vue.js
吃喝不愁霸王餐APP开发者18 小时前
Java后端服务在对接全国性霸王餐API时的多数据中心部署与就近调用策略
java·开发语言
从心归零18 小时前
springboot-jpa的批量更新方法
java·spring boot·spring
这周也會开心18 小时前
128陷阱,==与equals区别
java·开发语言
TAEHENGV19 小时前
回收站模块 Cordova 与 OpenHarmony 混合开发实战
android·java·harmonyos