Seedance 2.5 is live — 30-second cinematic video with native audio & real-person references
游戏中的 AI 图片生成:用缓存优化成本
2026/09/03

游戏中的 AI 图片生成:用缓存优化成本

用语义键、单阶请求、异步轮询、预算上限、持久存储和成本验收公式,构建高效的缓存优先游戏图片生成管线。通过精确的成本计算确保可控的资产生成开销。

缓存能把 AI 图片 API 的平均每玩家行为成本降至 $0.002,但无法让全新图片变便宜。按 Z-Image 在 reAPI 上的当前费率 $0.005 每完成一张,控制规则是 付费缺失率 × 每接受资产的完成尝试数 ≤ 0.4。40% 缺失率只在每个新资产首次完成即通过时才能达到天花板;要保持低于 $0.002 需要比这个边界还低。[1]

缓存的是物品的含义,而非仅是提示词。合并并发缺失,只预生成高概率物品,并按接受的游戏资产而非 API 响应次数计成本。

TL;DR

  • $0.005 每完成一张 Z-Image 生成 的价格下,游戏要达到每次合成 $0.002 或更低的有效成本,需要 付费缺失率 × 每接受资产的完成尝试数 ≤ 0.4。恰好一次尝试时,40% 缺失率刚好达到天花板;严格低于它意味着低于 40%。[1]
  • 保持一个 配方键 记录玩家合成了什么,和一个 语义资产键 记录生成的对象与美术版本。
  • 用共享数据库唯一性约束强制一个活跃资产任务。同进程调用者用内存 Promise 映射可以合并,但这不是保护多个 worker 的锁。
  • 图片生成是异步的。提交一次,保留任务 ID,轮询任务端点,把完成的输出复制到你控制的存储。[2][3]
  • 三任务烟雾测试在首次提交时完成了三个原始候选,共 15 credits 或 $0.015。它们未经视觉审查,所以是候选,不是接受的资产。[4]
  • 如果 3000 张日生成图已经是缓存后真正唯一的语义资产,传统缓存救不了成本。游戏必须重用更多美术、缩小生成范围,或接受更高成本。

真正的问题是每次合成的成本,不是每张图的成本

假设一款无限合成游戏把铁和火之类的物品组合起来,同时生成新结果及其图片。如果玩家每天触发 3,000 次合成,其中很多组合此前从未出现,怎样才能把媒体成本控制在每次合成 $0.002 以下?这个问题揭示了核心约束:游戏产生新请求的速度可能快于收入承担这些请求的速度。

先从这个公式开始:

日生成成本 =
  合成事件数
  × 付费缺失率
  × 每接受资产的完成尝试数
  × 每完成生成的价格

按 $0.005 每完成 Z-Image 任务,天花板是:

$0.005 × 付费缺失率 × 每接受资产的完成尝试数 <= $0.002
付费缺失率 × 每接受资产的完成尝试数 <= 0.4

对于 3000 次日合成超过 30 天,单次尝试场景看起来像这样:

付费缺失率新生成/天30 天 API 成本
100%3,000$450
40%1,200$180
20%600$90
10%300$45

这些是场景,非预测命中率,每行假设每接受资产一次完成尝试。比如 1.5 次尝试,缺失率必须不超过 26.7% 才能保持同样 $0.002 天花板。如果全部 3000 个事件在语义去重后仍然唯一,第一行适用。

配方键和语义资产键解决不同问题

配方键代表玩家的输入。如果你的规则中顺序无关,就对精确的规范物品 ID 排序后哈希。不哈希的身份容易检查:

配方身份:v3 | fire | iron

这样 iron + firefire + iron 通过同一个服务器拥有的配方行解析。包含规则版本,因为一个后续平衡补丁可能改变结果。不要对 key 中的 ID 进行 slugify:有损正规化可以合并两个不同物品。

语义资产键代表游戏最终显示的东西。其不哈希身份可以同样清晰:

资产身份:ember-shield | inventory-icon-v1 | z-image | 1:1 | filtered

几个配方可能解析到 ember-shield;翻译也可能用不同名称。这些路径仍然可以重用一个批准的视觉。

原始提示词是糟糕的 key:措辞改变会破裂缓存,相同提示词在美术方向改变后过时。把每个配方映射到稳定结果 ID,然后按结果 ID、模型、纵横比、安全模式和风格版本来 key 美术。在想要新美术时升版本。

缓存优先请求流

即使玩家启动,也要把生成看作后台资产制作。实用流程是:

  1. 把传入的配方解析到语义结果 ID。
  2. 在持久存储中查找语义资产键。
  3. 缓存命中时,立即返回存储的 URL。
  4. 缓存缺失时,原子插入一行按语义资产键的共享任务。
  5. 在同一数据库事务中,预留共享日提交预算中的一个槽。其它 worker 返回现有任务。
  6. 在发出一次 POST 前把行提交为 submitting,然后在响应后立即持久化任务 ID。
  7. 如果提交不明确,标记 submit_uncertain 并停止。用 GET 轮询已知任务 ID 直到它变成 completedfailed
  8. 记录终态和已清算 credits。把完成图片复制到你自己的存储并移到 pending_review
  9. 只有接受检查可以把 pending_review 移到 ready

在第六步之后,返回占位符和 pending 状态。让 worker 在客户端检查你的游戏端点时轮询。永远不要把 reAPI key 暴露给客户端。

轮询是免费的,在途任务端点被缓存 5 秒,所以大约每 5 秒轮询一次。[3]429 时尊重 Retry-After。不要盲目重试 POST:reAPI 不按 Idempotency-Key 去重生成请求,所以丢失响应可能隐藏接受的任务和第二次计费。[5]

一个有共享锁的可运行 Node 20 模块

最小的保护多个 worker 的例子需要共享状态。下面的模块是普通 .mjs,通过 postgres 包用 PostgreSQL,并在提交后返回而非保持玩家请求打开。

npm install postgres

创建一次表。从游戏自己的规则数据填充 game_recipes;浏览器从不发送结果 ID 或显示名称,只发送两个物品 ID。

CREATE TABLE game_recipes (
  recipe_key text PRIMARY KEY,
  result_item_id text NOT NULL,
  result_display_name text NOT NULL
);

CREATE TABLE game_asset_jobs (
  asset_key text PRIMARY KEY,
  origin_recipe_key text NOT NULL REFERENCES game_recipes(recipe_key),
  result_item_id text NOT NULL,
  state text NOT NULL CHECK (state IN (
    'submitting', 'submit_uncertain', 'processing', 'completed',
    'pending_review', 'ready', 'failed', 'rejected'
  )),
  submit_owner uuid NOT NULL,
  prompt text NOT NULL,
  task_id text UNIQUE,
  source_url text,
  durable_url text,
  usage_credits integer,
  error_message text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE game_generation_budgets (
  budget_day date PRIMARY KEY,
  submissions integer NOT NULL CHECK (submissions >= 0),
  submission_limit integer NOT NULL CHECK (submission_limit > 0)
);

存为 cache-first.mjs。完整的 SHA-256 key 哈希精确规范 ID;没有可以折叠两个不同 ID 的 slugify 步骤。

import { createHash, randomUUID } from 'node:crypto';
import postgres from 'postgres';

const API_BASE = 'https://reapi.ai/api/v1';
const apiKey = process.env.REAPI_API_KEY;
const databaseUrl = process.env.DATABASE_URL;
const dailyLimit = Number(process.env.DAILY_NEW_ASSET_LIMIT ?? '100');

if (!apiKey) throw new Error('Set REAPI_API_KEY on the server');
if (!databaseUrl) throw new Error('Set DATABASE_URL on the server');
if (!Number.isInteger(dailyLimit) || dailyLimit < 1) {
  throw new Error('DAILY_NEW_ASSET_LIMIT must be a positive integer');
}

const sql = postgres(databaseUrl);
const localFlights = new Map(); // L1 only; PostgreSQL owns correctness.

function hashIdentity(value) {
  return createHash('sha256')
    .update(JSON.stringify(value), 'utf8')
    .digest('hex');
}

function canonicalId(value) {
  if (typeof value !== 'string' || value.length === 0 || value.length > 200) {
    throw new Error('Item IDs must be non-empty canonical server IDs');
  }
  return value; // Preserve the exact string; do not trim, slugify, or case-fold.
}

export function recipeKey(leftItemId, rightItemId) {
  const pair = [canonicalId(leftItemId), canonicalId(rightItemId)].sort(
    (a, b) => (a < b ? -1 : a > b ? 1 : 0),
  );
  return `recipe:v3:${hashIdentity({ rulesVersion: 'v3', itemIds: pair })}`;
}

function assetKey(resultItemId) {
  return `asset:${hashIdentity({
    resultItemId,
    styleVersion: 'inventory-icon-v1',
    model: 'z-image',
    aspectRatio: '1:1',
    contentFilter: true,
  })}`;
}

async function resolveRecipe(leftItemId, rightItemId) {
  const key = recipeKey(leftItemId, rightItemId);
  const [recipe] = await sql`
    SELECT result_item_id, result_display_name
    FROM game_recipes
    WHERE recipe_key = ${key}
  `;
  if (!recipe) throw new Error('Unknown recipe');
  if (recipe.result_display_name.length > 200) {
    throw new Error('Canonical display name is too long');
  }
  return { ...recipe, recipeKey: key };
}

function publicJob(job) {
  return {
    assetKey: job.asset_key,
    state: job.state,
    taskId: job.task_id ?? null,
    url: job.state === 'ready' ? job.durable_url : null,
    usageCredits: job.usage_credits ?? null,
  };
}

async function claimSharedJob(recipe, key, prompt) {
  const owner = randomUUID();

  return sql.begin(async (tx) => {
    const created = await tx`
      INSERT INTO game_asset_jobs (
        asset_key, origin_recipe_key, result_item_id,
        state, submit_owner, prompt
      ) VALUES (
        ${key}, ${recipe.recipeKey}, ${recipe.result_item_id},
        'submitting', ${owner}, ${prompt}
      )
      ON CONFLICT (asset_key) DO NOTHING
      RETURNING *
    `;

    if (created.length === 0) {
      const [existing] = await tx`
        SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
      `;
      if (!existing) throw new Error('Concurrent job was not readable');
      return { ownsSubmit: false, job: existing };
    }

    const budget = await tx`
      INSERT INTO game_generation_budgets (
        budget_day, submissions, submission_limit
      ) VALUES (
        (now() AT TIME ZONE 'UTC')::date, 1, ${dailyLimit}
      )
      ON CONFLICT (budget_day) DO UPDATE SET
        submissions = game_generation_budgets.submissions + 1,
        submission_limit = EXCLUDED.submission_limit
      WHERE game_generation_budgets.submissions < EXCLUDED.submission_limit
      RETURNING submissions
    `;
    if (budget.length === 0) throw new Error('Shared daily budget reached');

    return { ownsSubmit: true, owner, job: created[0] };
  });
}

async function markSubmitUncertain(key, owner, message) {
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = 'submit_uncertain', error_message = ${message},
        updated_at = now()
    WHERE asset_key = ${key} AND submit_owner = ${owner}
      AND state = 'submitting'
    RETURNING *
  `;
  return job;
}

async function submitClaimedJob(claim, key, prompt) {
  let response;
  let body = '';

  try {
    response = await fetch(`${API_BASE}/images/generations`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'z-image',
        prompt,
        aspect_ratio: '1:1',
        content_filter: true,
      }),
    });
    body = await response.text();
  } catch (error) {
    const job = await markSubmitUncertain(
      key,
      claim.owner,
      error instanceof Error ? error.message : String(error),
    );
    return publicJob(job ?? claim.job);
  }

  let task;
  try {
    task = JSON.parse(body);
  } catch {
    task = null;
  }

  if (!response.ok || !task?.id) {
    const message = `Unconfirmed submit response: HTTP ${response.status}`;
    const job = await markSubmitUncertain(key, claim.owner, message);
    return publicJob(job ?? claim.job);
  }

  const [saved] = await sql`
    UPDATE game_asset_jobs
    SET state = 'processing', task_id = ${task.id}, updated_at = now()
    WHERE asset_key = ${key} AND submit_owner = ${claim.owner}
      AND state = 'submitting'
    RETURNING *
  `;
  if (!saved) {
    throw new Error(`Task ${task.id} was accepted but its ID was not persisted; do not resubmit`);
  }
  return publicJob(saved);
}

async function startOrRead(recipe, key) {
  const prompt = [
    `A clean 3D inventory icon of ${recipe.result_display_name}.`,
    'One centered object, readable silhouette, plain warm background,',
    'soft studio light, no text, no logo, no existing game character.',
  ].join(' ');

  const claim = await claimSharedJob(recipe, key, prompt);
  if (!claim.ownsSubmit) return publicJob(claim.job);
  return submitClaimedJob(claim, key, prompt);
}

export async function getOrCreateCraftAsset({ leftItemId, rightItemId }) {
  const recipe = await resolveRecipe(leftItemId, rightItemId);
  const key = assetKey(recipe.result_item_id);
  const existing = localFlights.get(key);
  if (existing) return existing;

  const work = startOrRead(recipe, key).finally(() => {
    if (localFlights.get(key) === work) localFlights.delete(key);
  });
  localFlights.set(key, work);
  return work;
}

export async function pollAsset(key) {
  const [job] = await sql`
    SELECT * FROM game_asset_jobs WHERE asset_key = ${key}
  `;
  if (!job) throw new Error('Unknown asset job');
  if (job.state !== 'processing' || !job.task_id) return publicJob(job);

  const response = await fetch(`${API_BASE}/tasks/${job.task_id}`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  if (response.status === 429) {
    const parsed = Number.parseInt(response.headers.get('retry-after') ?? '', 10);
    return {
      ...publicJob(job),
      retryAfterSeconds: Number.isFinite(parsed) ? Math.max(parsed, 1) : 5,
    };
  }
  if (!response.ok) {
    throw new Error(`GET task failed with HTTP ${response.status}; retry this GET, not the POST`);
  }

  const task = await response.json();
  if (task.status === 'processing') return publicJob(job);
  const credits = Number.isInteger(task.usage?.credits)
    ? task.usage.credits
    : null;

  if (task.status === 'failed') {
    const [failed] = await sql`
      UPDATE game_asset_jobs
      SET state = 'failed', usage_credits = ${credits},
          error_message = ${task.error?.message ?? 'Generation failed'},
          updated_at = now()
      WHERE asset_key = ${key} AND task_id = ${job.task_id}
      RETURNING *
    `;
    return publicJob(failed);
  }

  if (task.status !== 'completed') {
    throw new Error(`Unexpected task status: ${task.status}`);
  }

  const sourceUrl = task.output?.image_urls?.[0] ?? null;
  const [completed] = await sql`
    UPDATE game_asset_jobs
    SET state = 'completed', source_url = ${sourceUrl},
        usage_credits = ${credits},
        error_message = ${sourceUrl ? null : 'Completed task returned no image URL'},
        updated_at = now()
    WHERE asset_key = ${key} AND task_id = ${job.task_id}
    RETURNING *
  `;
  return publicJob(completed);
}

export async function stageForReview(key, durableUrl) {
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = 'pending_review', durable_url = ${durableUrl},
        updated_at = now()
    WHERE asset_key = ${key} AND state = 'completed'
      AND source_url IS NOT NULL
    RETURNING *
  `;
  if (!job) throw new Error('Only an archived completed asset can enter review');
  return publicJob(job);
}

export async function reviewAsset(key, accepted, reason = null) {
  const nextState = accepted ? 'ready' : 'rejected';
  const [job] = await sql`
    UPDATE game_asset_jobs
    SET state = ${nextState}, error_message = ${reason}, updated_at = now()
    WHERE asset_key = ${key} AND state = 'pending_review'
    RETURNING *
  `;
  if (!job) throw new Error('Asset is not pending review');
  return publicJob(job);
}

数据库行而非 localFlights 阻止第二个 worker 提交同一资产。在 POST 期间死亡的进程留下 submitting;把陈旧行视为 submit_uncertain 并调查它而非回收。在已知任务 ID 存在后,短暂轮询故障只重试 GET。

共享计数器是保守提交上限。未确认或被拒的 POST 仍消耗其槽,比悄悄超过上限更安全。如果终态任务失败,其状态和 usage.credits 留在行中。这个例子不自动重试失败或被拒资产;在支持重试前添加单独的尝试分类账和明确的、预算的操作员行为。

你的存储 worker 应该下载 source_url、上传到你的对象存储、把持久 URL 传给 stageForReview。只有 reviewAsset(..., true) 创建可缓存的 ready 资产。

三任务烟雾测试证明了什么

2026 年 9 月 3 日,我们用 content_filter: true 提交三个原始 1:1 库存物品提示词:一盏苔藓灯笼、一个玻璃罗盘和一个灰烬盾。每个任务在首次提交时完成并报告 5 credits。[4]

候选任务 ID观察完成时间Credits
Moss lanterntask_01a065490250739d893a1efcd83f4d1d25.1 s5
Glass compasstask_01a0654995df7265b44b132dce34a91425.0 s5
Ember shieldtask_01a0654995fd73e28e7c68217066407018.2 s5
Total3 tasks15 / $0.015

公开烟雾测试记录 包含请求设置、任务 ID、所用时间和已清算 credits。它故意忽略临时输出 URL。[4]

这是烟雾测试,不是延迟基准;它对 p95、突发或长期可靠性无话。协议记录了任务结果和清算但不包含美术审查。completed 表示三个候选到达,非美术审查员接受了它们。

这就是为什么有用的创意度量是:

每接受资产的成本 =
  总已清算生成 credits × $0.001 / 接受的资产

如果三次尝试产生一个可用图标,其有效成本是 $0.015。单独记录技术状态和创意接受。

预生成以降低延迟,不是虚幻的节省

只预生成可预测请求,比如从玩家库存可到达的前 20 个配方。用同样语义缓存和单阶路径,带单独日上限。

不要生成理论组合空间。未使用资产花钱,开放合成超越蛮力队列。按观察需求排名并在上限停止。

混合通常感觉最好:

  • 批准的语义资产立即返回;
  • 高概率下一个物品被预生成;
  • 长尾缺失显示占位符 while 一个后台任务运行;
  • 不受欢迎或滥用请求命中配额而非无限重试循环。

安全、存储和操作边界

为玩家面生成保持 content_filter: true。Z-Image 默认不启用该设置,所以依赖默认不够。[2]在玩家文本变成提示词前对其审核或约束,阻止冒充受保护角色或真人的尝试,按账户和设备速率限制。上游过滤是一层,不是完整游戏政策。

保持 key 服务器端。记录配方和资产键、任务 ID、状态、credits 和接受而不保留不必要的玩家数据。

最后,在把完成文件的 URL 发布给游戏客户端前,复制到持久存储。任务输出 URL 不是永久资产合同。[3]在资产记录旁存储校验和和内容类型,这样重试无法悄悄替换已批准的美术。

结论:缓存何时无法救济

缓存仅在游戏允许重用时有效。如果 付费缺失率 × 每接受资产的完成尝试数 超过 0.4,在 $0.005 单价下 $0.002 天花板不能坚持。如果原例 3000 日请求在缓存后已经是唯一的语义资产,改变缓存软件不会解决问题。

然后改变产品约束:在相似物品间共享美术、用过程重着色或叠加层、只生成受欢迎物品、限制发现、对长尾创建收费,或接受更大的媒体预算。

在设置预算前检查实时 Z-Image 模型页面,然后用 Z-Image API 指南Tasks API 参考 实现提交和轮询。价格和模型行为可以改变;你自己的已清算 credits 仍然是生产账单的真理来源。

FAQ

缓存会降低新 AI 图片的价格吗?

不会。它降低你多久买一个。当前 Z-Image 收费仍是每完成生成五 credits;缓存命中无新生成 credits 成本。

提示词本身应该是缓存键吗?

通常不。把配方解析到稳定结果 ID,然后单独版本其美术。

如果物品顺序很重要呢?

不排序配方输入。当你的规则给它们不同结果时,water + firefire + water 应该有不同配方键。

游戏应该预生成资产还是实时生成?

两个都用:预生成预算的可能集,立即提供命中,后台生成不可预测的长尾物品。

我应该多久轮询一次 reAPI 图片任务?

大约每 5 秒是实用起点。更快轮询不会让生成更快完成,可能浪费速率限制预算。

失败生成是否被计费?

Z-Image 文档说失败和被拒请求不被计费。仍检查 statususage.credits:完成但视觉不可用的结果不同于失败任务,计入创意成本。[2][3]

我能承诺每次合成平均低于 $0.002 吗?

仅在测量了你的付费缺失率和每接受资产的完成尝试数后。按 $0.005 每张图,严格保持在 $0.002 下需要其乘积保持低于 0.4。40% 缺失率仅在每资产首次完成即通过时才刚好达到天花板。

游戏客户端能直接调用图片端点吗?

不应该。客户端凭证可被提取和滥用。通过你的后端路由生成、在那里强制配额、让客户端查询游戏拥有的待处理任务。

References

  1. reAPI. Z-Image model page and live pricing. Retrieved September 3, 2026 from reapi.ai/models/z-image
  2. reAPI. Z-Image API documentation: inputs, asynchronous response, pricing, filters, and output retention. Retrieved September 3, 2026 from reapi.ai/docs/z-image
  3. reAPI. Tasks API: status, settled usage, polling, rate-limit behavior, and output storage. Retrieved September 3, 2026 from reapi.ai/docs/api/tasks
  4. reAPI. Three-task Z-Image smoke-test record. Run September 3, 2026. Temporary output URLs are omitted; no visual acceptance review was performed.
  5. reAPI. API overview: idempotency behavior. Retrieved September 3, 2026.