SSE 流式输出与 Markdown 渲染实现详解

本文档详细描述了本项目是如何实现类似 ChatGPT 的流式对话(Streaming Chat)功能的。涵盖了从后端 SSE 数据生成,到前端流式解析,再到最终 Markdown 实时渲染的全过程。

效果:


1. 核心架构概览

为了提供极致的用户体验(低延迟、打字机效果),我们采用了 Server-Sent Events (SSE) 技术。

  • 后端 (Backend):Python/FastAPI。负责调用 LLM,将生成的 Token 实时封装成标准 JSON 格式,通过 HTTP 长连接推送给前端。
  • 前端 (Frontend):TypeScript/Lit。负责建立连接,逐行读取数据,解析 JSON 提取内容,并实时将 Markdown 文本渲染为 HTML。

2. 后端实现:生成标准 SSE 数据流

为了保证兼容性和稳定性,我们严格遵循 OpenAI SSE 标准格式

代码位置src/services/chat_service.py

2.1 数据结构构造

后端不是直接发送原始文本(Raw Text),而是将每个 Token 包装在一个 JSON 对象中。这样做的好处是:

  1. 元数据支持:可以包含 ID、模型名、结束原因等信息。
  2. 格式安全 :换行符、特殊字符会被 JSON 转义(例如 \n),避免了传输过程中的歧义。
python 复制代码
# 构造符合 OpenAI 格式的 chunk 数据
chunk_data = {
    "id": f"chatcmpl-{request.conversation_id}",
    "object": "chat.completion.chunk",
    "created": int(time.time()),
    "model": request.model,
    "choices": [
        {
            "index": 0,
            "delta": {
                "content": chunk.content  # LLM 生成的片段(可能是 "好"、","、"\n" 等)
            },
            "finish_reason": None
        }
    ]
}

2.2 发送 SSE 事件

SSE 协议规定,每一条消息必须以 data: 开头,并以两个换行符 \n\n 结尾。

python 复制代码
# json.dumps 处理了所有特殊字符转义
yield f"data: {json.dumps(chunk_data)}\n\n"

2.3 结束信号

当流结束时,发送一个特殊的标记 [DONE],通知前端停止监听。

python 复制代码
yield "data: [DONE]\n\n"

3. 前端实现:流式解析与渲染

前端的工作是将接收到的二进制流还原为文本,提取有效内容,并渲染。

代码位置src/services/api.ts (解析), src/chat-app.ts (状态), src/components/message-bubble.ts (渲染)

3.1 获取流与解码

我们使用 fetch API 获取流,并使用 TextDecoder 将二进制块解码为字符串。

typescript 复制代码
const response = await fetch(API_URL, { ... });
const reader = response.body?.getReader();
const decoder = new TextDecoder();

while (true) {
  const { done, value } = await reader.read(); // value 是 Uint8Array
  if (done) break;
  // stream: true 选项处理跨 chunk 的多字节字符(如中文)
  buffer += decoder.decode(value, { stream: true }); 
  // ...
}

3.2 Buffer 处理与按行切分 (关键细节)

网络传输是分包的,一个 TCP 包可能只包含半条消息,或者包含多条消息。我们维护一个 buffer 字符串,每次只处理完整的行 (以 \n 结尾)。

typescript 复制代码
// 循环处理 buffer 中所有完整的行
let position;
while ((position = buffer.indexOf('\n')) >= 0) {
  const line = buffer.slice(0, position); // 提取一行
  buffer = buffer.slice(position + 1);    // 从 buffer 中移除已处理的行
  
  // ... 解析 line ...
}

3.3 解析 SSE 消息

对于提取出的每一行 line,我们按照 OpenAI 格式进行解析:

  1. 前缀检查 :必须以 data: 开头。
  2. 结束检查 :如果是 [DONE],退出。
  3. JSON 解析 :提取 content
typescript 复制代码
if (line.startsWith('data: ')) {
  let data = line.slice('data: '.length); // 去掉前缀
  
  if (data.trim() === '[DONE]') return; // 结束信号

  try {
    const parsed = JSON.parse(data);
    // 提取深层嵌套的内容:choices[0].delta.content
    const content = parsed.choices?.[0]?.delta?.content;
    if (content) {
        yield content; // 将纯文本片段 yield 给 UI
    }
  } catch (e) {
    // 错误处理...
  }
}

3.4 状态累积 (State Accumulation)

ChatApp 组件中,我们将 yield 出来的片段追加到当前消息的内容中。

typescript 复制代码
// src/chat-app.ts
for await (const chunk of streamChat(...)) {
  // 找到当前正在接收的消息,追加 content
  const lastMessage = convo.messages[...];
  const updatedMessage = { 
    ...lastMessage, 
    content: lastMessage.content + chunk  // 字符串拼接
  };
  // 更新状态,触发 Lit 重新渲染
  this._conversations = ...
}

3.5 Markdown 实时渲染

MessageBubble 组件中,每当 content 发生变化(即收到新 chunk),都会触发 updated 生命周期,重新调用 Markdown 解析器。

代码位置src/components/message-bubble.ts

typescript 复制代码
// 配置 marked 解析器,支持代码高亮和 GitHub 风格换行
const marked = new Marked(
  markedHighlight({ ... }), 
  { breaks: true } // 重要:将单个换行符 \n 渲染为 <br>
);

private _renderMarkdown(container: Element) {
  // 将完整的 Markdown 文本转换为 HTML
  const result = marked.parse(this.content);
  container.innerHTML = result;
}

为什么能渲染正常?

因为我们始终持有完整的 Markdown 文本(通过不断 append)。虽然每次都重绘会有性能开销,但对于聊天这种文本量级,它是最稳健的方案,能确保 Markdown 语法(如代码块 `````````、列表`````- `)在接收到足够字符后立即正确渲染。


4. 后端配合要点 (Requirements for Backend)

为了让这套机制完美运行,后端必须严格遵守以下约定:

  1. 协议标准化必须 使用 data: JSON\n\n 的格式。绝对不要发送裸文本(Raw Text),也不要试图自己在 data 之间插入额外的换行符。
  2. 换行符转义 :内容中的换行符必须在 JSON 字符串中转义为 \n。例如:{"content": "Line 1\nLine 2"}
  3. 原子性:尽量保证 JSON 的完整性。虽然 TCP 可能会拆包,但后端应用层应该按行发送完整的 JSON 字符串。
  4. 不包含业务噪音:不要在 SSE 流中混合发送 debug 日志或非标准格式的心跳包,这会导致前端解析失败。

通过这种"后端负责结构化,前端负责渲染"的分工,我们成功实现了稳定、流畅且格式完美的 AI 对话体验。

相关推荐
祈澈菇凉37 分钟前
Next.js 零基础开发博客后台管理系统教程(八):提升用户体验 - 表单状态、加载与基础验证
前端·javascript·ux
西柚小萌新38 分钟前
【深入浅出PyTorch】--9.使用ONNX进行部署并推理
人工智能·pytorch·python
有意义38 分钟前
从日常使用到代码实现:B 站签名编辑的 OOP 封装思路与实践
javascript·代码规范·ecmascript 6
LDG_AGI40 分钟前
【推荐系统】深度学习训练框架(十):PyTorch Dataset—PyTorch数据基石
人工智能·pytorch·分布式·python·深度学习·机器学习
是Dream呀1 小时前
昇腾实战|算子模板库Catlass与CANN生态适配
开发语言·人工智能·python·华为
哆啦A梦15881 小时前
62 对接支付宝沙箱
前端·javascript·vue.js·node.js
培根芝士1 小时前
使用llm-compressor 对 Qwen3-14B 做 AWQ + INT4 量化
人工智能·python
Tzarevich1 小时前
用 OOP 思维打造可复用的就地编辑组件:EditInPlace 实战解析
javascript·前端框架
拾贰_C1 小时前
【Python | Anaconda】 python-Anaconda 一些命令使用
开发语言·python