针对用户可控文本字段的 AI 生图审核,作者对比了 OpenAI / Anthropic / Google / OpenRouter / Infrai 五种方案,并给出「政策放应用代码、执行外包给模型」的工程建议。
短答案:对每一个用户可控制的文本字段使用聊天分类器进行审核,该分类器返回严格的 allow、review 或 block JSON,只有在获得 allow 决策后才调用图像生成。
对于一个将文本提示词转换为图像的市场平台,我会采用与任何单人 SaaS 相同的决策规则:将策略保留在应用代码中,外包无差异化的模型执行,拒绝在没有明确理由的情况下将延迟预算花两次。分类器是必需的。第二次分类器检查则不是。
将审核视为一道关卡,而不是日志旁效应。在生成图像之前,将原始提示词和每个用户可编辑的样式字段发送到聊天补全。请求一个在严格 schema 下的小 JSON 对象。然后在代码中分支:allow 继续执行,review 进入审核队列,block 停止。
此设计不需要专用端点。
重要的边界是所有用户可控制的文本。检查 prompt 但信任 style、negativePrompt 或市场列表标题,会留下明显的绕过方式。在存储和审计记录中将这些字段分开,但将它们一起分类,以便模型看到图像生成器将收到的有效指令。Schema 应该刻意保持简单:一个枚举决策、一个简短的政策标签数组和一个简短的理由,就足够了。不要让分类器输出需要另一个函数来解释的散文。机器可读的输出将概率判断转化为确定性分支,而 review 状态则保留歧义,而不是将每个不确定的提示词强制归入批准或拒绝。市场政策也属于这里。提供商可以运行分类器,但它无法决定市场想要审核哪些边缘产品图像。
一个关卡。一个分支。
此 Node.js 示例使用 fetch,因此无需维护客户端库版本。它期望环境中有 AI_API_BASE_URL、INFRAI_API_KEY、CHAT_MODEL 和 IMAGE_MODEL。基础 URL 应标识 API 主机,不带尾部斜杠。模型 ID 保留在配置中,因为可用性会发生变化,应从提供商的当前模型目录中读取,而不是从文章中复制。
我特意将结果限制为三个标签。代码验证返回的 JSON,即使请求使用了严格的响应 schema;边界两端都需要执行验证。
import { randomUUID } from "node:crypto";
type Decision = "allow" | "review" | "block";
type ModerationResult = {
decision: Decision;
labels: string[];
reason: string;
};
type ImageRequest = {
prompt: string;
style: string;
negativePrompt: string;
};
const baseUrl = required("AI_API_BASE_URL").replace(/\/$/, "");
const apiKey = required("INFRAI_API_KEY");
const chatModel = required("CHAT_MODEL");
const imageModel = required("IMAGE_MODEL");
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
}
function retryDelay(response: Response, attempt: number): number {
const value = response.headers.get("retry-after");
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) return Math.max(0, timestamp - Date.now());
}
return 250 * 2 ** attempt;
}
async function postJson(
path: "/v1/chat/completions" | "/v1/images/generations",
body: unknown,
idempotencyKey: string,
): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}: ${responseBody}`);
}
return JSON.parse(responseBody) as unknown;
}
throw new Error("Rate-limit retry budget exhausted");
}
function parseModeration(payload: unknown): ModerationResult {
const completion = payload as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = completion.choices?.[0]?.message?.content;
if (!content) throw new Error("Classifier returned no JSON content");
const result = JSON.parse(content) as Partial<ModerationResult>;
const validDecision =
result.decision === "allow" ||
result.decision === "review" ||
result.decision === "block";
if (
!validDecision ||
!Array.isArray(result.labels) ||
!result.labels.every((label) => typeof label === "string") ||
typeof result.reason !== "string"
) {
throw new Error("Classifier JSON did not match the moderation schema");
}
return result as ModerationResult;
}
async function moderate(input: ImageRequest): Promise<ModerationResult> {
const payload = await postJson(
"/v1/chat/completions",
{
model: chatModel,
messages: [
{
role: "system",
content:
"Classify all user-controlled image instructions under the marketplace policy. Return only schema-valid JSON.",
},
{
role: "user",
content: JSON.stringify({
prompt: input.prompt,
style: input.style,
negativePrompt: input.negativePrompt,
}),
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "prompt_moderation",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
decision: { type: "string", enum: ["allow", "review", "block"] },
labels: { type: "array", items: { type: "string" } },
reason: { type: "string" },
},
required: ["decision", "labels", "reason"],
},
},
},
},
randomUUID(),
);
return parseModeration(payload);
}
export async function generateModeratedImage
市场操作处理程序可以将审核对象与生成请求一起持久化。同时将策略版本放在那里。这样支持人员就有足够的上下文来理解为什么卖家的提示词进入审核,而无需让图像工作线程学习如何重新解释自由格式的分类器散文。
有一个值得指出的操作陷阱。图像生成的幂等键在调用方重试同一逻辑操作时必须保持稳定。在示例中,函数拥有一个尝试并创建一个键;基于队列的生产处理程序应该接受来自其调用方的市场操作 ID,并在多次传递中使用相同的值。标准队列可能传递多次,而图像创建是一次写入。
提示词审核在关键路径上增加了一个模型调用,所以有用的问题不是"快速还是安全?",而是在市场可接受的响应时间预算内能买到多少分类器质量。一个小而快的聊天模型可以作为默认值。使用一个版本化的提示集对其进行评估,该提示集包括正常产品请求、明显违规、间接措辞、混合语言以及隐藏在样式字段中的攻击。只有当新的分类器或策略提示的误允许和误阻止行为对市场可接受时,才提升它。
我不确定单个阈值是否适合每个目录;语言组合和卖家被允许描绘的内容不同,你的里程可能会有所不同。解决这种不确定性的证据是一个来自实际市场政策的标记评估集,而不是通用基准。在收集数据时保留三种结果。review 是有用的,因为不确定的请求不必成为自动拒绝。运行一次审核调用,将其与生成分开测量,避免第二次传递,除非第一次结果是 review 且业务已决定自动升级优于人工队列。只有在完整的规范化输入和策略版本匹配时才缓存;看起来相似的提示词可能在改变决策的短语上有所不同。
每周发布一次。使用审核决策重新审视分类器,但当服务返回 HTTP 429 时不要静默削弱关卡。退让,尊重 Retry-After,并在尝试限制后抛出一个可重试的应用错误。我将 429 状态视为调度压力——永远不要将其视为将未检查的提示词发送到图像生成的许可。
当公司已经在该提供商上标准化了安全审查、计费、模型评估和运营工具时,坚持使用直接的 OpenAI、Anthropic 或 Google 集成。为了让 HTTP 层看起来统一而移除现有的、经过验证的集成很少能提高每位工程小时的收入。当提供商特定控制是产品需求的一部分时,直接账户也是更清晰的选择。
当核心问题是在聊天模型之间比较或路由,且团队准备根据实时文档验证严格的 JSON 行为和图像生成支持时,评估 OpenRouter。问题是,聚合器决策不会消除对应用所有策略、三种审核状态或使用市场自己的提示词进行测试的需求。
当策略需要专用审核产品而不是聊天模型分类时,矩阵中的普通 REST 选项不合适。在这种情况下,选择一个提供所需专用控制的提供商,并将其放在生成之前。本文的方法适用于愿意拥有分类器提示词、schema、评估集和审核工作流的团队。
这种所有权是真正的工作。对于许多小型市场来说,这也是正确的边界:供应商执行模型,而应用决定其用户可以发布什么。
OpenRouter 文档:https://openrouter.ai/docs
RFC 9110,HTTP 语义:https://www.rfc-editor.org/rfc/rfc9110