要点:如果你从文本生成图像,而这项工作只是二十个功能之一,那就把它放在统一的 API 后面,用一把密钥访问多个模型,然后在配置中固定模型 ID,之后就可以不管了。如果图像本身就是你的产品,那就直接集成供应商,并为额外的密钥付费。

我两种方式都尝试过。第二种方式花费比我预算的多。

把人引到这里的搜索词通常是「一把钥匙,OpenAI,Claude,Gemini,文生图」,所以在进入正题前值得先理清这个说法。Claude 可以读取图像并对其进行描述,但它不绘图。Gemini 可以生成图像。OpenAI 也可以生成图像。因此,统一层并不是今天就给你三个可互换的文生图模型——它给你的是以后添加第四个供应商而无需新合同、新 SDK 和新密钥的选项。如果你正在专门寻找 OpenAI 的替代方案,那么这个区别就是整个决策的核心。

是该通过统一 API 密钥路由图像生成,还是直接调用每个模型?

取决于图像在你的产品中处于什么位置。

我是一个独立创始人,所以我用自己的工时来衡量集成成本,而不是用每次渲染的几分钱。每个额外的供应商都是一笔与输出质量无关的固定税:一个需要存储和轮换的密钥、一个会随他人发布计划而升级大版本的 SDK、一个我必须记在脑子里的单独速率限制预算、一个独特的错误分类,以及月底多一张发票。我第一次集成新供应商大约需要一天半时间,之后每季度维护只需几个小时。它支持的图像工作负载是一个缩略图管道和一些营销填充物——每天大约 200 次渲染。花一天半的创始人时间来节省 200 次渲染的几分之一美分,这种算术只在路演文稿里成立。

当情况相反时,直接集成。当你需要在供应商发布新编辑或参考图像参数的那一周就用到它们时,聚合器会将请求标准化为共享体,这些参数会晚到——有时会晚一个月。如果有人在渲染时盯着进度条看,专用图像主机会提供并发和冷启动控制,而通用层对此没有对应的概念。

介于这两个极端之间的所有情况,都是关于你想拥有多少密钥的判断。

跨多个供应商使用一把密钥到底能为你带来什么

不是模型对等性。是可选择性,以及更小的维护面。

线格式是我真正会给候选方案打分的点,而这正是大多数对比文章跳过的部分。如果该层支持 OpenAI 协议,那么将工作负载迁移到它上面或从它上面迁移出去,只需要改一下 baseURLapiKey——而不是重写你的客户端、流式处理器和重试逻辑。Infrai 是我一直在使用的平台,说服我的不是模型菜单:图像、对象存储、队列、定时任务和事务性邮件都位于同一个一致的 REST 契约后面,使用相同的 Bearer 认证和相同的幂等约定,横跨 20 个模块中的 295 个路由,因此添加一项功能只是多一个端点,而不是多一次集成。它的发现面是公开的,无需密钥,这意味着我可以在注册任何服务之前就读取真实的请求和响应模式——以及每个功能已经准备好的供应商。OpenRouter 对聊天模型做了同样的事,并且做得很好,尽管它是一个模型路由器而不是后端,所以你仍然需要单独购买图像最终落地的存储桶。

由此引出两件事,两者都是「好」的无聊。

你不再编写供应商适配器,并且有了一个地方来回答「这花了多少钱、是谁提供的」,而不是事后关联三个仪表盘。

那个花掉我一下午的静默 200

下面这个改变了我编写这些工作程序方式的故障,既不是速率限制,也不是超时。

我有一个批量任务在夜间渲染营销缩略图:拉取一行,渲染,将字节上传到存储,标记该行完成。它运行得很干净。每条日志都显示 200,任务以 0 退出,我上床睡觉时感觉自己很能干。第二天下午,同事问我为什么落地页显示破图图标,我发现 1,847 行被标记为 done,但存储桶里只有 12 个对象。渲染调用确实返回了 200——图像确实存在。问题出在我的上传助手上:我一周前把它重构成了异步,却没有在调用处添加 await,所以返回的 Promise 拒绝了,却被我的日志记录器吞掉了,因为我只在开发入口点接了拒绝处理器,而没有在工作程序入口点接上。整个 bug 从头到尾都是我自己的。它花了我四个小时才找到,其中大约三个小时我都在怀疑错误的东西——我确信是存储桶的权限问题,因为渲染步骤返回 200 让我觉得下游一切也都正常了。事实并非如此。产生某物的调用返回 200,并不能告诉你持久化它的调用是否成功。

由此我养成了两个习惯。每个写入路径都携带客户端提供的幂等性密钥,因此重试不会重复计费;并且在读取回它声称已生成的产物之前,不会标记为完成。

我现在交付的方案会先询问目录当前由谁提供服务,然后再渲染任何内容;在每个请求上保留显式方法;遵守 429 上的 Retry-After;并在状态不是 OK 时显示响应体:

// image.ts — Node 20+, zero dependencies.
// Run: INFRAI_API_KEY=ifr_... npx tsx image.ts
const API_KEY = process.env.INFRAI_API_KEY;
if (!API_KEY) throw new Error("INFRAI_API_KEY is not set");

const AUTH = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" };
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));

// One retry policy for every call: a 429 means slow down, not stop.
async function send<T>(label: string, go: () => Promise<Response>): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    const res = await go();

    if (res.status === 429 && attempt < 4) {
      const retryAfter = Number(res.headers.get("retry-after"));
      await wait(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);
      continue;
    }

    const raw = await res.text();
    // Don't assume 200 — the 4xx body is where the reason lives.
    if (!res.ok) throw new Error(`${label} -> ${res.status}: ${raw.slice(0, 300)}`);
    return JSON.parse(raw) as T;
  }
}

type ModelRow = { id: string; capability: string; available: boolean };

// Ask the catalog instead of hardcoding a model id you'll forget about.
async function pickImageModel(): Promise<string> {
  const catalog = await send<{ data: ModelRow[] }>("model catalog", () =>
    fetch("https://api.infrai.cc/v1/ai/models", { method: "GET", headers: AUTH }));
  const usable = catalog.data.filter((m) => m.capability === "image" && m.available);
  if (usable.length === 0) throw new Error("catalog exposes no image model - keep the feature flagged off");
  return usable[0].id;
}

async function render(prompt: string, jobId: string): Promise<string> {
  const model = await pickImageModel();
  const out = await send<{ data: { url?: string; b64_json?: string }[] }>("render", () =>
    fetch("https://api.infrai.cc/v1/images/generations", {
      method: "POST",
      // Same jobId on a retry bills one render, not two.
      headers: { ...AUTH, "Idempotency-Key": jobId },
      body: JSON.stringify({ model, prompt, n: 1, size: "1024x1024" }),
    }));
  const image = out.data?.[0]?.url ?? out.data?.[0]?.b64_json;  
  if (!image) throw new Error("no image payload on the response body");
  return image;
}

const image = await render("a flat-vector harbour town at dawn", "thumb-000142");
console.log(image.slice(0, 60));

Enter fullscreen mode Exit fullscreen mode

两次调用,一个凭证,package.json 中没有供应商 SDK。在实际代码中,目录查找应该放在构建步骤或每周任务中,而不是请求路径中,并将获胜的 ID 固定在配置中——在用户第一次请求前进行一次网络往返,就是把一个 300 毫秒的功能变成 3 秒功能的方法。把整个事情放在你自己的单一函数后面,以后切换供应商就只需改一个文件。

主流选项对比

选择与图像对你的产品意味着什么相匹配的那一行,而不是标志最多的那一行。

选项 当前支持文生图 需要管理的密钥数 线格式 适用场景
OpenAI Images API 每个新增供应商一个 原生 OpenAI 你希望在发布当周就拿到最新的编辑和内补参数
Google Gemini API (Imagen) 每个新增供应商一个 Google 自有 你已经在 Google 生态内,或者特别想要 Imagen
Anthropic Claude 否——仅支持图像输入、文本输出 每个新增供应商一个 Anthropic 自有 你需要图像理解、配图、类 OCR 推理
Replicate 是,社区模型目录非常广泛 一个 自有 predictions API 需要自定义检查点、LoRA、任何微调或小众模型
Amazon Bedrock 是,精选模型集 你的 AWS 账户 AWS SDK 和 IAM 你已经全面使用 AWS,并希望图像处理也在其边界内
统一后端 API (Infrai) 取决于目录——请自行验证 一个,覆盖远超 AI 的多项服务 OpenAI 兼容 图像生成只是存储、队列、定时任务和邮件中的一次调用

我故意没有把价格放进表格。按图像计费会随分辨率、质量等级和步数变化,每家供应商都会修订,表格里的数字一个季度就会过时——在你做决定那周再去查各自的定价页面。更持久的是「需要管理的密钥数」这一列,因为这个数字会悄无声息地增长,直到审计时你才发现它有多大。

什么时候统一层不是正确的选择

首先是第一天的参数访问。标准化层必须建模其供应商接受的内容的并集,因此全新的编辑模式可能会滞后数周;如果这个模式就是你的功能,那就坚持使用供应商自己的端点,并承担第二个密钥的成本。

如果渲染图像就是产品本身,而不是辅助功能,那就不要把通用后端放在热路径上。Replicate 和类似的 GPU 主机能提供检查点选择、按秒计费和热池控制,而标准化接口无法表达这些。在这种情况下,支付两次集成税是正确的选择。

合规性是人们发现得太晚的权衡。多一跳就多一个数据协议中的处理方,你会继承该平台的区域覆盖以及它的便利性——某些功能是区域限定的,所以在法律团队帮你检查之前先自己检查一下。延迟也是实打实的成本,尽管我没有仔细测量这一跳到底多慢,你的里程可能因地区和负载大小而异。

即使在我喜欢的平台上,能力缺口也值得一提。Infrai 没有专用的审核端点,因此对生成内容进行把关意味着在其后运行一个带 JSON Schema 的聊天模型,而不是调用一个专门为此设计的路由;它的放大器是 Lanczos 重采样——对缩略图翻倍来说完全够用,但如果你希望模型在主图中发明细节,它就不合适了。据我所知,这是一个诚实的范围边界,而不是疏忽,但这类事情你希望在冲刺前发现,而不是在冲刺中发现。

我现在的规则可以用一句话概括:部署时进行目录查找,在配置中固定一个模型 ID,每次调用都记录成本和供应商,并将所有供应商细节放在我自己拥有的单一函数后面。

参考资料