在应用层构建统一的多模型 API 网关:认证、路由、结构化输出校验、用量统计、重试策略全部自持,切换 provider 不影响产品数据库 schema。
核心权衡在于控制权,而非模型数量:小团队需要一个稳定的代码审查契约,它能在提供商更换时存活,而不会把每个模型都压成同样的功能集。简短回答:把认证、路由、结构化输出验证、用量统计和重试策略放在应用所有的多模型 API 背后;然后把提供商切换做成 CI 的一部分,在设计可移植性之前调用。
这比通用的 AI 网关更窄,是故意的。Pull request 事件提供 diff 和仓库策略。运行时选择一个符合条件的模型,请求结构化发现,验证响应,并向 B2B SaaS 应用返回一个提供商中立的结果。应用暴露一个内部密钥,而网关拥有上游凭证。OpenAI、Claude 和 Gemini 是边缘的部署选项,不是泄漏进产品数据库的类型。
那个边界买到了杠杆。它不能让提供商变得可互换。
小团队如何做出实用的多模型 API 选择并避免供应商锁定?
从产品必须信任的产物开始:一个 finding。对于代码审查,这意味着文件路径、行号、严重程度、简洁解释和稳定的规则标识符。模型消息、工具调用包装器、完成原因和 token 字段属于适配器,因为它们会变化,可以独立于产品进行变更。
选择测试很生硬:相同的保存的审查用例能通过第二个适配器运行,产生有效的 finding,并保留产品的审计字段而不改变调用者吗?如果切换需要数据库迁移、UI 分支或在业务逻辑中到处编辑,团队有的只是一个模型菜单而不是可移植性。
在可选的适配器方法背后保持原生能力可用。最低共母抽象老化很快。例如,更丰富的模型特定审查模式可以作为显式能力暴露,而基线审查方法保持可移植。调用者可以有意识地选择更丰富的路径,而不是在几个月后才发现意外的依赖。
这里一个密钥是有用的——一个应用面向的凭证减少了跨工作线程和预览环境的密钥分发。但托管的多提供商服务持有所有上游访问会产生一个新的依赖。自有网关保持契约和路由策略可迁移;托管网关可以减少运营工作。问题是它的请求格式、日志、保留规则、导出支持和故障语义成为退出计划的一部分。小团队应该慎重选择这种权衡。
用 TypeScript 实现一个提供商无关的代码审查是什么样的?
这个示例运行没有网络调用。两个适配器代表上游实现,所以重要的部分是可见的:产品发送一个请求结构,网关验证每个 finding,存储的记录保留足够的来源以重现路由决策。真正的适配器可以将这个契约转换为每个提供商的文档化请求和响应格式,而不改变 reviewChange。
type Severity = "low" | "medium" | "high";
type Finding = {
ruleId: string;
path: string;
line: number;
severity: Severity;
message: string;
};
type ReviewRequest = {
repository: string;
commitSha: string;
diff: string;
policy: string[];
};
type ReviewResult = {
schemaVersion: 1;
route: string;
model: string;
findings: Finding[];
usage: {
inputUnits?: number;
outputUnits?: number;
};
};
type AdapterResult = {
model: string;
findings: unknown;
usage: ReviewResult["usage"];
};
interface ReviewAdapter {
readonly route: string;
review(input: ReviewRequest): Promise<AdapterResult>;
}
function parseFindings(value: unknown): Finding[] {
if (!Array.isArray(value)) throw new Error("findings must be an array");
return value.map((item, index) => {
if (typeof item !== "object" || item === null) {
throw new Error(`finding ${index} must be an object`);
}
const record = item as Record<string, unknown>;
const validSeverity = ["low", "medium", "high"].includes(
String(record.severity),
);
if (
typeof record.ruleId !== "string" ||
typeof record.path !== "string" ||
!Number.isInteger(record.line) ||
!validSeverity ||
typeof record.message !== "string"
) {
throw new Error(`finding ${index} does not match schema version 1`);
}
return record as Finding;
});
}
async function reviewChange(
apiKey: string,
input: ReviewRequest,
adapter: ReviewAdapter,
): Promise<ReviewResult> {
if (apiKey.length < 20) throw new Error("invalid gateway credential");
const raw = await adapter.review(input);
return {
schemaVersion: 1,
route: adapter.route,
model: raw.model,
findings: parseFindings(raw.findings),
usage: raw.usage,
};
}
function fixtureAdapter(route: string, model: string): ReviewAdapter {
return {
route,
async review(input) {
const changedConfig = input.diff.includes("timeoutMs: 0");
return {
model,
findings: changedConfig
? [{
ruleId: "CONFIG_TIMEOUT",
path: "src/config.ts",
line: 18,
severity: "high",
message: "A zero timeout disables the request deadline.",
}]
: [],
usage: {},
};
},
};
}
const request: ReviewRequest = {
repository: "acme/billing",
commitSha: "a84f2c1",
diff: "+ timeoutMs: 0",
policy: ["Every outbound request must have a positive deadline."],
};
const primary = fixtureAdapter("primary", "model-a");
const candidate = fixtureAdapter("candidate", "model-b");
const key = "local-development-key-0001";
const [a, b] = await Promise.all([
reviewChange(key, request, primary),
reviewChange(key, request, candidate),
]);
console.log(JSON.stringify({ primary: a, candidate: b }, null, 2));
这个边界在格式错误的输出到达 UI 或数据库之前就拒绝它。它还分别记录路由和模型。这些字段在评估改变、客户 dispute 一个 finding 或路由策略将类似的 diff 发送到不同目的地时很重要。fixture 是有意做得无聊的。用保存的、编辑过的 diff 和预期属性替换它,而不是一个仅仅确认适配器正确复制了字段的提供商形状的 mock。
不要将用量规范化为虚构的通用 token 计数。Tokenization 可以因编码和模型而异;例如,tiktoken 是 OpenAI 模型的官方 BPE tokenizer 库,并暴露模型特定的编码。将上游用量字段保存在适配器拥有的遥测中,然后只映射产品明确定义的核算单位。否则,一个干净的仪表板可能掩盖无效的成本比较。
在 finding 进入生产环境之前 fail closed
自由形式的 prose 可以做一个快速的演示,却构建出一个糟糕的审查系统。困难的故障位于有效 JSON 和有用的 finding 之间:一行可以超出更改的 hunk,两个 finding 可以描述同一个问题,严重程度可以漂移,或者消息可以引用不应离开审查边界的源代码内容。Schema 验证捕获语法和类型。语义验证必须检查仓库上下文。
因此,一个实用的管道有两个关卡。第一个只接受声明的 schema 版本,并拒绝未知的枚举值、缺失的路径、非整数行和 oversized 字段。第二个检查路径是否存在于提交的更改中,该行是否符合审查条件,规则标识符是否被允许,以及重复项是否在确定性键下折叠。这段逻辑属于每个适配器之后,一个测试套件可以测试它。对 fallback 保持保守。在传输失败后重试另一个提供商可能是合理的,如果请求是幂等的且数据策略允许该目的地。因为第一个提供商返回零 finding 而重试会改变产品语义:"未发现问题"是一个有效的答案,而不是失败的证明。静默的基于质量的 fallback 也会使工作加倍,并使延迟和核算难以解释。隐私可以进一步缩小路由池。源 diff 可能包含凭证、客户数据或受监管信息,即使周围的产品不是作为合规产品销售的。Redaction 应在路由之前发生,目标资格应作为策略数据而不是埋在某个适配器中的 if 语句。如果受保护的健康信息在范围内,45 CFR Part 164 下的适用行政、物理和技术保障需要真正的合规审查。API 抽象不能回答那个法律或操作问题。这个部分很容易被低估。
将提供商变更转化为普通发布
将每个提供商或模型变更视为有风险的应用程序发布,包括一个产物、一个关卡、一个金丝雀和一个回滚目标。针对从审查作业构建的版本化语料库运行候选适配器:小 diff、删除的文件、重命名的路径、生成的代码、策略冲突、注释中的 prompt 注入文本,以及有意干净的更改。评分产品可以捍卫的属性。有用的度量包括 schema 接受度、人工裁审后的发现精度、变更行的有效性、重复率、端到端延迟,以及所选路由报告的计费使用量。然后在不对外发布那些 finding 的情况下,对有限部分的实时、策略符合条件的流量进行 shadow。比较验证失败和裁审后的有用性后再推广;不要仅凭模糊的信念认为更新颖的模型一定更好来路由。
我不确定静态的"最佳模型"表在用于创建它的确切 prompt、模型和语料库之外仍然有用。你的 mileage 可能会不同。可重复的回放工具比另一个排名更好地解决这种不确定性,因为它衡量的是团队实际将交付的工作量。
使用这样的发布记录,用你自己的测试填充值,而不是借来的基准:
推荐的网关模式在应用程序从根本上依赖一个提供商的独特能力且该能力驱动产品价值时是不合适的。在那种情况下,直接使用原生 API,将它隔离在一个模块中,并接受书面形式的依赖。对于没有存储输出且没有故障转移需求的短期内部脚本来说,这也是一个可疑的投资。可移植性有携带成本:适配器、回放 fixture、监控和定期测试都需要所有权。
在提供商切换期间,生产路由能保持无聊吗?
在部署之前,固定内部 schema 版本,限制 diff 和输出大小,定义时间预算,并决定哪些失败是可重试的。记录请求标识符、租户策略、选择的路由、模型标识符、schema 版本、验证结果、延迟和提供商报告的使用量,默认情况下不记录原始源。将凭证保留在服务器端,并将应用密钥限定在审查操作范围内。
然后排练切换。通过候选适配器发送相同的 shadow 语料库,比较裁审结果,审查数据资格,并通过配置而不是发布来推广路由。回滚应使用相同的控制。实际标准很简单:值班人员可以解释哪个路由处理了审查以及为什么,而产品代码保持对提供商包装不知情。
最后,每当 prompt、schema、路由规则、适配器或所选模型改变时,重新运行语料库。将这些输入一起版本化。小团队不需要一个宏大的平台;它需要一个狭窄的边界、边界持有的证据,以及关于原生是更好的工程选择的案例的诚实说明。
https://github.com/openai/tiktoken
https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164