OpenAI API 超时和流式中断怎么排查
简答:流总在约 60 秒断开,通常先查 Nginx 或负载均衡器的空闲读超时;推理模型迟迟没有首包,则要区分客户端 read timeout 与应用层首包看门狗。即使 HTTP 返回 200,也可能只收到半截内容,必须同时检查 Chat Completions 的 finish_reason、data: [DONE] 和 tool_calls 是否完整。本文按连接、首包、分块、代理四层给出可照做的排查步骤。
定义:OpenAI API 超时是连接建立、等待首个响应、读取后续分块或获取连接池资源超过时限;流式中断则是收到部分 SSE 数据后,在完整终止事件前连接异常关闭或长时间没有新数据。
先看症状:一张表定位该改哪一层
本站为第三方指南,非 OpenAI 官方,以其官网为准。先不要把所有问题都归结为“模型太慢”,记录请求发起时间、连接建立时间、首个字节或首个事件时间、相邻事件间隔、最后事件类型和连接关闭时间。
最快判断:返回 200 但只输出一半,不代表请求成功;应检查是否收到完整结束标记,以及工具调用参数是否能被解析为完整 JSON。
| 症状 | 优先检查 | 可采取的动作 |
|---|---|---|
| 连接建立前失败 | connect timeout、DNS、TLS、出口代理 | 单独提高 connect 阈值,检查代理和连接复用 |
| 首包前长时间没有数据 | read timeout、模型排队、上游首包限制 | 把 HTTPX read 设为覆盖范围的较大值,再用应用层首包看门狗单独判定 |
| 固定约 60 秒没有新数据后断开 | Nginx proxy_read_timeout、CDN、负载均衡器空闲超时 | 延长两次读取之间的允许等待时间,关闭代理缓冲并检查上游日志 |
| 并发升高时偶发超时 | pool timeout、连接池上限、429、Retry-After | 复用客户端、限制并发、按响应头退避 |
| HTTP 200 后只收到部分文本 | finish_reason、data: [DONE]、工具参数 JSON | 标记为 partial,不要当作最终答案入库 |
第一步:区分连接、首包、分块和总时限
HTTPX 把超时分为 connect、read、write 和 pool 四类;其中 read timeout 表示等待下一段响应数据的最长时间,因此它同时可能影响首包等待和首包后的分块间隔,并不能在 HTTPX 参数里直接拆成两个独立阈值。HTTPX 官方文档还说明,默认会对网络不活动执行超时控制。参考 HTTPX 官方超时文档,不要用普通短请求的默认值直接套在长流式请求上。
更稳妥的做法是:让底层 read timeout 大于首包阈值和分块间隔阈值,再用应用层看门狗分别判断“首包太慢”和“已开始输出后长时间无新事件”。同时保留一个业务总时限,避免连接无限占用工作进程。
import asyncio
import time
import httpx
# 底层 read 必须覆盖首包和分块间隔,否则应用层看门狗还没接管就会先抛 ReadTimeout。
transport_timeout = httpx.Timeout(
300.0,
connect=10.0,
read=180.0,
write=30.0,
pool=10.0,
)
FIRST_EVENT_LIMIT = 120.0 # 首包看门狗:示例值,按模型和业务 SLO 调整
CHUNK_GAP_LIMIT = 45.0 # 首包后相邻事件最大允许间隔
TOTAL_LIMIT = 600.0 # 业务总时限
async def consume_stream(client, url, headers, payload):
started = time.monotonic()
first_event = False
previous_event_at = started
async with client.stream(
"POST", url, headers=headers, json=payload
) as response:
response.raise_for_status()
lines = response.aiter_lines().__aiter__()
while True:
elapsed = time.monotonic() - started
if elapsed > TOTAL_LIMIT:
raise TimeoutError("business total timeout")
limit = CHUNK_GAP_LIMIT if first_event else FIRST_EVENT_LIMIT
try:
line = await asyncio.wait_for(lines.__anext__(), timeout=limit)
except StopAsyncIteration:
break
except asyncio.TimeoutError as exc:
phase = "chunk gap" if first_event else "first event"
raise TimeoutError(f"{phase} timeout") from exc
now = time.monotonic()
gap = now - previous_event_at
previous_event_at = now
first_event = True
process_sse_line(line, gap=gap)这个示例中的 120 秒、45 秒和 600 秒只是配置示例,不是官方推荐值。若使用 OpenAI 官方 Python SDK,官方仓库说明默认请求超时为 10 分钟,并支持传入细粒度的 HTTPX 超时对象;流式读取阶段的超时会抛出 APITimeoutError,其他传输失败通常属于连接错误。需要注意,SDK 的默认重试适用于部分连接错误、408、409、429 和 500 以上错误,但已经开始消费的流不应无条件从头重放。详见 OpenAI 官方 Python SDK。
第二步:Chat Completions 如何判断半截流
OpenAI 兼容接口常见的 Chat Completions 流式格式是 SSE:每条事件通常以 data: 开头,正常结束时会收到 data: [DONE]。仅收到 HTTP 200、若干文本片段或 TCP 连接关闭,都不足以证明答案完整。官方 API 参考说明,Chat Completion 的 choice 包含 finish_reason,可能表示正常停止、长度限制、工具调用等结束原因;流式请求被中断时,也可能收不到最终用量块。可参考 Chat Completions API 参考。
排查时至少同时记录三个状态:是否收到 [DONE]、最后一个 choice 的 finish_reason、工具调用参数是否已经拼接成合法 JSON。工具调用只收到一半时,常见表现是参数字符串无法解析为 JSON,或者尚未收到完整的工具调用结束信息;这时不能执行工具,也不能把半截参数写入任务队列。
import json
def inspect_chat_sse(lines):
text_parts = []
tool_arguments = {}
finish_reason = None
saw_done = False
for raw_line in lines:
line = raw_line.strip()
# SSE 注释行以冒号开头,是保活心跳,不是 JSON 数据。
if not line or line.startswith(":"):
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
saw_done = True
break
chunk = json.loads(data)
for choice in chunk.get("choices", []):
delta = choice.get("delta") or {}
if delta.get("content"):
text_parts.append(delta["content"])
for call in delta.get("tool_calls") or []:
index = call.get("index", 0)
function = call.get("function") or {}
tool_arguments[index] = tool_arguments.get(index, "")
tool_arguments[index] += function.get("arguments", "")
if choice.get("finish_reason") is not None:
finish_reason = choice["finish_reason"]
complete = saw_done and finish_reason in {"stop", "length", "tool_calls"}
if finish_reason == "tool_calls":
parsed_tools = []
for index, raw_arguments in tool_arguments.items():
try:
parsed_tools.append((index, json.loads(raw_arguments)))
except json.JSONDecodeError as exc:
raise ValueError("tool_calls arguments are incomplete") from exc
return {"complete": complete, "text": "".join(text_parts), "tools": parsed_tools}
return {"complete": complete, "text": "".join(text_parts), "tools": []}如果兼容网关把终止标记改成其他格式,应以该接口公开文档和实际原始响应为准,不要硬编码 OpenAI 格式。遇到半截流时,保留请求 ID、已接收文本、工具参数、最后事件和错误时间;将结果标记为 partial,而不是伪造 finish_reason 或自行补写 [DONE]。
第三步:SSE 心跳怎么写,为什么不能伪造事件
SSE 规范允许发送以冒号开头的注释行,例如 : keep-alive,客户端解析时应忽略这类行。规范还指出,代理可能因短时间没有数据而关闭连接,服务端可以定期发送注释行作为保活;因此,心跳应由你控制的流式服务端或反向代理发送,而不是把任意文本塞进上游模型事件中。详见 WHATWG Server-sent events 规范。
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
: keep-alive
客户端收到冒号开头的行后直接忽略即可。不要发送不符合协议的自定义 JSON 心跳,也不要把心跳当成模型内容;否则下游解析器可能报 JSON 错误,或者把保活内容展示给用户。心跳只能改善中间层的空闲断开,不能解决模型首包迟迟不到、上游限流或客户端自身超时。
第四步:Nginx 固定时间断流怎么查
Nginx 官方文档说明,proxy_read_timeout 默认值为 60 秒,限制的是两次连续读取之间允许等待的时间,不是整个响应的总时长;如果上游在该时间内没有发送任何数据,连接会被关闭。官方文档也说明,proxy_buffering 默认开启,关闭后响应会在收到上游数据时同步转发给客户端。可查看 Nginx 官方代理模块文档。
如果你的自有反向代理连接的是 HTTPS 上游,Host 应按实际上游域名显式填写,不能直接把客户端域名的 $host 转发给上游。Nginx 文档说明,proxy_ssl_server_name on 用于在 TLS 建连时发送 SNI;下面的域名只是示例,请按实际上游填写。
location /v1/ {
proxy_pass https://api.openai.com;
proxy_http_version 1.1;
# 流式响应通常需要关闭代理缓冲
proxy_buffering off;
# 允许上游在两次数据之间较长时间没有输出
proxy_read_timeout 10m;
proxy_send_timeout 60s;
# HTTPS 上游:按实际上游域名设置 Host 和 SNI
proxy_set_header Host api.openai.com;
proxy_ssl_server_name on;
# 避免向上游转发客户端连接头
proxy_set_header Connection "";
}
修改后分别测试非流式和流式请求,并在客户端、Nginx 错误日志和上游响应中记录时间点。如果每次都在接近固定阈值断开,优先排查 Nginx、负载均衡器、CDN 或网关的空闲读超时;如果只有某个模型或某类长输入失败,还要检查首包等待和上游排队。
Nginx 的 proxy_next_upstream 只能在还没有向客户端发送响应内容时切换上游;官方文档明确指出,一旦响应传输中途已经有内容发给客户端,继续切换无法修复已发送的半截内容。因此,故障转移不能替代半截流检测和业务层恢复。
第五步:Node.js 流式请求和断线恢复
OpenAI 官方 Node.js SDK 文档说明,Responses 流可以通过异步迭代消费;使用 responses.stream() 时,可以等待 finalResponse() 获取累积结果,但干净 EOF 也可能得到状态不是 completed 的部分响应,因此必须检查最终状态。相关实现见 OpenAI 官方 Node.js 流式文档。
import OpenAI from "openai";
const client = new OpenAI({
timeout: 10 * 60 * 1000,
maxRetries: 0,
});
async function run() {
const stream = client.responses.stream({
model: "your-model",
input: "your-input",
});
let sawCompleted = false;
let received = 0;
try {
for await (const event of stream) {
received += 1;
if (event.type === "response.output_text.delta") {
process.stdout.write(event.delta);
}
if (event.type === "response.completed") {
sawCompleted = true;
}
}
const final = await stream.finalResponse();
if (!sawCompleted || final.status !== "completed") {
throw new Error("stream ended without a completed response");
}
} catch (error) {
console.error({ received, error });
throw error;
}
}
run();需要更细的恢复能力时,官方 Node.js 文档给出的方案是创建 background: true 的 Responses,再保存响应 ID 和最后处理的事件序号;断开后使用 starting_after 继续读取。该方式不是普通 Chat Completions 的通用断点续传,且必须按文档检查 response.completed、response.failed、response.incomplete 等终止状态。
const initial = client.responses.stream({
model: "your-model",
input: "your-input",
background: true,
});
let responseId;
let lastSequenceNumber = -1;
let completed = false;
for await (const event of initial) {
lastSequenceNumber = event.sequence_number;
if (event.type === "response.created") {
responseId = event.response.id;
}
if (event.type === "response.completed") {
completed = true;
}
if (event.type === "response.failed" || event.type === "response.incomplete") {
throw new Error("background response did not complete");
}
// 示例:达到应用层条件后暂时断开,之后再恢复
if (event.sequence_number === 10 && !completed) {
break;
}
}
if (!completed) {
const resumed = client.responses.stream({
response_id: responseId,
starting_after: lastSequenceNumber,
});
for await (const event of resumed) {
if (event.type === "response.output_text.delta") {
process.stdout.write(event.delta);
}
}
const final = await resumed.finalResponse();
if (final.status !== "completed") {
throw new Error("resumed response did not complete");
}
}如果你使用的是 Chat Completions,而不是 Responses API 的后台流,就不能直接套用 response_id、starting_after 这套恢复字段;应根据兼容接口是否提供可恢复流、请求幂等能力和业务副作用,选择重新生成、上下文续写或提示用户重试。
哪些错误该重试,哪些不该:超时排查中的错误码对照
重试前先判断请求是否已经产生副作用,以及客户端是否已经向用户展示部分结果。对纯文本、无工具副作用的请求,可以有限次数指数退避;对工具调用、写入数据库、下单或发送消息等请求,应使用幂等键、任务状态和人工确认,不能因为网络断开就自动重复执行。
| 错误或现象 | 通常先查什么 | 是否适合直接重试 |
|---|---|---|
| 400 | 请求体、模型、字段或输入格式 | 通常不重试,删除可选字段并用最小请求验证 |
| unsupported_parameter | 当前模型或接口不支持所传参数 | 不重试,移除或替换不支持字段 |
| 401/403 | Key、项目权限、访问范围 | 不重试,核对认证和权限 |
| 408 | 客户端、代理或上游等待时间 | 幂等请求可有限退避 |
| 429 | 速率限制、配额和并发 | 读取 Retry-After,降低并发后退避 |
| 502/503/504 | 网关、负载均衡器或上游暂时不可用 | 未开始输出时可有限重试;半截流先判定能否安全重放 |
| ECONNRESET、socket hang up、ReadTimeout | 连接重置、首包等待或分块间隔 | 按发生阶段和幂等性决定,不要统一重试 |
重试应设置最大尝试次数、指数退避、随机抖动和熔断;对于已经收到部分文本的流,不要把普通重试器当作断点续传器。
import random
import time
RETRYABLE_STATUS = {408, 409, 429, 500, 502, 503, 504}
def should_retry(status_code, network_error, already_emitted, has_side_effect):
if has_side_effect or already_emitted:
return False
return network_error or status_code in RETRYABLE_STATUS
def backoff(attempt, retry_after=None):
if retry_after is not None:
return min(float(retry_after), 120.0)
base = min(2 ** attempt, 30.0)
return base + random.uniform(0, 0.5)
# 实际调用时还应加入总预算和熔断状态。
for attempt in range(3):
try:
result = call_api_once()
break
except TransientError as exc:
if not should_retry(
status_code=exc.status_code,
network_error=exc.network_error,
already_emitted=exc.already_emitted,
has_side_effect=exc.has_side_effect,
) or attempt == 2:
raise
time.sleep(backoff(attempt, exc.retry_after))
最后按这个顺序做一次定位
- 用相同模型和输入发送一次非流式最小请求,记录 HTTP 状态、响应头、请求 ID 和总耗时。
- 开启流式后记录首个事件、每个事件间隔、最后事件类型、
finish_reason和是否收到[DONE]。 - 把客户端的 connect、read、write、pool timeout 分开记录;HTTPX 的 read 同时覆盖首包和后续分块,因此再加应用层首包与分块看门狗。
- 绕过自有反向代理进行对照;如果直连正常而代理断开,优先检查
proxy_read_timeout、proxy_buffering、SNI、Host 和其他中间层空闲限制。 - 如果出现半截 tool_calls,先保存已拼接参数并验证 JSON,不要执行不完整工具调用。
- 对只读、可安全重放的请求采用有限退避;对已经输出内容或存在副作用的请求,改用幂等状态、上下文续写或人工确认。
- 日志中不要打印完整 API Key、Authorization 请求头或敏感输入,只记录脱敏后的请求标识和时间点。
核心判断可以概括为:固定时间断开先看代理空闲超时;首包前超时看 read 与首包看门狗;首包后断流看分块间隔、SSE 解析和连接层;HTTP 200 但内容不完整,则以终止事件和业务完整性为准,而不是以状态码为准。
常见问题
为什么设置了 stream=true,OpenAI API 仍然会超时?
stream=true 只改变响应传输方式,不保证首包立即到达,也不会绕过客户端、反向代理或负载均衡器的超时。需要分别检查首包等待、分块间隔、业务总时限和代理空闲读超时。
HTTPX 能不能单独设置首包超时和分块读取超时?
HTTPX 的 read timeout 同时作用于等待首个响应分块和等待后续分块,不能直接拆成两个独立参数。工程上可以把底层 read 设得足够覆盖两种场景,再用应用层看门狗对首包和分块间隔分别设置阈值。
返回 HTTP 200 但流式内容只到一半,算成功吗?
不算。应检查 Chat Completions 是否收到 data: [DONE]、最后的 finish_reason 是否存在,以及 tool_calls 参数是否能解析为完整 JSON。缺少完整终止信号时,应标记为 partial。
SSE 心跳为什么要写成冒号开头?
SSE 规范把以冒号开头的行定义为注释,客户端会忽略它,因此可以作为保活数据而不被当成模型事件。示例是 : keep-alive;不要发送无法被下游解析器识别的自定义 JSON 心跳。
Nginx 为什么经常在固定时间切断流式响应?
Nginx 的 proxy_read_timeout 默认值为 60 秒,限制两次连续读取之间允许等待的时间。若上游在这段时间没有新数据,连接可能被关闭;应同时检查 proxy_buffering、负载均衡器和 CDN 的空闲连接限制。
流式中断后能不能直接从头重试?
不能一概而论。没有输出且请求可安全重放时,可以有限退避;已经输出部分文本时,重试可能造成重复内容,工具调用或写入操作还可能重复产生副作用。应先保存事件序号和业务状态,再决定续写、重试或降级。
Chat Completions 的 tool_calls 只收到一半怎么办?
不要执行工具。先把增量 arguments 拼接起来,尝试解析 JSON;如果解析失败、没有完整结束信号或 finish_reason 不是 tool_calls,就把本次结果标记为未完成,并根据业务幂等性选择恢复或重新请求。
为什么超时问题还要检查 400 和 unsupported_parameter?
因为不支持的模型参数会导致请求本身无法正确处理,增加 timeout 或重试都不能修复。先用最小请求验证模型、Base URL、Key 和参数,再逐项恢复工具、结构化输出和长输入。
参考资料
- OpenAI 官方 Python SDK:流式、超时、重试与错误处理
- OpenAI 官方 Node.js SDK:流式响应、最终状态与后台流恢复
- HTTPX 官方文档:connect、read、write 和 pool 四类超时(python-httpx.org)
- Nginx 官方代理模块文档:proxy_read_timeout、proxy_buffering、SNI 与上游切换(nginx.org)
- WHATWG Server-sent events 规范:注释行、事件解析与保活(html.spec.whatwg.org)