前端转 AI 100 天 Day 33:流式输出——让 AI 像打字机一样回复

0 阅读9分钟

前端转 AI 第 33 天:流式输出 - 让 AI 像打字机一样回复

昨日回顾

Day 32 把 LLM 客户端工程化了,重试、超时、降级、限流全有了。

但用户体验还是差——发一个问题,转圈等 3-5 秒,然后"唰"地一下全部出现。

ChatGPT 为什么爽?因为它是一个字一个字往外蹦。

这个效果叫流式输出(Streaming),今天把它加上。

Day 33 目标:搞懂 SSE 协议,让 FastAPI 支持流式,前端收到"打字机效果"。


一、为什么需要流式输出

1. 非流式的痛苦

当前体验:

用户:解释一下什么是递归
     ↓
(等待 5 秒,转圈圈)
     ↓
AI:递归是一种编程技术,它允许函数调用自身...

问题:

问题后果
首字节延迟高用户等 5 秒才看到第一个字
无法预估时长不知道要等多久
长回答体验崩生成 2000 字要等 30 秒
无法中断看到一半想停也停不了

用户的心理:

  • 等 1 秒:可接受
  • 等 3 秒:开始焦虑
  • 等 10 秒:以为挂了
  • 等 30 秒:直接关掉

2. 流式的爽点

改造后:

用户:解释一下什么是递归
     ↓
(0.5 秒)
     ↓
AI:递归是...(后面还在继续出字)

体验差异:

维度非流式流式
首字节时间5s0.5s
用户感知慢快
可中断❌✅
长回答等很久边看边出
心理感受焦虑舒适

核心认知:

用户要的不是"总时长短",而是"感觉快"。

流式输出让"首字节时间"从 5 秒降到 0.5 秒——总时长没变,但感知快 10 倍。

3. 前端视角:流式就是"边下边播"

做过前端的你肯定熟悉:

  • 视频流:边下边播,不等整个文件下完
  • 图片渐进式加载:先模糊后清晰
  • Suspense:先显示骨架屏,后替换内容

流式输出就是"文本版的边下边播"。

技术上用 SSE 实现,前端用 EventSource 接收。


二、SSE 协议详解

SSE = Server-Sent Events,服务器推送事件。

1. SSE 是什么

一句话:服务器可以不断往客户端推数据,客户端只负责接收。

和 WebSocket 的区别:

特性SSEWebSocket
方向单向(服务器→客户端)双向
协议HTTP独立协议(ws://)
复杂度简单复杂
自动重连内置要自己实现
浏览器支持好好
适用场景流式文本、通知聊天、游戏、实时协作

LLM 流式输出用 SSE——单向、简单、够用。

2. SSE 的数据格式

SSE 是一种特殊的 HTTP 响应:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: 第一段数据

data: 第二段数据

data: 第三段数据

规则:

  1. Content-Type: text/event-stream(必须)
  2. 每条消息以 data: 开头
  3. 每条消息以 \n\n 结尾(两个换行)
  4. 可以包含其他字段:event: / id: / retry:

更完整的例子:

event: message
id: 1
data: {"text": "你好"}

event: message
id: 2
data: {"text": "世界"}

event: done
data: [DONE]

OpenAI 的流式响应格式(简化):

data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: {"choices":[{"delta":{"content":"呀"}}]}

data: [DONE]

每行都是一个 JSON,最后一个 [DONE] 表示结束。

3. SSE vs 普通 JSON

普通 JSON 响应:

HTTP/1.1 200 OK
Content-Type: application/json

{"reply": "完整的回复"}

SSE 响应:

HTTP/1.1 200 OK
Content-Type: text/event-stream

data: {"delta": "你"}

data: {"delta": "好"}

data: {"delta": "呀"}

区别:

  • JSON:一次返回完整数据
  • SSE:分多次返回碎片数据

核心:连接不关闭,持续推送。

4. 为什么不用 WebSocket

新手常问:WebSocket 不也能推送吗?

能,但对流式输出来说是"杀鸡用牛刀":

维度SSEWebSocket
协议基于 HTTP独立协议
服务器普通 HTTP 服务需要协议升级
反向代理和普通请求一样需要特殊配置
负载均衡简单麻烦
客户端 APIEventSourceWebSocket
断线重连自动手动
二进制数据❌✅

对"文本流式输出"这种场景,SSE 完胜。

WebSocket 更适合"双向通信 + 高频消息"——比如聊天室、游戏。

5. SSE 的局限

不是万能的:

  • ❌ 不能传二进制(只能文本)
  • ❌ 浏览器对并发 SSE 有限制(同域名最多 6 个)
  • ❌ 不能自定义 header(EventSource 的限制,除非用 polyfill)
  • ❌ 移动端后台不能持续接收

LLM 流式输出只需要"服务器推文本",完美匹配。


三、OpenAI SDK 的流式 API

先看底层怎么拿到流式数据。

1. 最简单的流式调用

创建 day33/simple_stream.py:

"""最简单的流式调用"""
import os
from openai import OpenAI


client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1",
)


# 关键:stream=True
stream = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "用三句话介绍 Python"}],
    stream=True,     # ← 关键
)


# 逐块接收
for chunk in stream:
    content = chunk.choices[0].delta.content
    if content:
        print(content, end="", flush=True)

print()   # 最后换行

输出效果:

Python 是一种高级编程语言。它简洁易读,适合初学者。
它拥有丰富的第三方库,广泛用于数据分析、AI 和 Web 开发。

看起来像一次性输出,实际是一个字一个字地打印出来的。

flush=True 是关键——否则 Python 会缓冲,看起来还是一次性输出。

2. 理解 chunk 结构

打印每个 chunk:

for chunk in stream:
    print(chunk)
    print("---")

输出:

ChatCompletionChunk(
    id='chatcmpl-xxx',
    choices=[
        Choice(
            delta=ChoiceDelta(content='Python', role=None, ...),
            finish_reason=None,
            index=0,
        )
    ],
    model='deepseek-chat',
    ...
)
---
ChatCompletionChunk(
    id='chatcmpl-xxx',
    choices=[
        Choice(
            delta=ChoiceDelta(content=' 是', role=None, ...),
            finish_reason=None,
            index=0,
        )
    ],
    ...
)
---
...

关键点:

  • chunk.choices[0].delta.content:本次新增的文本片段
  • chunk.choices[0].finish_reason:结束原因(最后一条才有)

注意是 delta 不是 message:

  • 非流式:choices[0].message.content
  • 流式:choices[0].delta.content

3. 处理首个和末尾 chunk

第一个 chunk 可能没有 content(只有 role):

for chunk in stream:
    delta = chunk.choices[0].delta
    if delta.content is not None:
        print(delta.content, end="", flush=True)
    # delta.role 可能是 "assistant"(首个 chunk)

delta.content 为 None 的 chunk 要跳过——通常出现在第一个和最后一个。

完整的逐块处理:

for chunk in stream:
    if not chunk.choices:
        continue

    choice = chunk.choices[0]
    delta = choice.delta

    # 内容
    if delta.content:
        print(delta.content, end="", flush=True)

    # 结束
    if choice.finish_reason:
        print(f"\n[结束原因: {choice.finish_reason}]")

4. usage 信息

流式默认不返回 usage(因为生成过程中不知道总量)。

要拿 usage 需要额外参数:

stream = client.chat.completions.create(
    model="deepseek-chat",
    messages=[...],
    stream=True,
    stream_options={"include_usage": True},   # ← 关键
)

for chunk in stream:
    # 最后一个 chunk 会带 usage
    if chunk.usage:
        print(f"\nPrompt tokens: {chunk.usage.prompt_tokens}")
        print(f"Completion tokens: {chunk.usage.completion_tokens}")

注意:这个参数不是所有厂商都支持。 用之前查一下。

5. 异步流式

FastAPI 场景下要用异步:

import asyncio
from openai import AsyncOpenAI


client = AsyncOpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com/v1",
)


async def main():
    stream = await client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user", "content": "你好"}],
        stream=True,
    )

    async for chunk in stream:
        content = chunk.choices[0].delta.content
        if content:
            print(content, end="", flush=True)


asyncio.run(main())

区别:

  • await client...create(...):获取 stream 对象要 await
  • async for chunk in stream:迭代用 async for

Day 31 的 stream_chat 方法已经是这个写法,直接能用。


四、FastAPI 的 SSE 实现

现在把流式输出暴露成 HTTP 接口。

1. 用 StreamingResponse

FastAPI 内置 StreamingResponse——最简单的实现:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()


async def generate_stream():
    """异步生成器,逐块 yield"""
    for i in range(5):
        yield f"data: 第 {i} 块\n\n"
        await asyncio.sleep(0.5)


@app.get("/stream")
async def stream():
    return StreamingResponse(
        generate_stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",     # 禁用 Nginx 缓冲
        },
    )

关键点:

1. generate_stream() 是异步生成器

用 yield 逐块返回,FastAPI 会边生成边发给客户端。

2. media_type="text/event-stream"

告诉浏览器这是 SSE 流。

3. X-Accel-Buffering: no

禁用 Nginx 缓冲——不加的话 Nginx 会攒一批再发,流式失效。

4. 块格式:data: xxx\n\n

SSE 协议要求。

2. 测试

curl -N http://localhost:8000/stream

-N 禁用 curl 缓冲,能实时看到输出:

data: 第 0 块

data: 第 1 块

data: 第 2 块

...

每 0.5 秒出一条。

3. 完整 LLM 流式接口

整合到我们的 API:

src/ai_assistant/api/routes.py 加新接口:

import json
from fastapi.responses import StreamingResponse

from ..llm_real import RealLLMError


@router.post("/chat/stream")
async def chat_stream(
    req: ChatRequest,
    storage = Depends(get_storage),
    llm = Depends(get_llm),
    memory = Depends(get_memory),
):
    """流式对话接口"""
    logger.info("流式请求", message_length=len(req.message))

    # 1. 加载/创建会话
    if req.session_id:
        session = storage.load(req.session_id)
        if not session:
            raise NotFoundError(f"会话不存在: {req.session_id}")
    else:
        import uuid
        session = SessionModel(session_id=uuid.uuid4().hex[:8])

    session.add_message("user", req.message)

    # 2. 准备上下文
    context = memory.get_context(session.messages)
    messages = [{"role": "system", "content": "你是一个友好的 AI 助手"}]
    messages.extend(m.to_llm_format() for m in context)

    # 3. 异步生成器
    async def event_generator():
        try:
            # 发送会话元信息
            meta = {
                "type": "meta",
                "session_id": session.session_id,
            }
            yield f"data: {json.dumps(meta, ensure_ascii=False)}\n\n"

            # 流式接收 LLM 输出
            full_reply = []
            async for chunk in llm.stream_chat(messages):
                full_reply.append(chunk)

                data = {
                    "type": "delta",
                    "content": chunk,
                }
                yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

            # 保存会话
            reply = "".join(full_reply)
            session.add_message("assistant", reply)
            storage.save(session)

            # 发送完成信号
            done = {
                "type": "done",
                "message_count": session.message_count,
            }
            yield f"data: {json.dumps(done, ensure_ascii=False)}\n\n"

        except RealLLMError as e:
            error = {
                "type": "error",
                "message": str(e),
            }
            yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )

4. 事件类型设计

SSE 消息用 type 字段区分语义:

type含义数据
meta会话元信息session_id
delta内容片段content
done生成完成message_count
error出错message

前端按 type 分别处理。

完整事件流示例:

data: {"type":"meta","session_id":"a3f9b2e1"}

data: {"type":"delta","content":"递归"}

data: {"type":"delta","content":"是"}

data: {"type":"delta","content":"一种"}

...

data: {"type":"done","message_count":2}

清晰、可扩展、易调试。

5. 测试

curl -N -X POST http://localhost:8000/api/chat/stream \
  -H "Content-Type: application/json" \
  -H "X-API-Key: test-key" \
  -d '{"message": "用三句话介绍 Python"}'

输出(实时流):

data: {"type":"meta","session_id":"a3f9b2e1"}

data: {"type":"delta","content":"Python"}

data: {"type":"delta","content":"是一种"}

data: {"type":"delta","content":"高级"}

data: {"type":"delta","content":"编程语言"}

...

data: {"type":"done","message_count":2}

🎉 后端流式接口完成。


五、前端如何接收 SSE

现在讲前端怎么接。这是你熟悉的部分。

1. 方式一:EventSource(原生,简单)

EventSource 是浏览器内置的 SSE 客户端:

const eventSource = new EventSource("/api/chat/stream");

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log(data);
};

eventSource.onerror = (error) => {
  console.error("SSE 错误:", error);
  eventSource.close();
};

问题:

EventSource 只支持 GET 请求,不能传自定义 Header。

但我们有两个需求:

  • POST 请求(要传 message)
  • 自定义 Header(X-API-Key 认证)

EventSource 无能为力。

2. 方式二:fetch + ReadableStream(推荐)

用 fetch 手动处理流:

async function streamChat(message, sessionId, onChunk, onDone) {
  const response = await fetch("/api/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": "your-key",
    },
    body: JSON.stringify({
      message,
      session_id: sessionId,
    }),
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    // SSE 以 \n\n 分割每条消息
    const messages = buffer.split("\n\n");
    buffer = messages.pop() || "";   // 最后一段可能不完整

    for (const msg of messages) {
      if (!msg.trim()) continue;

      // 解析 "data: xxx"
      const line = msg.split("\n").find((l) => l.startsWith("data: "));
      if (!line) continue;

      const data = JSON.parse(line.slice(6));   // 去掉 "data: "

      if (data.type === "delta") {
        onChunk(data.content);
      } else if (data.type === "done") {
        onDone(data);
      } else if (data.type === "error") {
        throw new Error(data.message);
      }
    }
  }
}

使用:

let fullText = "";

await streamChat(
  "用三句话介绍 Python",
  null,
  (chunk) => {
    fullText += chunk;
    document.querySelector("#output").textContent = fullText;
  },
  (meta) => {
    console.log("完成", meta);
  }
);

核心思路:

  1. fetch 发 POST 请求
  2. response.body.getReader() 拿到读流
  3. 循环读块,解析 data: xxx
  4. 按 type 分别处理

为什么用 TextDecoder?

流式数据是 Uint8Array(字节),不是字符串。 TextDecoder 负责转字符串。

{ stream: true } 很重要——跨块的多字节字符(比如中文)可能被拆开,加这个参数保证解码正确。

3. 更优雅的封装:AsyncGenerator

前端也能玩"生成器"——用 async function* 封装成可迭代对象:

async function* streamChatAsync(message, sessionId) {
  const response = await fetch("/api/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": "your-key",
    },
    body: JSON.stringify({ message, session_id: sessionId }),
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });

      const messages = buffer.split("\n\n");
      buffer = messages.pop() || "";

      for (const msg of messages) {
        if (!msg.trim()) continue;
        const line = msg.split("\n").find((l) => l.startsWith("data: "));
        if (!line) continue;

        yield JSON.parse(line.slice(6));
      }
    }
  } finally {
    reader.releaseLock();
  }
}

使用(超简洁):

let fullText = "";

for await (const event of streamChatAsync("你好", null)) {
  if (event.type === "delta") {
    fullText += event.content;
    updateUI(fullText);
  } else if (event.type === "done") {
    break;
  }
}

for await...of 是前端也能玩的语法——和 Python 的 async for 完全对应。

Python 后端用 async for,JS 前端用 for await——两种语言在异步流上完全对齐。

4. React 封装示例

在 React 里做流式聊天:

import { useState, useRef } from "react";

function useStreamChat() {
  const [messages, setMessages] = useState([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef(null);

  const send = async (text) => {
    // 加用户消息
    setMessages((prev) => [...prev, { role: "user", content: text }]);

    // 加一个空的 assistant 消息(流式填充)
    setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
    setIsStreaming(true);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const response = await fetch("/api/chat/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: text }),
        signal: controller.signal,
      });

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const msgs = buffer.split("\n\n");
        buffer = msgs.pop() || "";

        for (const msg of msgs) {
          if (!msg.trim()) continue;
          const line = msg.split("\n").find((l) => l.startsWith("data: "));
          if (!line) continue;

          const data = JSON.parse(line.slice(6));

          if (data.type === "delta") {
            setMessages((prev) => {
              const newMessages = [...prev];
              const last = newMessages[newMessages.length - 1];
              newMessages[newMessages.length - 1] = {
                ...last,