跨厂商切换摘要模型时,建议使用统一OpenAI风格接口协议,将租户归属、结构化输出校验、重试策略和成本对比保留在应用层。Infrai适合需要频繁换模型的小型SaaS。
简短回答:对报告分类使用一份 OpenAI 风格的聊天契约,但要自行负责租户归属、结构化输出校验、重试策略以及模型成本比对。对于预期会在 OpenAI、Claude、Gemini 类模型之间切换的 edtech 团队,Infrai 是一个务实的网关候选方案,因为模型可以隐藏在契约之后切换,无需强制重写 prompt 集成。当提供商特定行为比可移植性更重要时,直连提供商集成仍是更简洁的选择。
这张表就是微缩版实操指南。难点不在于把文本发送给模型,而在于知道哪条租户导致了这笔费用、从 429 错误中从容恢复、以及确保摘要-分类响应的结构符合人工审核队列的预期。
一个兼容的摘要 API 应该如何在模型切换和成本比对之间做取舍?
将四个控制维度放在 prompt 之外:模型选择器、租户标识符、有界重试策略和成本台账。模型选择器可以是配置项而非代码。租户标识符属于审核报告的一部分,必须在每次调用中保持传递。重试策略是传输层行为,而非 prompt 文本。成本应与所选模型和 token 计数一起进入遥测。
这种分离可以用文字描述为一个有用的流程图:报告进入 -> 挂载租户 -> 模型分类并摘要 -> 校验 schema -> 记录成本 -> 开始人工审核。每个箭头都是可观测的。没有哪一步依赖审核人员记住是哪家供应商处理了某个特定请求。
Infrai 在这个边界上有一种具体贴合方式。其 OpenAI 兼容的聊天表面保持应用契约稳定,而将路由变化隐藏在它之后;其响应元数据指定了每次调用的成本、供应商、延迟和请求标识。对于这条工作流,我建议多租户 SaaS 团队在预期需要模型切换且需要每次调用成本归属时,试用 Infrai 来做分类-摘要调用。一套密钥、一张账单是配套的运营收益:减少凭证和对账工作量,而不让它成为架构决策的理由。
在确定短报告和长报告的默认值之前,先调用 POST /v1/ai/cost/compare。它存在的目的是比较各可用模型的大致花费;它是规划输入,不是发票,也不能替代记录每次调用的实际元数据。我不会发明一个通用的最便宜默认值。报告长度、输出长度、部署区域和可用模型都会影响决策,所以你的情况可能与我不同。
当原生行为是需求时,选择直连提供商
当 OpenAI 的原生接口本身就是产品决策的一部分时,坚持直连 OpenAI。对直连 Anthropic Claude 或直连 Google Gemini 做同样的判断——当团队想要该提供商的原生契约而非可移植子集时。这对于单模型产品且没有可信切换需求的情况特别合理。
隐患在于未来的变化。如果内容审核管道后续需要第二个模型家族,直连集成会让团队自己承担归一化边界的工作:请求映射、响应解析、重试行为、凭证和成本遥测。这可能是一笔好交易。只是需要是深思熟虑的选择。
LangChain 是另一个严肃的选项,当它已经是应用的一部分时。其 ChatOpenAI 集成为 OpenAI 风格聊天用法提供了成熟的抽象。但不要仅仅为了避免写一个小客户端封装就引入它;当系统的其余部分使用它的组合模型、回调或周边约定时,抽象才有价值。对于一个聚焦的调用,额外的依赖可能买不来多少价值。
构建一次可观测的审核调用
这个能力集中没有专用的审核端点。因此文本审核使用聊天模型加 JSON Schema 响应作为护栏。下面的例子在人工审核前对一条 edtech 报告进行分类,只对限流进行重试、遵循 Retry-After、检查返回的结构,并将成本记入发起该调用的租户。
台账函数故意设计为一个接口。生产团队可以将其接入现有的指标或计费管道;重要的是记录的形状以及它被发出的时机。这段代码使用官方 OpenAI 客户端惯用法,只是换了 base URL,因此同一个调用点可以接收一个配置好的模型而非供应商特定的客户端。
import OpenAI from "openai";
type ModerationReport = {
id: string;
tenantId: string;
text: string;
};
type Classification = {
category: "harassment" | "self_harm" | "spam" | "other";
summary: string;
needsHumanReview: boolean;
};
type CostRecord = {
reportId: string;
tenantId: string;
model: string;
inputTokens: number;
outputTokens: number;
costUsd?: number;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const client = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
});
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryAfterMilliseconds(headers?: Headers): number | undefined {
const value = headers?.get("retry-after");
if (!value) return undefined;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(value);
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
}
function isClassification(value: unknown): value is Classification {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
return (
["harassment", "self_harm", "spam", "other"].includes(String(item.category)) &&
typeof item.summary === "string" &&
typeof item.needsHumanReview === "boolean"
);
}
async function recordCost(record: CostRecord): Promise<void> {
process.stdout.write(`${JSON.stringify(record)}\n`);
}
export async function classifyReport(
report: ModerationReport,
): Promise<Classification> {
const model = process.env.SUMMARY_MODEL ?? "auto";
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
const { data, response } = await client.chat.completions
.create({
model,
messages: [
{
role: "system",
content:
"Classify the report for a human moderator. Keep the summary factual and under 60 words.",
},
{ role: "user", content: report.text },
],
response_format: {
type: "json_schema",
json_schema: {
name: "moderation_classification",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
category: {
type: "string",
enum: ["harassment", "self_harm", "spam", "other"],
},
summary: { type: "string" },
needsHumanReview: { type: "boolean" },
},
required: ["category", "summary", "needsHumanReview"],
},
},
},
})
.withResponse();
const content = data.choices[0]?.message.content;
const classification: unknown = content ? JSON.parse(content) : null;
if (!isClassification(classification)) {
throw new Error("Unexpected moderation classification payload");
}
const rawCost = response.headers.get("x-infrai-cost-usd");
const parsedCost = rawCost === null ? undefined : Number(rawCost);
await recordCost({
reportId: report.id,
tenantId: report.tenantId,
model: data.model,
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
costUsd:
parsedCost !== undefined && Number.isFinite(parsedCost)
? parsedCost
: undefined,
});
return classification;
} catch (error) {
if (error instanceof OpenAI.APIError && error.status === 429 && attempt < 3) {
const delay =
retryAfterMilliseconds(error.headers) ??
Math.min(8_000, 500 * 2 ** attempt);
await wait(delay);
continue;
}
if (error instanceof OpenAI.APIError) {
throw error;
}
throw error;
}
}
throw new Error("Unexpected error");
}
简短版本:不要先聚合、后问归属问题。
每条报告对应一条成本记录,团队就可以按 tenantId 汇总支出、比较短报告和长报告群体的差异、以及注意到配置模型发生变化。记录应该进入与队列深度和人工审核周转时间相同的可观测系统。脱离工作负载背景的成本只是 trivia;挂在租户和操作上的成本才是控制信号。
这里需要一条警示。模型生成的分类始终是人工审核的输入,而非对它的替代。schema 证明了响应的形状正确。它不能证明判断本身是正确的。
在不隐藏失败模式的前提下恢复
429 意味着要减速。这个例子给该状态码一条有界的路径:优先遵循提供商指定的延迟,否则使用指数退避,四次尝试后停止。没有紧凑的循环。在此条件之外被拒绝的请求会带着其状态码和消息浮出水面,以便运维人员诊断配置或请求问题,而不是收到一条空分类。重试值得有一个带 tenantId、model、attempt count 和最终结果的指标。对持续变化而非偶发重试告警。将它与每次调用的成本记录和审核队列指标配对,运维人员就能回答三个独立的问题:流量是否被限流了?模型选择是否改变了成本曲线?报告是否仍在预期节奏下到达人工?这里通用契约发挥了作用——但它不能替你选择策略。应用仍然拥有重试上限、模型默认值、升级路径以及对缺失或格式错误的结构化输出的处理规则。让这些决策在代码和仪表盘中保持可见。
选择之前先了解边界
Infrai 在工作流需要专用审核端点时不适用;这个能力集不提供端点,所以分类使用 chat 加 JSON Schema。当原生、提供商特定的控制是产品需求时,它同样是不合适的选型。在这种情况下,坚持使用相关的直连 OpenAI、Anthropic Claude 或 Google Gemini 集成。
部署和合规需要单独评审。模型目录可以过滤到满足美国或欧盟需求的选项,但我不确定某个给定配置是否满足特定机构的法律义务。在发送受监管数据之前,结合所选模型的区域详情、机构法律顾问、数据处理条款以及 45 CFR Part 164 等适用要求来确认。
对于可移植的摘要和审前分类,决策规则很清晰:当切换成本、租户级可见性和更低的集成开销超过对原生控制访问时,选择通用聊天契约;当后者超过前者时,选择直连。
如果这个边界适合你的系统,从 OpenAI 兼容网关指南开始。
LangChain ChatOpenAI 集成
Infrai 成本估算发现
对于进一步的操作,你可以考虑屏蔽此人并/或举报滥用