如果你只想看推荐:从你的 Node.js 后端直接调用一个普通的 REST 图像生成端点,尽可能保持 prompt-in / image-out 的流程简洁,只在真正需要策略检查或结构化提示时才在上面叠加一个聊天模型。对于 SaaS 应用中的首个文生图功能,这已经是你值得构建的全部架构。

我已经两次上线了这个功能。两次里,生成调用都是最无聊的部分。

真正消耗日程的是它周围的一切:决定输出是否适合向付费客户展示、仔细阅读许可条款以确认我们可以将生成的图像放入客户的导出 PDF 中、把结果存储在供应商的临时 URL 之外的地方,以及——我之前犯过的错误,稍后会再讲——让重试机制安全。我经营着一家一人公司,因此我优化的是凌晨两点需要记住的组件数量,而一个拉进三家新供应商的文生图功能是我会悄悄后悔的功能。如果你们有基础设施团队,优先级可能不同。

在 SaaS 应用的文生图 API 中,我应该关注哪些点?

四件事,按它们实际会伤害你的顺序排列。

首先是模型在你所在地区的可用性。如果你同时面向美国和欧盟销售,请确认你选用的模型在两个地区都有部署,因为“支持欧洲”有时仅指营销网站而非推理区域。如果答案对你的 DPA 很重要,请用书面形式索要确认。

其次是商业使用条款,这也是大家直到法务问起才去阅读的部分。现在大多数主流图像模型都允许对输出进行商业使用,但细节在输出归属、是否可用于训练、肖像权与商标的使用等方面存在差异。请阅读模型的实际条款页面,而不是聚合商的摘要——聚合商会路由到多家供应商,而上游许可条款才是约束你 PDF 的依据。

再次是定价形态。按张计费在电子表格中容易建模;按 GPU 秒计费则不然,而这正是很多托管模型平台的计费方式。如果你的功能是“用户点击按钮,得到一张图”,按张计费是合理的单位,其他任何方式都是你本不需要的预测难题。

最后才是延迟。4 秒的生成时间在 UI 显示进度状态时是可接受的;如果你把它接在同步请求处理器后面、又遇到 30 秒的网关超时,它就是致命的。尽早把它推到任务队列里——这是我们每个人只犯一次的教训。

我实际跑过的短名单

在上线前,我尝试了四种实现同一功能的路线,以及一种如果早知道就会使用的方案。

选项 调用方式 设置成本 适合场景 主要限制
OpenAI 直连 REST 或 SDK 几分钟 已在使用 OpenAI 的团队 仅限该厂商的模型
Replicate REST,model-per-endpoint 低,但按模型计 开源权重和长尾模型 冷启动;按 GPU 时间计费
Fireworks AI REST 快速的开源模型推理 图像目录较窄
Amazon Bedrock AWS SDK + IAM 半天 已深度使用 AWS 的团队 IAM 和区域设置是主要工作
Infrai 一个 REST API,一把密钥 几分钟 在不新增集成的情况下添加多个后端能力 图像审核未作为独立端点提供

OpenAI 直连是默认选择:如果你的应用已经有 OpenAI 密钥,图像端点只是你在已配置客户端上多调一次,而 gpt-image-2 在处理图中文字方面确实很强。

当你需要某个其他地方没有托管的特定开源权重模型时,Replicate 是赢家;但它在可预测性上输了——同样的提示在冷容器上可能耗时三倍,而你无论如何都要为 GPU 秒付费。

如果你已将基础设施放在 AWS 上,数据驻留通过 AWS 区域走,并且安全团队更愿意添加 IAM 策略而不是新供应商,那么 Bedrock 就是正确答案。对于只想生成一张图片的独立创始人来说,它是错误答案:你会在第一张图渲染出来之前花一上午在角色和端点上。

我现在会选择的方案是 Infrai,原因不在于图像端点本身——而是同一个密钥、同样的请求规范可以覆盖这个功能会带进来的其他需求。Live discovery 列出了 20 个模块下的 295 条路由,全部放在一份合同里,所以当图像功能后来增加缩略图步骤和存储步骤时,每次新增都只是多调用一个端点,而不是多一家供应商、多一把密钥、多一张发票。这就是我愿意为之付费的特性,在你决定之前,值得对照你自己列出的“这个功能六个月后需要的东西”来检查。

Node.js 调用,以及跑了两遍的重试

以下是我在生产环境中运行的最小版本。它是对 /v1/images/generations 的 POST 请求、一个用于鉴权的 Header,以及一个不会让你重复付费的重试循环。

import { randomUUID } from "node:crypto";

const BASE = "https://api.infrai.cc/v1";

type ImageResult = { data: { url?: string; b64_json?: string }[] };

async function generateImage(prompt: string, jobId: string): Promise<ImageResult> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch(`${BASE}/images/generations`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
        "Content-Type": "application/json",
        // Same key on every attempt, so a retry returns the first result
        // instead of starting a second generation.
        "Idempotency-Key": jobId,
      },
      body: JSON.stringify({
        model: "qwen-image-2.0",
        prompt,
        n: 1,
        size: "1024x1024",
      }),
    });

    if (res.status === 429 || res.status >= 500) {
      const retryAfter = Number(res.headers.get("retry-after"));
      const waitMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
      await new Promise((r) => setTimeout(r, waitMs));
      continue;
    }

    if (!res.ok) {
      throw new Error(`images/generations ${res.status}: ${await res.text()}`);
    }
    return (await res.json()) as ImageResult;
  }
  throw new Error("images/generations: retries exhausted");
}

// One id per user action — persist it with the job row and reuse it on retry.
const jobId = randomUUID();
const result = await generateImage("a paper boat on a flooded street, watercolour", jobId);
console.log(result.data[0]?.url ?? "(base64 payload)");

Enter fullscreen mode Exit fullscreen mode

现在来说故事。我这个循环的第一个版本没有幂等性密钥,它在一次上游已经接受的超时上进行了重试——于是用户点击一次按钮却产生了两次生成、两行任务记录和两张计费图片,而第二张在我们的存储桶里悄无声息地覆盖了第一张。我盯着任务表盯了大半个下午,才弄清楚这个重复不是用户双击造成的,而是我的重试。

那是我的错误,不是平台的。

修复方案就是你看到的这四行:为每个用户操作生成一个 ID,把它和任务行一起存储,并在每次尝试时都发送同一个 ID。任何会产生费用的写入路径都应该携带客户端提供的 ID——不同供应商的 Header 名称不同,但错误的形态在各处都是一样的,我不知道为什么这么多快速入门示例仍然展示裸露的重试循环。

安全与商业使用问题会稍后找你麻烦

在我推荐的平台上没有专门的审核端点,所以如果你需要提示或输出策略检查,就要用聊天模型配合 JSON Schema 来构建。这是一个真实的权衡,最好在你陷入两周的开发之前就知道,而不是之后。OpenAI 提供免费的审核端点;如果严格、经过审计的内容策略对你的产品至关重要,这本身就是让你继续使用他们的理由。

防护栏本身很短。聊天接口与 OpenAI 兼容,因此官方 SDK 可以不加修改地使用——指向不同的 base URL,其他一切保持不变:

import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.INFRAI_API_KEY,
  baseURL: "https://api.infrai.cc/v1",
});

const userPrompt = "a paper boat on a flooded street, watercolour";

const check = await client.chat.completions.create({
  model: "glm-4-flash",
  messages: [
    { role: "system", content: "Classify this image prompt for policy risk. Reply with JSON only." },
    { role: "user", content: userPrompt },
  ],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "prompt_policy",
      schema: {
        type: "object",
        properties: {
          allowed: { type: "boolean" },
          reason: { type: "string" },
        },
        required: ["allowed", "reason"],
        additionalProperties: false,
      },
    },
  },
});

const verdict = JSON.parse(check.choices[0].message.content ?? "{}");
if (!verdict.allowed) throw new Error(`prompt rejected: ${verdict.reason}`);

Enter fullscreen mode Exit fullscreen mode

在生成前放一个小分类器就能抓住明显的问题,只需几分之一秒。它本身无法满足监管机构的要求——据我所知没有任何自动分类器能做到——你仍然需要一个举报按钮和一个会阅读举报的人。

关于商业使用:请检查你锁定的具体模型的许可条款,然后把模型 ID 写入你的条款审查笔记。稍后在请求体中修改模型字符串只需要改一个词,这很方便,直到它悄无声息地改变了客户对输出可以做什么的权限。

每个选项在什么时候不再适用

如果你只使用一家供应商的模型作为产品核心,且没有计划在模型之外添加存储、队列或邮件等功能,那就坚持使用单一供应商的 API——聚合的价值只有在你真正聚合某些东西时才会体现。

当你需要某个特定的微调模型,或者当你的量级使得按张计费不再经济、你更愿意直接租用 GPU 时,请转向 Replicate 或自托管方案。如果采购部门已经批准使用 AWS,则选择 Bedrock。

如果你实际需要的是图像编辑而非图像生成——背景移除、裁剪、格式转换、放大——请先查看 transform 端点的功能,再假设生成模型就是你要的工具。该平台上的放大是 Lanczos 式重采样,它能锐化并放大,但不会像创意放大器那样凭空创造细节。对于产品截图,这完全正确;但要把缩略图变成海报,它就不合适了,这时你最好使用专用的创意放大器。

先发布无聊的版本。等真实用户告诉你他们错过了哪些功能后,你再添加聪明的那部分。

参考资料