学习目标
- 理解 SSE(Server-Sent Events)是什么(搞定!✅)
- 学会用 Flask 后端返回流式数据(搞定!✅)
- 学会用前端接收流式数据并逐字显示(搞定!✅)
- 升级聊天页面,实现 AI 边想边说的效果(搞定!✅)
一、为什么需要流式输出?
1.1 普通请求的问题
现在的聊天是这样工作的:
用户发送 → 后端调 AI → AI 憋完整个回复 → 后端返回 → 前端显示
问题:
- 等 AI 憋完整个回复,可能要 5-10 秒
- 用户看着空白页面,不知道 AI 在干嘛
- 体验不好,感觉卡住了
1.2 流式输出的效果
用户发送 → 后端调 AI → AI 生成第一个字 → 立即返回 → 前端显示
→ AI 生成第二个字 → 立即返回 → 前端显示
→ ...一直循环...
效果:
- AI 边想边说,字一个个蹦出来
- 像 ChatGPT 那样,体验丝滑
- 用户知道 AI 在干活,没卡住
1.3 对比
| 方式 | 等待体验 | 代码复杂度 |
|---|---|---|
| 普通请求 | 憋完再显示,等得心慌 | 简单 |
| 流式输出 | 边生成边显示,体验好 | 稍复杂 |
二、SSE 是什么?
SSE(Server-Sent Events) 是浏览器接收服务器推送数据的技术。
2.1 SSE 的特点
- 单向:只能服务器往浏览器推,浏览器不能往服务器发
- 长连接:浏览器和服务器保持连接,持续接收数据
- 文本格式:数据格式是纯文本,每行一个事件
2.2 SSE 数据格式
服务器返回的数据格式是这样的:
data: 第一块内容
data: 第二块内容
data: 第三块内容
data: [DONE]
每行以 data: 开头,后面是实际内容。空行表示一个事件结束。
2.3 和 WebSocket 的区别
| 技术 | 方向 | 用途 |
|---|---|---|
| SSE | 单向(服务器→浏览器) | 服务器推送消息、通知、流式数据 |
| WebSocket | 双向 | 实时聊天、游戏、需要浏览器也发数据 |
我们只需要服务器往浏览器推数据,用 SSE 就够了,更简单。
三、Flask 后端实现 SSE
3.1 核心代码
from flask import Response
@app.route("/chat/stream", methods=["POST"])
def chat_stream():
user_message = request.json.get("message", "")
# 调用 AI,开启流式
data = {
"model": "glm-5",
"messages": [{"role": "user", "content": user_message}],
"stream": True # 开启流式
}
response = requests.post(AI_URL, headers=AI_HEADERS, json=data, stream=True)
# 用生成器函数返回流式数据
def generate():
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
if line.startswith("data: "):
yield line + "\n\n" # SSE 格式:每行后面加两个换行
return Response(generate(), mimetype="text/event-stream")
3.2 关键点解释
生成器函数 generate():
def generate():
for line in response.iter_lines():
yield line + "\n\n"
yield是 Python 的生成器语法- 每次
yield,就往外"吐"一块数据 - 不是一次性返回,而是边生成边返回
Response(generate(), mimetype="text/event-stream"):
Response是 Flask 的响应对象generate()是生成器,源源不断产生数据mimetype="text/event-stream"告诉浏览器这是 SSE 流
3.3 为什么不用 jsonify?
# 普通请求:一次性返回
return jsonify({ "reply": "完整回复" })
# 流式请求:源源不断返回
return Response(generate(), mimetype="text/event-stream")
jsonify() 是一次性返回完整数据,不适合流式。流式要用 Response + 生成器。
四、前端接收流式数据
4.1 用 fetch 接收
fetch("/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "你好" }),
}).then((response) => {
// 获取读取器
const reader = response.body.getReader();
const decoder = new TextDecoder();
// 循环读取
function read() {
reader.read().then(({ done, value }) => {
if (done) {
console.log("流结束");
return;
}
// 解码数据
const text = decoder.decode(value);
console.log("收到:", text);
// 继续读下一块
read();
});
}
read();
});
4.2 关键点解释
response.body.getReader():
response.body是响应的流式主体getReader()获取一个读取器,可以逐块读取
reader.read():
- 每次调用返回
{ done, value } done: true表示流结束了value是当前这块数据(Uint8Array)
TextDecoder:
- 把 Uint8Array(字节)解码成字符串
decoder.decode(value)返回文本
4.3 处理 SSE 格式
服务器返回的格式是:
data: 第一块
data: 第二块
需要解析:
function parseSSE(text) {
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6); // 去掉 "data: " 前缀
if (data === "[DONE]") {
// 流结束
} else {
// 处理数据
console.log(data);
}
}
}
}
五、完整示例代码
5.1 后端 app.py
# -*- coding: utf-8 -*-
"""
Week 2 Day 3: 流式输出在 Web 里
运行:
python app.py
然后浏览器访问 http://127.0.0.1:5000
"""
from flask import Flask, request, Response, render_template
import requests
import json
app = Flask(__name__)
# ===== AI API 配置 =====
AI_URL = "https://coding.dashscope.aliyuncs.com/v1/chat/completions"
AI_HEADERS = {
"Content-Type": "application/json",
"Authorization": "your app-key"
}
AI_MODEL = "glm-5"
# ===== 对话历史 =====
messages = []
# ===== 路由定义 =====
@app.route("/")
def index():
"""首页"""
return render_template("index.html")
@app.route("/chat/stream", methods=["POST"])
def chat_stream():
"""流式聊天 API"""
user_message = request.json.get("message", "")
if not user_message:
return Response("data: 请输入内容\n\n", mimetype="text/event-stream")
# 加入历史
messages.append({"role": "user", "content": user_message})
# 调用 AI(开启流式)
data = {
"model": AI_MODEL,
"messages": messages,
"stream": True
}
try:
response = requests.post(AI_URL, headers=AI_HEADERS, json=data, stream=True, timeout=60)
# ===== 错误处理 =====
if response.status_code != 200:
# API 返回错误,把错误信息发给前端
try:
error_info = response.json()
error_msg = error_info.get("error", {}).get("message", f"API错误: {response.status_code}")
except:
error_msg = f"API错误: {response.status_code}"
print(f"API 错误: {response.status_code} - {error_msg}")
# 把用户消息从历史里删掉(因为没成功)
messages.pop()
return Response(f"data: {error_msg}\n\ndata: [DONE]\n\n", mimetype="text/event-stream")
def generate():
full_reply = ""
print(f"AI API 状态码: {response.status_code}")
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
print(f"收到行: {line}")
if line.startswith("data: "):
json_str = line[6:]
if json_str == "[DONE]":
break
try:
chunk = json.loads(json_str)
delta = chunk["choices"][0].get("delta", {})
content = delta.get("content", "")
if content:
full_reply += content
print(f"发送内容: {content}")
yield f"data: {content}\n\n"
except json.JSONDecodeError as e:
print(f"JSON 解析错误: {e}")
pass
# 流结束后,把完整回复加入历史
if full_reply:
messages.append({"role": "assistant", "content": full_reply})
# 发送结束信号
yield "data: [DONE]\n\n"
return Response(generate(), mimetype="text/event-stream")
except requests.exceptions.RequestException as e:
# 网络错误
messages.pop()
return Response(f"data: 网络错误: {str(e)}\n\ndata: [DONE]\n\n", mimetype="text/event-stream")
@app.route("/clear", methods=["POST"])
def clear():
"""清空对话历史"""
messages.clear()
return {"status": "ok"}
# ===== 启动 =====
if __name__ == "__main__":
print("启动 Flask 服务器...")
print("访问 http://127.0.0.1:5000 开始聊天")
app.run(debug=True, port=5000)
5.2 前端 index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>流式聊天</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 20px auto;
}
#chat-box {
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
padding: 10px;
}
.user {
color: blue;
}
.ai {
color: green;
}
</style>
</head>
<body>
<h1>流式聊天</h1>
<div id="chat-box"></div>
<input type="text" id="user-input" placeholder="输入内容..." />
<button id="send-btn">发送</button>
<script>
document.getElementById("send-btn").onclick = sendMessage;
function sendMessage() {
const input = document.getElementById("user-input");
const text = input.value.trim();
if (!text) return;
addMessage("user", text);
input.value = "";
// 创建 AI 消息容器(流式输出时往这里追加)
const aiDiv = addMessage("ai", "");
fetch("/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
}).then((response) => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({ done, value }) => {
if (done) return;
const text = decoder.decode(value);
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const content = line.slice(6);
if (content !== "[DONE]") {
aiDiv.innerText += content; // 追加内容
}
}
}
read(); // 继续读
});
}
read();
});
}
function addMessage(role, text) {
const chatBox = document.getElementById("chat-box");
const div = document.createElement("div");
div.className = role;
div.innerText = text;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
return div; // 返回元素,方便后续追加
}
</script>
</body>
</html>
六、实际运行日志分析
6.1 正常请求的日志
发送"你好"后,终端日志是这样的:
AI API 状态码: 200
收到行: data: {"choices":[{"delta":{"content":null,"reasoning_content":"收到","role":"assistant"},...}]}
收到行: data: {"choices":[{"delta":{"content":null,"reasoning_content":"用户发"},...}]}
...(很多 reasoning_content)...
收到行: data: {"choices":[{"delta":{"content":"你好!","reasoning_content":null},...}]}
发送内容: 你好!
收到行: data: {"choices":[{"delta":{"content":"很高兴见到你。我是GL",...}]}
发送内容: 很高兴见到你。我是GL
...
收到行: data: [DONE]
6.2 GLM-5 返回格式的特殊之处
GLM-5 模型的返回有两个字段:
| 字段 | 含义 |
|---|---|
reasoning_content | 思考过程(AI 内部的推理内容,不显示给用户) |
content | 实际输出内容(显示给用户) |
流程是这样的:
1. AI 先"思考" → 返回 reasoning_content(一堆思考内容)
2. AI 思考完毕 → 返回 content(实际要说的内容)
3. 最后返回 data: [DONE]
所以代码里要判断:
content = delta.get("content", "") # 只取 content,不取 reasoning_content
if content:
yield f"data: {content}\n\n" # 只发送实际内容
6.3 错误请求的日志
API Key 失效时(401):
API 错误: 401 - invalid access token or token expired
127.0.0.1 - - [14/Apr/2026 17:53:12] "POST /chat/stream HTTP/1.1" 200 -
为什么 HTTP 状态码是 200?
因为 SSE 响应一旦开始,HTTP 状态码就固定了。后端把错误信息作为 SSE 数据返回给前端,前端收到后会显示错误信息。
6.4 Flask Debug 模式
日志里经常看到:
* Detected change in 'd:\\testcode\\学习文件夹\\week2\\02_stream_web\\app.py', reloading
* Restarting with stat
这是因为 app.run(debug=True),代码改动后 Flask 会自动重启,不用手动重启。
七、错误处理
7.1 为什么需要错误处理?
如果 API Key 失效或网络出问题,前端只收到 [DONE],没有任何提示,用户不知道发生了什么。
7.2 错误处理代码
try:
response = requests.post(AI_URL, headers=AI_HEADERS, json=data, stream=True, timeout=60)
# ===== 错误处理 =====
if response.status_code != 200:
# 解析错误信息
try:
error_info = response.json()
error_msg = error_info.get("error", {}).get("message", f"API错误: {response.status_code}")
except:
error_msg = f"API错误: {response.status_code}"
print(f"API 错误: {response.status_code} - {error_msg}") # 终端日志
# 把用户消息从历史里删掉(因为没成功)
messages.pop()
# 返回错误信息给前端
return Response(f"data: {error_msg}\n\ndata: [DONE]\n\n", mimetype="text/event-stream")
# 正常处理流式数据...
except requests.exceptions.RequestException as e:
# 网络错误
messages.pop()
return Response(f"data: 网络错误: {str(e)}\n\ndata: [DONE]\n\n", mimetype="text/event-stream")
7.3 错误处理的效果
| 错误 | 前端显示 |
|---|---|
| API Key 失效(401) | "invalid access token or token expired" |
| API 其他错误 | 具体错误信息 |
| 网络超时/连接失败 | "网络错误: xxx" |
八、踩坑记录
Q1: 流式输出没效果,还是一次性显示
咋回事:可能 Flask 没用 Response + 生成器,或者 mimetype 写错了
咋办:检查返回方式:
# 错误
return jsonify(data) # 一次性返回
# 正确
return Response(generate(), mimetype="text/event-stream") # 流式返回
Q2: 前端报错 response.body.getReader is not a function
咋回事:fetch 返回的 response 没有 body 属性,可能是浏览器不支持
咋办:确保用现代浏览器(Chrome、Firefox、Edge),IE 不支持
Q3: 中文乱码
咋回事:解码时编码不一致
咋办:
- 后端:
line.decode('utf-8') - 前端:
new TextDecoder()默认就是 UTF-8
Q4: 流式输出时页面卡住,不能输入
咋回事:JavaScript 单线程,如果处理逻辑太复杂会阻塞
咋办:用 async/await 或确保处理逻辑简单:
async function read() {
const { done, value } = await reader.read();
if (done) return;
// 处理...
read();
}
Q5: Flask 报错 "generator didn't yield"
咋回事:生成器没有 yield 任何数据
咋办:检查 generate() 函数,确保有数据进来时 yield:
def generate():
for line in response.iter_lines():
if line:
yield f"data: {line}\n\n" # 确保有 yield
Q6: API Key 失效,前端只显示 [DONE]
咋回事:没有错误处理,API 返回 401 时前端不知道发生了什么
咋办:加错误处理,把错误信息返回给前端:
if response.status_code != 200:
error_msg = response.json().get("error", {}).get("message", "未知错误")
return Response(f"data: {error_msg}\n\ndata: [DONE]\n\n", mimetype="text/event-stream")
Q7: 日志里看到很多 reasoning_content,但没有显示出来
咋回事:GLM-5 模型会先返回思考过程(reasoning_content),然后才返回实际内容(content)
咋办:代码里只取 content 字段:
content = delta.get("content", "") # 只取 content
# 不取 reasoning_content,那是思考过程,不显示给用户
九、知识点总结
9.1 核心概念
| 概念 | 是什么 |
|---|---|
| SSE | Server-Sent Events,服务器往浏览器推送数据 |
| yield | Python 生成器语法,边生成边返回 |
| Response + mimetype | Flask 流式响应的正确写法 |
| getReader() | 前端获取流式读取器 |
| TextDecoder | 把字节解码成字符串 |
9.2 Python f-string
f"..." 是 Python 的格式化字符串,类似前端的模板字符串:
# Python
name = "张三"
text = f"我叫{name},今年{18}岁"
print(text) # 输出:我叫张三,今年18岁
# JavaScript 等价写法
const text = `我叫${name},今年${18}岁`
在代码里的用法:
line = "你好世界"
yield f"data: {line}\n\n"
# 输出:data: 你好世界\n\n
就是把 {变量} 替换成实际的值。
十、今日总结
今天干了啥:
✅ 理解了 SSE 流式输出的原理
✅ 学会了 Flask 后端用生成器返回流式数据
✅ 学会了前端用 fetch + getReader 接收流式数据
✅ 升级了聊天页面,实现了 AI 边想边说的效果
明天干嘛:整合英语 Agent 的所有功能到 Web 版。
记于 2026-04-14,流式输出搞定,体验丝滑!