流式响应 SSE 开发指南:原理、实现与踩坑记录

3 阅读6分钟

一、为什么要用流式输出?

大模型生成文本是逐 token 输出的,一个完整回答可能要 3~10 秒才能生成完毕。

非流式(等全部生成再返回):

  • 用户盯着空白页等 5 秒
  • 心理上感觉"卡了"、"没反应"
  • 首字节时间(TTFB)= 完整生成时间

流式(生成一点返回一点):

  • 第一个字 200ms 内就出现
  • 用户看到"正在打字"的感觉
  • TTFB 极低,体验好得多

这就是为什么几乎所有 AI 产品都用流式输出——不是技术炫技,是用户体验的基本要求


二、SSE 协议基础

流式 AI 响应基于 Server-Sent Events(SSE) 协议,非常简单:

响应头:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

数据格式:

data: {"choices":[{"delta":{"content":"你好"}}]}\n\n
data: {"choices":[{"delta":{"content":",今天"}}]}\n\n
data: {"choices":[{"delta":{"content":"天气不错"}}]}\n\n
data: [DONE]\n\n

几个关键点:

  • 每行以 data: 开头
  • 每个事件末尾两个换行 \n\n
  • 流结束时发送 data: [DONE]
  • 客户端收到 [DONE] 后关闭连接

三、Python 流式调用

以 DeepSeek 为例,使用 openai 库的 stream=True 参数:

from openai import OpenAI

client = OpenAI(
    api_key="your-api-key",
    base_url="https://api.deepseek.com/v1",
)

def stream_chat(prompt: str):
    stream = client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user", "content": prompt}],
        stream=True,  # 开启流式
        max_tokens=1024,
    )

    full_text = ""
    for chunk in stream:
        # 每个 chunk 是一个增量片段
        delta = chunk.choices[0].delta

        if delta.content:
            print(delta.content, end="", flush=True)
            full_text += delta.content

        # 检查结束原因
        finish_reason = chunk.choices[0].finish_reason
        if finish_reason == "length":
            print("\n[警告:输出被截断,max_tokens 不够]")
        elif finish_reason == "stop":
            pass  # 正常结束

    print()  # 换行
    return full_text


if __name__ == "__main__":
    stream_chat("用三句话介绍一下量子计算")

finish_reason 说明:

含义处理建议
stop正常结束无需处理
length触发 max_tokens 截断提示用户或增大 max_tokens
content_filter内容过滤提示用户修改输入
tool_calls模型调用工具见 Function Calling 篇

四、Node.js 流式调用

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: "your-api-key",
  baseURL: "https://api.deepseek.com/v1",
});

async function streamChat(prompt) {
  const stream = await client.chat.completions.create({
    model: "deepseek-chat",
    messages: [{ role: "user", content: prompt }],
    stream: true,
    max_tokens: 1024,
  });

  let fullText = "";

  for await (const chunk of stream) {
    const delta = chunk.choices[0]?.delta;
    if (delta?.content) {
      process.stdout.write(delta.content);
      fullText += delta.content;
    }
  }

  console.log(); // 换行
  return fullText;
}

streamChat("解释一下什么是递归,用一个简单例子");

如果你用的是 Fastify/Express 做 API 服务,把 SSE 透传给前端:

// Express 示例:把模型流式响应透传给前端
app.get("/api/chat", async (req, res) => {
  const { prompt } = req.query;

  // 设置 SSE 响应头
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.flushHeaders();

  try {
    const stream = await client.chat.completions.create({
      model: "deepseek-chat",
      messages: [{ role: "user", content: prompt }],
      stream: true,
    });

    for await (const chunk of stream) {
      const data = JSON.stringify(chunk);
      res.write(`data: ${data}\n\n`);
    }

    res.write("data: [DONE]\n\n");
  } catch (err) {
    res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
  } finally {
    res.end();
  }
});

五、前端接收 SSE

浏览器接收 SSE 有两种方式:EventSource API 和 fetch + ReadableStream

AI 场景推荐 fetch,因为 EventSource 不支持 POST 请求,也不支持自定义请求头(比如携带 token)。

async function streamToUI(prompt, onChunk, onDone) {
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${getToken()}`,
    },
    body: JSON.stringify({ prompt }),
  });

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

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

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

    // 解码二进制数据
    buffer += decoder.decode(value, { stream: true });

    // 按行切割,处理粘包
    const lines = buffer.split("\n");
    buffer = lines.pop(); // 最后一行可能不完整,留到下次

    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;

      const raw = line.slice(6).trim(); // 去掉 "data: " 前缀
      if (raw === "[DONE]") {
        onDone?.();
        return;
      }

      try {
        const chunk = JSON.parse(raw);
        const content = chunk.choices?.[0]?.delta?.content;
        if (content) {
          onChunk(content);
        }
      } catch {
        // 忽略解析失败的行
      }
    }
  }
}

React 组件使用示例:

function ChatBox() {
  const [answer, setAnswer] = useState("");
  const [loading, setLoading] = useState(false);

  const handleAsk = async (question) => {
    setAnswer("");
    setLoading(true);

    await streamToUI(
      question,
      (chunk) => setAnswer((prev) => prev + chunk),  // 追加增量文本
      () => setLoading(false)
    );
  };

  return (
    <div>
      <button onClick={() => handleAsk("什么是大语言模型?")}>提问</button>
      <div className="answer">
        {answer}
        {loading && <span className="cursor"></span>}
      </div>
    </div>
  );
}

六、常见问题排查

问题 1:明明调用了流式,前端却"一次性输出"

原因:Nginx 开启了代理缓冲(proxy_buffering on 是默认值)。

Nginx 默认会把后端响应攒够一批再转发给客户端,这让 SSE 完全失效。

解决:在 location 块里关闭缓冲:

location /api/ {
    proxy_pass http://backend;
    proxy_buffering off;           # 关闭响应缓冲 ✓
    proxy_cache off;               # 关闭缓存
    proxy_set_header Connection ''; # 防止 keep-alive 超时
    chunked_transfer_encoding on;  # 开启分块传输
}

问题 2:流式输出中途断开

可能原因:

  1. Nginx proxy_read_timeout 太短(默认 60s)

    大模型长文本生成可能超过 60 秒,需要调大:

    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    
  2. CDN/负载均衡超时

    各云厂商 CDN 有自己的空闲超时。可以让后端每隔 15 秒发一个空注释保活:

    : keep-alive\n\n
    
  3. 客户端网络中断

    在前端加重试:

    async function streamWithRetry(prompt, onChunk, onDone, maxRetries = 3) {
      let lastError;
      for (let i = 0; i < maxRetries; i++) {
        try {
          await streamToUI(prompt, onChunk, onDone);
          return;
        } catch (err) {
          lastError = err;
          const delay = Math.min(1000 * 2 ** i, 8000); // 指数退避:1s, 2s, 4s
          await new Promise((r) => setTimeout(r, delay));
        }
      }
      throw lastError;
    }
    

问题 3:200 状态码但中途返回错误

流式场景下,HTTP 状态码在第一个字节发出时就已经确定了。如果模型 API 先返回 200,然后中途发生错误,状态码不会变成 4xx/5xx

正确处理方式:在流中检查是否有 error 字段:

try {
  const chunk = JSON.parse(raw);
  if (chunk.error) {
    throw new Error(chunk.error.message || "模型返回错误");
  }
  const content = chunk.choices?.[0]?.delta?.content;
  // ...
}

问题 4:finish_reason: length — 输出被截断

模型在 max_tokens 耗尽前没有生成完整回答。

# 检测截断
for chunk in stream:
    if chunk.choices[0].finish_reason == "length":
        # 1. 提示用户
        # 2. 或者自动续写:用当前已生成内容再发一次请求
        pass

七、生产环境 Nginx 配置模板

笔者在 TheRouter 网关中处理过大量 SSE 流式转发,上述几个坑几乎都踩过一遍。其中最隐蔽的是 CDN 层的空闲超时——云厂商文档里往往不会明确写出默认值,只有流量跑起来才会发现 30 秒断流的问题。

upstream ai_backend {
    server 127.0.0.1:3030;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # SSL 配置省略...

    location /v1/ {
        proxy_pass http://ai_backend;

        # SSE 必要配置
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
        proxy_connect_timeout 10s;

        # 保持 HTTP/1.1(HTTP/2 对 SSE 支持更好,但需要后端也支持)
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        # 标准头
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # 允许跨域(如果前端和 API 不同域)
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Headers "Authorization, Content-Type";
    }
}

总结

问题解决方案
缓冲导致一次性输出Nginx proxy_buffering off
流中途断开调大 proxy_read_timeout,加保活注释
客户端网络抖动指数退避重试
中途报错检测解析 chunk 里的 error 字段
输出被截断检查 finish_reason: length,增大 max_tokens

流式响应的核心在于:后端不缓冲、Nginx 不缓冲、前端逐块渲染。只要把这三层的缓冲全部关掉,剩下的就只是按格式解析 data: 行了。


代码已在 DeepSeek API + Nginx 生产环境验证。如有问题欢迎评论区交流。

作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai