Qwen3.8-Max-Preview 超时重试——重发请求前该先确认什么?

文章导读
请求超时后立刻重发,最常见的后果是把一次失败变成一串看不出源头的失败记录。在按下重发之前,至少要先确认三件事:这次超时发生在连接阶段还是读取阶段;同一逻辑请求在日志里有没有能串起来的标识;重试次数和间隔有没有明确上限。这三件事没确认就加大重试次数,通常只是让日志更乱,原始失败点反而更难定位。
📋 目录
  1. Ⅰ 先分清请求没发出去还是发出去了没等到响应
  2. Ⅱ 给重试加上次数上限和退避间隔
  3. Ⅲ 在客户端生成请求标识并打进每行日志
  4. Ⅳ 把超时和重试信息打在同一行
  5. Ⅴ 构造一次人为超时做演练
A A

请求超时后立刻重发,最常见的后果是把一次失败变成一串看不出源头的失败记录。在按下重发之前,至少要先确认三件事:这次超时发生在连接阶段还是读取阶段;同一逻辑请求在日志里有没有能串起来的标识;重试次数和间隔有没有明确上限。这三件事没确认就加大重试次数,通常只是让日志更乱,原始失败点反而更难定位。

超时后先看异常类型和耗时落在哪个阶段:连接超时通常说明请求没发出去,重试代价低;读取超时说明请求已发出,重发可能造成重复执行,要先确认接口幂等和计费口径。给重试设次数上限与退避间隔,并为每次逻辑请求生成 request id 打进每行日志,才能用一个检索条件看清尝试序号、耗时和失败原因。边界:这些判断只能从异常类型、日志和配置上核实,不要凭感觉判断。

先分清请求没发出去还是发出去了没等到响应

这两种情况的处理方式完全不同。连接阶段失败(DNS 解析、TCP 握手、TLS 协商没走完)意味着请求体大概率没有到达服务端,重发是相对安全的动作。读取阶段失败意味着请求已经发出、服务端可能已经在处理,重发会让同一个业务动作执行两次,是否可重试取决于你的调用是否幂等、是否重复计费。

判别方法有两个,建议同时用:

  • 看异常类型名:客户端库通常把连接阶段和读取阶段区分成不同异常,例如 ConnectTimeout 与 ReadTimeout;通用骨架里可以用各自的捕获分支来区分,不要只捕获一个笼统的 Timeout。
  • 看日志时间差:从发起调用到抛错的总耗时与配置的哪个超时阈值接近,就能反推卡在哪一步。耗时几乎贴着连接超时阈值、中间没有写出请求体的记录,通常属于没发出去;耗时贴着读超时阈值,且能看到请求已写出或已收到响应头,则属于已发出没等到完整响应。
现象对应阶段判断依据建议动作
ConnectTimeout、DNS 解析失败、TLS 握手失败连接阶段耗时接近连接超时阈值;日志里没有请求体写出记录可以重试,仍要设次数上限
ReadTimeout,且已收到响应头读取阶段请求体已写出;流式场景下已收到部分片段先确认幂等与计费口径,再决定是否重试
ReadTimeout,未收到任何响应字节读取阶段(不确定)请求已写出,但无法判断服务端是否开始处理按最保守处理:只在幂等接口上重试,或在请求里带业务去重键
连接被重置、网关返回 502/503不确定异常来自中间层而非超时阈值先看网关侧日志,再决定重试策略

给重试加上次数上限和退避间隔

超时不加重试上限,问题会被放大:一次 30 秒超时叠三次重试,调用方看到的就是上百秒的卡住,上游再对调用方超时,日志里就会出现两层重试互相叠加的记录。下面的骨架只保留三个必要分支,参数按你的超时阈值和上游容忍度替换。

import random, time

MAX_ATTEMPTS = 3          # 总尝试次数,含首次
BASE_DELAY = 1.0          # 秒
MAX_DELAY = 8.0

RETRYABLE = (ConnectTimeout, ReadTimeout, ServerError5xx)
FATAL = (AuthError, BadRequest, NotFound)

def call_with_retry(send, rid, max_attempts=MAX_ATTEMPTS):
    for attempt in range(1, max_attempts + 1):
        t0 = time.monotonic()
        try:
            return send(rid, attempt)
        except FATAL as e:
            log_line(rid, attempt, t0, 'fatal', type(e).__name__)
            raise                       # 分支三:不可重试,立即退出
        except RETRYABLE as e:
            reason = type(e).__name__
            log_line(rid, attempt, t0, 'retryable', reason)
            if attempt == max_attempts:  # 分支一:次数到顶,停止重试
                raise
            delay = min(MAX_DELAY, BASE_DELAY * (2 ** (attempt - 1)))
            delay = delay * (0.5 + random.random())   # 加抖动,避免同时重发
            time.sleep(delay)           # 分支二:指数退避 + 抖动

次数上限建议从 2 到 3 次起,把它当成“覆盖一次网络抖动”的余量,而不是当作提高成功率的手段。退避间隔要小于上游调用方的整体超时,否则重试还没跑完,外层已经先判失败。遇到鉴权失败、参数错误这类不可重试错误,必须立即退出,重试只会把它们刷满日志。

在客户端生成请求标识并打进每行日志

没有标识时,同一次逻辑请求的三次重试在日志里是三段互不相干的记录,只能靠时间戳猜。做法是在发起调用的最外层生成一个标识,随每次重试原样透传,并把尝试序号作为独立字段记录,不要把标识改成“每次请求新生成”。

import uuid

def new_request_id():
    return uuid.uuid4().hex[:16]

rid = new_request_id()
# 透传方式按接入形态任选:HTTP 头、请求体字段、SDK 的附加头
headers = {'X-Request-Id': rid}
body = {'model': 'qwen3.8-max-preview', 'messages': [...], 'request_id': rid}

日志字段统一写成 rid=<16位十六进制> 的键值形式,方便直接检索。演练或排查时,用一次标识就能筛出这条逻辑请求的全部重试记录:

Qwen3.8-Max-Preview 超时重试——重发请求前该先确认什么?
grep 'rid=9f2c7a1b4d6e8f03' app.log
grep -c 'rid=9f2c7a1b4d6e8f03' app.log   # 实际发生了几次尝试

如果服务端也接受这个标识并回显在响应头里,客户端和服务端日志就能对齐;没有回显条件时,至少保证客户端侧是完整的。

把超时和重试信息打在同一行

排查时最耗时的动作,往往是在应用日志、网关日志、SDK 自己的日志之间来回对照。建议让每次尝试只产生一行结构化日志,字段顺序固定为:时间、级别、事件名、rid、尝试序号、耗时、阶段、结果、原因、模型名。

def log_line(rid, attempt, t0, result, reason='-'):
    elapsed = int((time.monotonic() - t0) * 1000)
    print(f'ts={now_iso()} level=WARN evt=llm_call rid={rid} '
          f'attempt={attempt}/{MAX_ATTEMPTS} elapsed_ms={elapsed} '
          f'result={result} reason={reason} model={MODEL}')

实际项目里把 print 换成你用的日志库即可,关键是不要拆成多行、不要把耗时放在另一条日志里。一行日志长这样:

ts=12:00:03.412 level=WARN evt=llm_call rid=9f2c7a1b4d6e8f03 attempt=2/3 elapsed_ms=30012 stage=read result=retryable reason=ReadTimeout model=qwen3.8-max-preview

看到这样的行,可以直接读出:这条逻辑请求第 2 次尝试、等了约 30 秒、失败在读取阶段、还会再试一次。stage 字段如果暂时拿不到,可以先留空,但别删掉这个位置,后面补上时字段顺序不用再改。

构造一次人为超时做演练

重试代码写好不等于会执行。常见情况是异常类型捕获写错、超时值根本没生效、或者上层已经先超时,重试分支从来没被走到。用一次可控的演练确认链路通不通。

  1. 把超时值临时调小:在配置或环境变量里把连接超时和读取超时都改到明显小于正常响应时间的值(例如几百毫秒),改完确认进程已经重新加载配置。
  2. 发一个最简请求:用最小输入跑通一次调用,不要拿关键业务参数做实验。
  3. 核对预期日志:应看到同一 rid 下出现 attempt=1、attempt=2、attempt=3 三行,每行 elapsed_ms 都接近你设置的超时值,行与行之间的时间间隔依次拉长(退避生效)。最后一行 result 为终止状态,并且调用方最终拿到异常,而不是静默返回空结果。
  4. 恢复:把超时值改回原值并重新加载,再跑一次同样的请求,确认日志里只出现 attempt=1/3 且 result 为成功,elapsed_ms 回到正常量级。把演练用的 rid 记一下,方便对照两次日志差异。

如果演练里只看到一行日志、或者三次尝试的耗时完全一样,先别调重试参数,回去检查这两处:异常类型是否真的被 RETRYABLE 捕获到,退避分支是否被 return 或上层超时提前跳过了。演练建议放在测试环境或低峰时段,避免对额度、计费等产生额外影响。