取证页记录从两份官方文档实际读到的事实:两个端点、参数取舍、尺寸四条算术规则、
响应字段映射、错误映射、同步接口的两笔代价。
两处如实标注的不确定性:
- 尺寸上限「3840x2160」文档未说清逐边还是按像素,本页写明采用的读法与理由
(逐边理解会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的标准竖图)。
- 两份文档都没有错误码表,故错误映射按状态码为主推断,整节挂 ⚠️ 未经证实,
并写明拿到真实错误数据后的校正路径。
火山页三处已过期的状态:图片适配层已落地(但产物转存仍阻塞,故仍不播种模型)、
HTTP 端点已实现、Java 侧 provider 适配已实现。
mkdocs build --strict 通过。
15 KiB
OpenAI gpt-image-2 图片生成:接口事实与映射
取证日期:2026-09-21 来源:用户提供的两份官方文档(
doc_temp/gpt生图.md、doc_temp/gpt图像编辑.md) 实现位置:patbond-ai的app/providers/openai/(client.py传输 /errors.py错误映射 /models.py厂商 DTO /image.py图片业务 /capabilities.py参数限制 /tasks.py同步→异步桥)
本页记录从官方文档实际读到的事实,供实现与排查引用。凡本页与代码不一致, 以代码为准并回改本页;凡本页与厂商文档不一致,以厂商文档为准并回改两处。
1. 两个端点
| 用途 | 方法与路径 | 载荷形式 |
|---|---|---|
| 文生图 | POST https://api.openai.com/v1/images/generations |
JSON |
| 图生图(图像编辑) | POST https://api.openai.com/v1/images/edits |
multipart/form-data |
| 鉴权 | Authorization: Bearer $OPENAI_API_KEY |
— |
没有第三个端点:没有任务提交接口,也没有任务查询接口。两个端点都是同步 的——一次请求里等到图片生成完才返回。这一条决定了第 2、7 两节的全部内容。
2. 与火山视频的三处根本差异
| 火山 Seedance(视频) | OpenAI gpt-image-2(图片) | |
|---|---|---|
| 调用模型 | 异步:提交返回 {"id"},另有查询端点 |
同步:一次请求等到底,无任务 ID |
| 产物形式 | URL,有效期 24 小时 | base64 字节,只在内存里 |
| 图生图载荷 | JSON 里放图片 URL | multipart,image 是文件 |
三处差异的处置,逐条对应到代码:
- 同步 → 异步由
tasks.py的SyncTaskRegistry在本服务内部吸收:submit把同步调用丢进线程池、立刻返回一个本地生成的local-<uuid>任务 ID,fetch查这个内存注册表。对外契约仍是 ADR-023 §4 的两段式提交/轮询,Java 侧看不出 两家供应商的同步性差异。代价见第 7 节。 - base64 产物没有「URL 过期」问题(这比火山的 24 小时硬约束宽松),但字节
只在内存里,进程重启即丢。领域结果里放的是
data:image/png;base64,...这个 data URI,它只为满足「SUCCEEDED 必须有 asset_url」的不变式而存在,不是可长期 保存的地址——一张 1024×1024 的 png 约 1~2MB,base64 后更大,绝不能原样写进media.assets.external_url。转存落地后这里应改为返回上传后的 object key。 - multipart 图生图:我们的输入是一个 URL,故需先下载再作为 multipart 上传。
下载失败按参数错误同步拒绝(见第 3 节末),而不是变成一个注定失败的后台
任务。字段名统一用
image[](该端点支持多图且共用此名),避免单图一种写法、 多图另一种的分叉。
另一处差异:尺寸不必查对照表。 响应直接带 size 字段(WIDTHxHEIGHT),
故像素宽高是确定值——不像火山那边只给 ratio + resolution、需要一张随模型族
变化的对照表。
3. 请求参数:接了哪些、没接哪些
接入的(ImageSubmission):
| 厂商字段 | 来源 | 说明 |
|---|---|---|
model |
任务行冻结的快照,缺失时回落 OPENAI_IMAGE_MODEL |
适配层不自行选模型——任务记录与实际调用必须一致,否则事后无法对账 |
prompt |
领域请求 | — |
n |
固定 1,不开放 | 一个任务一个产物(generation_jobs.output_asset_id 是单值),放开 n>1 要先改数据模型 |
size |
width_px×height_px 拼成 WIDTHxHEIGHT |
两个都有才拼;只给一个时不给 size、交模型自选(半个尺寸不如不给) |
quality |
可选 | 不传 = 服务商默认 auto |
output_format |
可选 | png/jpeg/webp,默认 png |
background |
可选 | transparent 时 output_format 只能 png/webp |
user |
领域的 safety_identifier |
⚠️ 厂商字段名是 user,不是 safety_identifier。必须传哈希,不可传明文用户 ID |
刻意没接的:stream、partial_images、mask、input_fidelity、
output_compression、moderation、response_format。理由分两类:
response_format对 GPT image 系列根本不支持(文档原文:这些模型 always return base64-encoded images),传了无意义。- 其余是能力而非缺陷——多暴露一个参数,就多一处要在契约、Java 客户端、文档三处
同步维护的地方。其中
input_fidelity: high对「参考图要保真」的场景有价值, 是最可能第一个被补上的,记在这里以备后用。
参数校验发生在受理之前。 capabilities.py 的三个 validate_* 与参考图下载
都在 registry.submit() 之前执行,故违规请求以 400 同步返回,而不是变成一个
注定失败的后台任务——后者要等 Java 侧一轮轮询才被发现,还白占一个线程池槽位。
4. 尺寸约束:四条算术规则
文档对 size 施加的是算术约束而非枚举(这是 gpt-image-2 与 2.5 系列共有的能力,
dall-e 系列才是固定枚举)。逐条:
| 规则 | 文档原文依据 | 代码 |
|---|---|---|
| 宽高都能被 16 整除 | width and height must both be divisible by 16 | EDGE_MULTIPLE = 16 |
| 宽高比在 1:3 ~ 3:1 | the requested aspect ratio must be between 1:3 and 3:1 | MIN_ASPECT / MAX_ASPECT |
| 上限 3840x2160 | the maximum supported resolution is 3840x2160 |
MAX_EDGE / MAX_PIXELS |
| 超过 2560x1440 属实验性 | Resolutions above 2560x1440 are experimental |
放行 + 记一条 INFO 日志 |
标准尺寸 1024x1024 / 1536x1024 / 1024x1536 与 auto 一律直接放行,
不过算术校验:它们是文档明示支持的取值。
⚠️ 一处由我们推断的语义:上限是逐边还是按像素
文档那句「the maximum supported resolution is 3840x2160」没说清限制是逐边 (宽≤3840 且 高≤2160)还是按总像素。
按逐边理解会禁掉所有高于 2160 的竖图——而 1024x1536 是文档明示的标准竖图
尺寸、竖版显然受支持,故逐边理解必然过严。
采用的读法:最长边 ≤ 3840,且总像素 ≤ 3840×2160。它允许 2160x3840 这类
与横图同像素量的竖图,又拦住明显越界的尺寸。同理,「实验性」阈值也按面积理解。
拿到更明确的文档或实测结果后,回来校正 capabilities.py 与本节。
文档还有一句「The requested size must also satisfy the model's current pixel and edge limits」——没给具体数值,故这一条无法在本地校验,只能由服务商的 400 兜住。
画质档按模型族分叉
low/medium/high/auto 是 GPT image 系列共有;xhigh 与 max 仅
gpt-image-2.5-sunburst / gpt-image-2.5-flare(含其 2026-09-08 快照)支持。
capabilities.py 目前只覆盖 gpt-image-2 一张表——接入 2.5 系列时再按模型名分表,
现在分是给一个不存在的需求写代码。
5. 响应体的关键字段
两个端点响应同形(ImagesResponse):
{
"created": 1713833628,
"data": [{ "b64_json": "..." }],
"size": "1024x1024",
"output_format": "png",
"quality": "high",
"usage": {
"total_tokens": 100, "input_tokens": 50, "output_tokens": 50,
"input_tokens_details": { "text_tokens": 10, "image_tokens": 40 }
}
}
| 字段 | 映射到领域 | 说明 |
|---|---|---|
data[0].b64_json |
asset_url(data URI) |
GPT image 系列始终返回它;data[].url 只用于 dall-e,对本系列不支持 |
size |
width_px / height_px |
直接解析,无需对照表 |
output_format |
output_format |
— |
usage.total_tokens |
billing_tokens |
计费对账依据 |
| — | asset_url_expires_in_sec = None |
字节在手,无时效。用 None 而不是编一个假的秒数 |
| — | progress |
同步接口不提供进度:在途一律 0、完成 100。契约本不承诺平滑进度(ADR-023 §10) |
2xx 但 data 为空当作失败处理(provider_code = MissingImageData,
可退避重试)。服务商说成功却没给图,那不是成功——把它当成功会让上层拿到一个没有
产物的 SUCCEEDED 去空指针。
6. 错误映射 ⚠️ 未经文档证实
两份文档都没有错误码一节。 这与火山那边给了完整错误码表的情况不同。
因此 errors.py 的映射建立在 OpenAI 通用错误约定之上(错误体形如
{"error": {message, type, code, param}}),并以 HTTP 状态码为主、
type/code 为辅。选这个策略是因为状态码语义由 HTTP 规范保证,而 type/code
的取值集没有文档支撑——凭记忆列一张表只会得到一张看起来权威、实则可能错漏的映射。
| 依据 | retry |
用户可见 |
|---|---|---|
code/type = insufficient_quota |
QUOTA |
配额耗尽 |
code/type = rate_limit_exceeded |
BACKOFF |
限流 |
code = moderation_blocked / content_policy_violation |
NEVER |
内容被拒 |
code = invalid_api_key |
NEVER |
服务端错误 |
| 网络层失败(连不上、超时) | BACKOFF |
上游不可用 |
| HTTP 429(且拿不到 type/code) | BACKOFF |
按限流处理 |
| HTTP 401 / 403 | NEVER |
服务端错误 |
| HTTP 400 | NEVER |
服务端错误,不猜成「内容被拒」 |
| 其余 4xx | NEVER |
服务端错误 |
| 5xx 及一切未知 | BACKOFF |
上游不可用 |
两处取舍值得记下来:
- 429 拿不到 type/code 时按限流而非配额耗尽。 429 在 OpenAI 上两义兼有;猜成 配额耗尽会把一次本可重试的限流判死,代价更大。
- 400 归「服务端错误」而不是「内容被拒」。 OpenAI 用 400 同时表示参数问题与 内容被拒;拿不到 type/code 时无法区分,而错报「内容被拒」会让用户去改一个其实 没问题的提示词。
未知情形一律偏向可退避重试:把一次可恢复的抖动判成用户可见的失败是更糟的
错误,而重试预算本身有上限(max_attempts)。
待补:拿到官方错误码文档、或在真实调用中观察到实际取值后,校正 errors.py
并回改本节。在那之前,errors.py 顶部那段 ⚠️ 说明不要删。
7. 同步接口的两笔代价
把同步接口包成异步是有代价的,且这两笔代价无法靠代码消除,只能明写出来。
① patbond-ai 从此持有状态
SyncTaskRegistry 是本服务唯一的有状态组件。后果:
- 进程重启会丢掉所有在途任务。 重启后 Java 侧轮询该任务 ID 得到
task_not_found,按失败处理。这与「任务 ID 从未存在过」无法区分,故代码不假装 区分——两种情形下调用方要做的是同一件事:停止轮询、任务判失败。 patbond-ai因此不能随意多副本部署。 任务 ID 只在签发它的那个进程里有效, 一旦多副本 + 无粘性路由,轮询会落到不认识该 ID 的副本上。当前单机部署无此问题, 但横向扩容前必须先解决它(把注册表外移到共享存储,或在网关做按 ID 的粘性 路由)。视频那侧没有这个约束——火山的任务 ID 是全局的,任何副本都能查。- 内存有界:
OPENAI_MAX_INFLIGHT(默认 256)满了以后拒绝新提交(503), 而不是挤掉在途任务——挤掉一个在途任务会让一次可能正在计费的调用变成无人认领的 开销。已完成条目按OPENAI_COMPLETED_TTL_SEC过期,在途条目永不过期。
② 超时窗口无法靠「再查一次」弥补
OPENAI_TIMEOUT_SEC 的语义与 ARK_TIMEOUT_SEC 不同,这一点最容易配错:
ARK_TIMEOUT_SEC |
OPENAI_TIMEOUT_SEC |
|
|---|---|---|
| 含义 | 单次 HTTP 短往返(提交只入队即返回) | 生成耗时的上限 |
| 默认 | 10 秒 | 180 秒 |
请求超时时,那次调用可能已经在服务商侧完成并计费,而我们拿不到结果。图片接口
没有任务 ID 可供事后查询,故这个窗口无法像火山那样靠重查一次弥补——只能由
max_attempts 兜住上限。这是选用同步型供应商的固有成本,不是实现缺陷。
一处已修的死锁,记下来避免重犯
SyncTaskRegistry 起初用 add_done_callback 记录完成时刻。这会死锁:快速完成
的任务在 add_done_callback 时已经 done,回调便同步地在调用线程执行,而该线程
此刻正持有注册表的锁(非可重入)。现在的做法是——完成时刻在首次被观察到时
惰性记录(fetch 与过期清理里)。代价是 TTL 起算点可能略晚于实际完成时刻,
这对「结果被取走前不要清掉」这个目的无害,反而更保守。
8. 实现状态
app/providers/openai/ 与火山适配层刻意不共用传输层:两家的错误体形状不同
(OpenAI 是 {"error": {message, type, code, param}},火山是 {"error": {code, message}}),
且 OpenAI 的编辑端点走 multipart。强行抽一个共同基类会让两边都被对方的特例污染
——供应商适配层之间不该互相复用,各自复用自家的传输层才是分层的本意。分层约束
由 tests/test_layering.py 用 AST 自动守着。
| 项 | 状态 |
|---|---|
HTTP 传输与鉴权(client.py) |
✅ 离线测试(MockTransport) |
错误分类(errors.py) |
✅ 有测试,但映射本身未经文档证实(第 6 节) |
参数预校验(capabilities.py) |
✅ 四条算术规则 + 画质/格式 |
同步→异步桥(tasks.py) |
✅ 含容量上限、TTL、死锁回归 |
图片业务(image.py) |
✅ 文生图 + 图生图(含参考图下载) |
装配与配置(assembly.py / config.py / .env.sample) |
✅ 缺 OPENAI_API_KEY 时跳过装配,服务仍可启动 |
| 产物转存 | ⏸ 阻塞于存储决策(ADR-023 §7)。注意 base64 字节只在内存里 |
| V7 目录种子(图片模型) | ⏸ 未播种——播一个没有转存能力的模型会让客户端出现点了没反应的入口 |
| Java 侧图片 provider 路由 | ⏸ 待上一项 |
跨语言约定,两侧都要对齐:目录表的 provider_code 必须与适配器的
PROVIDER_CODE 逐字一致(openai-gpt-image)。对不上时 Worker 按任务行冻结的
快照查不到适配器,任务会永远停在排队——而那看起来像队列坏了,不像两个字符串不
一致。播种图片模型时须同时加上这条一致性断言(火山那侧已有)。