偶发报错时最难受的不是报错本身,而是改完参数不知道有没有改对。要解决这个问题,先把能稳定失败的那一条请求原样存下来:同一个 endpoint、同一份请求体、同一组请求头,记录它的状态码、完整错误体和耗时,把这三个观测量固定成基准。之后每次只改一个参数,跑同一条基准请求,对比这三项有没有变化。MiniMax M3.1 Flash 的报错排查也一样,先把失败样本固定住,再谈参数。
适用场景:接入 MiniMax M3.1 Flash 后出现间歇性报错,改参数无法判断是否改对。操作动作:把一条可复现的失败请求存成基线文件,记录状态码、错误体原文、耗时;再按状态码段分流,读错误体字段名定位被拒参数;用单变量对照跑出结论。验证方式:同一基线文件重复执行,状态码与错误体 code 稳定复现即为有效样本。风险边界:接口字段名、限流阈值、超时上限以你实际接入的接口文档和控制台配置为准,本文示例中的 endpoint 与字段名均为占位,需替换。
把失败请求的参数原样存成一份文件
排查的第一步不是看日志找规律,而是让每次排查都从同一份输入开始。建议把失败请求的 endpoint、请求头、请求体整体落盘,之后所有实验都从这份文件读参数,不再手工重敲。手工重敲最容易漏掉的是嵌套字段和默认值,而这些恰恰是报错的常见来源。
保存前需要先脱敏。通常要处理的有三类:鉴权头(Authorization、api-key 这类)、请求体里用户输入中的个人信息、以及账号或会话标识。标识类字段可以保留前后几位、中间打码,既不影响复现,也便于对照日志。脱敏只做在保存环节,实际发送时仍用真实值。
import json, hashlib, datetime, pathlib
SENSITIVE = ('authorization', 'api-key', 'apikey', 'cookie', 'token')
def redact(headers):
return {k: ('***' if k.lower() in SENSITIVE else v) for k, v in headers.items()}
def save_case(name, endpoint, headers, payload, note=''):
d = pathlib.Path('cases') / name
d.mkdir(parents=True, exist_ok=True)
body = json.dumps(payload, ensure_ascii=False, sort_keys=True)
h = hashlib.sha1(body.encode('utf-8')).hexdigest()[:8]
ts = datetime.datetime.now().strftime('%Y%m%d-%H%M')
f = d / (ts + '-' + h + '.json')
f.write_text(json.dumps({
'endpoint': endpoint,
'headers': redact(headers),
'payload': payload,
'note': note,
}, ensure_ascii=False, indent=2), encoding='utf-8')
return f
文件命名建议带上日期和请求体哈希,例如 cases/400-empty-messages/20250101-0930-3f9a2c1b.json。哈希的作用是:参数一改,文件名就变,不会覆盖上一版基线。每次改动参数都新增一个文件,并在 note 里写清“相对基线改了什么”,这样翻目录就能看到整条排查路径,不用靠记忆还原。
从状态码开始分类,而不是从错误文案开始
错误文案会随版本、语言、上游服务变化,状态码段相对稳定,所以先按段分流。4xx 段通常指向请求本身:401/403 是凭证与权限,404 常与路径或模型名不匹配有关,429 是频率或配额触顶,其余 4xx 多为参数缺失、类型不符、格式非法。5xx 段则更多与服务端或链路有关,例如网关超时、上游不可用,这类通常不该先去改请求体。
分流的价值在于缩小动作范围:401/403 去查密钥与控制台权限,429 去查调用频率与配额,5xx 去看超时设置和重试,其余 4xx 才回到参数本身。把 429 和 400 混在一起改参数,是排查里最常见的无效功。
def classify(status):
if status == 200:
return 'ok' # 注意:200 也可能带业务错误字段,仍需看 body
if status in (401, 403):
return 'auth' # 不重试,查凭证与权限
if status == 404:
return 'route_or_model'
if status == 429:
return 'rate_limit' # 退避重试
if 400 <= status < 500:
return 'bad_request' # 回看请求体
if 500 <= status < 600:
return 'server_side' # 退避重试 + 观察是否稳定复现
return 'unknown'
读错误体里的字段名,别只读提示语
提示语只能告诉你“出错了”,字段名才能告诉你“哪一项被拒”。建议拿到响应后先无条件打印三段:状态码、content-type、响应体前若干字符。content-type 用来判断是不是 JSON,很多时候返回的是网关的 HTML 错误页,此时按 JSON 解析会直接抛异常,反而掩盖真实原因。
resp = session.post(url, headers=headers, json=payload, timeout=60)
print(resp.status_code)
print(resp.headers.get('content-type'))
print(resp.text[:2000])
try:
err = resp.json()
except ValueError:
err = None # 非 JSON,先按原始文本排查
结构化错误体里常见的字段名有 code、message、type、param,以及 request_id 或 trace_id 这类追踪标识。各家命名并不统一,嵌套层级也不同,所以不要凭猜测取字段,先整体打印、再对照确认。request_id 建议连同用例一起记录,找服务方确认问题时它是必需信息。
def walk(obj, prefix=''):
if isinstance(obj, dict):
for k, v in obj.items():
yield from walk(v, (prefix + '.' + k) if prefix else k)
elif isinstance(obj, list):
for i, v in enumerate(obj[:3]):
yield from walk(v, prefix + '[' + str(i) + ']')
else:
yield prefix, obj
if err:
for path, value in walk(err):
print(path, '=', value)
如果输出里出现类似 messages 或 messages[0].content 的路径,说明被拒的是请求体里的具体字段,回到基线文件只改这一项即可,不用整份请求重写。
用同一请求只改一个参数做对照
多变量同改是排查结论不可信的主要原因:同时换了模型名、调了 max_tokens、又加了 system 消息,最后不报错了,也不知道是哪个起的作用。约束很简单:其余字段全部从基线文件原样读取,每条用例只允许一个字段与基线不同,并且同一用例连跑几次,确认不是偶发。
BASE = json.loads(pathlib.Path('cases/400-empty-messages/xxx.json').read_text())
def variant(**override):
payload = json.loads(json.dumps(BASE['payload'])) # 深拷贝,避免互相污染
payload.update(override)
return payload
记录表建议固定几列,方便横向对比:
| 用例ID | 唯一改动项 | 状态码 | 错误体 code/param | 耗时(s) | 连续3次是否一致 |
|---|---|---|---|---|---|
| C0 | 无(基线) | ||||
| C1 | messages 补一条内容 | ||||
| C2 | max_tokens 改小 |
耗时一列要注明口径:是首字节时间还是整段响应结束时间,两者差别很大。网络波动导致的耗时差异,不要直接归因到参数改动上。
把结论固化成脚本里的重试与降级
对照实验得出的结论,如果不落到代码里,下次还会重新踩一遍。建议把状态码分流直接写成调用策略:可重试的状态码走退避重试,不可重试的直接降级或抛错。判断依据用状态码,不要用错误文案做字符串匹配。
import time
RETRYABLE = {429, 500, 502, 503, 504}
def backoff(attempt):
return min(2 ** attempt, 8)
def call_with_policy(session, url, headers, payload, log):
for attempt in range(3):
r = session.post(url, headers=headers, json=payload, timeout=60)
cls = classify(r.status_code)
log(attempt=attempt, status=r.status_code, branch=cls, rid=r.headers.get('x-request-id'))
if r.status_code == 200:
return r
if r.status_code in RETRYABLE and attempt < 2:
time.sleep(backoff(attempt))
continue
if cls == 'auth':
return degrade('auth_failed') # 不重试,直接上报
if cls == 'bad_request':
return degrade('payload_rejected')# 回看基线文件,别在这里重试
if cls == 'server_side':
return degrade('upstream_unstable')
return degrade('unknown')
return degrade('retry_exhausted')
降级分支要看业务能接受什么:换更小的输入重发、切到备用模型、返回上一次的稳定结果,或者直接把错误交给上层并提示用户稍后再试。降级的目的只是让请求不彻底失败,它不是性能优化手段,也不要指望它提升吞吐。
验证是否按预期走,最直接的办法是在日志里打全 attempt、status、branch 三个字段,然后人为构造一条会触发限流的请求(把并发或频率压到测试环境的上限附近,具体阈值需结合控制台确认),看日志里是否出现 branch=rate_limit 且 attempt 递增。另一条路是用 mock 让请求固定返回某个状态码,跑单元测试断言分支名,避免依赖真实网络。