
最近在给一个跨境电商平台做内容审核分类模块,踩了几个坑,这篇把选型思路说清楚。核心观点就一句:比较 API 网关的时候,别光看每 token 多少钱,要看每成功分类一条举报实际花了多少成本。对接美国和欧盟团队的 Node.js 项目来说,可审计的人工复审队列是底线,缓存和批量处理必须在严格的 TypeScript 证据信封里验证,任何无法追溯、无法验证、无法回放的结果一律拒绝。
对于电商平台来说,这个流程规模足够小,可以直接推演。一条举报进来,带着地区、策略版本、语言区域和文本原因。应用生成一个稳定的缓存 key,向适配器请求一次受限的分类,验证结果,记录用量,把原始举报连同分类结果一起发给人工队列。模型只负责给工单排序,不做最终执行决策。
这就改变了比较的维度。OpenAI 兼容、Claude 兼容、Gemini 兼容的 API 网关可能给你一个熟悉的请求结构,但这个熟悉的结构不等于流式响应、用量字段、缓存、批量执行、区域处理行为一致。测试你的复审队列依赖的那些证据。
下面这个可运行的核心里故意不写死商业端点。候选适配器各自负责传输层,市场平台代码拥有输入契约、验证逻辑、缓存标识和计费。证据行随着举报进入人工队列。这个边界让后续替换供应商变得可观测:应用侧看到的标签不变,复审员看到的原始举报不变,运维人员有请求 ID 和标准化用量记录可供排查。
import { createHash } from "node:crypto"; type Region = "US" | "EU";
type Label = "fraud" | "harassment" | "prohibited_item" | "other"; type Report = { id: string; region: Region; locale: string; text: string; policyVersion: string;
}; type Usage = { inputTokens: number; outputTokens: number; cachedInputTokens: number };
type Classification = { label: Label; rationale: string };
type GatewayResult = { value: unknown; usage: Usage; requestId: string };
type GatewayCall = (prompt: string, region: Region) => Promise<GatewayResult>; type LedgerRow = { reportId: string; region: Region; requestId: string; cacheHit: boolean; valid: boolean; usage: Usage;
}; const labels = new Set<Label>(["fraud", "harassment", "prohibited_item", "other"]);
const cache = new Map<string, Classification>();
const ledger: LedgerRow[] = []; function cacheKey(report: Report): string { return createHash("sha256") .update(JSON.stringify({ region: report.region, locale: report.locale, text: report.text.trim(), policyVersion: report.policyVersion, taxonomyVersion: 3 })) .digest("hex");
} function parseClassification(value: unknown): Classification | null { if (typeof value !== "object" || value === null) return null; const item = value as Record<string, unknown>; if (typeof item.label !== "string" || !labels.has(item.label as Label)) return null; if (typeof item.rationale !== "string" || item.rationale.length > 240) return null; return { label: item.label as Label, rationale: item.rationale };
} async function classify(report: Report, call: GatewayCall): Promise<Classification | null> { const key = cacheKey(report); const saved = cache.get(key); if (saved) { ledger.push({ reportId: report.id, region: report.region, requestId: "local-cache", cacheHit: true, valid: true, usage: { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 } }); return saved; } const prompt = JSON.stringify({ task: "Classify this marketplace report for human queue ordering", policyVersion: report.policyVersion, locale: report.locale, allowedLabels: [...labels], report: report.text }); const result = await call(prompt, report.region); const parsed = parseClassification(result.value); ledger.push({ reportId: report.id, region: report.region, requestId: result.requestId, cacheHit: false, valid: parsed !== null, usage: result.usage }); if (parsed) cache.set(key, parsed); return parsed;
} const fixture: Report = { id: "report-1842", region: "EU", locale: "en-IE", text: "Seller asked me to pay outside the marketplace", policyVersion: "2026-04"
}; const testAdapter: GatewayCall = async () => ({ value: { label: "fraud", rationale: "The report describes off-platform payment solicitation." }, usage: { inputTokens: 86, outputTokens: 17, cachedInputTokens: 0 }, requestId: "fixture-001"
}); await classify(fixture, testAdapter);
console.log(JSON.stringify(ledger, null, 2));
适配器故意写得干巴巴的。保持这个风格。它只负责把稳定的应用请求转成候选者的请求格式,标准化响应,暴露供应商的请求 ID 和用量数据,不做市场策略决策。如果一个适配器同时还管策略提示词、重试逻辑、缓存规则和队列优先级,那替换它就等于重写。
测试运行时要把 SCHEMA_INVALID 算作付费工作且没有采纳决策,哪怕上游调用本身是成功的。这一条会计处理能防止格式错误的 JSON 和未知标签消失在"请求成功率"后面。也给了个有用的比较单位:
type RunTotals = { billedUnits: number; acceptedDecisions: number }; function unitsPerAcceptedDecision(run: RunTotals): number { return run.acceptedDecisions === 0 ? Number.POSITIVE_INFINITY : run.billedUnits / run.acceptedDecisions;
}
把 billedUnits 换算成钱之前,先把候选者的账单规则和用量字段映射清楚。测试套件保持稳定,映射关系可以改。
想象 report-1842 在平台发布新规则之后。原始文本没变,但 2026-04 策略下的缓存分类不再是新队列位置的证据。策略版本写进 key 必然导致缓存未命中。适配器返回分类结果和 fixture-001;本地验证接受标签和理由;账本记录 86 个输入单元和 17 个输出单元;人工复审员收到原始举报和建议的 fraud 标签。如果验证拒绝该响应,举报留在普通队列,计费工作仍然可见。如果同样举报在相同策略下再次提交,本地缓存可以回答,但这次命中会生成自己的账本行,而不是伪装成远程请求。一条举报现在告诉你的不只是 HTTP 调用是否返回了:它同时测试策略失效、模式强制、归属追踪、队列回退和计费,作为一条完整链路。
没有隐藏的升级操作。
从这个链路构建更大的测试语料。需要重复样本来测试缓存、短文本和长文本叙事、多语言、模糊举报、以及应该落入每个允许标签的案例。在调优提示词之前拆分数据。否则一个看起来高效的提示词可能只是过拟合了对比集合。
每次运行记录输入、输出、缓存输入单元数,严格按照返回结果;记录应用缓存是否命中;记录结果是否通过相同本地模式验证;记录耗时;记录执行模式;记录请求的地区;记录人工复审员的最终标签。把原始商业费率放在结果行外面,这样后续价格变动不需要重放模型输出。
缓存需要两个独立列。应用缓存可以避免对相同举报和策略上下文发起远程调用。供应商侧的提示词缓存在提供时可以改变重复提示词前缀的计费。两者不可互换,合并成一个"缓存节省"数字会让比较无法审计。上面的本地 key 包含了策略和分类版本,因为昨天的分类在今天的规则下可能是错的,即使举报文本完全相同。
批量处理也需要截止时间,不是一个是/否单元格。需要立即人工关注的举报走交互路径。策略更新后的积压重新分类可能适合异步批量路径。测量截止时间前完成的、有效的决策数,不要把已提交的项目算作有效输出。
关于美国和欧盟的处理,要求在你市场平台实际需要的层面提供证据:请求内容在哪里处理、日志和缓存条目存在哪里、合同怎么写的、你选择的模型和执行模式是否遵循同一套规则。光有一个地区选择器不能回答这些问题。合同加上受控的验证运行才能消除这种不确定性。
比较顺序很重要:先看采纳输出、再看截止时间、再看地区证据、最后看计费单元。你丢弃的工作单价再低也不是便宜货。
"兼容"只是一个起点假设。让网关用覆盖你将发布的应用行为的契约测试来争取这个标签。同样的测试套件要能跑每个适配器,包括你保留的自托管选项。有用的测试套件要验证:模式有效的非流式输出、未知标签的确定性处理、取消操作、超时分类、请求 ID、用量标准化、地区元数据保留。
流式处理需要自己的测试,因为 Server-Sent Events 是分帧机制,不是保证每个网关发出相同事件载荷的承诺。MDN 文档了浏览器端事件流模型和 text/event-stream 响应