调用 GPT-6 Luna 失败时,如果只拿到一个笼统提示,先不要急着改代码。按三步取证:把原始错误体完整打印出来、用最小请求回放一次、看失败在时间上的分布。这三步做完,原因通常能落到鉴权、限流、参数不合法三类里,再决定是换密钥、降频率还是改参数。
报错文本只说明现象,不说明原因。先拿到状态码和错误体原文,再用只含 model 与单条 messages 的最小请求回放;换一把有效密钥就恢复的偏鉴权,失败在时间上聚集、与并发峰值同相的偏限流,最小请求能成功、加回某个字段才失败的偏参数不合法。鉴权类和参数类错误不要重试,只对限流和临时性错误做有限次退避重试。
把原始错误体完整打印出来
先解决“看不见”的问题。多数 SDK 或业务封装会把异常压成一句“请求失败”,而真正能区分原因的状态码、错误类型、错误信息原文都藏在 HTTP 响应里。建议把打印动作放在最外层 HTTP 客户端拦截的位置,而不是业务代码的 try/except 里,避免被二次封装吞掉关键字段。
日志中建议保留这些字段:请求时间(含毫秒与时区)、endpoint、model 名、HTTP 状态码、request-id / trace-id、错误类型字段、错误信息原文、重试次数、单次耗时。响应体原文通常值得留存,但若里面带用户输入,需要脱敏或截断后再写日志。
status = resp.status_code
rid = resp.headers.get("request-id")
body = resp.text
log.error("luna_call_failed status=%s request_id=%s body=%s", status, rid, body[:2000])
拿到这些字段后,可以先按下表做初判,再到对应小节验证。错误码的具体含义以官方文档为准,这里只给出常见的归类方向。
| 观察点 | 鉴权问题 | 限流 | 参数不合法 |
|---|---|---|---|
| HTTP 状态码 | 多为 401 / 403 | 多为 429 | 多为 400 / 422 |
| 换一把有效密钥 | 恢复 | 通常无变化 | 无变化 |
| 换成最小请求 | 仍失败 | 可能成功,也可能仍被限 | 成功,加回某字段后失败 |
| 时间分布 | 稳定复现 | 聚集成簇,与并发同相 | 稳定复现 |
| 重试是否有用 | 无用 | 退避后可能有用 | 无用 |
用一条最小请求排除参数干扰
这一步的目的,是判断问题出在请求本身,还是出在参数组合上。先把线上那份请求体复制一份,删到只剩必需字段,发出去看结果。
{
"model": "<你的模型标识>",
"messages": [
{"role": "user", "content": "ping"}
]
}
model 名以你实际接入的标识为准,上面只是骨架。最小请求通过后,再逐项把参数加回来:先加回 max_tokens,再加 temperature,然后依次加 system 提示、多轮 messages、tools / 结构化输出、stream 等。一次只加一项,每加一项发一次请求,哪一项加回去就复现失败,问题就在那一项上。如果最小请求本身就失败,说明问题不在参数组合,回头去看鉴权和时间分布。
换密钥或换环境复现一次
鉴权问题最容易被“密钥没问题”这种假设掩盖。检查清单建议按这个顺序走:密钥从哪来(环境变量、.env 文件、配置文件、代码常量、容器 secret、CI 变量)、这些来源同时存在时谁覆盖谁、加载顺序是否符合预期;密钥字符串前后是否混入空格、引号、换行或 BOM;这把密钥对应的项目或组织是否正确、额度是否已用尽、是否已被禁用或轮换。
然后做对照:用同一把密钥在另一台机器或另一个网络出口,绕开业务代码的加载逻辑,直接用脚本或命令行发一次最小请求;再换一把确认有效的密钥,用同样的最小请求再发一次。前者失败后者成功,基本可判为密钥或加载链路问题;两把密钥都报同样的错误、且错误体指向某个字段,则更可能是参数问题。这里只讲机制,具体密钥内容不要写进日志和代码仓库。
按时间分布判断是不是限流
参数错误稳定复现,限流往往与并发和流量峰值相关,所以时间分布是最省事的区分手段。记录每次失败的时间戳(精确到秒或毫秒)、当时的并发数或正在进行的请求数、是否处于重试、以及状态码。并发数可以来自你的工作线程数、连接池占用数或网关侧的请求指标。
grep luna_call app.log | awk '{print $1, $2}' | cut -c1-16 | sort | uniq -c | sort -rn | head
上面这条只是把日志按分钟聚合成失败次数,字段位置需要按你自己的日志格式调整。判读时关注:失败是否集中在某几个时间窗内、是否与并发升高同相、失败之间是否交替出现成功请求、重试后是否延迟成功。如果符合这些特征,通常更接近限流或配额耗尽;如果同样的请求体在任何时间点都失败、换低并发也一样,那更可能是参数或鉴权问题。
写一个错误分类分支
分类的意义在于让重试只发生在该重试的错误上。下面是一段通用骨架,状态码到类别的映射需要按你接入方的文档确认后再调整。
RETRYABLE = {408, 409, 429, 500, 502, 503, 504}
def classify(status):
if status in (401, 403):
return "auth" # 鉴权:不重试
if status == 429:
return "rate_limit" # 限流:退避后重试
if status in (400, 422):
return "bad_request" # 参数:不重试
if status in RETRYABLE:
return "transient" # 临时性:可重试
return "fatal" # 其他:不重试
def call(payload, max_attempts=3):
for attempt in range(1, max_attempts + 1):
status, rid, body = call_once(payload)
kind = classify(status)
log.warning("kind=%s status=%s request_id=%s attempt=%s",
kind, status, rid, attempt)
if kind in ("auth", "bad_request", "fatal"):
raise LunaError(kind, body) # 直接抛出
if attempt == max_attempts:
raise LunaError(kind, body)
time.sleep(backoff(attempt) + random.uniform(0, 0.3))
需要直接抛出、不再重试的,通常是鉴权失败、参数不合法、模型标识不存在、内容被策略拒绝、请求体超出大小限制这几类——重试只会重复消耗时间,并不会改变结果。适合重试的是限流和临时性服务端错误,重试次数建议设上限,退避里加一点随机抖动,避免多实例同时重试把压力又叠回去。重试次数、退避曲线这些取值要结合你的并发规模和接入方的配额来定,先用小并发验证分类是否准确,再放到真实流量里。