本文档详细描述了本项目是如何实现类似 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 对象中。这样做的好处是:
- 元数据支持:可以包含 ID、模型名、结束原因等信息。
- 格式安全 :换行符、特殊字符会被 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 格式进行解析:
- 前缀检查 :必须以
data:开头。 - 结束检查 :如果是
[DONE],退出。 - 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)
为了让这套机制完美运行,后端必须严格遵守以下约定:
- 协议标准化 :必须 使用
data: JSON\n\n的格式。绝对不要发送裸文本(Raw Text),也不要试图自己在data之间插入额外的换行符。 - 换行符转义 :内容中的换行符必须在 JSON 字符串中转义为
\n。例如:{"content": "Line 1\nLine 2"}。 - 原子性:尽量保证 JSON 的完整性。虽然 TCP 可能会拆包,但后端应用层应该按行发送完整的 JSON 字符串。
- 不包含业务噪音:不要在 SSE 流中混合发送 debug 日志或非标准格式的心跳包,这会导致前端解析失败。
通过这种"后端负责结构化,前端负责渲染"的分工,我们成功实现了稳定、流畅且格式完美的 AI 对话体验。