上周接了个需求,给内部知识库问答加「打字机效果」------就是像 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
基础调用
python
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)
跑起来就能在终端看到打字机效果了。几个注意点:
delta不是message:流式响应里每个 chunk 的内容在choices[0].delta.content,不是choices[0].message.content,刚开始很容易搞混。content可能是None:第一个和最后一个 chunk 通常 content 为 None,必须判空。flush=True很重要:不加的话 Python 会缓冲输出,看到的还是一坨一坨的。
用 FastAPI 把流式响应转发给前端
实际项目里不能让前端直接调大模型 API(Key 会暴露),得有个后端中转。用 FastAPI 的 StreamingResponse 很方便:
python
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 配置里加:
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,看起来很美好:
javascript
// ❌ 别用这个
const es = new EventSource('/chat/stream?prompt=hello');
es.onmessage = (e) => {
console.log(e.data);
};
问题在于:
- 只支持 GET 请求------聊天接口通常是 POST,消息体可能很长,不适合放 URL 参数里
- 不能自定义 Header------加个 Authorization?不支持
- 断线重连是黑盒------自动重连,但你控制不了重连策略
用 fetch + ReadableStream 才是正解:
javascript
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:
css
第一次: data: {"content":"你
第二次: 好"}\n\n
这就是上面代码里 buffer 的作用------把没处理完的数据攒着,等下一次 read 拼起来。不做这个缓冲,JSON.parse 就会疯狂报错。
网上很多教程都没处理这个,本地测试没问题是因为数据量小一般不会被切,上了生产数据量一大就炸。
坑 4:模型返回的 token 粒度问题
不同模型粒度差异很大。GPT-5.4 通常一两个词一个 chunk,Claude Opus 4.6 有时候攒几个词,Gemini 3 偶尔一大段一大段地吐。
导致前端打字机效果体验不一致------有些模型一顿一顿的,有些又太快像直接刷出来的。
解决办法是在前端加一个渲染队列,用 requestAnimationFrame 控制节奏:
javascript
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:
javascript
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;
}
}
后端也要处理连接断开,不然生成器会继续跑:
python
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 和鉴权方式。
小结
流式输出就三件事:
- 后端 :
stream=True拿到生成器,逐 chunk yield 成 SSE 格式 - 中间链路:确保 Nginx、CDN 等不要缓冲 SSE 响应
- 前端 :用
fetch+ReadableStream,做好 buffer 拼接和异常处理
chunk 被切割、Nginx 缓冲、用户取消这三个坑,基本每个做流式输出的人都会踩一遍。
代码都是完整可运行的,直接 copy 改改配置就能用。有问题评论区聊。