GPT-6.1 Terra 调用老超时 / 重试次数和并发该压到多少?

文章导读
调 GPT-6.1 Terra 一直超时,通常不只是一个参数没调好,而是三件事缠在一起:单次请求本身慢、超时阈值设得太紧、重试把流量放大之后自己把自己压垮。先把日志里的首次超时和被重试放大的超时分开,再按阶梯压测找到成功率开始下滑的并发区间,最后用观察到的耗时分布来定超时阈值,才知道并发和重试次数该压到多少。
📋 目录
  1. 一 在日志里区分首次超时和重试后的超时
  2. 二 把并发从低位数开始做阶梯压测
  3. 三 重试写成指数退避并加总时限截断
  4. 四 用观察到的耗时分布定超时阈值
A A

调 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())

压测结果按统一表格记录,判断“成功率开始下滑”的那一档,实际配置取它前一档,留一点余量。表里至少要包含以下字段,完成率之外的耗时分位用来判断是排队变长还是单次变慢。

并发样本数成功数成功率超时数限流/5xxp50p90p99
4
8
16

如果某一档 p50 还稳、p99 明显抬高、超时数开始冒出来,说明这一档已经进入拥塞区间;如果 p50 同步上抬,多半是上游整体变慢或者被限流,先看返回码再谈并发。

GPT-6.1 Terra 调用老超时 / 重试次数和并发该压到多少?

重试写成指数退避并加总时限截断

重试最容易出的事故是放大流量:一次失败变成三次请求,并发乘以重试倍数,本来只是略慢的请求被推成大面积超时。所以重试必须同时满足两个条件:退避递增、总时限截断。重试次数建议从 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 没有继续上抬,说明原来截断过早;如果放宽后超时少了一点但整体耗时继续拉长,说明瓶颈在并发或上游,不在阈值。