调 GPT-6.1 Terra 一直超时,通常不只是一个参数没调好,而是三件事缠在一起:单次请求本身慢、超时阈值设得太紧、重试把流量放大之后自己把自己压垮。先把日志里的首次超时和被重试放大的超时分开,再按阶梯压测找到成功率开始下滑的并发区间,最后用观察到的耗时分布来定超时阈值,才知道并发和重试次数该压到多少。
判断顺序建议是:先看日志里最终失败的请求,有多少次是第 1 次尝试就超时,有多少次是第 2、3 次才失败。首次就超时偏多,问题在超时阈值或单次耗时;重试后才超时偏多,先把重试次数和总时限压下来。并发不要凭感觉设,从低位数起做阶梯压测,取成功率开始下滑前一档。每个改动都用同一批请求重跑验证,不要在生产上直接试。
在日志里区分首次超时和重试后的超时
很多调用超时看起来是同一个错误,差别藏在日志里:单次请求本身慢,第一次尝试的 elapsed 就已经接近或超过阈值;重试放大的情况则是第一次很快失败,后面几次把总耗时推高,看起来像“整条链路超时”。要区分这两种情况,每次尝试都要单独记一条日志,不要只记最终结果。
必须记录的字段:请求标识(能串起同一次逻辑请求的所有尝试)、开始时间、本次尝试耗时、第几次尝试、错误类型;再补上本次超时阈值、剩余预算、是否最终失败,方便判断是超时截断还是预算耗尽。错误类型要拆细,连接超时、读超时、限流、5xx 的处理方式不一样,混成一个 timeout 就没法定位。
{"ts":"<ISO8601>","request_id":"req_8f3a","attempt":1,"attempt_start":"...","elapsed_ms":30012,"timeout_ms":30000,"error":"timeout_read","remaining_budget_ms":20000,"final":false}
{"ts":"<ISO8601>","request_id":"req_8f3a","attempt":2,"attempt_start":"...","elapsed_ms":905,"timeout_ms":20000,"error":"rate_limit_429","remaining_budget_ms":18000,"final":false}
{"ts":"<ISO8601>","request_id":"req_8f3a","attempt":3,"attempt_start":"...","elapsed_ms":19880,"timeout_ms":19880,"error":"timeout_read","remaining_budget_ms":0,"final":true}
这段日志能直接读出:第 1 次是读超时,耗时贴着阈值;第 2 次被限流很快失败;第 3 次是用剩余预算硬跑,最后预算耗尽。这时真正该动的是限流与并发,而不是继续加重试。反过来,如果大部分请求第 1 次就 timeout_read 且耗时接近阈值,先怀疑阈值偏紧或单次生成本身就慢。
把并发从低位数开始做阶梯压测
并发不是拍一个数就完事,要从低位开始,一档一档往上加,直到成功率开始下滑。压测的输入(提示词、最大输出长度)要和真实调用一致,否则耗时分布对不上。每档样本量建议至少几百次,样本太少时分位耗时抖动大,几个失败就能把成功率拉得很难看。
import asyncio, time, statistics
LEVELS = [4, 8, 16, 32, 64] # 并发档位,从低往高
SAMPLES = 200 # 每档样本量,按时间预算调整
TIMEOUT_S = 30
async def one_call(i, sem):
async with sem:
t0 = time.monotonic()
try:
await call_model(prompt="ping", timeout=TIMEOUT_S) # 替换为你的调用封装
return {"ok": True, "ms": (time.monotonic() - t0) * 1000}
except Exception as e:
return {"ok": False, "err": type(e).__name__, "ms": (time.monotonic() - t0) * 1000}
async def run_level(conc):
sem = asyncio.Semaphore(conc)
return await asyncio.gather(*[one_call(i, sem) for i in range(SAMPLES)])
async def main():
for c in LEVELS:
rs = await run_level(c)
ms = sorted(r["ms"] for r in rs)
ok = sum(1 for r in rs if r["ok"])
print(c, f"{ok}/{len(rs)}", f"p50={ms[len(ms)//2]:.0f}")
await asyncio.sleep(30) # 档与档之间留间隔,避免互相干扰
asyncio.run(main())
压测结果按统一表格记录,判断“成功率开始下滑”的那一档,实际配置取它前一档,留一点余量。表里至少要包含以下字段,完成率之外的耗时分位用来判断是排队变长还是单次变慢。
| 并发 | 样本数 | 成功数 | 成功率 | 超时数 | 限流/5xx | p50 | p90 | p99 |
|---|---|---|---|---|---|---|---|---|
| 4 | ||||||||
| 8 | ||||||||
| 16 |
如果某一档 p50 还稳、p99 明显抬高、超时数开始冒出来,说明这一档已经进入拥塞区间;如果 p50 同步上抬,多半是上游整体变慢或者被限流,先看返回码再谈并发。
重试写成指数退避并加总时限截断
重试最容易出的事故是放大流量:一次失败变成三次请求,并发乘以重试倍数,本来只是略慢的请求被推成大面积超时。所以重试必须同时满足两个条件:退避递增、总时限截断。重试次数建议从 2~3 次起,具体取几取决于单次超时和总预算的比值。
retry:
max_attempts: 3
base_delay_ms: 300
factor: 2
jitter: 0.2 # 0~1 的随机抖动比例,避免同时重试
per_attempt_timeout_ms: 8000
max_total_budget_ms: 20000 # 含首次请求在内的总时限
retry_on: [timeout, connect_error, 429, 502, 503, 504]
no_retry_on: [400, 401, 403, 404, 422]
总时限判断逻辑放在每次尝试之前:先算剩余预算,剩余不足一次有意义的尝试就不再重试。这样即使 max_attempts 设了 3,也不会因为退避和超时叠加而超过总预算。
deadline = start + max_total_budget_ms
for attempt in 1..max_attempts:
remaining = deadline - now()
if remaining <= 0:
return deadline_exceeded(last_error, attempt)
if attempt > 1 and remaining < per_attempt_timeout_ms * 0.5:
return deadline_exceeded(last_error, attempt)
delay = min(base_delay_ms * factor ** (attempt - 1), remaining)
sleep(delay * (1 + random.uniform(-jitter, jitter)))
result = call_model(timeout=min(per_attempt_timeout_ms, deadline - now()))
if result.ok: return result
超过总时限后的返回方式要明确:返回一个可识别的错误(例如 deadline_exceeded),带上已尝试次数和最后一次错误类型,由上层决定是降级到更短的任务、返回可读提示,还是落到异步队列。不要在这里静默继续重试,也不要把重试次数藏在封装里,调用方看不到尝试次数就无法判断容量。
用观察到的耗时分布定超时阈值
阈值最好从日志里的耗时分布来,而不是先设一个看着顺眼的数。做法是按同一类调用(同一提示词长度区间、同一输出上限)过滤出最终请求的 elapsed_ms,统计 p50、p90、p95、p99,单次超时阈值一般放在 p99 附近或略高于 p99,同时必须短于总预算除以最大尝试次数,否则后面的重试没有意义。
import json
ms = []
for line in open("calls.jsonl"):
r = json.loads(line)
if r.get("final"):
ms.append(r["elapsed_ms"])
ms.sort()
for p in (50, 90, 95, 99):
print(p, ms[int((len(ms) - 1) * p / 100)])
阈值取值要两头看:设得比 p95 还低,会把正常的慢请求截断,截断后触发重试,反而抬高整体失败率和流量;设得高到接近总预算,单次请求就把预算吃光,重试等于没有。验证方式是固定同一批请求(同提示词、同并发、同样本量),只改超时阈值重跑,比较超时条数和最终成功数如何变化。如果放宽阈值后超时明显减少而 p99 没有继续上抬,说明原来截断过早;如果放宽后超时少了一点但整体耗时继续拉长,说明瓶颈在并发或上游,不在阈值。