
游戏中的 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 + fire 和 fire + iron 通过同一个服务器拥有的配方行解析。包含规则版本,因为一个后续平衡补丁可能改变结果。不要对 key 中的 ID 进行 slugify:有损正规化可以合并两个不同物品。
语义资产键代表游戏最终显示的东西。其不哈希身份可以同样清晰:
资产身份:ember-shield | inventory-icon-v1 | z-image | 1:1 | filtered几个配方可能解析到 ember-shield;翻译也可能用不同名称。这些路径仍然可以重用一个批准的视觉。
原始提示词是糟糕的 key:措辞改变会破裂缓存,相同提示词在美术方向改变后过时。把每个配方映射到稳定结果 ID,然后按结果 ID、模型、纵横比、安全模式和风格版本来 key 美术。在想要新美术时升版本。
缓存优先请求流
即使玩家启动,也要把生成看作后台资产制作。实用流程是:
- 把传入的配方解析到语义结果 ID。
- 在持久存储中查找语义资产键。
- 缓存命中时,立即返回存储的 URL。
- 缓存缺失时,原子插入一行按语义资产键的共享任务。
- 在同一数据库事务中,预留共享日提交预算中的一个槽。其它 worker 返回现有任务。
- 在发出一次 POST 前把行提交为
submitting,然后在响应后立即持久化任务 ID。 - 如果提交不明确,标记
submit_uncertain并停止。用 GET 轮询已知任务 ID 直到它变成completed或failed。 - 记录终态和已清算 credits。把完成图片复制到你自己的存储并移到
pending_review。 - 只有接受检查可以把
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 lantern | task_01a065490250739d893a1efcd83f4d1d | 25.1 s | 5 |
| Glass compass | task_01a0654995df7265b44b132dce34a914 | 25.0 s | 5 |
| Ember shield | task_01a0654995fd73e28e7c682170664070 | 18.2 s | 5 |
| Total | 3 tasks | — | 15 / $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 + fire 和 fire + water 应该有不同配方键。
游戏应该预生成资产还是实时生成?
两个都用:预生成预算的可能集,立即提供命中,后台生成不可预测的长尾物品。
我应该多久轮询一次 reAPI 图片任务?
大约每 5 秒是实用起点。更快轮询不会让生成更快完成,可能浪费速率限制预算。
失败生成是否被计费?
Z-Image 文档说失败和被拒请求不被计费。仍检查 status 和 usage.credits:完成但视觉不可用的结果不同于失败任务,计入创意成本。[2][3]
我能承诺每次合成平均低于 $0.002 吗?
仅在测量了你的付费缺失率和每接受资产的完成尝试数后。按 $0.005 每张图,严格保持在 $0.002 下需要其乘积保持低于 0.4。40% 缺失率仅在每资产首次完成即通过时才刚好达到天花板。
游戏客户端能直接调用图片端点吗?
不应该。客户端凭证可被提取和滥用。通过你的后端路由生成、在那里强制配额、让客户端查询游戏拥有的待处理任务。
References
- reAPI. Z-Image model page and live pricing. Retrieved September 3, 2026 from reapi.ai/models/z-image
- reAPI. Z-Image API documentation: inputs, asynchronous response, pricing, filters, and output retention. Retrieved September 3, 2026 from reapi.ai/docs/z-image
- reAPI. Tasks API: status, settled usage, polling, rate-limit behavior, and output storage. Retrieved September 3, 2026 from reapi.ai/docs/api/tasks
- reAPI. Three-task Z-Image smoke-test record. Run September 3, 2026. Temporary output URLs are omitted; no visual acceptance review was performed.
- reAPI. API overview: idempotency behavior. Retrieved September 3, 2026.
更多文章

GPT-6 Astra API 迁移:Responses、工具与回滚
学会使用模型发现、Responses API、推理档位控制和工具检查,安全迁移至 GPT-6 Astra 并规划回滚策略。


Seedance 2.0 vs Kling 3.0:基准、价格与结论
从盲测 Elo、功能规格到每秒价格,全面比较 Seedance 2.0 与 Kling 3.0:Seedance 的画质优势,以及 Kling 在 4K 和音频上的领先。


AI 视频代理:构建批评循环实现质量保证
构建 AI 视频代理的批评循环,定义镜头验收规则、修复范围和重试预算,通过人工审核门控和选择性修复防止不合格视频进入最终编辑流程。
