一、为什么要用流式输出?
大模型生成文本是逐 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:流式输出中途断开
可能原因:
-
Nginx proxy_read_timeout 太短(默认 60s)
大模型长文本生成可能超过 60 秒,需要调大:
proxy_read_timeout 300s; proxy_send_timeout 300s; -
CDN/负载均衡超时
各云厂商 CDN 有自己的空闲超时。可以让后端每隔 15 秒发一个空注释保活:
: keep-alive\n\n -
客户端网络中断
在前端加重试:
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