AI API调用超时?这份完整解决方案请收好

超时不是玄学:先把三类根因拆开

上周有人丢来 openai.APITimeoutError,问是不是中转站挂了。他的代码是 timeout=30、非流式、模型挂 claude-opus-4-5-thinking、提示词塞了八万字。这跟中转站没关系,是他把超时需要的条件全凑齐了。ai api 超时大半的排查时间浪费在猜上,把根因切成三类,猜的部分就没了。

网络层:DNS、建连、TLS、跨境丢包

特征是请求没到服务端,或者响应在回来路上掉了。DNS 解析慢、TCP 握手失败、TLS 卡在 SNI 或证书链回源、跨境丢包重传——RTT 三百毫秒叠加百分之二丢包,一次握手拖到十几秒。表现是连接阶段就失败,时好时坏没规律。

换一台机器、换一个网络跑同一条命令:症状跟着机器走是本地环境问题,跟着网络走才是链路问题。

服务端:长上下文、thinking、排队

请求已稳定送达,服务端在算,只是比你设的超时久。触发条件很明确:上下文几十万 token、开了 thinking 的推理模型、上游高峰排队。思考链实打实耗时,长上下文下几十秒很正常。你设 60 秒,它 65 秒返回,你看到的就是超时。

客户端:默认超时太短、非流式等整段

最好修也最容易被忽略。不少 SDK 默认超时只有 10 到 30 秒;更致命的是非流式,服务端必须生成完整段才吐第一个字节。生成四千字要九十秒,前面网络再快,客户端也会在第 30 秒判定超时。

用 curl 分阶段计时,几秒定位卡在哪一步

不用抓包,curl 的 -w 就够。任何超时工单我都先跑这一条,再决定往哪个方向查。

-w 五个字段怎么读

curl -o /dev/null -s -w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
  -X POST https://xyuapi.top/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}],"stream":false}'

五个值是累加的绝对值,做减法才是每步的真实耗时。

ping / tcping / mtr 看链路

ping 只看 ICMP,很多服务端直接屏蔽,别拿它下结论。看 TCP 用 tcping,看每跳延迟丢包用 mtr。

tcping -t 5 api.openai.com 443
mtr -rwzc 50 api.openai.com

中间跳轻微丢包很常见,路由器给探测包降优先级而已。关键看最后两跳:持续丢包超过百分之三、抖动上百毫秒,跨境直连就是这个命。

换模型、换流式做对照实验

同一把 Key、同一段提示词、同一台机器,一次只改一个变量:

跨境链路为什么天生容易超时

绕行路由与出口抖动

直连海外的路径不归你控制。同一个域名电信走日本、联通走美国、移动绕欧洲都正常。一旦绕行,RTT 从八十毫秒变三百毫秒,丢包跟着上来,长连接还容易被中途重置。晚高峰出口带宽被抢占,抖动翻几倍。

缺 TCP 层优化

公网裸连用不上 BBR、没有多路复用、连接无法预热,TLS 每次重新握手。长上下文请求上下行几十 MB,慢启动还没跑起来就被超时打断。

国内直连中转改变了什么

把请求交给国内能直达的节点,客户端这一段 RTT 从三四百毫秒降到几十毫秒,跨境那段由中转侧维护长连接和连接池去扛,客户端只面对一段稳定短链路。像 https://xyuapi.top/v1 这种 OpenAI 兼容入口,只改 base_url 和 Key。

长输出任务必须流式,这不是优化是刚需

非流式的超时是怎么来的

非流式是全有或全无:服务端把整段 token 生成完,才拼成完整 JSON 一次返回。客户端期间收不到任何字节,只能干等,等生成完四千字,它早就放弃三次了。

TTFB 正常但总时长超时,说明输出太长

我踩过最典型的坑。curl 测出 time_starttransfer 只有 1.8 秒,time_total 却是 95 秒。我以为是服务端慢,其实是输出确实长,服务端一直在吐数据,非流式下客户端看不到中间过程。改成流式,首字节 1.8 秒到手,只要首字节前不超时,后面就能续命。

同一任务两种写法的对照代码

import time
from openai import OpenAI

client = OpenAI(api_key="sk-xxx", base_url="https://xyuapi.top/v1")
PROMPT = "写一份两万字的行业分析报告,分章节输出"

t0 = time.time()          # 错误写法:非流式 + 极短超时,等整段返回
try:
    resp = client.chat.completions.create(
        model="claude-sonnet-4-5-thinking",
        messages=[{"role": "user", "content": PROMPT}],
        timeout=30,          # 生成远不止 30 秒,必然超时
        stream=False,
    )
    print("非流式耗时", time.time() - t0)
except Exception as e:
    print("非流式失败", type(e).__name__, round(time.time() - t0, 1))

t0 = time.time()          # 正确写法:流式,首字节到手就说明链路通了
first, chars = None, 0
try:
    stream = client.chat.completions.create(
        model="claude-sonnet-4-5-thinking",
        messages=[{"role": "user", "content": PROMPT}],
        timeout=600,         # 总时长给足,首字节不会等这么久
        stream=True,
    )
    for chunk in stream:
        if not chunk.choices:
            continue
        piece = chunk.choices[0].delta.content or ""
        if piece and first is None:
            first = time.time() - t0
        chars += len(piece)
    print("流式 TTFB", round(first, 2), "总耗时", round(time.time() - t0, 1), "字数", chars)
except Exception as e:
    print("流式失败", type(e).__name__, round(time.time() - t0, 1))

同一个任务跑两遍,第一遍死在 30 秒,第二遍首字节两秒内到、全程跑完。输出可能超过三十秒就必须流式。

客户端超时参数:connect 和 read 根本不是一回事

read timeout 管的是两次数据之间的间隔

很多人以为 read timeout 是整段请求的总时长上限,不是。它管两次成功收到数据之间的最大间隔,流式下每来一个 chunk 就重置,衡量的是服务端多久没吭声。connect timeout 管建立 TCP 连接含 TLS 握手,只在开头生效。

为什么 read 必须远大于 connect

建连是纯网络动作,几百毫秒到几秒完成,给 10 秒绰绰有余。服务端处理可能几十秒到几分钟。read 设 10 秒等于要求服务端每 10 秒必须吐一次数据,长任务必挂。合理量级是 connect 5 到 10 秒、read 120 到 600 秒。

SDK 里到底该怎么写

import httpx
from openai import OpenAI

client_bad = OpenAI(api_key="sk-xxx", base_url="https://xyuapi.top/v1", timeout=10)   # 错误写法:一个数字同时管建连和读取

client = OpenAI(   # 正确写法:分开设置,读取给足
    api_key="sk-xxx",
    base_url="https://xyuapi.top/v1",
    timeout=httpx.Timeout(connect=5.0, read=300.0, write=30.0, pool=5.0),
    max_retries=2,
    http_client=httpx.Client(limits=httpx.Limits(max_keepalive_connections=20)),
)

Node 侧同理,SDK 的 timeout 只管单次请求,底层还要自己兜一层取消信号:

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.KEY,
  baseURL: "https://xyuapi.top/v1",
  timeout: 300000,   // 5 分钟
  maxRetries: 2,
});

const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 600000);   // 外层硬上限
try {
  const stream = await client.chat.completions.create(
    { model: "grok-4.5", messages: [{ role: "user", content: "写篇长文" }], stream: true },
    { signal: ac.signal }
  );
  for await (const chunk of stream) {
    process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
  }
} finally {
  clearTimeout(timer);
}

错误配置与正确配置对照:

场景错误配置正确配置后果
长文生成timeout=30read 300 以上 + 流式生成没完就断
推理模型timeout=60 非流式流式 + read 600thinking 未完就断
批量脚本每请求新建 client复用 client 连接池每次重建 TLS
海外直连connect=30connect 5 到 10网络差时干等
重试立即重试 5 次指数退避 2 次撞同一个坏节点

重试策略:能不重试的别重试

哪些请求能安全重试

判断标准只有一个:重试会不会让世界发生两次变化。

带幂等键的请求可以放心重试,前提是服务端真按幂等键去重。很多上游只在创建类接口实现这套语义,对话补全接口压根没有。

指数退避加抖动的最小实现

import random, time

RETRYABLE = {429, 500, 502, 503, 504}

def backoff(attempt, base=1.0, cap=30.0):
    delay = min(cap, base * (1 << attempt))
    return delay * (0.5 + random.random() * 0.5)   # 抖动,避免同时重试

def should_retry(err) -> bool:
    status = getattr(err, "status_code", None)
    if status in RETRYABLE:
        return True
    if status and 400 <= status < 500:
        return False          # 401/403 重试一万次也一样
    return True               # 连接错误、读超时属于可重试

抖动别省。一堆并发任务同时失败同时重试,会把上游再打一遍。400、401、403、404、422 属于请求本身有问题,重试只浪费时间额度。

代理是隐形超时元凶

环境变量与显式注入

HTTP_PROXY / HTTPS_PROXY 会被 curl、requests、httpx 一起读走,NO_PROXY 用来排除内网,比如 NO_PROXY=localhost,127.0.0.1,.internal。环境变量是最脏的一层配置,它会不分青红皂白套在所有请求上,包括本来直连很快的中转入口。

import httpx
from openai import OpenAI

client = OpenAI(
    api_key="sk-xxx",
    base_url="https://xyuapi.top/v1",
    http_client=httpx.Client(
        proxy="http://127.0.0.1:7890",
        timeout=httpx.Timeout(connect=5.0, read=300.0),
    ),
)

代理链本身是元凶怎么排查

同一段请求走代理、绕过代理各跑一次,比数字:

echo '--- 走代理 ---'
curl -x http://127.0.0.1:7890 -o /dev/null -s -w 'proxy total=%{time_total}\n' \
  -X POST https://xyuapi.top/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"grok-4.1","messages":[{"role":"user","content":"hi"}]}'

echo '--- 绕过代理 ---'
curl --noproxy '*' -o /dev/null -s -w 'direct total=%{time_total}\n' \
  -X POST https://xyuapi.top/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model":"grok-4.1","messages":[{"role":"user","content":"hi"}]}'

差几倍问题就在代理。更隐蔽的一类会先把流式响应整体缓冲再转发,表现是流式也拿不到首字节,等半天一次性吐完,换节点或给这个域名加 NO_PROXY。

nginx 与服务器侧:自己搭中转必须调的参数

客户端参数调得再漂亮,nginx 一层默认值就能全部推翻。

proxy_buffering off 是流式生死线

nginx 默认开 proxy_buffering,会先把上游响应攒进缓冲区再发给客户端。对普通网页是性能优化,对流式 LLM 是灾难:首字节拿不到,SSE 变成卡半天一次性返回。流式路径必须关。

三个 timeout 分别管什么

这三个默认都是 60 秒,也就是很多人请求超过一分钟必断的真正原因。还有一处容易漏:如果前面挂了 CDN,CDN 侧也有自己的读超时,nginx 调到 900 秒而 CDN 第 60 秒断开连接,照样白搭。

完整 location 配置

location /v1/ {
    proxy_pass http://upstream_api;

    proxy_http_version 1.1;
    proxy_set_header Connection "";              # 清掉 close,启用上游长连接池
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;

    proxy_connect_timeout 10s;                   # 建连,短
    proxy_send_timeout 300s;                     # 写请求体
    proxy_read_timeout 900s;                     # 读响应,给推理模型留足空间

    proxy_buffering off;                         # 流式必须关,否则被缓冲
    proxy_cache off;
    chunked_transfer_encoding on;                # 分块传输,边生成边下发
    gzip off;                                    # 压缩会引入额外缓冲延迟
    add_header X-Accel-Buffering no;             # 告诉上游不要再缓冲
}

upstream upstream_api {
    server 127.0.0.1:8080;
    keepalive 64;                                # 上游连接池,省掉重复握手
    keepalive_timeout 300s;
}

客户端侧的 keepalive_timeout 太短,长任务没跑完连接就被回收了。

降级链挂哪些模型,价格得一起算

超时降级不能随手挑个模型接上,能力和成本都得看。

为什么降级模型不能只看便宜

降级模型要顶替原模型跑完那批超时的任务,能力差太多等于把问题从超时改成答错。我一般选同档或略低一档,并且优先挑响应更快的,让第二次尝试真的能跑完。

按次计费和按 token 计费的差别

这个差别对长输出任务很关键。按 token 计费时输出越长越贵,人会本能地把 read timeout 压小、把 max_tokens 卡死;按次计费下,一次对话发一个字和发二十万字同价,长任务真正该给的超时就敢放开。

模型价格
gemini-2.5-pro0.031元/次
gemini-3-pro-preview0.05元/次
gemini-3.1-pro-preview0.09元/次
claude-sonnet-4-5-thinking0.09元/次
claude-opus-4-5-thinking0.12元/次
claude-opus-4-6-thinking0.25元/次
claude-opus-4-7-thinking0.25元/次
grok-4.10.05元/次
grok-4.50.09元/次
deepseek-v4-pro-thinking0.09元/次
gpt-5.50.2元/次
kimi-k2.60.09元/次

一份可以直接抄的容错封装

把前面所有东西合起来就是 safe_chat。要点四个:connect 与 read 分离、默认走流式、超时自动降级(先切流式,再换轻量模型)、每步打计时日志。

完整代码

import time, random, logging
import httpx
from openai import OpenAI

log = logging.getLogger("llm")

class LLMClient:
    def __init__(self, api_key, base_url="https://xyuapi.top/v1", proxy=None):
        self.client = OpenAI(
            api_key=api_key,
            base_url=base_url,
            max_retries=0,                       # 重试自己管,方便打日志
            timeout=httpx.Timeout(connect=5.0, read=300.0, write=30.0, pool=5.0),
            http_client=httpx.Client(
                proxy=proxy,
                limits=httpx.Limits(max_keepalive_connections=20, max_connections=50),
            ),
        )

    def _call(self, model, messages, stream, read_timeout):
        t0 = time.time()
        self.client.timeout = httpx.Timeout(
            connect=5.0, read=read_timeout, write=30.0, pool=5.0
        )
        if not stream:
            resp = self.client.chat.completions.create(
                model=model, messages=messages, stream=False
            )
            log.info("model=%s stream=False cost=%.1fs", model, time.time() - t0)
            return resp.choices[0].message.content

        buf, first = [], None
        for chunk in self.client.chat.completions.create(
            model=model, messages=messages, stream=True
        ):
            if not chunk.choices:
                continue
            piece = chunk.choices[0].delta.content or ""
            if piece and first is None:
                first = time.time() - t0
            buf.append(piece)
        log.info(
            "model=%s stream=True ttfb=%.2fs total=%.1fs chars=%d",
            model, first if first else -1, time.time() - t0, sum(len(x) for x in buf),
        )
        return "".join(buf)

    def chat(self, messages, model="grok-4.5",
             fallback_models=("gemini-2.5-pro",), max_retries=3, read_timeout=300.0):
        chain = [(model, True)] + [(m, True) for m in fallback_models]
        last_err = None
        for attempt in range(max_retries):
            for current, use_stream in chain:
                try:
                    return self._call(current, messages, use_stream, read_timeout)
                except Exception as err:
                    last_err = err
                    status = getattr(err, "status_code", None)
                    log.warning("fail model=%s status=%s err=%s",
                                current, status, type(err).__name__)
                    if status and 400 <= status < 500 and status != 429:
                        raise                      # 请求本身有问题,重试无用
            delay = min(30.0, 1.0 * (1 << attempt)) * (0.5 + random.random() * 0.5)
            log.info("retry attempt=%d after %.1fs", attempt + 1, delay)
            time.sleep(delay)
        raise last_err

几个设计决定的理由

降级链是原模型流式接备用模型流式,不是先试非流式再试流式。非流式在长输出上几乎没胜算,排在前面只白烧一次超时。

SDK 自带重试关成 max_retries=0,重试自己写,让失败的模型名、状态码、耗时都进日志,排查线上问题时这三样比堆栈有用。

日志同时打 ttfb 和 total,是踩坑之后加的:ttfb 正常而 total 离谱是输出长度问题,ttfb 就很慢才需要去看链路和代理。

流式开关默认开启,不做成可配参数。线上没人会记得去改配置。

上面所有配置里,base_url 是最容易换也最容易见效的一项。小鱼API(官网 https://xyuai.cc,接入地址 https://xyuapi.top/v1)国内直连,一个 Key 调全系列模型,按次计费、最低 7 元起充,接进来只改两行配置。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信