Gemini 4 Carbon 接入后频繁报超时,第一件事不是把超时时间调大,而是先用分段计时确认慢在哪一段。curl 输出的 time_connect、time_pretransfer、time_starttransfer 恰好对应三个环节:连接建立(DNS、TCP、TLS)、请求体上传、服务端处理到首字节。连接段占大头,通常说明本机网络出口或链路质量有问题;连接段正常而首字节段占大头,就要怀疑输入太长、请求体太大,或服务端排队时间变长。判断顺序建议是:先量一次,只改一处,再量一次。
超时很少由单一原因造成。用 curl 把总耗时拆成连接、上传、首字节、传输四段:连接段偏高就先查 DNS 解析、出口链路和 TLS 协商;首字节段偏高且请求体字节数明显偏大,先把提示词裁剪到最小可复现版本再对比耗时。客户端读超时通常按“两次读取之间的间隔”计算,不是端到端总耗时,容易把正常的长请求判成超时。重试只对超时、连接错误和 5xx 有意义,请求体本身导致的 4xx 重试没有价值。这些判断都需要在你自己的环境里用日志复现一次才能定论。
用 curl 计时把总耗时拆成连接、首字节、传输三段
把下面的命令保存为脚本,在能访问接口的机器上执行,请求体放在 payload.json 里,避免把长提示词直接写进命令历史。执行 3 到 5 次,看各段耗时的波动,单次结果不足以判断。
curl -s -o /tmp/resp.json \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} pretranster=%{time_pretransfer} first_byte=%{time_starttransfer} total=%{time_total} upload_bytes=%{size_upload} code=%{http_code}\n' \
-X POST "$GEMINI_ENDPOINT" \
-H "Authorization: Bearer $GEMINI_KEY" \
-H 'Content-Type: application/json' \
`--data-binary` @payload.json
解读时按差值看,不要只看绝对值:
- connect 减 dns:TCP 握手耗时。明显偏大,先怀疑本机出口到目标地址的链路质量或防火墙策略。
- tls 减 connect:TLS 协商耗时。偏大常与证书链、握手往返次数有关,可加
`--tlsv1`.3复测对比。 - pretransfer 减 tls:请求体上传耗时。这一段会随请求体字节数增长,是“请求体太大”最直接的证据。
- first_byte 减 pretransfer:服务端开始返回首字节前的处理时间,与输入长度、模型排队相关,本地网络无法影响这一段。
- total 减 first_byte:响应体传输耗时。偏大通常是响应内容长或下行带宽受限。
如果本地 curl 的 total 远小于应用日志里的耗时,问题更可能在客户端配置或连接复用,而不是网络出口。
对照不同输入长度的耗时记录
验证请求体大小是不是主要变量,最直接的办法是同一份提示词裁剪前后各发一次请求,其余变量(时间、目标地址、鉴权方式)保持一致。字符数和字节数分别记录:汉字在多字节编码下字节数会明显大于字符数,只看字符数容易误判。
# 统计提示词字符数
wc -m prompt.txt
# 统计完整请求体字节数,curl 的 size_upload 也会给出实际发送字节数
wc -c payload.json
把结果填进下面这张表,同一台机器、同一时段连续测,测试顺序建议先从短到长、再从长到短各一轮,避免只看单向趋势。
| 场景 | 输入字符数 | 请求体字节数 | connect | pretransfer | first_byte | total | 状态码 |
|---|---|---|---|---|---|---|---|
| 裁剪后(最小复现) | |||||||
| 裁剪前(完整输入) | |||||||
| 裁剪前,重复一次 |
判断口径:如果 connect 在两行之间基本一致,而 pretransfer 与 first_byte 随字节数同步增长,说明请求体大小是主要变量,处理方向是裁剪上下文、分段提交或改走异步任务;如果 connect 本身就不稳定、两行之间差别很大,则应先排查出口链路与 DNS,把请求体当作次要因素。
检查客户端超时与连接复用设置
不少“超时”其实是客户端配置把正常请求判成了失败。要点有两个:一是连接超时和读超时要分开设置,二是连接池要复用,否则每次请求都重新握手,连接段的耗时会叠加到总耗时上。
Python requests 中,timeout 传元组表示(连接超时,读超时),读超时是两次读取之间的最大间隔,不是总耗时上限;需要总耗时上限时,自己在调用外层计时并中断。
import requests
from requests.adapters import HTTPAdapter
sess = requests.Session()
sess.mount('https://', HTTPAdapter(pool_connections=10, pool_maxsize=10, max_retries=0))
timeout = (5, 60) # 连接超时 5s,读超时 60s
resp = sess.post(url, json=body, timeout=timeout)
异步场景下 aiohttp 需要同时设置总时长和分项时长,否则总时长会把长请求提前掐断:
import aiohttp
timeout = aiohttp.ClientTimeout(total=120, sock_connect=5, sock_read=60)
async with aiohttp.ClientSession(timeout=timeout) as s:
async with s.post(url, json=body) as r:
data = await r.json()
Go 的 http.Client 里,Timeout 是整个请求的上限,连接和首字节要分别放到 Transport 上控制:
tr := &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 120 * time.Second}
配置写完不算完,要在启动日志里打印实际生效值,避免环境变量、框架默认值和代码里写死三者的覆盖关系看错:
logger.info('http config connect_timeout=%s read_timeout=%s total_timeout=%s pool_maxsize=%s', connect_to, read_to, total_to, pool_size)
print(os.environ.get('HTTP_READ_TIMEOUT', 'unset'))
如果日志里看到的读超时只有十几秒,而你实际请求要几十秒才能返回首字节,那报超时就和网络出口无关,先把这个值调到与首字节耗时的量级匹配,并确认业务侧能接受这个等待时间。
设计可观察的重试与退避
偶发超时可以用重试掩盖,但前提是只重试能被重试的错误。超时、连接错误、429 和 5xx 值得重试;请求体格式错误、参数越界这类 4xx 重试多少次结果都一样,只会拉长故障时间。重试次数建议控制在 2 到 3 次,退避用指数加随机抖动,避免同一时刻集中重发。
import random, time, logging
log = logging.getLogger('gemini')
def call_gemini(payload, max_attempts=3, base_delay=1.0, cap=8.0):
last_err = None
for attempt in range(1, max_attempts + 1):
started = time.monotonic()
try:
resp = sess.post(url, json=payload, timeout=(5, 60))
if resp.status_code >= 500 or resp.status_code == 429:
last_err = 'http_%d' % resp.status_code
elif resp.status_code >= 400:
# 请求体或参数问题,直接抛出,不重试
log.warning('non_retryable status=%d bytes=%d', resp.status_code, len(resp.content))
resp.raise_for_status()
else:
log.info('ok attempt=%d cost=%.2fs', attempt, time.monotonic() - started)
return resp
except requests.exceptions.ConnectTimeout:
last_err = 'connect_timeout'
except requests.exceptions.ReadTimeout:
last_err = 'read_timeout'
except requests.exceptions.ConnectionError:
last_err = 'connect_error'
log.warning('retry attempt=%d err=%s cost=%.2fs', attempt, last_err, time.monotonic() - started)
if attempt == max_attempts:
break
time.sleep(min(cap, base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.5)))
raise RuntimeError('gemini call failed after %d attempts: %s' % (max_attempts, last_err))
每次重试都要把错误类型写进日志,尤其是区分 connect_timeout 与 read_timeout:前者指向链路和出口,后者指向服务端处理时长或读超时设得太短,调参方向完全不同。
在日志里留下判定依据
让下一次超时能一眼看出是哪一段变慢,需要固定记录这些字段:
- 请求时间与请求 ID,用于和服务端或网关侧日志对齐
- 模型标识与接口路径
- 输入字符数、请求体字节数
- dns、connect、upload、first_byte、total 各阶段耗时(毫秒)
- HTTP 状态码、重试次数、错误类型
日志样例(数值为占位,替换成本机实测值):
{"ts":"<ISO8601>","req_id":"a1b2c3","model":"gemini-4-carbon","input_chars":8400,"body_bytes":9200,"dns_ms":10,"connect_ms":28,"upload_ms":180,"first_byte_ms":8500,"total_ms":9200,"status":200,"attempt":1,"err":null}
有了这条日志,再遇到超时就看两个差值:connect_ms 持续偏大,先查出口和 DNS;upload_ms 随 body_bytes 同步上涨且占比明显,先裁剪输入;first_byte_ms 占了大头而前两段正常,则属于服务端处理时长,需要回到客户端超时与异步调用方式上调整,而不是继续在网络上找原因。