上周有人丢来 openai.APITimeoutError,问是不是中转站挂了。他的代码是 timeout=30、非流式、模型挂 claude-opus-4-5-thinking、提示词塞了八万字。这跟中转站没关系,是他把超时需要的条件全凑齐了。ai api 超时大半的排查时间浪费在猜上,把根因切成三类,猜的部分就没了。
特征是请求没到服务端,或者响应在回来路上掉了。DNS 解析慢、TCP 握手失败、TLS 卡在 SNI 或证书链回源、跨境丢包重传——RTT 三百毫秒叠加百分之二丢包,一次握手拖到十几秒。表现是连接阶段就失败,时好时坏没规律。
换一台机器、换一个网络跑同一条命令:症状跟着机器走是本地环境问题,跟着网络走才是链路问题。
请求已稳定送达,服务端在算,只是比你设的超时久。触发条件很明确:上下文几十万 token、开了 thinking 的推理模型、上游高峰排队。思考链实打实耗时,长上下文下几十秒很正常。你设 60 秒,它 65 秒返回,你看到的就是超时。
最好修也最容易被忽略。不少 SDK 默认超时只有 10 到 30 秒;更致命的是非流式,服务端必须生成完整段才吐第一个字节。生成四千字要九十秒,前面网络再快,客户端也会在第 30 秒判定超时。
不用抓包,curl 的 -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 只看 ICMP,很多服务端直接屏蔽,别拿它下结论。看 TCP 用 tcping,看每跳延迟丢包用 mtr。
tcping -t 5 api.openai.com 443
mtr -rwzc 50 api.openai.com
中间跳轻微丢包很常见,路由器给探测包降优先级而已。关键看最后两跳:持续丢包超过百分之三、抖动上百毫秒,跨境直连就是这个命。
同一把 Key、同一段提示词、同一台机器,一次只改一个变量:
stream=true 后首字节两秒到、总时长七十秒,链路正常,是输出太长直连海外的路径不归你控制。同一个域名电信走日本、联通走美国、移动绕欧洲都正常。一旦绕行,RTT 从八十毫秒变三百毫秒,丢包跟着上来,长连接还容易被中途重置。晚高峰出口带宽被抢占,抖动翻几倍。
公网裸连用不上 BBR、没有多路复用、连接无法预热,TLS 每次重新握手。长上下文请求上下行几十 MB,慢启动还没跑起来就被超时打断。
把请求交给国内能直达的节点,客户端这一段 RTT 从三四百毫秒降到几十毫秒,跨境那段由中转侧维护长连接和连接池去扛,客户端只面对一段稳定短链路。像 https://xyuapi.top/v1 这种 OpenAI 兼容入口,只改 base_url 和 Key。
非流式是全有或全无:服务端把整段 token 生成完,才拼成完整 JSON 一次返回。客户端期间收不到任何字节,只能干等,等生成完四千字,它早就放弃三次了。
我踩过最典型的坑。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 秒,第二遍首字节两秒内到、全程跑完。输出可能超过三十秒就必须流式。
很多人以为 read timeout 是整段请求的总时长上限,不是。它管两次成功收到数据之间的最大间隔,流式下每来一个 chunk 就重置,衡量的是服务端多久没吭声。connect timeout 管建立 TCP 连接含 TLS 握手,只在开头生效。
建连是纯网络动作,几百毫秒到几秒完成,给 10 秒绰绰有余。服务端处理可能几十秒到几分钟。read 设 10 秒等于要求服务端每 10 秒必须吐一次数据,长任务必挂。合理量级是 connect 5 到 10 秒、read 120 到 600 秒。
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=30 | read 300 以上 + 流式 | 生成没完就断 |
| 推理模型 | timeout=60 非流式 | 流式 + read 600 | thinking 未完就断 |
| 批量脚本 | 每请求新建 client | 复用 client 连接池 | 每次重建 TLS |
| 海外直连 | connect=30 | connect 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,会先把上游响应攒进缓冲区再发给客户端。对普通网页是性能优化,对流式 LLM 是灾难:首字节拿不到,SSE 变成卡半天一次性返回。流式路径必须关。
这三个默认都是 60 秒,也就是很多人请求超过一分钟必断的真正原因。还有一处容易漏:如果前面挂了 CDN,CDN 侧也有自己的读超时,nginx 调到 900 秒而 CDN 第 60 秒断开连接,照样白搭。
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 计费时输出越长越贵,人会本能地把 read timeout 压小、把 max_tokens 卡死;按次计费下,一次对话发一个字和发二十万字同价,长任务真正该给的超时就敢放开。
| 模型 | 价格 |
|---|---|
| gemini-2.5-pro | 0.031元/次 |
| gemini-3-pro-preview | 0.05元/次 |
| gemini-3.1-pro-preview | 0.09元/次 |
| claude-sonnet-4-5-thinking | 0.09元/次 |
| claude-opus-4-5-thinking | 0.12元/次 |
| claude-opus-4-6-thinking | 0.25元/次 |
| claude-opus-4-7-thinking | 0.25元/次 |
| grok-4.1 | 0.05元/次 |
| grok-4.5 | 0.09元/次 |
| deepseek-v4-pro-thinking | 0.09元/次 |
| gpt-5.5 | 0.2元/次 |
| kimi-k2.6 | 0.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 元起充,接进来只改两行配置。