Crazyrouter

OpenAI API 超时和流式中断怎么排查

更新于 2026-10-23 · crazyrouter.cyou

简答:流总在约 60 秒断开,通常先查 Nginx 或负载均衡器的空闲读超时;推理模型迟迟没有首包,则要区分客户端 read timeout 与应用层首包看门狗。即使 HTTP 返回 200,也可能只收到半截内容,必须同时检查 Chat Completions 的 finish_reason、data: [DONE] 和 tool_calls 是否完整。本文按连接、首包、分块、代理四层给出可照做的排查步骤。

定义:OpenAI API 超时是连接建立、等待首个响应、读取后续分块或获取连接池资源超过时限;流式中断则是收到部分 SSE 数据后,在完整终止事件前连接异常关闭或长时间没有新数据。

先看症状:一张表定位该改哪一层

本站为第三方指南,非 OpenAI 官方,以其官网为准。先不要把所有问题都归结为“模型太慢”,记录请求发起时间、连接建立时间、首个字节或首个事件时间、相邻事件间隔、最后事件类型和连接关闭时间。

最快判断:返回 200 但只输出一半,不代表请求成功;应检查是否收到完整结束标记,以及工具调用参数是否能被解析为完整 JSON。

OpenAI API 超时与流式中断症状速查
症状优先检查可采取的动作
连接建立前失败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 这套恢复字段;应根据兼容接口是否提供可恢复流、请求幂等能力和业务副作用,选择重新生成、上下文续写或提示用户重试。

哪些错误该重试,哪些不该:超时排查中的错误码对照

重试前先判断请求是否已经产生副作用,以及客户端是否已经向用户展示部分结果。对纯文本、无工具副作用的请求,可以有限次数指数退避;对工具调用、写入数据库、下单或发送消息等请求,应使用幂等键、任务状态和人工确认,不能因为网络断开就自动重复执行。

OpenAI API 常见错误与处理方向
错误或现象通常先查什么是否适合直接重试
400请求体、模型、字段或输入格式通常不重试,删除可选字段并用最小请求验证
unsupported_parameter当前模型或接口不支持所传参数不重试,移除或替换不支持字段
401/403Key、项目权限、访问范围不重试,核对认证和权限
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))

最后按这个顺序做一次定位

  1. 用相同模型和输入发送一次非流式最小请求,记录 HTTP 状态、响应头、请求 ID 和总耗时。
  2. 开启流式后记录首个事件、每个事件间隔、最后事件类型、finish_reason 和是否收到 [DONE]。
  3. 把客户端的 connect、read、write、pool timeout 分开记录;HTTPX 的 read 同时覆盖首包和后续分块,因此再加应用层首包与分块看门狗。
  4. 绕过自有反向代理进行对照;如果直连正常而代理断开,优先检查 proxy_read_timeout、proxy_buffering、SNI、Host 和其他中间层空闲限制。
  5. 如果出现半截 tool_calls,先保存已拼接参数并验证 JSON,不要执行不完整工具调用。
  6. 对只读、可安全重放的请求采用有限退避;对已经输出内容或存在副作用的请求,改用幂等状态、上下文续写或人工确认。
  7. 日志中不要打印完整 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 和参数,再逐项恢复工具、结构化输出和长输入。

参考资料

  1. OpenAI 官方 Python SDK:流式、超时、重试与错误处理
  2. OpenAI 官方 Node.js SDK:流式响应、最终状态与后台流恢复
  3. HTTPX 官方文档:connect、read、write 和 pool 四类超时(python-httpx.org)
  4. Nginx 官方代理模块文档:proxy_read_timeout、proxy_buffering、SNI 与上游切换(nginx.org)
  5. WHATWG Server-sent events 规范:注释行、事件解析与保活(html.spec.whatwg.org)