大模型流式输出 Streaming API 完整教程:从原理到踩坑,一篇搞定

17 阅读1分钟

上周接了个需求,给内部知识库问答加「打字机效果」——就是像 ChatGPT 那样一个字一个字蹦出来,而不是等半天突然啪一大坨文字糊你脸上。

说实话之前一直觉得这个很简单,不就是 SSE 嘛。结果真上手写才发现,光是把各家大模型的流式响应跑通、前端正确解析、处理各种断流异常,就折腾了两天。

把完整的踩坑过程和最终方案整理出来,前后端代码都能直接跑。

先说结论

环节关键点坑的密集程度
后端调用stream=True 参数 + 逐 chunk 迭代⭐⭐
传输协议SSE(Server-Sent Events)⭐⭐⭐
前端解析fetch + ReadableStream,别用 EventSource⭐⭐⭐⭐
异常处理断流、超时、不完整 JSON⭐⭐⭐⭐⭐

流式输出本身不复杂,但把链路串起来的细节非常多,任何一环出问题用户看到的都是「卡住了」或者「乱码」。

流式输出到底是什么?

非流式请求:你发请求 → 模型花 10 秒生成完整回答 → 一次性返回。用户这 10 秒看到的是一片空白或者 loading 转圈。

流式请求:你发请求 → 模型生成一个 token 就推一个 token → 前端拿到就渲染。用户几乎毫秒级就能看到第一个字,体验完全不同。

底层是 SSE(Server-Sent Events),本质上是一个持久的 HTTP 连接,服务端不断往里写 data: ...\n\n 格式的文本。不是 WebSocket,比 WebSocket 简单得多,单向推送够用了。

后端实现:Python 调用流式 API

基础调用

from openai import OpenAI

client = OpenAI(
    api_key="your-key",
    base_url="https://api.ofox.ai/v1"  # 聚合接口,一个 Key 切不同模型
)

# 关键就是 stream=True
response = client.chat.completions.create(
    model="gpt-5.4",
    messages=[{"role": "user", "content": "用 Python 写一个快排"}],
    stream=True
)

# 逐 chunk 迭代
for chunk in response:
    # 每个 chunk 的结构和非流式响应不一样
    if chunk.choices[0].delta.content is not None:
        print(chunk.choices[0].delta.content, end="", flush=True)

跑起来就能在终端看到打字机效果了。几个注意点:

  1. delta 不是 message:流式响应里每个 chunk 的内容在 choices[0].delta.content,不是 choices[0].message.content,刚开始很容易搞混。
  2. content 可能是 None:第一个和最后一个 chunk 通常 content 为 None,必须判空。
  3. flush=True 很重要:不加的话 Python 会缓冲输出,看到的还是一坨一坨的。

用 FastAPI 把流式响应转发给前端

实际项目里不能让前端直接调大模型 API(Key 会暴露),得有个后端中转。用 FastAPI 的 StreamingResponse 很方便:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import OpenAI
import json

app = FastAPI()

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


def generate_stream(prompt: str):
    """生成器函数,yield SSE 格式的数据"""
    response = client.chat.completions.create(
        model="gpt-5.4",
        messages=[{"role": "user", "content": prompt}],
        stream=True
    )

    for chunk in response:
        content = chunk.choices[0].delta.content
        if content is not None:
            # SSE 格式:data: {json}\n\n
            data = json.dumps({"content": content}, ensure_ascii=False)
            yield f"data: {data}\n\n"

    # 发送结束标记
    yield "data: [DONE]\n\n"


@app.post("/chat/stream")
async def chat_stream(prompt: str):
    return StreamingResponse(
        generate_stream(prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",  # 防止 Nginx 缓冲
        }
    )

这里有个大坑要单独说一下。

踩坑记录

坑 1:Nginx 把流式响应攒成一坨

这个排查了大半天。本地开发一切正常,打字机效果丝滑。一部署到测试服务器,前端又变成等半天一坨出来了。

原因是 Nginx 默认开着 proxy_buffering,会把上游响应攒够一定量才往客户端发。普通请求没问题,SSE 就是灾难。

在 Nginx 配置里加:

location /chat/stream {
    proxy_pass http://backend;
    proxy_buffering off;           # 关掉缓冲
    proxy_cache off;               # 关掉缓存
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

也可以像上面代码那样加 X-Accel-Buffering: no 响应头,Nginx 看到这个头会自动关闭缓冲。两种方式都行,我两个都加了。

坑 2:前端用 EventSource 踩的雷

浏览器原生有个 EventSource API 专门接 SSE,看起来很美好:

// ❌ 别用这个
const es = new EventSource('/chat/stream?prompt=hello');
es.onmessage = (e) => {
    console.log(e.data);
};

问题在于:

  • 只支持 GET 请求——聊天接口通常是 POST,消息体可能很长,不适合放 URL 参数里
  • 不能自定义 Header——加个 Authorization?不支持
  • 断线重连是黑盒——自动重连,但你控制不了重连策略

fetch + ReadableStream 才是正解:

async function chatStream(prompt) {
    const response = await fetch('/chat/stream', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer your-token'
        },
        body: JSON.stringify({ prompt })
    });

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

        // 按 \n\n 分割 SSE 事件
        const parts = buffer.split('\n\n');
        buffer = parts.pop(); // 最后一个可能不完整,留着

        for (const part of parts) {
            const line = part.trim();
            if (!line.startsWith('data: ')) continue;

            const data = line.slice(6); // 去掉 "data: " 前缀
            if (data === '[DONE]') {
                console.log('\n--- 结束 ---');
                return;
            }

            try {
                const parsed = JSON.parse(data);
                document.getElementById('output').textContent += parsed.content;
            } catch (e) {
                console.warn('JSON 解析失败:', data);
            }
        }
    }
}

坑 3:chunk 被切到一半

这个比较隐蔽。reader.read() 返回的 chunk 是按网络包切的,不是按 SSE 事件切的。也就是说一个 data: {"content":"你好"}\n\n 可能被切成两次 read:

第一次: data: {"content":"你
第二次: 好"}\n\n

这就是上面代码里 buffer 的作用——把没处理完的数据攒着,等下一次 read 拼起来。不做这个缓冲,JSON.parse 就会疯狂报错。

网上很多教程都没处理这个,本地测试没问题是因为数据量小一般不会被切,上了生产数据量一大就炸。

坑 4:模型返回的 token 粒度问题

不同模型粒度差异很大。GPT-5.4 通常一两个词一个 chunk,Claude Opus 4.6 有时候攒几个词,Gemini 3 偶尔一大段一大段地吐。

导致前端打字机效果体验不一致——有些模型一顿一顿的,有些又太快像直接刷出来的。

解决办法是在前端加一个渲染队列,用 requestAnimationFrame 控制节奏:

class StreamRenderer {
    constructor(element) {
        this.element = element;
        this.queue = [];
        this.isRendering = false;
    }

    push(text) {
        // 把文本拆成单个字符入队
        for (const char of text) {
            this.queue.push(char);
        }
        if (!this.isRendering) {
            this.isRendering = true;
            this.render();
        }
    }

    render() {
        if (this.queue.length === 0) {
            this.isRendering = false;
            return;
        }

        // 每帧渲染 2-3 个字符,视觉上比较舒服
        const batchSize = Math.min(3, this.queue.length);
        for (let i = 0; i < batchSize; i++) {
            this.element.textContent += this.queue.shift();
        }

        requestAnimationFrame(() => this.render());
    }
}

// 使用
const renderer = new StreamRenderer(document.getElementById('output'));
// 在 fetch 的循环里把 parsed.content 传给 renderer.push()

不管后端吐得多快多慢,前端渲染都是均匀的。

坑 5:用户中途取消请求

用户点了「停止生成」,后端那个流式连接不主动断开的话,模型会继续生成到结束,白白浪费 token。

前端用 AbortController

let controller = null;

function startChat(prompt) {
    if (controller) {
        controller.abort();
    }
    controller = new AbortController();

    fetch('/chat/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ prompt }),
        signal: controller.signal  // 关键
    }).then(/* ... 处理流 ... */)
      .catch(err => {
        if (err.name === 'AbortError') {
            console.log('用户取消了请求');
        } else {
            throw err;
        }
    });
}

function stopChat() {
    if (controller) {
        controller.abort();
        controller = null;
    }
}

后端也要处理连接断开,不然生成器会继续跑:

from fastapi import Request

@app.post("/chat/stream")
async def chat_stream(prompt: str, request: Request):
    async def generate():
        response = client.chat.completions.create(
            model="gpt-5.4",
            messages=[{"role": "user", "content": prompt}],
            stream=True
        )
        for chunk in response:
            if await request.is_disconnected():
                response.close()  # 关闭上游连接
                break
            content = chunk.choices[0].delta.content
            if content is not None:
                data = json.dumps({"content": content}, ensure_ascii=False)
                yield f"data: {data}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

不同模型的流式表现

顺便说下这段时间测的几个模型:

模型首 token 延迟流畅度备注
GPT-5.4~300ms非常流畅token 粒度均匀
Claude Opus 4.6~500ms流畅偶尔有小停顿
Gemini 3~200ms不稳定有时候一大段一大段吐
Doubao-Seed-Code~400ms流畅代码场景表现不错

这几个模型都是通过同一个聚合接口调的,协议都是 OpenAI 兼容,上面的代码换个 model 名字就行,不用去适配各家不同的 SDK 和鉴权方式。

小结

流式输出就三件事:

  1. 后端stream=True 拿到生成器,逐 chunk yield 成 SSE 格式
  2. 中间链路:确保 Nginx、CDN 等不要缓冲 SSE 响应
  3. 前端:用 fetch + ReadableStream,做好 buffer 拼接和异常处理

chunk 被切割、Nginx 缓冲、用户取消这三个坑,基本每个做流式输出的人都会踩一遍。

代码都是完整可运行的,直接 copy 改改配置就能用。有问题评论区聊。