把参数拼装、返回字段解析、超时与重试收进调用层,是判断「出参与入参对不上」的前提:业务代码只传语义参数、只读归一化结果,字段错位或重试写重了,排查范围能缩到一个文件里。建议的顺序是先抓一次真实收发记录,据此写字段对照表,再补集中拼装、错误分组和幂等键,最后用同一请求重复触发确认没有重复写入。
可落地的做法是先抓一次真实的请求体和响应体,把业务语义与两端字段名对齐,再把拼装、解析、超时、重试全部收进调用层;业务侧只传语义参数、只读归一化结果。重试只覆盖超时和连接类错误,确定性错误直接抛出。若官方文档没有写明字段名,不要凭记忆补齐,先用一次真实调用确认,再决定映射怎么写。
抓一次真实调用的请求体与响应体,列出字段对应关系
先看清实际收发的是什么,再讨论字段映射。记录方式按你能改动的层次选:一是打开所用 SDK 或客户端的调试日志开关,让它把请求体和响应体原文落到本地文件;二是用同样参数在命令行重放一次同一请求,完整保留响应头;三是在调用层临时加一行打印,输出请求体 JSON、响应头(重点看请求标识和内容类型)与响应体原文。打印时截断超长文本并脱敏,不要把关凭据写进日志。
字段对照表的列结构建议固定为七列:业务语义名、请求字段路径、响应字段路径、类型、是否必填、赋值来源、备注。一行只对应一个语义,不要把「输入内容」和「输出长度上限」挤在同一行。
| 业务语义名 | 请求字段路径 | 响应字段路径 | 类型 | 是否必填 | 赋值来源 | 备注 |
|---|---|---|---|---|---|---|
| 请求标识 | <幂等键请求头名> | 响应头中回显的请求标识 | string | 是 | 调用层生成 | 同一次逻辑请求的所有重试复用同一值 |
| 模型标识 | <模型标识字段> | 响应体中的模型名 | string | 是 | 业务传入 | 回显不一致时说明路由到了别的版本 |
| 输入内容 | <输入列表字段> | — | array | 是 | 业务传入 | 只做结构校验,不改写内容 |
| 输出文本 | — | <输出文本字段> | string | 是 | 解析函数提取 | 流式与非流式的取法可能不同 |
| 用量统计 | — | <用量字段> | object | 否 | 解析函数提取 | 缺失时不要报错,置空 |
| 错误信息 | — | <错误码字段> | object | 否 | 解析函数提取 | 用于下一节的错误分组 |
表中路径是占位写法,实际字段名以官方文档为准;文档没有写明的字段不要凭猜测补上,先看真实响应里到底返回了什么,再决定是否进入对照表。
在调用层集中写参数拼装函数
业务侧只传语义参数,拼装函数负责字段名映射、类型转换、空值填充;解析函数负责把响应原文转成固定结构。约定好之后,业务代码里不再出现任何接口字段名。
# 通用接入骨架,字段名以官方文档为准
DEFAULTS = {'timeout_ms': 15000, 'max_attempts': 3}
def build_request(biz, idem_key):
body = {}
body['<模型标识字段>'] = biz['model']
body['<输入列表字段>'] = biz['inputs']
body['<输出上限字段>'] = biz.get('max_output', 512)
headers = {
'Content-Type': 'application/json',
'<幂等键请求头名>': idem_key,
}
return {'body': body, 'headers': headers,
'timeout_ms': DEFAULTS['timeout_ms']}
def parse_response(raw):
return {
'text': raw.get('<输出文本字段>'),
'request_id': raw.get('<请求标识字段>'),
'error': raw.get('error'),
}
def make_idem_key(biz):
# 由业务唯一键派生,可读且可复现
return biz['tenant'] + '-' + biz['trace_id']
替换项有三处:字段名按官方文档填,默认值按业务上限填,超时值按客户端预算填。拼装函数里不要写业务判断,例如按用户等级改参数,那属于调用方要做的事,放进来会让这个函数重新变成第二个业务层。
为超时和连接失败加分类重试
重试的目标只是让偶发失败自动恢复,确定性错误不该在这里空转。判断分支按异常类型和状态码分开写,超时与连接失败才进入重试,其余直接抛出。
for attempt in range(1, max_attempts + 1):
try:
resp = call(build_request(biz, idem_key))
if resp.status < 400:
return parse_response(resp.body)
cls = classify_http(resp)
if not cls.retryable or attempt == max_attempts:
raise CallError(cls, resp)
except (Timeout, ConnectionReset, DnsError) as e:
cls = classify_network(e)
if attempt == max_attempts:
raise CallError(cls, e)
sleep(backoff(attempt))
重试间隔从配置读取,不要写死在代码里:给 initial_delay、multiplier、max_delay、max_attempts 四个值,间隔按 min(max_delay, initial × multiplier^attempt) 计算,再加一点随机抖动,避免多个请求在相近时刻同时重试。数值按你的接口响应时间和客户端等待预算调整,没有通用最优值。另外,已经开始返回内容的流式调用不要整段重试,否则会拼接出重复内容。
把错误返回按可重试与不可重试分组
分组依据从两处读取:HTTP 状态码,以及响应体里可解析的错误码或错误类型字段。状态码覆盖超时、限流、服务端错误;响应体的业务错误码用来区分「服务端暂时不可用」和「请求本身有问题」。两者冲突时以业务错误码为准,并在日志里同时记录状态码和错误码,方便回看。
| 错误分类 | 读取依据 | 是否重试 | 备注 |
|---|---|---|---|
| 客户端超时 | 本地超时异常,无完整响应 | 是 | 重试前确认幂等键未变 |
| 连接失败、连接被重置、DNS 解析失败 | 传输层异常 | 是 | 间隔可略长于超时类 |
| HTTP 429 | 状态码,可参考 Retry-After 响应头 | 是 | 优先按响应头给的时间等待 |
| HTTP 5xx | 状态码 | 通常是 | 连续失败时由上层决定是否降级 |
| HTTP 400、401、403、404 | 状态码 | 否 | 请求或凭据问题,重试无用 |
| 参数校验失败、内容审核类错误 | 响应体错误码或错误类型 | 否 | 需要改请求内容后才可能通过 |
分组结果记录在调用层返回的固定字段里,例如 error_class、retryable、attempts、request_id、idempotency_key。业务代码只看 retryable 和归一化数据,不自己判断状态码,这样同一类错误不会在业务层再被重试一次。
用同一请求重复跑三次确认重试不产生重复写入
幂等标识放在请求头里,或放在请求体中业务唯一键对应的字段里,具体哪个位置生效以官方文档为准。它在拼装函数中生成一次,写进这次逻辑请求的上下文,所有重试都复用同一个值;重试分支里绝不要重新生成,否则每次重试对服务端都是新请求。
检查方法:对同一逻辑请求连续触发三次,其中至少一次人为制造超时(把超时值调到很小,或指向一个不可达的地址触发连接失败)。然后核对三件事:调用层日志中三次的 idempotency_key 是否一致、attempts 是否按预期累加、失败后成功那次返回的资源标识是否与其他两次相同;再通过查询接口或对账 SQL 看目标记录是否只增加一条。若出现两条,说明幂等键没有透传或服务端不认这个键,需要换到文档支持的位置重新验证,而不是在业务层加去重逻辑绕过。