对比直连适配器、聚合网关和自托管路由三种方案;要求每个provider路径输出相同JSON结构,验证后再接受,并记录路由或回退原因。
TL;DR:选择 moderation 分类的网关时,应基于它保留了哪些证据,而非一个 API key 能塞进多少模型名称。要求每条 provider 路径都产出相同的小型 JSON 契约,在接受结果前先验证它,并记录路由或降级的原因。单一应用凭证可以简化集成,但不会让 provider 的输出变得可互换。
本文档是关于一个媒体队列的实战指南:用户举报帖子、评论或消息,之后由人工审核。模型给出一个窄分类和紧急程度,不负责删除内容。这个区别让架构保持清醒:分类器负责优先级排序,确定性策略和人保留对实质性行动的最终决定权。
请让这个边界保持可见。
一个网关 key 给应用一个认证边界。它也可以让团队在一个地方表达路由策略、附加请求标识符,以及计量流量。这些都是有用的属性。但它们不能证明两个 provider 对 schema、拒绝、timeout 或安全边界的理解是一致的。
把这条路径想象成五个盒子:举报信封、策略路由器、provider 适配器、schema 验证器、人工审核队列。从适配器到验证器的箭头是关键箭头。没有结果能越过这条箭头进入队列,除非它已经被解析和检查过。如果一个降级模型运行了,它的输出也经过同一条箭头。没有可信赖的捷径。
这个框架改变了比较方式。Provider 数量是库存。工程问题是网关是否暴露了足够的信息来区分传输失败和有效但不可用的分类。包含未知标签的 200 响应仍然是失败的分类。语法正确但缺少举报标识符的 JSON 也是如此。
这些失败都要计入。
将应用凭证保存在密钥管理器中,按照网关的文档流程进行轮换。上游凭证(如果网关使用的话)保持为独立资产,有独立的作用域和审计追踪。"一把钥匙"描述的是调用者体验,不是整个安全模型。
直连适配器对于两到三条 provider 路径是一个认真的选项。它们让边界清晰可见:每个适配器拥有认证、timeout 转换、响应提取,以及到内部结果的规范化。发生事故时歧义更少,因为应用知道自己调用的是哪个上游。
代价是重复。每个适配器都必须用相同语义实现取消、有界重试、错误分类和遥测。Provider 原生的结构化输出功能可以提高一致性,但应用仍然要验证接收到的值。JSON Schema 定义了描述实例结构的词汇;它不会仅仅因为请求包含了 schema 就让远程响应变得可信。
当小团队可以审查每个适配器的变更,且 provider 多样性变化缓慢时,选择这条路。它也是评估网关的干净基线:用同一份固定的举报语料库跑两条路径,比较契约有效率、标签分歧率、拒绝率和端到端延迟分布。不要把这些信号压缩成一个"成功"计数器。
当中心化策略是核心工作时,聚合网关才值得拥有:租户配额、允许模型列表、路由约束、凭证隔离,或一致的请求信封。应用应该依赖自己的接口而非网关特定的响应形状。这让 moderation 工作流可读,也让替换变成一个边界适配器任务。
重要的比较是语义可见性。运维能否知道是哪个 provider 和模型处理了举报?路由原因是否可获取?timeout、rate-limit、拒绝、格式错误的 JSON 和 schema 拒绝能否区分?请求 ID 能否在应用和网关之间关联而不记录用户文本?如果这些答案是模糊的,一把钥匙就隐藏了系统运行所需的证据。
降级也需要书面规则。对于这个工作负载,降级适用于重试一次传输或可用性失败后,或响应不符合分类契约后。不应该仅仅因为另一个模型可能选择不同标签就静默重新解读已接受的结果。那会把弹性变成非确定性裁决。
避免以最便宜优先路由器作为默认决策规则。价格只是一个约束,一个可变的每 token 数字对团队实际 moderation 分类法上的 schema 有效率没有任何说明。更好的路由器先按数据处理要求和观察到的契约性能筛选候选者,然后应用延迟和预算上限。人工审核容量应该纳入这个计算:低置信度洪水可能消耗注意力,即使推理支出不大。
便宜的无效输出是浪费。
自托管层让团队直接控制日志、采样、 rollout 和规范化契约。它适用于举报数据必须留在定义的网络路径内,或者路由决策需要自定义、可审查逻辑的环境。
所有权是真实的。有人必须打补丁依赖、保护凭证、限制重试、卸负载、测试 provider 变更。值班团队现在拥有了位于每条举报和每个分类器之间的那层。选择它是因为需要这种控制,不是因为代理在图上看起来很小。
从无聊的路由开始:允许列表、一个主要候选、一个合格的降级,以及一个总期限。只有在遥测能解释它之后才添加自适应路由。没有可读证据的聪明选择难以调试,在 moderation 策略审查期间更难辩护。
内部契约应该比任何 provider 的原生响应更小。这个示例四个字段就够了:举报标识符、一个允许的队列标签、一个紧急程度整数,以及给人工审核者的简短理由。标签集来自审核操作,而非模型的偏好词汇。
以下是最小的 Node.js 边界。它使用通用适配器、检查未知键,并将可重试的 provider 失败与无效输出分开。所有候选者面对同一个解析器。
type QueueLabel = "threat" | "harassment" | "spam" | "other";
type Classification = {
reportId: string;
label: QueueLabel;
urgency: 1 | 2 | 3 | 4 | 5;
reason: string;
};
type Candidate = {
id: string;
classify(input: string, signal: AbortSignal): Promise<unknown>;
};
const labels = new Set<QueueLabel>([
"threat",
"harassment",
"spam",
"other",
]);
function parseClassification(value: unknown, reportId: string): Classification {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("schema:root");
}
const record = value as Record<string, unknown>;
const allowedKeys = new Set(["reportId", "label", "urgency", "reason"]);
if (Object.keys(record).some((key) => !allowedKeys.has(key))) {
throw new Error("schema:unknown_key");
}
if (record.reportId !== reportId) throw new Error("schema:report_id");
if (typeof record.label !== "string" || !labels.has(record.label as QueueLabel)) {
throw new Error("schema:label");
}
if (!Number.isInteger(record.urgency) || Number(record.urgency) < 1 || Number(record.urgency) > 5) {
throw new Error("schema:urgency");
}
if (typeof record.reason !== "string" || record.reason.length < 1 || record.reason.length > 240) {
throw new Error("schema:reason");
}
return record as Classification;
}
注意这里缺席了什么:自由格式的分类、推断的标识符,以及自动强制。严格拒绝是刻意的。将未知标签映射为 other 会让破损的契约看起来健康,而把输入 ID 复制到省略了它的响应中会掩盖关联失败。
路由器添加了一个总期限并为每次尝试记录一个事件。在生产中,emitAttempt 会通过应用的遥测层发送一个指标和一个跨度事件。它不应该包含举报正文。
type AttemptEvent = {
requestId: string;
candidateId: string;
outcome: "accepted" | "timeout" | "provider_error" | "schema_rejected";
elapsedMs: number;
};
declare function emitAttempt(event: AttemptEvent): void;
export async function classifyWithFallback(
requestId: string,
reportId: string,
reportText: string,
candidates: readonly Candidate[],
totalTimeoutMs = 8_000,
): Promise<Classification> {
const deadline = Date.now() + totalTimeoutMs;
for (const candidate of candidates) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) throw new Error("classification_deadline_exceeded");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), remainingMs);
const startedAt = performance.now();
try {
const raw = await candidate.classify(reportText, controller.signal);
const result = parseClassification(raw, reportId);
emitAttempt({
requestId,
candidateId: candidate.id,
outcome: "accepted",
elapsedMs: performance.now() - startedAt,
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "provider_error";
const outcome = controller.signal.aborted
? "timeout"
: message.startsWith("schema:")
? "schema_rejected"
: "provider_error";
emitAttempt({
requestId,
candidateId: candidate.id,
outcome,
elapsedMs: performance.now() - startedAt,
});
} finally {
clearTimeout(timer);
}
}
throw new Error("classification_unavailable");
}
这个示例故意给所有尝试一个共享的八秒预算。一个诱人的初步设计给每个降级一个新的 timeout,因为每个适配器看起来是独立的。按三条候选者走这条路:主候选等八秒,第一个降级再收八秒,第二个降级再收八秒。算上网络开销、队列时间和序列化,举报现在可能等待大约 24 秒。同时,调用者可能因为自己的期限到期而重试,造成重复工作但请求 ID 不同。单一绝对期限防止了这种倍增。每次新尝试只收到剩余时间,当预算耗尽时队列得到一个清晰的终端结果。权衡是粗暴的但可解释的:慢的主候选留给降级的时间更少。如果这不可接受,改变候选顺序或总服务目标;不要在适配器内部隐藏另一个完整 timeout。
一个时钟。一个结果。
生产队列应该存储接受的分类,连同 requestId、候选者身份、可用的模型标识符、契约版本和策略版本。将原始举报存储在媒体系统的访问控制下,而非复制到遥测中。OpenTelemetry 的 trace 模型提供 trace 和 span 标识符用于关联,而其指南警告不要将敏感数据放在属性中。低基数计数器可以按候选者和结果跟踪尝试次数。延迟放在直方图中。告警应该结合持续的 schema 拒绝率和队列年龄;单独任一信号都可能是有噪声的。
按层测试这个边界。单元测试给解析器喂送缺失的键、额外的键、错误的标识符、小数紧急程度值和超长的理由。适配器契约测试重放记录的、编辑过的响应形状。离线评估集测量按策略分类的标签分歧和混淆,有人审核的预期结果作为基准。最后,分阶段部署对一小批策略批准的样本进行 shadow 复制,不改变队列优先级。发布门控是契约有效性加上审核质量,而非 JSON 解析率。
Schema 有效的输出仍然可能是错的。它可能反映模糊的分类法、弱的指令、对抗性文本,或举报的分布漂移。由同一模型生成的置信度文本不是独立证据。用人工审核的评估数据和队列结果来判断质量。
降级可以提高可用性同时改变行为。记录它。保持候选者列表简短,限制总期限,并将耗尽表面化为不可用的分类,进入安全的人工队列。永远不要把失败变成一个虚构的标签。
网关也不能消除数据治理义务。团队仍然需要审查保留期、区域处理、子处理者边界、事故流程,以及哪些举报字段可以离开应用。因此最终选择是条件性的:小而明确的矩阵用直连适配器,中心化策略用聚合网关,本地控制用自托管。每种情况下,持久的设计都是相同的内部契约和相同的可观察验证边界。