先按“自己的超时设太短”去验证,往往比反过来猜并发排队更快:把每一条请求的开始、首字节、结束三个时间点打出来,如果耗时集中贴在某个固定数值上(例如总是接近你设置的 30s 或 60s),多半是被客户端自己掐断;如果耗时随并发档位整体上移、状态码仍正常,才更像服务端排队;如果快速返回 429 或 5xx,那是限流或过载,跟超时阈值无关。三段打点加上连接/读取超时分开设置后,这三类原因基本能分开。
Ling 3.0 Flash 的响应波动不要先在“并发 vs 超时”之间二选一,先做时间分段:开始到收到响应头这段覆盖网络、入口排队与模型前置处理,首字节到结束这段才是生成耗时。客户端把连接超时和读取超时分开设置,可以排除“读取超时太短掐断长生成”的假象;再用低并发到高并发的固定梯度复测,看状态码与耗时是否随并发同步抬升。重试只应针对 429、5xx 与超时,并配合退避与幂等前提,否则会把排队放大成雪崩。
在请求侧打上开始、首字节、结束三个时间点
排查响应忽快忽慢,第一件事不是改参数,而是让每次请求自己说明时间花在哪。用流式读取,可以在收到响应头、拿到第一个正文分片、读完最后一个分片时各打一个时间戳,从而把“连接与排队”和“生成”切开。
import time, json, httpx
def probe(url, payload, headers=None):
t0 = time.monotonic() # 请求发出前
with httpx.stream("POST", url, json=payload, headers=headers,
timeout=httpx.Timeout(connect=5.0, read=60.0)) as r:
t_head = time.monotonic() # 已收到状态行与响应头
t_first, body = None, []
for chunk in r.iter_text():
if t_first is None:
t_first = time.monotonic() # 首个正文分片到达
body.append(chunk)
t_end = time.monotonic() # 响应体读完
marks = {
"到响应头_ms": round((t_head - t0) * 1000),
"首字节_ms": round(((t_first or t_end) - t0) * 1000),
"生成_ms": round((t_end - (t_first or t_end)) * 1000),
"总耗时_ms": round((t_end - t0) * 1000),
}
print(r.status_code, r.headers.get("retry-after"),
json.dumps(marks, ensure_ascii=False))
三个时间戳的分工要记清楚:开始到响应头这一段包含 DNS、TCP/TLS 建连、请求发送、服务端排队与首 token 前的预处理,是排队症状最容易暴露的位置;首字节到结束这一段是流式生成的实际耗时,与输出长度强相关,长短波动不能直接当成服务端抖动;总耗时是两者的和,混用总耗时会掩盖问题。
落地时把输出长度一并记录。同一份提示如果输出长度差异很大,生成段变长属于正常;只有输出长度接近而生成段差异明显,才值得继续往服务端方向查。流式接口和一次性返回接口的行为不同,一次性接口拿不到真实的首字节,只能看到“到响应头”和“总耗时”,这时需要靠连接/读取超时分开设置来补足判断。
把连接超时与读取超时改成不同取值再复测
很多“响应忽快忽慢”其实是读取超时设得太短:连接很快,但读取等待只给了几秒,长一点的生成被客户端主动掐断,日志里看起来像服务端卡住。把连接超时设短、读取超时设长,再复测一轮,就能把这种假象排除掉。
需要留意语义差异:读取超时通常指“两次网络读取之间的最大等待时间”,不是整个请求的总时长;流式输出只要持续有分片返回,即使总耗时很长也不会触发读取超时。按总时长做限制要另外用外层计时或任务级截止时间实现。
# httpx:四个维度分开设置
TIMEOUT = httpx.Timeout(connect=3.0, read=45.0, write=10.0, pool=3.0)
try:
resp = client.post(url, json=payload, headers=headers, timeout=TIMEOUT)
except httpx.ConnectTimeout:
log.warning("connect_timeout") # 连不上:网络、入口排队或连接数耗尽
except httpx.ReadTimeout:
log.warning("read_timeout") # 已连上但读等待超时:生成慢或被限速
except httpx.PoolTimeout:
log.warning("pool_timeout") # 本地连接池不够,先查客户端并发上限
# requests:元组第一位是连接超时,第二位是读取超时
resp = requests.post(url, json=payload, headers=headers, timeout=(3, 45))
确认配置真的生效,不靠猜:把 ConnectTimeout、ReadTimeout、PoolTimeout 分别捕获并打成不同的日志字段,复测后看日志里出现的是哪一种。若日志里 ReadTimeout 数量随输出变长而增加,说明读取超时偏短;若 ConnectTimeout 或 PoolTimeout 占比高,问题在客户端连接池或入口,不在模型生成。
用固定并发梯度压一轮并记录状态码与耗时
判断是否并发排队,关键不是“压到很高”,而是用固定梯度观察曲线形状:从 1 逐档升到 2、4、8,每档发相同数量的请求,看耗时中位数与状态码是否随档位同步变化。阶梯上升且状态码正常,通常是排队;某一档开始出现 429 或 5xx,是限流或过载;各档耗时都贴在同一个数值附近,仍是超时阈值问题。
import asyncio, time, collections, statistics, httpx
async def one(client, sem, url, payload, out):
async with sem:
t0 = time.monotonic()
try:
r = await client.post(url, json=payload)
out.append({"status": r.status_code,
"ms": round((time.monotonic() - t0) * 1000),
"bytes": len(r.content),
"retry_after": r.headers.get("retry-after"),
"req_id": r.headers.get("x-request-id")})
except httpx.HTTPError as e:
out.append({"status": type(e).__name__,
"ms": round((time.monotonic() - t0) * 1000)})
async def ramp(url, payload, levels=(1, 2, 4, 8), rounds=20):
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, read=60.0)) as client:
for n in levels:
sem, out = asyncio.Semaphore(n), []
await asyncio.gather(*[one(client, sem, url, payload, out) for _ in range(rounds)])
ms = sorted(o["ms"] for o in out)
print(f"并发={n} p50={statistics.median(ms):.0f} max={ms[-1]} "
f"状态={dict(collections.Counter(str(o['status']) for o in out))}")
# asyncio.run(ramp(URL, PAYLOAD))
每档要记录的字段清单建议固定下来:并发档位、请求序号、开始时间、HTTP 状态码或异常类型、总耗时、首字节耗时、响应体字节数、retry-after、request id、错误体摘要。字段固定后,两次复测的差异才有可比性。测试时保持提示与输出长度一致,否则生成段波动会盖过排队信号。
读响应头与错误体里的重试提示
排队和限流在响应层留下的痕迹不同,看对地方就能分开。先看状态码:429 通常表示请求过于频繁或触发了配额;503、502、504 表示上游不可用或网关等待超时;500 需要结合错误体判断。再看响应头,常见的有 Retry-After(建议等待秒数,也可能是时间点)、各类限流剩余量字段、以及请求 id 字段,具体字段名以你实际接到的响应为准,先用日志把完整响应头打一次再看。
错误体一般给出结构化的类型与消息,例如 type、code、message、request_id 这类字段,看 message 里是“rate limit”“quota”“overloaded”还是“timeout”。限流类错误的特征是返回很快、耗时短、状态码为 4xx,和“耗时随并发缓慢抬升但状态正常”的排队特征可以区分开。若响应头里带了 Retry-After,重试间隔应优先服从该值,不要用自己设定的固定间隔硬顶。
把结论固化成超时与重试配置
排查结束后要把判断写成配置,而不是留在记忆里。三条取值原则:其一,重试只覆盖 429、502、503、504 以及连接超时与读取超时,总次数控制在两到三次,避免把排队放大;其二,退避采用指数加随机抖动,首个间隔可以取一秒级别,并尊重响应头里的 Retry-After,不要所有客户端在同一时刻一起重试;其三,重试的前提是幂等——请求侧带上可复用的请求标识,服务端支持去重才重试,否则只在“尚未建立连接或尚未收到任何字节”时重试。
import random, time, httpx
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 3
def call_with_retry(client, url, payload, request_id, base=1.0, cap=20.0):
headers = {"X-Request-Id": request_id} # 复用同一标识,供服务端去重
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
r = client.post(url, json=payload, headers=headers,
timeout=httpx.Timeout(connect=3.0, read=45.0))
if r.status_code not in RETRYABLE_STATUS:
return r
wait = float(r.headers.get("retry-after", 0)) or \
min(cap, base * 2 ** (attempt - 1)) * (0.5 + random.random())
except httpx.ConnectTimeout:
wait = min(cap, base * 2 ** (attempt - 1)) * (0.5 + random.random())
except httpx.ReadTimeout:
if attempt == MAX_ATTEMPTS:
raise # 已产生输出后不再盲目重试
wait = min(cap, base * 2 ** (attempt - 1))
log.warning("retry attempt=%s wait=%.1fs id=%s", attempt, wait, request_id)
time.sleep(wait)
return None
配置写完要做一次核对:读取超时是否明显大于典型生成时长,连接超时是否短到足以快速暴露入口问题,重试总次数乘以单次超时是否仍在你可接受的整体等待范围内。这三项互相牵制,任何一项改动后都要回到前面的三段打点复测一次,用日志确认新的超时与重试确实按预期触发,而不是凭配置文本推断。