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 + 负载均衡 + 应用网关,每层都可能缓冲或超时。

延伸阅读

需要稳定的长连接上游?注册后创建 Key,地址 https://api.zaimod.com/v1,支持流式输出与 SSE 心跳。

#llm 流式输出中断#sse 流式输出断开#openai stream 中断#nginx 缓冲 sse
免费创建 API Key

相关阅读

API Key 泄露被盗刷怎么办:排查、止损与预防

账单异常暴涨、余额一夜归零,多半是 Key 泄露了。这篇给出泄露的 5 个征兆、30 分钟止损清单、4 个最常见的泄露渠道,以及一套让泄露不再重演的 Key 管理方案。

DeepSeek 免费档能干什么:5 个生产级用法

平台上有一个按次计费 0 元的 deepseek-v4-flash-free 免费档,每天限流。本文给出 5 个真正能上生产的用法:意图分类、内容打标签、结构化抽取、会话摘要、批量预处理,并附用量测算与降级策略。

Claude API 代码审查流水线:从 hook 到 CI

用 Claude Messages API 搭一条三层代码审查流水线:pre-commit 拦住本地问题、GitHub Actions 审查 PR diff、夜间扫全仓。含完整 review.py、误报压制方法与真实成本账。