Gemini 4 Carbon 接入后总是超时 / 是网络出口还是请求体太大?

文章导读
Gemini 4 Carbon 接入后频繁报超时,第一件事不是把超时时间调大,而是先用分段计时确认慢在哪一段。curl 输出的 time_connect、time_pretransfer、time_starttransfer 恰好对应三个环节:连接建立(DNS、TCP、TLS)、请求体上传、服务端处理到首字节。连接段占大头,通常说明本机网络出口或链路质量有问题;连接段正常而首字节段占大头,就要怀疑
📋 目录
  1. 壹 用 curl 计时把总耗时拆成连接、首字节、传输三段
  2. 贰 对照不同输入长度的耗时记录
  3. 叁 检查客户端超时与连接复用设置
  4. 肆 设计可观察的重试与退避
  5. 伍 在日志里留下判定依据
A A

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

把结果填进下面这张表,同一台机器、同一时段连续测,测试顺序建议先从短到长、再从长到短各一轮,避免只看单向趋势。

场景输入字符数请求体字节数connectpretransferfirst_bytetotal状态码
裁剪后(最小复现)
裁剪前(完整输入)
裁剪前,重复一次

判断口径:如果 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 需要同时设置总时长和分项时长,否则总时长会把长请求提前掐断:

Gemini 4 Carbon 接入后总是超时 / 是网络出口还是请求体太大?
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 占了大头而前两段正常,则属于服务端处理时长,需要回到客户端超时与异步调用方式上调整,而不是继续在网络上找原因。