拿到 doc_temp/gpt错误码.md 后核对,**此前猜的六个键里错了五个**: rate_limit_exceeded / moderation_blocked / content_policy_violation / invalid_api_key / image_generation_user_error 在官方文档里都不存在。真实的是 type rate_limit_error + code slow_down、type service_unavailable_error + code server_is_overloaded、四个 billing 码。唯一对的 insufficient_quota 也是宽 type 而非 code。这段历史留在 errors.py 顶部,作为凭记忆列错误码表代价的记录。 官方表的 13 条全是基础设施与计费,**没有任何内容审核/侵权码**——但图片接口确有 moderation 参数,说明审核存在、只是错误形态未文档化。直接后果:一次侵权或违规 拒绝很可能以普通 400 到达,只带一句人类可读消息。若一律归「服务端错误」,用户会 看到「服务出错了」并反复重试一个注定被拒的提示词。 故用 error.param 的有无做结构化区分(带 param = 参数错,不带 = 按内容被拒), 三条依据:文档的 service_tier 那条明确说参数错会带 param;内容拒绝不指向任何 具体参数;我方已在调用前校验过尺寸/画质/格式,能被 400 拒掉的参数嫌疑本就不大。 这一条是推断,走到时记 WARNING——那是日后校正它的唯一素材。client.py 相应补上 param 的解析。 两处顺带收获: - 无 code 的 429 按限流处理,现在是**文档支持的默认**(六种 429 里只有「Rate limit reached for requests」没有文档化的 code),不再是猜测。 - 文档提醒视频过载已从 429 改判为 503。我们两者都归可重试,故不受影响;加了一条 测试钉住这个「无影响」。 同一张表按 code 与 type 两个字段分别查:文档把标识列在哪个字段下,不保证响应里 它就在那个字段,查错的代价是把配额耗尽降级成限流、白烧一轮重试预算。 未接 Retry-After 并记录理由:Java 侧退避基线 30 秒、上限 10 分钟,已长于服务商 通常给的值,忽略只会偏慢、不会加剧限流;接它是一次跨语言契约变更。 测试 271 → 284。四道门禁逐条读退出码,全绿。
patbond-ai
Patbond 的 AI 生成服务(Python)。负责调用 AI 服务商 API 完成图片与视频生成, 是四仓中唯一直接持有 AI 服务商凭证的仓库。
架构与边界的完整决策见 patbond-doc docs/architecture/decisions.md ADR-023。
以下为要点,改动前请以 ADR 原文为准。
职责边界
- 只负责调用 AI 服务商 API。 不访问数据库——生成任务状态的唯一事实来源是
Java 侧的
creation.generation_jobs。 - 不持有对象存储凭证。 产物由 Java 下发预签名 PUT 地址,本服务直传后回报,
再由 Java 核实并建
media.assets行(即客户端两步上传,本服务站在客户端位置)。 - 对上游同步或异步一律对外呈现为异步:
submit立即返回任务 id,同步型 服务商(长连接等待返回)在本服务内部以后台任务承载。这样 Java 侧无长连接占线程, 且provider_request_id立即可得,超时不再产生「是否已扣费」的歧义。
与 Java 侧的接口
- 传输为 REST(JSON),非 gRPC(理由见 ADR-023 §4:避免引入第二套无门禁契约体系)。
- 契约为本仓内独立的
openapi-internal.yaml,独立版本号, 不进 patbond-api 的四模块快照锁,也不写入面向客户端的openapi.yaml。 - 鉴权为 服务身份 JWT(独立密钥对,非用户 token 转发):本服务强制校验
aud=patbond-ai与typ=service。 - Java 主动轮询本服务查询进度;回调是将来的可选优化,当前不启用。
- 请求体不含
userId(数据最小化):完成工作只需jobId、生成参数与预签名上传地址。
运行
cp .env.sample .env # 填入实际值;.env 已被 gitignore
python -m app # 默认 127.0.0.1:8000
只监听回环是默认:本服务只接受来自 patbond-creation 的内部调用,不该对外
可达(与项目「默认拒绝、需要外部访问时经 nginx 反代」的纪律一致)。
探活:GET /health(在 /internal 之外,不需要服务票)。它会报出实际装配了
哪些适配器——缺服务商凭证时适配器不装配,而那个症状(任务永远排队)离根因很远,
让探活直接说出来能省很多排查时间。
接口
| 端点 | 说明 |
|---|---|
POST /internal/generation/tasks |
提交任务,返回服务商任务 ID |
GET /internal/generation/tasks/{provider_request_id} |
查询任务 |
GET /health |
探活,无需服务票 |
权威契约是仓库根目录的 openapi-internal.yaml(手写),不是 FastAPI 自动生成
的 /openapi.json(后者仅供调试,交互文档已关闭)。两者的一致性由
tests/api/test_contract_drift.py 守着——比对路径、方法、字段集、枚举取值与必填项,
不比对字节(那永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。
三类失败必须分开
| 类别 | 形态 | 调用方该做什么 |
|---|---|---|
service |
非 2xx + error.kind = "service" |
我方 bug 或配置问题。除 internal_error/provider_unavailable 外不该重试 |
provider |
非 2xx + error.kind = "provider" + 完整分类 |
一律以 retry 字段为准,不要自己解读厂商码 |
| 任务失败 | 200 + state = "failed" + failure |
同上看 retry |
第三类刻意不走错误通道:查询一个失败的任务,HTTP 调用是成功的,失败的是任务。 做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
判别字段是 error.kind,不是 HTTP 状态码——状态码在两类之间会撞。
provider 信封与 failure 的分类字段故意完全一致,故调用方可复用同一段处置
逻辑,不必为「提交时被限流」与「任务失败于限流」写两套分支。
代码结构
分层,依赖单向向内(api → assembly/config → providers → domain),由
tests/test_layering.py 自动守着——靠纪律守不住的东西,靠测试才守得住。
app/
├── domain/generation.py 与服务商无关的领域模型
├── providers/
│ ├── base.py 生成能力抽象(新增供应商 = 实现它)
│ ├── registry.py provider_code → 适配器
│ ├── volcengine/
│ │ ├── client.py HTTP 传输与鉴权(不认识「视频」)
│ │ ├── errors.py 火山错误码 → 领域 Failure
│ │ ├── models.py 厂商请求/响应 DTO
│ │ ├── capabilities.py 模型能力与参数限制
│ │ ├── dimensions.py ratio+resolution → 像素对照表
│ │ └── video.py Seedance 视频业务
│ └── openai/
│ ├── client.py HTTP 传输与鉴权(JSON + multipart)
│ ├── errors.py OpenAI 错误 → 领域 Failure
│ ├── models.py 厂商请求/响应 DTO
│ ├── capabilities.py 尺寸算术约束与画质档
│ ├── tasks.py 同步→异步桥(本服务唯一的有状态组件)
│ └── image.py gpt-image-2 图片业务(文生图 + 图生图)
├── api/ HTTP 端点、服务票校验、错误信封
├── config.py 集中配置(全部旋钮从环境变量读)
└── assembly.py 装配根(唯一同时知道配置与具体供应商的地方)
为什么这么切:
domain/对厂商与 httpx 零依赖。 这是「换供应商不动上层」的支点。判断一个 概念该放哪:问「换一家供应商,它还在吗」——Retry/UserFacing描述的是 Worker 与客户端该怎么做,与谁家无关,故在领域层;expired状态、QuotaExceeded这类码是火山特有的,故在适配层。client.py与video.py分开。 client 只管鉴权、超时、错误解包,不认识 「视频」。以后接火山的图片或语音接口时新建兄弟模块复用同一个 client, 而不是再抄一遍鉴权与错误解包——这是拆它出来的全部理由。- 两家的
client.py刻意不共用。 错误体形状不同(OpenAI 是{"error": {message, type, code, param}},火山是{"error": {code, message}}), 且 OpenAI 的编辑端点走 multipart。抽一个共同基类会让两边都被对方的特例污染 ——同一家的不同能力之间才复用传输层,不同供应商之间不复用。 - 适配层不读配置,全部通过构造参数注入。否则单测得摆弄环境变量,且同一进程 内没法跑两套不同配置的实例。
新增一家供应商 / 一种能力
- 实现
app/providers/base.py的GenerationProvider; - 在
app/assembly.py的build_registry()里登记一行; - 目录表的
provider_code与适配器的provider_code必须同值——Java 侧按 任务行冻结的provider_code_snapshot查适配器(不按当前目录,因为目录 可能被改指向,而在途任务必须仍由当初提交它的适配器轮询)。
上层与领域层一行都不用改。
已接入的服务商
| 服务商 | 能力 | provider_code | 状态 |
|---|---|---|---|
| 火山方舟 Seedance | 视频生成 | volcengine-seedance |
提交与查询已实现,产物转存待存储决策 |
| OpenAI gpt-image-2 | 图片生成(文生图 / 图生图) | openai-gpt-image |
同上;另见下方「同步型供应商的代价」 |
接口事实与错误码映射的完整取证见 patbond-doc
docs/development/providers/volcengine-seedance.md 与
docs/development/providers/openai-gpt-image.md。其中几条尤其要记住:
- 火山不支持幂等键,「提交超时后不知道有没有扣费」只能由
max_attempts兜住; - 火山产物 URL 只有 24 小时有效期,因此厂商 URL 不能当存储用,必须在时限内转存;
- OpenAI 的官方错误码表里全是基础设施与计费错误,没有任何内容审核/侵权码
——一次「这个提示词不能生成」只能以通用 400 到达。故
providers/openai/errors.py用error.param的有无区分「参数错」与「内容被拒」,那一条是推断并记 WARNING; Retry-After目前不读:Java 侧退避阶梯基线 30 秒、上限 10 分钟,已长于服务商 通常给的值,忽略它只会偏慢、不会加剧限流。接它是一次跨语言契约变更。
同步型供应商的代价
OpenAI 的图片接口是同步的(没有任务 ID,也没有查询端点),由
providers/openai/tasks.py 在本服务内部包成异步。两笔代价无法靠代码消除:
- 本服务从此持有状态。 进程重启会丢掉所有在途图片任务(Java 侧轮询得到
task_not_found,按失败处理)。也因此patbond-ai横向扩容前必须先解决 任务 ID 只在签发进程内有效的问题——多副本 + 无粘性路由会让轮询落到不认识 该 ID 的副本上。视频那侧没有这个约束,火山的任务 ID 是全局的。 - 超时窗口无法靠重查弥补。 超时那次调用可能已在服务商侧完成并计费,而图片
接口没有任务 ID 可供事后查询。只能由
max_attempts兜住。
配置
全部旋钮见 app/config.py,常用项:
| 环境变量 | 默认值 | 说明 |
|---|---|---|
ARK_API_KEY |
(无) | 火山方舟密钥。缺失时该适配器不装配,服务仍可启动 |
ARK_BASE_URL |
北京区官方地址 | |
ARK_TIMEOUT_SEC |
10 |
单次 HTTP 往返超时,不是生成耗时 |
ARK_VIDEO_MODEL |
(无) | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
ARK_TASK_EXPIRY_SEC |
3600 |
厂商 execution_expires_after,区间 [3600, 259200]。默认取下限——厂商默认 48 小时对交互式应用过长 |
OPENAI_API_KEY |
(无) | OpenAI 密钥。缺失时该适配器不装配,服务仍可启动 |
OPENAI_TIMEOUT_SEC |
180 |
⚠️ 与 ARK_TIMEOUT_SEC 语义不同:图片接口同步,这里是生成耗时的上限 |
OPENAI_IMAGE_MODEL |
(无) | 仅作兜底;正常由 Java 侧传入任务行的模型快照 |
OPENAI_MAX_CONCURRENT |
4 |
同时执行的同步调用数上限,也是「同时有多少钱在花」的上界 |
OPENAI_COMPLETED_TTL_SEC |
900 |
已完成任务在内存注册表的保留时长。须大于 Java 侧最长轮询间隔,否则结果会在被取走前被清掉 |
OPENAI_MAX_INFLIGHT |
256 |
注册表条目上限。满了拒绝新提交(503),不挤掉在途任务 |
PATBOND_SERVICE_JWT_PUBLIC_KEY |
(无) | 校验 Java 侧服务票的 RS256 公钥 |
PATBOND_SERVICE_JWT_AUDIENCE |
patbond-ai |
必须强制校验,否则独立密钥对失去意义 |
非法值响亮失败而不静默回落:一次配置笔误变成难查的行为偏差是最糟的结果。
本地门禁
与 CI 同一套命令(.gitea/workflows/ci.yml)。逐条分开跑并读各自的退出码——
ruff check . | tail -1; echo $? 读到的是 tail 的退出码,会把一次真实的 lint
失败读成通过(本仓已因此红过一次):
sh scripts/check-secrets.sh --all # 凭证防泄漏全量自查
ruff format --check .
ruff check .
pytest -q
每人每仓启用一次 pre-commit 防泄漏钩子:
git config core.hooksPath scripts/hooks
scripts/check-secrets.sh 是四仓同构副本,调整规则时四仓同步提交。
它覆盖 AI 服务商密钥形态(sk-ant-、sk-/sk-proj-/sk-svcacct-)与
api_key/auth_token 实值赋值——本仓正是这些规则的主要适用对象。
凭证纪律
真实密钥只允许存在于被 gitignore 的文件中(.env),占位样例进 .env.sample。
拦下真实凭证时的第一动作是去服务商控制台轮换/禁用该密钥,然后才是清理提交。
分支与发布
dev 直推 + CI 绿,main 经 PR 合并。本仓纳入四仓 tag 同步与发布回归
(ADR-023 §12)。发布流程见 patbond-doc docs/development/releases.md。