LLM API 流式输出中断怎么排查解决
流式输出"吐一半就停"是上线后最常见的体验问题。根因通常不在模型,而在代理缓冲、网关超时、心跳缺失、前端解析这四个环节。这篇给一层层的排查顺序,每层配可直接验证的命令和最小改法。
流式输出(SSE)的体验问题是这样的:本地开发一切正常,上线后用户反馈"回答说到一半就没了"。刷新一下又能用,过一会儿又断。日志里往往没有异常,因为请求在服务端看来已经正常结束。
这篇把断点拆成四层,按从前到后的顺序排查:代理缓冲 → 网关超时 → 心跳 → 前端解析。
先说结论
- 九成的"吐一半就停"是中间代理缓冲导致的:Nginx 攒够 4KB 才下发,短回答永远攒不够
- 第二常见是网关超时:连接是长连接,空闲超过阈值就被切,所以要发心跳
- 如果断在固定字数附近,几乎一定是缓冲;断在固定秒数附近,几乎一定是超时
- 前端解析 SSE 时必须以
\n\n为事件边界,用split("\n")逐行解析会在分包时丢数据 - 排查顺序不要乱:先用 curl 直连验证,再逐层加代理复现
第一层:先用 curl 直连确认服务端
关掉一切代理,直接打上游,看流是不是完整的:
curl -N -sS https://api.zaimod.com/v1/chat/completions \
-H "Authorization: Bearer $ZAIMOD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"stream": true,
"messages": [{"role": "user", "content": "分十点说明如何做接口压测"}]
}'
要点:
-N关掉 curl 自己的缓冲,不然看起来也像断流- 正常输出是一行行
data: {...},最后以data: [DONE]结束 - 如果这里就断了,问题在服务端或本机网络,继续往下查代理没意义
直连完整、走站点就断 → 问题在中间。这一步能砍掉一半的排查范围,别跳过。
第二层:代理缓冲
Nginx 默认会对响应做缓冲,攒到一定量再发给客户端。SSE 是持续小包,攒不满就一直不下发。判断方法很直接:
- 断在短回答上(几十个字就停)→ 缓冲
- 长回答能出完,但前三秒没有任何输出再突然一次性刷出来 → 缓冲
标准修法是三处一起改:
location /v1/ {
proxy_pass https://upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
add_header X-Accel-Buffering no;
}
proxy_buffering off 是关缓冲,proxy_http_version 1.1 和空的 Connection 头保证是长连接而不是 HTTP/1.0 短连接,X-Accel-Buffering: no 是在应用层显式告诉 Nginx 别缓冲(有些环境层级多,需要这一行兜底)。
如果前面还有 CDN,CDN 也要单独关缓冲,否则 Nginx 改好了依然断。CDN 一般有"流式/SSE"开关,或者对 text/event-stream 走不缓存规则。
第三层:超时与心跳
连接是长连接,中间任何一环都有空闲超时。模型生成慢的时候,两段 token 之间可能隔十几秒,代理认为连接空闲就切了。
对策是服务端主动发心跳注释。SSE 规范里以 : 开头的行是注释,客户端会忽略,正好用来保活:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
async def gen():
# 立刻发一条注释,让代理知道连接已建立
yield ": ping\n\n"
async with upstream_stream() as stream:
while True:
try:
chunk = await asyncio.wait_for(anext(stream), timeout=15)
except asyncio.TimeoutError:
yield ": keep-alive\n\n" # 空闲时保活
continue
except StopAsyncIteration:
break
yield f"data: {json.dumps(chunk)}\n\n"
yield "data: [DONE]\n\n"
心跳间隔要小于链路上最短的那个空闲超时。常见链路是 60s,那心跳设 15s 或 20s 都安全。
第四层:前端解析
前端最容易犯的错是按行读:
// 错误写法:分包边界会把一个事件切成两半
stream.split("\n").forEach(line => { ... });
正确做法是维护一个缓冲区,以空行作为事件分隔:
let buf = "";
for await (const chunk of reader) {
buf += decoder.decode(chunk, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop(); // 最后一段可能不完整,留到下次
for (const part of parts) {
const line = part.split("\n").find(l => l.startsWith("data:"));
if (!line) continue; // 注释行(心跳)直接跳过
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
render(JSON.parse(payload));
}
}
两个关键点:parts.pop() 保留不完整尾巴,以及跳过 : 开头的心跳行。很多"少几个字"的问题就是这两处没处理。
一张分层排查表
| 现象 | 最可能的层 | 第一步验证 |
|---|---|---|
| 短回答只有第一句 | 代理缓冲 | 直连 curl 是否完整 |
| 长时间无输出后一次刷完 | 代理缓冲 | 看响应头有无缓冲标记 |
| 固定 60 秒整断开 | 网关超时 | 加心跳后是否消失 |
| 字数刚好少几个 token | 前端解析 | 打印原始 buffer |
| 一断开就重连,反复循环 | 客户端重试逻辑 | 检查是否对 200 也重试 |
FAQ
流式断了能续传吗?
标准 chat/completions 协议里没有断点续传。能做的是出错时用已收到的部分作为上下文重新发起,但要注意别让用户看到重复开头。更稳的做法是把心跳和超时配好,从根上减少中断。
一定要用 SSE 吗,WebSocket 行不行?
浏览器到你的服务端用什么都不影响上游——上游就是 SSE。WebSocket 能自己控制心跳,但对代理配置更挑剔,静态站点或 Serverless 环境往往不支持。SSE 更通用。
本地正常线上断,最该先查什么?
先查线上链路上有几层代理。本地通常直连,线上可能走了 CDN + 负载均衡 + 应用网关,每层都可能缓冲或超时。
延伸阅读
- SSE 流式输出:从 curl 到前端怎么落地:协议细节和完整前端实现
- LLM API 报错码排查手册:按状态码定位而不是瞎猜
- Node.js 接入 OpenAI 兼容接口:服务端转发层的写法
- 价格与计费说明:流式与非流式的计费口径一致,按实际 token 计
需要稳定的长连接上游?注册后创建 Key,地址 https://api.zaimod.com/v1,支持流式输出与 SSE 心跳。