流式输出让 AI 应用从"等待黑盒"变成"实时打字机",但从 demo 到生产,中间有一片雷区。
为什么你的流式输出"看起来没在流"
第一次给 LLM 应用加流式输出的时候,大多数人都踩过同一个坑:代码写好了,stream=True 也加了,但浏览器端依然是等好几秒,然后"咔"一下把所有文字一起甩出来。
这不是 LLM 的问题,也不是你的代码问题------是中间层把流给"攒"了。
本文从生产角度梳理 LLM 流式输出的完整技术栈:SSE 协议细节、Nginx/Caddy 代理配置、背压处理、断流重连、tool_call streaming 下的 partial JSON 解析,以及 6 个真实踩过的生产陷阱。每个问题都有可以直接用的代码。
1. SSE 协议:你以为你懂,但可能没懂
SSE(Server-Sent Events)是 LLM 流式输出的事实标准传输协议。OpenAI、Anthropic、Google Gemini 的 API 都用它。理解协议本身是排查问题的基础。
1.1 SSE 帧格式
SSE 是纯文本协议,每个事件由若干字段组成,以空行结束:
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}
data: {"choices":[{"delta":{"content":",世界"},"index":0}]}
data: [DONE]
关键规则:
Content-Type必须是text/event-stream- 每个字段
field: value\n,事件结束是\n\n(两个换行) data:可以多行拼接(每行data:前缀,最终值用\n连接)id:字段的值会被存为Last-Event-ID,断线重连时自动携带: comment是注释行,不触发解析器,但能防止代理层因空闲超时关闭连接
1.2 为什么不用 WebSocket?
常见问题:为什么 LLM API 选 SSE 而不是 WebSocket?
SSE 的优势在于简单:
- HTTP/1.1 原生支持,不需要协议升级
- HTTP/2 下天然多路复用,多个流共享一个 TCP 连接
- 浏览器
EventSource内置自动重连和Last-Event-ID续传 - 代理/CDN/负载均衡器理解 HTTP,无需特殊配置(除了关缓冲)
WebSocket 的优势在于双向:当需要音频输入/文字输出的全双工通信时才考虑 WebSocket。如果你只是推 LLM 文本 token,SSE 足够了。
1.3 EventSource vs fetch + ReadableStream
浏览器内置的 EventSource 有一个重大限制:只支持 GET 请求,无法自定义请求头。
javascript
// ❌ EventSource 的局限
const es = new EventSource('/api/chat/stream');
// 无法传 Authorization header
// 无法发 POST body
生产中几乎所有 LLM 应用都需要传认证信息和请求体,所以实际用的是 fetch + ReadableStream:
javascript
// ✅ fetch + ReadableStream 方案
async function* streamChat(messages) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ messages }),
signal: controller.signal,
});
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 events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // 最后一个可能不完整,留到下次
for (const event of events) {
const dataLine = event.split('\n').find(l => l.startsWith('data: '));
if (!dataLine) continue;
const data = dataLine.slice(6);
if (data === '[DONE]') return;
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) yield delta;
} catch (e) {
console.warn('Failed to parse SSE chunk:', data);
}
}
}
}
注意这里有个细节:buffer 的拆分逻辑 。\n\n 是事件分隔符,但网络传输可能在任意位置截断,所以必须维护一个跨 read() 调用的 buffer,不能每次独立解析。
2. 代理层:每个配置项都是陷阱
生产陷阱 #1:Nginx 默认配置会静默缓冲所有流式响应
这是最常见的"流式但不流"原因。Nginx 默认开启 proxy_buffering,会把上游响应全部缓冲到内存/磁盘后再转发给客户端。对于 LLM 流式输出,这意味着客户端要等整个响应完成才能看到内容。
2.1 Nginx 完整配置
nginx
location /api/chat/stream {
proxy_pass http://backend:3000;
# ✅ 关键:关闭代理缓冲
proxy_buffering off;
proxy_cache off;
# ✅ 告诉 Nginx 不要缓冲(应用层 header,双重保险)
proxy_set_header X-Accel-Buffering no;
# ✅ HTTP/1.1 保持连接
proxy_http_version 1.1;
proxy_set_header Connection '';
# ✅ 长超时:LLM 生成可能持续几分钟
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# ✅ 关闭 gzip:压缩算法需要缓冲足够数据才能压缩
gzip off;
}
每一项都有原因:
proxy_buffering off:核心,必须proxy_cache off:防止缓存策略覆盖 buffering 设置proxy_set_header X-Accel-Buffering no:应用层也能控制,双重保险proxy_http_version 1.1:HTTP/1.0 每次请求后关闭连接,流式场景必须用 1.1proxy_set_header Connection '':清空 Connection header,防止影响长连接gzip off:gzip 必须积累足够数据才能压缩,对流式来说就是强制缓冲
生产陷阱 #2:gzip 是隐蔽的流式杀手
很多工程师关了 proxy_buffering 但忘了关 gzip,然后发现响应还是以"批次"到达而不是逐 token。原因是 gzip 压缩需要攒够数据才能发出第一个压缩块。
2.2 Caddy 配置
caddyfile
reverse_proxy /api/chat/* localhost:8000 {
flush_interval -1 # -1 = 立即 flush,CRITICAL
transport http {
compression off # 禁用压缩
response_header_timeout 10m
dial_timeout 10s
keepalive 2m
}
}
Caddy 的 flush_interval -1 等同于 Nginx 的 proxy_buffering off,同样关键。
2.3 应用层的 flush
代理配置对了,应用层也要配合:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def stream_llm_response(messages):
async with openai.AsyncOpenAI() as client:
stream = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield f"data: {chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
return StreamingResponse(
stream_llm_response(request.messages),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
)
3. 背压:当 LLM 比网络快时
生产陷阱 #3:忽略背压导致内存爆炸
背压(Backpressure)是流式系统的经典问题:当生产者(LLM)速度超过消费者(网络/客户端)时,中间需要缓冲或限速。
3.1 Node.js 背压检测
javascript
app.post('/api/chat/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: req.body.messages,
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (!delta) continue;
const data = `data: ${JSON.stringify(chunk)}\n\n`;
// 检查背压:write() 返回 false 表示缓冲区已满
const canContinue = res.write(data);
if (!canContinue) {
// 等待 drain 事件,防止内存溢出
await new Promise(resolve => res.once('drain', resolve));
}
}
res.write('data: [DONE]\n\n');
res.end();
});
res.write() 返回 false 时,说明写缓冲区已满,必须等待 drain 事件。
3.2 Python asyncio 背压
python
import asyncio
async def stream_with_backpressure(messages, response_queue: asyncio.Queue):
async with openai.AsyncOpenAI() as client:
stream = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
)
async for chunk in stream:
# maxsize 满时自动等待------背压生效
await response_queue.put(chunk)
await response_queue.put(None) # 结束信号
# 使用:maxsize 限制缓冲区大小
queue = asyncio.Queue(maxsize=50)
producer = asyncio.create_task(stream_with_backpressure(messages, queue))
consumer = asyncio.create_task(send_to_client(queue, websocket))
await asyncio.gather(producer, consumer)
asyncio.Queue(maxsize=50) 配合 await queue.put() 是 Python 异步背压的标准写法。
4. 断流重连:不丢 token 的设计
生产陷阱 #4:断线重连重复生成,用户看到重复内容
网络不稳定时,SSE 连接会中断。如果没有续传实现,新连接会触发全新的 LLM 生成------用户看到重复内容,服务端浪费算力。
4.1 基于 Last-Event-ID 的断点续传
python
import redis
import uuid
import json
r = redis.Redis()
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest, last_event_id: str = Header(None)):
generation_id = request.generation_id or str(uuid.uuid4())
# 携带 Last-Event-ID 时尝试续传
if last_event_id:
cached = r.get(f"stream:{generation_id}")
if cached:
resume_data = json.loads(cached)
return StreamingResponse(
resume_stream(generation_id, resume_data, last_event_id),
media_type="text/event-stream",
)
return StreamingResponse(
generate_and_cache(generation_id, request.messages),
media_type="text/event-stream",
)
async def generate_and_cache(generation_id: str, messages):
token_index = 0
token_buffer = []
async with openai.AsyncOpenAI() as client:
stream = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
token_buffer.append(delta)
# 每 token 缓存到 Redis(TTL 5 分钟)
r.setex(
f"stream:{generation_id}",
300,
json.dumps({"tokens": token_buffer, "done": False})
)
# 用 token_index 作为 event id
yield f"id: {token_index}\ndata: {chunk.model_dump_json()}\n\n"
token_index += 1
r.setex(
f"stream:{generation_id}",
300,
json.dumps({"tokens": token_buffer, "done": True})
)
yield "data: [DONE]\n\n"
4.2 客户端指数退避重连
javascript
class ResilientLLMStream {
constructor(url, body, options = {}) {
this.url = url;
this.body = body;
this.generationId = body.generation_id || crypto.randomUUID();
this.lastEventId = null;
this.retryCount = 0;
this.maxRetries = options.maxRetries ?? 3;
this.baseDelay = options.baseDelay ?? 1000;
this.onToken = options.onToken ?? (() => {});
this.onDone = options.onDone ?? (() => {});
}
async start() {
while (this.retryCount <= this.maxRetries) {
try {
await this.connect();
return;
} catch (err) {
if (err.name === 'AbortError') return;
this.retryCount++;
if (this.retryCount > this.maxRetries) throw err;
// 指数退避
const delay = this.baseDelay * Math.pow(2, this.retryCount - 1);
await new Promise(r => setTimeout(r, delay));
}
}
}
async connect() {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
};
if (this.lastEventId !== null) {
headers['Last-Event-ID'] = this.lastEventId;
}
const response = await fetch(this.url, {
method: 'POST',
headers,
body: JSON.stringify({ ...this.body, generation_id: this.generationId }),
});
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) return;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const event of events) {
const lines = event.split('\n');
let id = null, data = null;
for (const line of lines) {
if (line.startsWith('id: ')) id = line.slice(4);
if (line.startsWith('data: ')) data = line.slice(6);
}
if (id) this.lastEventId = id;
if (!data) continue;
if (data === '[DONE]') { this.onDone(); return; }
try {
const chunk = JSON.parse(data);
const token = chunk.choices?.[0]?.delta?.content;
if (token) this.onToken(token);
} catch (e) {
console.warn('Parse error:', data);
}
}
}
}
}
5. partial JSON 解析:tool_call streaming 的噩梦
生产陷阱 #5:直接 JSON.parse() tool_call arguments 导致崩溃
tool_call 的 arguments 字段会被分散到多个 chunk:
json
// chunk 1: {"arguments": "{\"lo"}
// chunk 2: {"arguments": "ca"}
// chunk 3: {"arguments": "tion\": \"Beijing\"}"}
直接对每个 chunk 做 JSON.parse() 必然报错。
5.1 正确的 tool_call 累积模式
typescript
interface ToolCallAccumulator {
[index: number]: {
id: string;
name: string;
arguments: string;
}
}
async function processStreamWithTools(stream: AsyncIterable<ChatCompletionChunk>) {
const toolCallAcc: ToolCallAccumulator = {};
let content = '';
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
if (delta.content) {
content += delta.content;
onToken(delta.content);
}
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index;
if (!toolCallAcc[idx]) {
toolCallAcc[idx] = {
id: tc.id ?? '',
name: tc.function?.name ?? '',
arguments: '',
};
}
// ✅ 累积,不解析
if (tc.function?.arguments) {
toolCallAcc[idx].arguments += tc.function.arguments;
}
}
}
// ✅ finish_reason 为 tool_calls 时才解析
if (chunk.choices[0]?.finish_reason === 'tool_calls') {
for (const [idx, tc] of Object.entries(toolCallAcc)) {
try {
const args = JSON.parse(tc.arguments);
await executeTool(tc.name, tc.id, args);
} catch (e) {
// 降级:使用 jsonrepair 修复
const repaired = jsonRepair(tc.arguments);
await executeTool(tc.name, tc.id, JSON.parse(repaired));
}
}
}
}
return { content, toolCalls: Object.values(toolCallAcc) };
}
关键原则:累积 arguments 到 finish_reason === 'tool_calls' 时才解析 JSON,不要在每个 delta chunk 里解析。
5.2 实时 UI 的增量解析
如果需要在 arguments 还没完整时就更新 UI,可以用 jsonrepair:
javascript
import { jsonrepair } from 'jsonrepair';
function tryParsePartialJSON(partial) {
try {
return JSON.parse(partial);
} catch {
try {
return JSON.parse(jsonrepair(partial));
} catch {
return null;
}
}
}
// 实时更新 UI(仅用于预览,不用于执行工具)
if (tc.function?.arguments) {
toolCallAcc[idx].arguments += tc.function.arguments;
const partial = tryParsePartialJSON(toolCallAcc[idx].arguments);
if (partial) {
updateToolCallUI(tc.id, partial);
}
}
jsonrepair 对常见不完整 JSON(缺闭括号、未完成字符串)修复成功率很高,但只用于 UI 预览,真正执行工具时等完整的 JSON。
6. keepalive:防止代理层"以为没人了"
生产陷阱 #6:慢速模型被代理层 60s 超时断开
思考型模型(如 DeepSeek-R1)在"思考"阶段可能完全没有 token 输出,持续几十秒。这段时间很多代理层会因"空闲超时"断开连接。
6.1 服务端 keepalive 注释行
SSE 规范支持以 : 开头的注释行,不触发客户端事件监听器,但让连接上有数据流动:
python
import asyncio
async def stream_with_keepalive(generation_coro, keepalive_interval=15):
"""包装生成器,每 15s 发送 keepalive 注释"""
async def keepalive_loop(queue: asyncio.Queue):
while True:
await asyncio.sleep(keepalive_interval)
await queue.put(': keepalive\n\n') # SSE 注释行
queue = asyncio.Queue()
async def producer():
try:
async for chunk in generation_coro:
await queue.put(chunk)
finally:
await queue.put(None)
keepalive_task = asyncio.create_task(keepalive_loop(queue))
producer_task = asyncio.create_task(producer())
try:
while True:
item = await queue.get()
if item is None:
break
yield item
finally:
keepalive_task.cancel()
producer_task.cancel()
注释格式是 : any text\n\n(冒号开头,双换行)。代理层看到数据就不会超时,但客户端解析器跳过注释行,不影响业务逻辑。
6.2 超时配置调整
nginx
# Nginx:针对流式端点单独设置
location /api/chat/stream {
proxy_read_timeout 600s; # 思考型模型可能需要 5-10 分钟
proxy_send_timeout 600s;
# ... 其他配置
}
python
# OpenAI SDK 超时配置
client = openai.AsyncOpenAI(
timeout=httpx.Timeout(
connect=10.0,
read=600.0, # 读取超时 10 分钟
write=10.0,
pool=10.0,
)
)
TTFT 优化:从感受到数字
TTFT(Time To First Token,首 token 延迟)是流式输出的核心体验指标。研究显示,即使总生成时间相同,用户感知流式界面比批量响应快 40% 。业界 p50 目标是 < 800ms。
全链路 TTFT 拆解:
用户点击 → 前端发请求 → 网关/LB → 应用服务器 → LLM 推理服务 → 第一个 token
↓
前端显示第一个字 ← 浏览器渲染 ← 网络传输 ← 应用 flush ← 第一个 chunk
每个环节都可能增加延迟。除了已讨论的代理缓冲外,还有一个隐蔽问题:某些浏览器需要接收到足够大的初始 chunk(通常 2KB)才开始渲染。
python
async def stream_with_padding(messages):
first_token_sent = False
async with openai.AsyncOpenAI() as client:
stream = await client.chat.completions.create(
model="gpt-4o", messages=messages, stream=True,
)
async for chunk in stream:
if not first_token_sent:
# 发送 2KB padding 注释,确保浏览器立即开始渲染
yield ': ' + ' ' * 2048 + '\n\n'
first_token_sent = True
yield f"data: {chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
生产 Checklist
| 类别 | 检查项 | 不做的后果 |
|---|---|---|
| 代理配置 | Nginx proxy_buffering off |
流式变批量输出 |
| 代理配置 | Nginx gzip off(流式路径) |
gzip 缓冲导致延迟 |
| 代理配置 | proxy_read_timeout >= 300s |
长生成被超时断开 |
| 应用层 | 每次 yield 后立即 flush | token 被攒批发出 |
| 应用层 | keepalive 注释每 15s 一次 | 思考型模型被代理超时 |
| 背压 | Node.js: res.write() drain 检测 |
内存无限增长 |
| 背压 | Python: asyncio.Queue(maxsize=N) |
生产者过快导致 OOM |
| 断流 | 服务端缓存 generation_id + tokens | 重连触发重复生成 |
| 断流 | 客户端指数退避重试(最多 3 次) | 服务端请求风暴 |
| tool_call | 累积 arguments 后再 JSON.parse | 每 chunk 解析必然崩溃 |
| TTFT | 初始 2KB padding chunk | 浏览器延迟渲染 |
| 监控 | 记录 TTFT p50/p95 | 无法发现退化 |
总结
LLM 流式输出的核心挑战不在 LLM 本身,而在全链路的传输工程:
- SSE 协议层 :理解
data:/id:/:/retry:字段语义;用fetch + ReadableStream替代EventSource - 代理层 :
proxy_buffering off+gzip off+ 超时配置,三项缺一不可 - 背压 :检测
drain/Queue阻塞,不要无限缓冲 - 断流重连 :
Last-Event-ID+ 服务端 token 缓存,续传而不是重发 - partial JSON :累积
arguments到finish_reason === 'tool_calls'再解析 - keepalive :每 15s 发
: keepalive\n\n注释,维持思考型模型的连接
这些问题每一个单独看都不复杂,但在生产中同时踩到三四个,排查起来会非常痛苦。希望这篇文章能帮你在踩之前就绕过去。
本文代码基于 Python 3.11+ / Node.js 20+ / OpenAI SDK v2,实际使用时根据你的 SDK 版本做调整。