文档中心内部

错误处理与避坑

这个接口最难缠的地方不是报错,是不报错。有五种情况会照常返回 200、照常出图、照常计费,只是结果不是你要的。这一页先讲这五个坑,再讲哪些错该重试、哪些重试也没用。

五个"不报错但结果是错的"坑

这五条的共同点:状态码 200、响应体结构完整、日志干干净净、钱一分不少地扣了,只有人眼看图才会发现不对。任何监控告警都抓不到它们,只能写代码时提前避开。

你会看到的现象怎么避
size 传了比例别名,或者干脆没传 静默出错 出图尺寸不是你要的,多半变成方图 只传 "1536x864" 这样的像素串,而且永远要传
多参考图只有第一张生效 静默出错 后面几张参考图被悄悄丢掉,出图只体现底图 人工看图确认一次;不透传就改单图 + 写进提示词
n 参数根本不存在 静默出错 要 4 张,只回来 1 张 发 4 次请求,并且注意重试可能双计费
moderation 被接受但被忽略 静默出错 你以为审核放宽了,实际每次都被拦、每次都计费 用 A/B 对比确认;没差异就别传这个字段
返回的图片 URL 是临时的 静默出错 今天入库,明天用户点开是一张破图 拿到的当下就下载转存到自己的存储

坑一 · size 只能用像素串,而且永远要传

实测:传 "16:9" 这类比例别名不会报错,但会被静默回落成 2048x2048 方图,只有显式像素串 "1536x864" 会被精确遵循。同样重要的另一半是size 永远要传,图生图也要传 —— "省略了上游就会跟随原图"是错的,实测一张 1536×864 的源图省略 size,输出直接塌成 1024×1024 方图。

没有"跟随原图"这个值可以直接发出去

接口里没有 match_input_image 这类值。"跟随原图比例"必须你自己在客户端算:读源图真实像素 → 规整成合法值 → 显式传。规整算法、尺寸速查表和 EXIF 旋转的处理在接口参考那一页。

坑二 · 多参考图可能只有第一张生效

image[] 能不能真正多图融合,取决于中转层有没有原样透传这个数组。失败形态是完全静默的:200、正常出图、正常计费,只是第二张之后被丢掉了。

没有自动化手段能发现它,只能人工看图确认一次:拿两张视觉差异极大的图(纯红方块 + 纯蓝圆形),提示词写"把两张参考图合成一张,两个物体都要清晰可见",看结果里是不是两个都在。确认一次,之后照结论写死。如果只有第一张生效,两条出路任选或组合:

  1. 改用单个 image 字段,只发底图

    其余参考图想传达的信息(颜色、材质、风格)用文字写进提示词。

  2. 先在本地把多张图拼成一张

    并排或九宫格拼好再当单图发,并在提示词里说明"左边是 A、右边是 B"。

不论走哪条,都别忘了 image[] 里的第一张(接口参考的示例里写作 images[0])就是底图,也就是要被改的那张 —— 顺序有语义,退回单图时发出去的就是它,所以最重要的那张一定要排在最前面。

坑三 · n 参数不存在,多张图就是多次请求

一次请求恒定出 1 张图。要 4 张就发 4 个请求,每个单独计费。并发控制在 3–5 个,开多了会撞限流;部分失败要如实暴露给调用方,别因为一张失败废掉整批,也别把错误吞掉当成功。

超时重试可能双计费

客户端 abort 之后,上游那一次其实可能已经跑完并计了费,重试就是第二次付钱。批量场景尤其要留意:4 张 × 最多试 3 次 = 最坏 12 次调用的账单。所以重试上限别设大,超时值宁可设长(300 秒)也别设短了反复重试

坑四 · moderation 基本不可控

原生 Images API 有 moderation 这个参数,但经过中转之后是否真正生效并未经证实。最贵的失败形态不是报错,而是中转层接受了这个字段却忽略它 —— 你以为审核放宽了,继续发边界内容,每一次都被拦、每一次都照常计费。

判断方法必须是 A/B 对比,不能只看有没有报 400:同一句被拦过的提示词,一次带 moderation: 'low'、一次不带,比较结果。两边一样就说明被静默忽略了,生产代码里别传它,免得给自己制造"已经放宽了"的错觉。真正可控的是被拦之后怎么处理。

坑五 · 返回的图片 URL 是临时的

响应里那个 url 存活时间以小时计,域名也不固定。拿到的当下就下载转存到你自己的存储,不要存进数据库当长期地址,也不要直接发给终端用户。概览页的示例代码已经这么做了,直接抄那段就行。

错误处理矩阵

分错类别的代价是不对称的:把瞬时故障判成永久失败,用户看到的是"随机失败、手动重跑就好,程序自己从来不重试";反过来把审核拦截当成渠道故障疯狂重试,则是每试一次付一次钱、一次都不会成功。

上游返回能重试吗怎么办
451不能 内容被安全审核拦截。重跑无用,改提示词或换参考图
400 + 审核关键词不能 同上,只是换了个状态码穿出来
400 + 超时 / 内部错关键词,要有上限 这是故障伪装成 400,见下面的顺序规则一
400 参数错(尺寸 / 模型名 / 提示词太短)不能 修请求本身。原样重发必然再错一次
400 源图问题(格式不支持 / 无法解码)不能 换一张参考图,或按真实字节重新定 MIME 和后缀
401 / 403不能 key 错了或没权限,找管理员
402不能 余额不足,找管理员
429 指数退避 + 随机抖动,别用定长间隔硬撞
5xx,要有上限 上游临时故障
超时 / 连接被断,要有上限 同上。前提是你的 fetch 真的设了 AbortSignal.timeout

两条顺序规则

这是全页最容易写错的地方 —— 上面那张表如果匹配顺序不对,反而会害你。这两条是判定逻辑的硬性次序,不是风格建议。

规则一 · 先匹"故障黑名单",再匹审核关键词

上游超时会伪装成 400 回来,正文形如 Timeout when executing ... with timeout 54s。按直觉写成"400 一律是确定性错误、不重试",这类本可重试的瞬时故障就会被永久判死 —— 表现为偶发失败、人工重跑一次立刻就好,程序却怎么都不肯自己重试。

规则二 · 短码关键词必须用词边界正则

一个真实踩过的反例

一条参数错的响应正文是 不合法的size(traceid: 7b3e005af91c4d),traceid 里恰好含 e005 片段。用 includes('e005') 会把它误判成内容审核,于是提示用户"请调整提示词"(实际是尺寸传错了)。短码一律用 /\be005\b/ 这种带词边界的正则。

可直接用的分类函数

把响应变成带 kindretryable 的 Error,上层只看这两个字段即可。两条顺序规则是靠这段代码里 if 的先后次序生效的,挪动分支前先回头看上一节。

errors.js · 错误分类
// 审核关键词(小写匹配)。同一个逻辑错误会有多种文案变体,中英都要覆盖
const MODERATION_KEYWORDS = [
  'violate our guardrails', 'may violate our content polic', 'violated our content polic',
  'content policy', 'content_policy_violation', 'safety polic', 'blocked by safety',
  'appear to be unsafe', 'appears to be unsafe', 'flagged as sensitive', 'nsfw',
  'moderation_blocked', 'result of our safety system', "i can't help with", 'i cannot help with',
  '安全政策', '内容政策', '防护限制', '色情', '裸露', '不能帮助', '无法用于生成', '违反了我们的',
];
const MODERATION_REGEXES = [/\be005\b/];   // ← 词边界,别改成 includes(见规则二)

// ★ 必须先跑:真故障穿着 400 的皮
const TRANSIENT_400 = [/timeout|timed out|超时/, /unknown internal error|internal server error/];

const BAD_REQUEST  = [/不合法的size/, /size must be/, /invalid size/, /model .{0,64} not found/, /提示词太模糊/];
const SOURCE_IMAGE = [/invalid image file/, /unsupported image format/, /image format .* not supported/, /无法解码/];

export async function buildUpstreamError(resp) {
  let raw = '';
  try { raw = await resp.text(); } catch { /* body 读不出就只按状态码判 */ }
  // ★ 分类要跑完整 body,截断只用于打日志
  const body = raw.toLowerCase();
  const s = resp.status;

  const mk = (kind, retryable) =>
    Object.assign(new Error(`[${kind}] ${s}: ${raw.slice(0, 300)}`), { kind, status: s, retryable });

  if (s === 401 || s === 403) return mk('AUTH', false);
  if (s === 402)              return mk('INSUFFICIENT_BALANCE', false);
  if (s === 429)              return mk('RATE_LIMITED', true);
  if (s >= 500)               return mk('UPSTREAM_5XX', true);
  if (s === 451)              return mk('CONTENT_MODERATED', false);   // 状态码级判定,不看正文

  if (s === 400) {
    if (TRANSIENT_400.some((re) => re.test(body)))  return mk('UPSTREAM_TRANSIENT', true);   // ← 必须第一个
    if (SOURCE_IMAGE.some((re) => re.test(body)))   return mk('BAD_SOURCE_IMAGE', false);
    if (MODERATION_KEYWORDS.some((k) => body.includes(k))
        || MODERATION_REGEXES.some((re) => re.test(body)))
                                                    return mk('CONTENT_MODERATED', false);
    if (BAD_REQUEST.some((re) => re.test(body)))    return mk('INVALID_REQUEST', false);
    return mk('UNKNOWN_4XX', true);   // 认不出的 4xx 允许重试,理由见下文
  }
  return mk('UNKNOWN', false);
}
分类要跑完整 body,截断只用于打日志

别图省事写成先 slice(0, 256) 再匹关键词。上游随时可能在正文前面加一段前言,关键词一旦被切到截断点之外,整类审核识别会静默失效且没有任何告警,表现是审核错误开始被当成故障疯狂重试。

最后那行 UNKNOWN_4XX 标成可重试,看着和矩阵里"400 参数错不重试"冲突,其实不矛盾:认得出的参数错走的是前面几个分支,落到最后一行的是暂时还认不出的新错因。取舍是宁可多试一次,也别把一整类没见过的错误永久判死 —— 上游改文案时,前者只多花一点钱,后者是整条功能悄悄失效。

重试封装(指数退避 + 抖动)

有了 retryable 字段,重试逻辑就很薄。两个细节:限流(429)的退避基数要明显大于普通故障,否则只是换个节奏继续撞墙;退避一定要加随机抖动,不然并发的几个请求会踩着同一节拍一起重试、一起再被限流。

errors.js · 重试封装
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function withRetry(fn, { max = 3 } = {}) {
  let last;
  for (let attempt = 1; attempt <= max; attempt++) {
    try { return await fn(); }
    catch (err) {
      last = err;
      const retryable = err?.retryable === true
        || err?.name === 'TimeoutError' || err?.name === 'AbortError';   // fetch 超时
      if (!retryable || attempt === max) throw err;
      const base = err?.kind === 'RATE_LIMITED' ? 15_000 : 3_000;
      await sleep(base * 2 ** (attempt - 1) + Math.random() * 2_000);    // 指数退避 + 抖动
    }
  }
  throw last;
}

max = 3 是刻意保守的默认值:出图一次要跑一两分钟、超时重试还可能双计费,调大上限不提高成功率,只放大账单。

给终端用户看的文案

分类做对了,还要把结论翻译成用户看得懂、并且能据此行动的话。

错误类别该说什么不要说什么
CONTENT_MODERATED 内容被安全审核拦截,请调整描述或更换参考图 "重试一下"(重试无用)、"系统故障"(这不是故障)
INVALID_REQUEST 请求参数不对,把具体哪一项说出来 笼统的"生成失败",用户无从下手
RATE_LIMITED / UPSTREAM_5XX 服务繁忙,正在自动重试,请稍候 让用户自己手动重试 —— 程序已经在重试了
INSUFFICIENT_BALANCE / AUTH 提示联系管理员 暴露 key、余额数字这类内部信息
审核有一定随机性,文案可以如实这么写

同一句提示词有时能过、有时被拦,手动改一改重来偶尔是能过的。文案可以照实说"调整描述后再试一次",但别承诺"重跑一定没用";同时也别让程序自动重试 —— 自动重试是纯浪费,人工改词才有意义。

排查速查表

照着现象往下找,大部分问题在这张表里能直接对上号。

现象原因处理
出图永远是方图 传了 "16:9" 这类比例别名,或者压根没传 size 改用像素串,而且恒传
图生图输出比例不对 没传 size,或者没处理 EXIF 旋转 接口参考的尺寸规整
竖拍手机照片被当成横图处理 没做 EXIF orientation 5–8 的换轴 同上
多参考图只有第一张生效 中转层没有透传 image[] 见本页坑二
请求永远不返回 Node 的 fetch 没有默认超时 每个 fetch 都必须加 AbortSignal.timeout
调了 quality: 'low' 却没变化 该档位可能没被支持、被悄悄按默认处理了 直接传 high
图片链接过一阵就 404 返回的是临时 URL 拿到当下就下载转存,见本页坑五
一张违规图导致后面正常图也失败 把审核错误当成渠道故障、疯狂重试 按分类函数走,审核类不重试
偶发 400 说超时,手动重跑就好了 故障伪装成 400 故障黑名单必须先于审核关键词匹配,见规则一
参数错却被提示"请调整提示词" includes('e005') 子串误判 改成词边界正则 /\be005\b/,见规则二
415 或提示图片不合法 文件名后缀和真实格式不一致 按真实字节定 MIME 和后缀,别一律写 .png
上线前的三条自查

一、每个 fetch 都有超时,包括下载生成图那一次。二、非 2xx 时把响应正文带出来,否则只剩一个光秃秃的状态码,什么都判断不了。三、多参考图的透传情况人工看图确认过一次,别赌。

土土金内部文档 · 本页最后更新 2026-09-09

内容有误或步骤跑不通,请把页面地址和截图发给内部技术支持。