API 流式输出中断怎么办?一套分层排查流程
简答:API 流式输出中断不要先重试,先判断是否能完成非流式请求,再用 curl --no-buffer 观察原始 SSE。若 curl 正常而 SDK 或页面中断,重点查解析、AbortController、读取空闲超时与代理缓冲;若 curl 也中断,再核对 Base URL、模型、Key、网关超时和上游事件。
定义:API 流式输出中断,是指请求已经建立或返回 HTTP 200 后,SSE 数据没有出现完整结束事件、连接提前关闭,或客户端因超时、取消和解析错误停止读取。
先做这三步:把问题定位到客户端还是接口链路
本文是一篇独立的 AI API 接入排查指南,不代表任何 API 服务商或模型平台的官方立场。对于 OpenAI 兼容接口,具体事件名、结束标记和错误字段仍应以实际接口文档为准。
最有效的顺序不是直接更换模型,而是先建立三个基线:非流式请求是否成功、原始 SSE 是否持续返回、业务客户端是否正确解析。OpenAI 的 Responses API 在启用 stream: true 后会通过 Server-Sent Events 逐步发送事件;官方事件参考中包含创建、进行中、完成和失败等不同事件类型,不能只根据 HTTP 200 判断请求已经成功。
可先用下面的命令检查接口地址、模型名和 Key 是否基本可用。示例中的 BASE_URL、MODEL 和 API_KEY 都需要替换为你的实际配置,Key 不要写进 URL 查询参数。
#!/usr/bin/env bash
set -eu
command -v curl >/dev/null || { echo "缺少 curl" >&2; exit 1; }
command -v jq >/dev/null || { echo "缺少 jq" >&2; exit 1; }
read -r -p "Base URL,例如 https://api.example.com/v1: " BASE_URL
read -r -p "模型名: " MODEL
read -r -s -p "API Key: " API_KEY
echo
BASE_URL="${BASE_URL%/}"
BODY=$(jq -n --arg model "$MODEL" '{model:$model,input:"请只回复:ok"}')
curl --silent --show-error --max-time 60 \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
"$BASE_URL/responses" \
-d "$BODY"如果非流式请求失败,先处理地址、模型、权限或上游错误;如果非流式成功,再测试流式请求。curl 的 --no-buffer 会尽快输出收到的数据,--max-time 则限制单次传输总时长。需要注意,Authorization 头可能出现在本机进程参数或调试日志中,因此不要把完整命令直接发到工单或聊天群。
curl --no-buffer --silent --show-error --max-time 120 \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
"$BASE_URL/responses" \
-d "$(jq -n --arg model "$MODEL" '{model:$model,input:"请输出一段较长的测试文本",stream:true}')"若接口实际使用 Chat Completions 路径,可将请求路径和请求体改成该接口要求的格式。官方 Chat Completions 流式事件通常以增量 chunk 返回,Responses API 则使用事件类型和字段结构,两者不能混用解析器。可分别参考 Responses 流式事件参考 和 Chat Completions 流式事件参考。
按现象判断:无首字节、批量出现、半途断开还是一直转圈
| 现象 | 优先怀疑位置 | 下一步动作 |
|---|---|---|
| 长时间没有任何数据 | 连接建立、模型排队、网关读取超时或上游未产生首个事件 | 记录请求开始时间,先用 curl 测试,并区分连接超时与首字节等待时间 |
| 内容一次性成批出现 | 代理或网关响应缓冲,客户端也可能在积累完整块后才刷新 | 检查响应头、网关缓冲配置和客户端读取循环;Nginx 可核对 proxy_buffering |
| 输出到一半停止 | 上游连接关闭、读取空闲超时、代理断开、解析器遇到拆包或非法 JSON | 保留最后一个完整事件,查看是否有失败事件或完成事件,再对比 curl 原始输出 |
| HTTP 200 但界面一直转圈 | 客户端等待错误的结束标记,或没有处理完成事件 | 按事件类型判断结束,不要只等待固定字符串;同时记录最后收到的事件类型 |
| 浏览器偶发断开并自动重连 | EventSource 默认重连、浏览器连接限制、网络变化或服务端主动关闭 | 明确区分自动重连与重复生成,必要时使用请求 ID 或业务幂等键 |
| 非流式成功,流式失败 | 流式路径、SSE 格式、代理长连接或兼容层实现差异 | 确认请求体中的 stream 类型、响应的 Content-Type 和原始事件边界 |
SSE 不是普通的逐行 JSON。规范要求事件流使用 UTF-8 编码,消息之间用空行分隔;一个事件可以包含多行 data:,客户端需要把同一事件的多行数据合并后再解析。因此,按每次网络读取直接调用 JSON.parse,可能在数据被拆包时失败。MDN 对 SSE 的事件格式、UTF-8、空行分隔、多行 data 和重连行为有完整说明,可参考 Using server-sent events。
一个重要判断是:连接关闭不等于正常完成。Responses API 应检查是否收到完成事件或失败事件;Chat Completions 则应检查最后 chunk 的结束字段。对于兼容接口,如果只返回通用 data 块或自定义结束标记,应把该接口的真实样本保存下来再编写解析规则,不能凭经验假设所有平台都返回相同字段。
Node.js、Python 和浏览器 Fetch 的最小自查方式
Node.js:把读取超时和主动取消分开
Node.js 的 AbortSignal.timeout() 可以为整个 fetch 设置截止时间;如果业务还需要“连续一段时间没有新数据就中止”,应在每次成功读取后单独刷新空闲计时器。两者不是同一个概念:总超时限制整个请求,读取空闲超时限制两次数据之间允许停顿的时间。Node.js 官方文档说明了 AbortSignal.timeout() 和 Fetch 的可用方式,可参考 Node.js Global objects 文档。
const BASE_URL = process.env.BASE_URL.replace(/\/$/, "");
const API_KEY = process.env.API_KEY;
const MODEL = process.env.MODEL;
const response = await fetch(`${BASE_URL}/responses`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Accept": "text/event-stream"
},
body: JSON.stringify({
model: MODEL,
input: "请输出一段测试文本",
stream: true
}),
signal: AbortSignal.timeout(120000)
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
if (!response.body) throw new Error("响应没有可读取的 body");
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let lastEventAt = Date.now();
while (true) {
const { value, done } = await reader.read();
if (done) break;
lastEventAt = Date.now();
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop();
for (const part of parts) {
if (!part.trim()) continue;
console.log("RAW EVENT:", part);
}
}
console.log("stream closed; last data age(ms)=", Date.now() - lastEventAt);这段代码故意先打印原始事件,不直接假设事件字段。确认原始流稳定后,再针对实际协议处理 type、delta、response.completed、response.failed 或 Chat Completions 的 chunk 字段。
Python:不要把网络分块当成完整事件
import json
import os
import requests
base_url = os.environ["BASE_URL"].rstrip("/")
api_key = os.environ["API_KEY"]
model = os.environ["MODEL"]
payload = {
"model": model,
"input": "请输出一段测试文本",
"stream": True,
}
with requests.post(
f"{base_url}/responses",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json=payload,
stream=True,
timeout=(10, 120),
) as response:
response.raise_for_status()
event_lines = []
for line in response.iter_lines(decode_unicode=True):
if line == "":
if event_lines:
print("RAW EVENT:", "\\n".join(event_lines))
event_lines = []
continue
event_lines.append(line)
if event_lines:
print("INCOMPLETE EVENT:", "\\n".join(event_lines))示例中的连接超时和读取超时只是诊断模板,具体数值应按模型生成时间、网关限制和业务容忍度调整。一个请求可能已经产生部分输出但没有完成事件,此时不要把已显示文本直接拼接后无条件重试,否则可能得到重复内容,甚至产生重复计费。
浏览器 Fetch:检查 AbortController、CORS 和自动重连
const controller = new AbortController();
const timer = setTimeout(() => controller.abort("total timeout"), 120000);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "测试" }),
signal: controller.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (!res.body) throw new Error("浏览器没有返回可读流");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop();
for (const event of events) {
if (event.trim()) console.log(event);
}
}
} catch (error) {
console.error("stream error:", error);
} finally {
clearTimeout(timer);
}如果使用原生 EventSource,浏览器在连接关闭后可能自动重连;这适合事件订阅,但不一定适合会产生模型输出的 POST 请求。对于生成任务,应明确记录请求 ID、是否已经收到部分内容以及是否收到完成事件,避免把自动重连误当成安全续传。
代理、Nginx 和网关:重点查缓冲与读取空闲超时
如果 curl 能看到原始事件,但经过 Nginx、CDN、API 网关或业务后端后变成一次性返回,优先检查响应缓冲。Nginx 官方文档显示,proxy_buffering 默认开启;关闭后,响应会在从上游收到时同步传给客户端。对于流式接口,还应核对响应是否使用 Content-Type: text/event-stream,以及中间层是否改写、压缩或缓存了响应。
location /api/stream {
proxy_pass http://upstream_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 60s;
}上面的配置只是排查模板,不能直接替代你的生产配置。Nginx 的 proxy_read_timeout 是两次读取之间的等待时间,不是整个响应的总时长;如果上游在该时间内没有发送任何数据,连接会被关闭。若模型生成期间可能出现较长空档,应由服务端发送合适的 SSE 注释或心跳,同时确保客户端不会把心跳当成模型文本。相关定义可查看 Nginx ngx_http_proxy_module 文档。
建议按以下顺序对比:
- 在应用服务器所在机器直接请求上游,记录响应头和最后事件。
- 绕过业务后端请求网关入口,比较首字节时间和事件到达间隔。
- 在浏览器或 SDK 中请求同一路径,确认是否只是前端显示或解析延迟。
- 逐层检查响应缓冲、压缩、缓存、连接复用、空闲超时和最大响应时间。
如果只能修改客户端,先把原始响应写入日志或文件,确认中断发生在网络层还是解析层;如果只能修改网关,优先保留请求 ID、上游状态、连接关闭原因和最后一次转发时间。
HTTP 200 仍可能失败:如何判断是否完整成功
流式请求的成功条件至少包括三层:HTTP 状态码正常、事件流格式可解析、协议层出现明确的完成状态。HTTP 200 只说明服务器接受并开始返回响应,不代表模型已经完整生成。
| 证据 | 可得出的结论 | 不能直接得出的结论 |
|---|---|---|
| HTTP 200 | 请求在 HTTP 层获得成功响应 | 不能证明模型输出完整,也不能证明客户端已读完 |
| 收到增量文本 | 至少有部分输出到达客户端 | 不能证明没有后续错误或输出截断 |
| 收到完成事件 | 按该协议的事件语义,服务端报告生成完成 | 不能证明你的业务已经正确保存内容 |
| 收到失败事件 | 服务端明确报告失败 | 不能仅凭错误文本判断责任一定在上游 |
| 连接直接 EOF | 传输层或中间层结束了连接 | 不能当作正常完成,除非协议明确规定 EOF 即结束 |
Responses API 官方事件参考包含 response.completed 和 response.failed 等事件;如果使用的是兼容层,可能只映射其中一部分事件,或者返回不同的结束标志。因此,排查时应保存最后 20 个原始事件、HTTP 响应头、模型名、请求 ID、客户端版本和各层耗时,不要只保存页面上已经显示的文本。
安全重试也要分情况处理:连接在首个事件前失败时,通常更容易判断请求未产生可见输出;已经收到部分文本后断开,则不能默认从中断位置继续,也不能默认再次请求不会重复生成。除非接口提供明确的响应恢复、游标或幂等机制,否则应把重试视为一次新的模型请求,并在业务层提示用户确认。
一份可提交给服务商或内部网关团队的脱敏记录
为了避免反复沟通,建议一次性提供以下信息:
- 发生时间,使用具体时区和时间范围。
- 请求路径、Base URL 的主机名、模型名和接口类型;不要提交完整 API Key。
- HTTP 状态码、响应头中的 Content-Type、请求 ID 和上游请求 ID。
- 首次收到数据的时间、最后一次收到数据的时间、总持续时间。
- 最后一个完整 SSE 事件和是否收到完成或失败事件。
- 客户端语言、SDK 版本、Node.js 或 Python 版本、操作系统。
- 是否经过浏览器、Nginx、CDN、API 网关或其他中转层。
- 非流式请求结果和 curl --no-buffer 流式结果是否一致。
时间:2026-10-26 14:30:00 -0500
接口类型:Responses / Chat Completions / 兼容接口
模型:已脱敏模型名
HTTP 状态:200
首字节耗时:已记录
最后事件时间:已记录
完成事件:是 / 否 / 未知
失败事件:是 / 否 / 未知
客户端:Node.js 版本或 Python 版本
链路:客户端 → 业务后端 → Nginx/CDN → 上游
复现命令:已脱敏,Key 未提交
请求 ID:仅保留非敏感标识如果需要确认服务状态或延迟分布,可结合平台的服务状态页、用量面板和请求日志进行比对;OpenAI 的错误与延迟排查说明也建议结合请求时间、请求 ID、模型和服务层级定位,而不是只看前端现象,详见 Troubleshooting API Errors and Latency。
常见问题
API 流式输出中断,先换模型还是先查客户端?
先做非流式和 curl 原始流测试。若 curl 稳定而 SDK 或页面中断,应优先查 SSE 拆包、完成事件、AbortController、读取空闲超时和代理缓冲;只有原始流也失败时,再比较模型、上游通道和接口状态。
为什么 API 返回 200,内容还是没有完整生成?
HTTP 200 只代表 HTTP 层已经开始返回响应。流式请求还要确认事件可以持续解析,并收到该协议定义的完成事件;如果连接直接 EOF、收到失败事件或最后一个事件不完整,都不能直接当作正常完成。
SSE 解析时为什么不能每读一块就 JSON.parse?
网络读取边界不等于 SSE 事件边界,一个事件可能被拆成多次读取,也可能包含多行 data。应先按空行组装完整事件,再合并同一事件的 data 内容,最后按实际协议解析 JSON。
Nginx 的 proxy_read_timeout 是整个请求的最大时长吗?
不是。Nginx 文档说明它限制的是两次读取之间的等待时间;如果上游在该时间内没有发送数据,连接会被关闭。整个请求的总时长还可能受到客户端、网关、CDN、SDK 或业务代码的其他超时限制。
流式输出中断后能不能直接重试?
可以重试,但不能默认安全续传。首个事件前失败与已经收到部分文本后失败的风险不同;后者可能造成重复生成或重复计费。除非接口明确提供游标、恢复或幂等机制,否则应记录原请求状态,并把重试当作一次新的请求。
相关阅读
参考资料
- Responses streaming events
- Chat Completions streaming events
- Create a model response
- Using server-sent events
- Module ngx_http_proxy_module(nginx.org)
- curl command line options(curl.se)
- Troubleshooting API Errors and Latency