刚接入 GLM-5.3-FlashX 时,超时和失败大多不是模型本身的问题,而是两处基础配置没有显式设置:一是流式响应沿用了普通请求的全局超时,读超时一到就把输出截断;二是并发上来后遇到限流或临时错误,请求既不等待也不重试,直接把失败抛给上层。这两处可以分开处理:先给流式请求单独设读超时,再给并发调用加带抖动的指数退避和并发上限,最后用并发脚本和日志指标确认调整方向对不对。
流式调用和并发请求是两套参数,但会互相放大:读超时管的是两次数据到达之间的间隔,不是整条响应的总时长;退避管的是限流和临时错误之后的重试节奏。建议先单独设好读超时并看日志,再加上带抖动的指数退避与并发上限,最后用并发脚本对比调整前后的成功数和延迟分布。若上游或业务方有整体截止时间,最坏耗时(重试次数 × 读超时 + 等待总和)必须留出余量,否则应减少重试次数,而不是继续放大超时。
在 HTTP 客户端里为流式响应单独设置读超时
全局 timeout 通常按“一个请求在合理时间内整体返回”来设,比如 30 秒,这对普通请求够用,但流式响应的生命周期可能远超过它。多数 HTTP 客户端的读超时(read timeout)指的是两次数据到达之间的最大间隔,不是整条响应的总时长,所以正确做法是给流式请求单独构造一个超时对象,覆盖默认值,而不是把全局超时整体调大——后者会让普通请求的失败发现变慢。
下面是 httpx 的骨架,把连接、写、读、连接池四项分开设:
import httpx
API_URL = "https://your-endpoint/v1/chat/completions"
# 普通请求:整体 30s 足够
plain_client = httpx.Client(timeout=httpx.Timeout(30.0))
# 流式请求:连接/写保持较短,读超时按服务端静默时间放宽
stream_timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
with httpx.Client(timeout=stream_timeout) as client:
with client.stream("POST", API_URL, json=payload, headers=headers) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line:
continue # 空行或心跳,直接跳过
handle_chunk(line) # 你自己的 SSE 解析逻辑
如果用的是 requests,超时参数是 (连接超时, 读超时) 元组,配合 stream=True 使用:
import requests
with requests.post(API_URL, json=payload, headers=headers,
stream=True, timeout=(5.0, 60.0)) as resp:
for line in resp.iter_lines(decode_unicode=True):
if line:
handle_chunk(line)
read 给多少,取决于服务端在第一个数据块之前会不会有较长静默(排队、长推理)。可以先设一个偏保守的值,比如 60 秒,再用一次真实的长输出来确认它不会在中途被切断;不同客户端和版本在流式场景下读超时的触发时机略有差异,需要结合你实际使用的库确认。另外,读超时管不到总时长,如果你需要“单次调用最多 120 秒”,得在业务层自己算 deadline 并主动关闭连接。
为并发请求实现带抖动的指数退避
退避不是对所有错误都重试。适合重试的是 429 限流、5xx、连接失败和读超时;400、401、403、参数校验失败这类错误重试多少次结果都一样,应当直接抛出。如果响应头里有 Retry-After,优先按它等待,比自己的退避值更准。
下面是一个通用骨架,包含基础等待、上限和随机抖动:
import random, time
BASE = 0.5 # 基础等待秒数
CAP = 30.0 # 单次等待上限
MAX_RETRY = 5 # 最大重试次数
def backoff_delay(attempt, base=BASE, cap=CAP):
exp = min(cap, base * (2 ** attempt))
return random.uniform(0, exp) # full jitter:0 到 exp 之间均匀取值
def call_with_retry(fn, max_retry=MAX_RETRY):
for attempt in range(max_retry + 1):
try:
return fn()
except (httpx.ConnectError, httpx.ReadTimeout) as e:
if attempt == max_retry:
raise
time.sleep(backoff_delay(attempt))
except httpx.HTTPStatusError as e:
code = e.response.status_code
if code == 429 or 500 <= code < 600:
if attempt == max_retry:
raise
retry_after = e.response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else backoff_delay(attempt))
else:
raise
抖动的作用是避免多个 worker 在同一时刻一起重试,形成新的一波限流。如果你希望重试更集中,可以把 full jitter 换成“exp/2 + random(0, exp/2)”,效果类似但更贴近期望值。两个配置同时生效时有几点要注意:读超时设得太短又开启重试,会把大量只输出了半截的流整条重发,既浪费额度也放大服务端压力,所以流式请求一旦已经有可见输出,默认不要整条重试,除非业务允许丢弃已输出内容;最坏耗时大致等于“重试次数 ×(读超时 + 退避等待)”,这个值要小于调用方的整体超时;并发侧最好同时加一个信号量或连接池上限,否则重试会把瞬时并发推得更高,退避也就白做了。
用并发测试脚本验证超时与退避的组合效果
调整完参数后,用一个并发脚本做前后对比,重点看成功数、超时数和延迟分布的变化,而不是只盯一次调用的结果。下面的骨架用 asyncio 控制并发度,并统计各类结果:
import asyncio, time, statistics
from collections import Counter
import httpx
TOTAL = 20 # 总共发起的请求数
CONCURRENCY = 5 # 同时在途的请求数
stats = Counter()
latencies = []
async def one_request(client, sem, idx):
async with sem:
start = time.monotonic()
try:
async with client.stream("POST", API_URL, json=payload) as r:
async for _ in r.aiter_lines():
pass
stats["ok"] += 1
except httpx.ReadTimeout:
stats["timeout"] += 1
except Exception:
stats["error"] += 1
finally:
latencies.append(time.monotonic() - start)
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
async with httpx.AsyncClient(timeout=timeout) as client:
await asyncio.gather(*(one_request(client, sem, i) for i in range(TOTAL)))
latencies.sort()
print("ok=%d timeout=%d error=%d" % (stats["ok"], stats["timeout"], stats["error"]))
print("p50=%.2fs p95=%.2fs" % (
statistics.median(latencies),
latencies[int(len(latencies) * 0.95) - 1]))
asyncio.run(main())
脚本里的 API_URL、payload 替换成你自己的;如果调用链里已经封装了重试,可以在 stats 里增加一个 retry 计数,在每次触发退避时累加,这样能直接看出“成功是靠重试换来的”还是“本来就稳”。建议先用低并发(比如 2)跑一遍作为基线,再用实际线上并发量跑一遍,对比超时数和 p95 延迟的方向;如果超时数随并发明显上升,通常说明读超时偏紧或并发上限过高,先调这两个,不要急着加更多重试次数。
监控重试次数与超时比例设置告警阈值
参数设完不等于一直合适。服务端限流策略、模型版本的排队行为都可能变化,所以要在日志里留下可统计的字段,建议每次重试和超时都打一条结构化日志,例如:
{"event":"retry","attempt":2,"reason":"status_429","model":"GLM-5.3-FlashX","elapsed_ms":1200}
{"event":"stream_timeout","phase":"first_chunk","elapsed_ms":60000,"model":"GLM-5.3-FlashX"}
基于这些日志可以算出三个核心指标:重试率 = 触发重试的请求数 / 总请求数;超时率 = 超时请求数 / 总请求数;平均延迟和 p95 延迟,按请求类型分开统计,流式和普通请求混在一起算会失去意义。
阈值不建议直接照抄别人的数字,比较稳妥的做法是先观察一段时间的正常水位,再取基线的 2 到 3 倍作为告警线。还没有基线时,可以从几个偏保守的起点开始:重试率持续 5 分钟高于 5%、超时率高于 1%、p95 延迟超过你设置的读超时的 80%。告警触发后按顺序排查——先看是不是服务端限流(日志里 429 占比升高),再对照并发上限是否需要下调,最后才考虑动读超时。把重试率和超时率放在同一张图上看,如果两者同时抬升,通常是并发压力问题;只有超时率单独抬升,更像是读超时设得太紧或某次长输出确实超时了。