第2周 Day 3:让 AI 边想边说:Web 流式输出实战

0 阅读10分钟

学习目标

  • 理解 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 就够了,更简单。

image.png


三、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);
      }
    }
  }
}

动画.gif


五、完整示例代码

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 核心概念

概念是什么
SSEServer-Sent Events,服务器往浏览器推送数据
yieldPython 生成器语法,边生成边返回
Response + mimetypeFlask 流式响应的正确写法
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,流式输出搞定,体验丝滑!

image.png