大模型 API 超时与连接管理
大模型 API 的超时比普通 HTTP 接口复杂:非流式请求需等模型生成完毕(可能 30-120s),流式请求则需持续读取直到 [DONE]。错误配置超时会导致大量假性失败或内存泄漏,本文给出各场景的参考值与代码示例。
先说个真实场景:你把某个接口从传统 REST 服务切到大模型 API,线上跑了两天,监控面板突然一堆 504,报错信息长这样:
httpx.ReadTimeout: The read operation timed out
或者 Node 这边是:
FetchError: request to https://api.xxx.com/v1/chat/completions failed, reason: read ECONNRESET
排查半天发现服务端其实在正常生成,只是你用的还是旧接口那套「5 秒超时」的老配置——大模型吐 token 是按秒甚至几秒一个字往外挤的,尤其长文生成或推理模型(o1/DeepSeek-R1 这类会先”思考”再输出),首字延迟(TTFT,Time To First Token)动辄五六秒起步,你拿老配置去卡新接口,超时率蹭蹭涨,而这些请求在服务端其实是成功的,纯粹是客户端等不及先断了——这就是本文开头说的”假性失败”:日志里一堆超时,但对方账单里这些 token 照样计费。
超时类型与推荐值
| 超时类型 | 含义 | 推荐值 |
|---|---|---|
| 连接超时(connect timeout) | TCP 握手 + TLS 建立 | 5-10s |
| 请求超时(request timeout) | 从发出请求到收到完整响应 | 非流式:60-120s;流式:300s+ |
| 读超时(read timeout) | 两个数据包之间的最大间隔 | 30-60s(流式逐 token 推送时适用) |
| 总超时(total timeout) | 整个操作的上限 | 视业务 SLA 定,建议 120-180s |
这张表里最容易被搞混的是”请求超时”和”读超时”——两者不是一回事。请求超时是从你发出请求到拿到完整响应的总时长,流式场景下这个值要给得很大(因为整个流可能持续几分钟);读超时则是相邻两个数据包/chunk 之间的间隔,只要模型还在陆续吐 token,哪怕总耗时超过 5 分钟也不该触发读超时。反过来,如果模型卡住不吐字了(比如上游排队、GPU 资源紧张),读超时才是真正保护你的那道闸——它能在合理时间内判定”这次请求死掉了”,而不是傻等到总超时那么久。
连接超时给 5-10s 是因为大模型服务商大多走国际线路或专线中转,跨境 TLS 握手(尤其是 TLS 1.3 的一次往返 + DNS 解析)比国内接口慢,给太短(比如默认的 3s)在网络抖动时会误杀正常请求;但也别无脑设成 30s,那样一旦服务商真的挂了,你的请求会白白卡住半分钟才报错,拖慢整体的失败重试节奏。
Python:openai SDK 超时配置
import os
from openai import OpenAI
import httpx
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
timeout=httpx.Timeout(
connect=10.0, # TCP+TLS 握手
read=60.0, # 两包之间最大间隔
write=10.0, # 发送请求体
pool=5.0, # 从连接池获取连接
),
max_retries=2, # 超时自动重试次数
)
# 单次请求覆盖全局超时
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "写一篇 500 字文章"}],
timeout=120.0, # 覆盖为 120s 总超时
)
print(resp.choices[0].message.content)
这段配置里有个容易踩的坑是 pool=5.0——这个参数管的是”从连接池里拿到一个空闲连接”这个动作的等待时间,不是网络请求本身。如果你的并发量超过了连接池的最大连接数(httpx 默认 max_connections=100),后来的请求会在这里排队,排队超过 5 秒就抛 PoolTimeout,报错信息类似:
httpx.PoolTimeout: All connection attempts failed
看到这个错误第一反应不该是调大 pool 的秒数,而是应该去查你的并发上限设置对不对——把等待时间调大只是把问题往后拖,真正的修法是显式配置 httpx.Limits(max_connections=200, max_keepalive_connections=50) 并传给 httpx.Client,再套进 OpenAI(http_client=...) 里,让连接池匹配你实际的并发规模。max_retries=2 这行也值得多说一句:openai SDK 内置的重试只对连接类错误、429、5xx 生效,且默认走指数退避加抖动,不需要你自己再包一层——除非你想自定义退避策略或加日志埋点,否则没必要重复造轮子。
Python:流式请求超时
流式请求应把 read 超时设大,因为模型生成慢时两包间隔可能超过默认值:
import os
from openai import OpenAI
import httpx
stream_client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=5.0),
)
stream = stream_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "写一篇 2000 字技术博客"}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
print(delta, end="", flush=True)
这里把 read 从 60s 提到 120s 是因为流式场景下”两包间隔”更容易被拉长——推理模型在思考阶段可能十几秒都没有 chunk 吐出来,普通对话模型在生成到复杂段落(比如代码块、表格)时偶尔也会卡顿一两秒。判断该给多大的 read 超时,最简单的办法是先跑一段时间线上日志,统计相邻 chunk 的间隔分布,取 p99 再乘 1.5-2 倍作为安全边界,而不是拍脑袋定一个数字。
还有个常被忽略的点:流式请求里,for chunk in stream 这个循环本身也可能因为网络中断而卡死不报错——这时候光靠 httpx 的超时不够,建议配合业务层的”心跳检测”,比如记录上一个 chunk 到达的时间戳,超过阈值主动 stream.close() 并触发重试,而不是干等 SDK 内部超时生效。
Node.js:超时配置
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.lidayun.com/v1",
timeout: 90_000, // 全局 90s 总超时(毫秒)
maxRetries: 2,
});
// 单次请求覆盖
const resp = await client.chat.completions.create(
{ model: "gpt-4o-mini", messages: [{ role: "user", content: "你好" }] },
{ timeout: 30_000 } // 第二参数覆盖全局
);
console.log(resp.choices[0].message.content);
Node.js 这边底层用的是 undici(Node 18+ 内置 fetch 的实现),跟 Python 的 httpx 有个明显区别:undici 默认的连接超时和请求超时是合在一起算的,不像 httpx 那样把 connect/read/write/pool 拆成四个独立维度。所以你在 timeout: 90_000 这里设的其实是一个”总盘子”,如果你需要更细粒度的控制(比如单独设连接超时),得自己传 dispatcher,用 undici.Agent({ connect: { timeout: 10_000 } }) 构造后塞进 fetch 的 dispatcher 选项——多数业务场景不需要走到这一步,但如果你发现连接慢和生成慢分不清是谁的锅,这是唯一能拆开看的办法。
另外提醒一句:Node 项目里如果用的是自己拼的 fetch 而不是 openai 官方 SDK,超时得靠 AbortController 手动实现,官方 SDK 帮你把这层封装好了,没有特殊需求没必要自己重写这段逻辑。
连接池复用
openai SDK 底层(Python 用 httpx,Node.js 用 undici)默认启用 HTTP/1.1 keep-alive 连接复用。建议复用 client 单例,避免每次请求新建 TCP 连接:
# 推荐:模块级单例
_client: OpenAI | None = None
def get_client() -> OpenAI:
global _client
if _client is None:
_client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
)
return _client
// 推荐:模块级单例
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export default client;
为什么强调”单例”这件事,值得展开说说背后的成本账。每次新建连接都要走一遍 TCP 三次握手 + TLS 握手,跨境线路下这一趟往返可能就是 100-300ms,如果你在 Serverless 函数里每次调用都 new OpenAI()(而不是复用容器间的模块级实例),相当于每次请求都白白多付出这几百毫秒,量一大就是实打实的延迟和成本。下面这张表是两种写法在真实压测下的大致差异(具体数值随网络环境浮动,仅供判断量级参考):
| 写法 | 首次请求耗时 | 后续请求耗时 | 适用场景 |
|---|---|---|---|
| 每次新建 client + 新建连接 | 正常 | 每次都多 100-300ms 握手开销 | 几乎没有合理场景 |
| 单例 client + keep-alive 复用 | 正常 | 复用已有 TCP 连接,省去握手 | 常驻服务、Web 后端 |
| Serverless 冷启动 | 首次较慢 | 同一容器内后续请求可复用 | 需注意容器复用窗口 |
Serverless 场景要格外注意:只有同一个执行环境(容器)被复用时,模块级单例才有意义;如果平台每次都冷启动新容器,复用效果会打折扣,这时候更该关注的是缩短冷启动本身,而不是纠结连接池配置。
超时后的兜底
import httpx, time
for attempt in range(3):
try:
resp = get_client().chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "你好"}],
timeout=30.0,
)
break
except httpx.TimeoutException:
if attempt == 2:
raise
time.sleep(2 ** attempt)
这段代码用的是最基础的指数退避(2 ** attempt:2s、4s),能用但有个隐患——如果你的服务在高并发下集中触发超时(比如上游服务商短暂抖动,同时影响了你几百个并发请求),这些请求会在完全相同的时间点集体重试,形成新一轮的流量脉冲,反而可能把刚恢复的服务又打挂,这就是所谓的”重试风暴”(thundering herd)。生产环境建议在退避基础上加个随机抖动:
import random
sleep_seconds = (2 ** attempt) + random.uniform(0, 1)
time.sleep(sleep_seconds)
加了 random.uniform(0, 1) 之后,同时失败的请求会被打散到一个时间窗口里逐个重试,而不是撞在同一秒,这是分布式系统里退避重试的标准做法,成本几乎为零但能显著降低重试造成的二次冲击。
还有一点容易被忽略:重试次数不是越多越好。像本例这样重试 3 次、每次退避 2-5 秒,最坏情况下一个请求可能要拖到十几秒才最终失败并抛出异常,如果你的业务对响应时间敏感(比如同步等待结果的用户交互场景),2 次重试、退避时间压到 1-3 秒会更合适;如果是后台批量任务对时延不敏感,重试次数可以放宽到 5 次并配合更长的退避。这个取舍没有标准答案,取决于你的业务对”晚一点拿到结果”和”多消耗一点重试成本”哪个更能接受。
常见问题
Nginx 反向代理后流式超时怎么处理? Nginx 的 proxy_read_timeout 默认 60s,流式推送慢时会被截断,需调大:proxy_read_timeout 300s;,同时设 proxy_buffering off;。
超时时间设多长合适? 非流式对话类建议 60s,长文生成建议 120s,批量任务建议 300s。过长会占用连接资源,过短会产生大量假失败。
超时后请求是否还在服务端执行? 大多数平台会在连接断开后终止生成,但计费可能已计入已生成的 token。建议在超时重试前评估是否真有必要重发。
用 async/await 时超时如何设置? Python 异步场景用 asyncio.wait_for 包裹,或在 AsyncOpenAI 初始化时同样传 httpx.Timeout,参数相同。
超时和 429 限流报的错看着很像,怎么区分? 超时是客户端等待时间到了主动断开(异常类型是 TimeoutException/ReadTimeout),本质是”还没等到结果”;429 是服务端明确返回了状态码,告诉你”这次请求我拒绝了”,本质是”配额或速率超限”。两者的处理逻辑完全不同——超时可以直接按固定间隔重试,429 必须尊重响应头里的 Retry-After 字段等够时间再重试,否则只会越限越死。限流的具体处理见文末链接的 429 专题文章。
同一个请求,为什么有时候 3 秒返回、有时候 30 秒才返回? 大概率是撞上了服务商那边的排队。大模型推理是排他性占用 GPU 资源的,高峰时段(尤其是国内工作日上午 10 点和晚上 8-10 点这两个使用高峰)请求要先在服务端排队等资源释放,这段排队时间对你来说就是纯粹的”白等”,跟你自己的网络或代码质量无关。如果你的业务对延迟敏感,一个实用的做法是给关键请求设置比平时更短的超时(比如 15-20s)并快速失败转人工兜底话术,而不是让用户对着加载动画等一分钟。
超时配置要不要按不同接口区分? 要。同一个服务里,“生成一句欢迎语”和”生成一篇长文报告”不该用同一套超时参数——如果全局统一设成 120s,短请求超时时也要等 2 分钟才能判定失败,体验很差。更合理的做法是在业务层按接口类型维护一张超时配置表,短请求给短超时(20-30s),长文/批量任务单独传更大的 timeout 参数覆盖全局值(就是本文示例代码里单次请求覆盖全局配置那种写法)。
自查清单
配置改完别急着上线,照着这几条过一遍,能避开大部分线上超时相关的坑:
- 区分了流式和非流式两套超时参数,没有用同一套配置糊弄两种场景。
- 连接超时给了 5-10s 的余量,不是照抄某个默认值(很多 HTTP 库默认连接超时只有几秒甚至更短)。
- 重试逻辑加了随机抖动,不是纯粹的
2 ** attempt死板退避。 - 客户端用的是模块级单例,没有每次请求都新建 client 实例。
- 反向代理(Nginx/Caddy 等)的超时设置跟客户端超时对齐了,没有出现代理侧先掐断连接、客户端还傻等的情况。
- 日志里能区分出”超时失败”和”429 限流失败”和”401 鉴权失败”这三类,而不是混在一起的一个大坨”请求失败”。
更多接入基础见大模型 API 接入完全指南与接入教程专题。速率限制触发的 429 处理见 429 限流:指数退避与重试;鉴权问题见 401 鉴权失败排查。