构建私有知识库时应先确保 Schema 校验通过,再比较模型覆盖率和计费透明度,而非只看 Token 价格。
短答案:当私有知识库必须返回有效的结构化答案时,多模型网关只有在通过与直接调用provider相同的schema验证测试后才能入选;然后比较的是已采纳答案的成本、模型覆盖范围和账单透明度,而不是表面的token费率。
运营约束改变了排名逻辑。一次廉价的回复如果验证失败、遗漏了引用、或编造了文档ID,那它就不是廉价答案,而是重试、升级,或是不良数据进入应用的入口。
对于 Node.js 开发者工具,我建议先把正确性变得可观测,再讨论路由问题。实际的选择是:网关适合快速模型实验和集中式用量数据,直接 API 则适用于应用依赖于provider特定控制的场景。别从logo墙里挑。
之前的模型很常见:prompt进去,看起来像JSON的文本出来,成功的HTTP状态码让绿色计数器加一。Token总量出现在后续账单上。团队比较表面费率、换一个模型名称,然后希望应用行为保持稳定。
之后的模型有四层:传输成功、JSON解析、schema验证、领域验证。想象请求穿过四道门。第一道问调用是否完成。第二道问body是否能解析。第三道检查精确契约。第四道检查每个引用的documentId是否存在于检索集合中。来看一个合理的拒绝场景:服务返回200,body能解析,每个字段都符合schema,但主张二引用了kb-policy-91,而检索只提供了kb-auth-17和kb-retry-04。路由通过了三道门,第四道失败。把这个事件计为成功请求会奖励错误行为;把它计为通用模型失败则丢弃了传输和格式化都正常的线索。将其记录为领域拒绝,保留路由和模型标签,让评估回放显示这个失误是孤立的还是系统性的。只有通过全部四道门的答案才能进入用于路由和计费决策的已采纳集合。
最后这个区别很重要。一次响应可能在语法上完美无缺,但仍然指向了错误的私有文档。如下例所示,领域规则故意收窄:每个主张至少需要一个引用,每个置信度分数保持在0到1之间,每个引用必须匹配提供给模型的ID之一。应用可以解释和告警这些失败,而不必假装所有格式不良的输出都是同一类事件。
小计数器就足够起步了:requests_total、transport_errors_total、schema_rejections_total、domain_rejections_total、accepted_answers_total、accepted_input_tokens_total。按路由和模型分割,但要注意request ID等高基数字段。这提供了清晰的分子分母。也能防止一个有很多被拒绝答案的provider看起来效率虚高。
计数已采纳答案。
这个 TypeScript 示例通过 OpenAI 兼容客户端发送问题和检索到的文档ID,要求JSON Schema输出,在流程中再次验证它,并发出一个紧凑的观察事件。它使用环境变量存储key。遇到限流时会以指数延迟重试并遵守Retry-After;其他HTTP失败通过客户端立即暴露。
import OpenAI from "openai";
import { z } from "zod";
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseURL) throw new Error("INFRAI_BASE_URL is required");
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 0,
});
const Answer = z.object({
answer: z.string().min(1),
claims: z.array(
z.object({
text: z.string().min(1),
confidence: z.number().min(0).max(1),
documentIds: z.array(z.string()).min(1),
}),
),
});
const responseSchema = {
name: "knowledge_base_answer",
strict: true,
schema: {
type: "object",
additionalProperties: false,
required: ["answer", "claims"],
properties: {
answer: { type: "string", minLength: 1 },
claims: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["text", "confidence", "documentIds"],
properties: {
text: { type: "string", minLength: 1 },
confidence: { type: "number", minimum: 0, maximum: 1 },
documentIds: {
type: "array",
minItems: 1,
items: { type: "string" },
},
},
},
},
},
},
} as const;
const documents = [
{ id: "kb-auth-17", text: "API keys must be loaded from environment variables." },
{ id: "kb-retry-04", text: "Rate-limited requests should honor Retry-After." },
];
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function createAnswer(attempt = 0): Promise<OpenAI.Chat.Completions.ChatCompletion> {
try {
return await client.chat.completions.create({
model: "deepseek-v4-flash",
temperature: 0,
messages: [
{
role: "system",
content: "Answer only from the supplied documents and cite every claim by document ID.",
},
{
role: "user",
content: JSON.stringify({ question: "How should API keys and rate limits be handled?", documents }),
},
],
response_format: { type: "json_schema", json_schema: responseSchema },
});
} catch (error) {
if (error instanceof OpenAI.RateLimitError && attempt < 3) {
const retryAfter = Number(error.headers?.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
return createAnswer(attempt + 1);
}
throw error;
}
}
const startedAt = Date.now();
const completion = await createAnswer();
const content = completion.choices[0]?.message.content;
if (!content) throw new Error("The model returned no answer content");
const answer = Answer.parse(JSON.parse(content));
const allowedIds = new Set(documents.map((document) => document.id));
for (const claim of answer.claims) {
for (const documentId of claim.documentIds) {
if (!allowedIds.has(documentId)) {
throw new Error(`Unknown citation: ${documentId}`);
}
}
}
console.log(JSON.stringify({
event: "knowledge_answer_accepted",
model: completion.model,
latencyMs: Date.now() - startedAt,
promptTokens: completion.usage?.prompt_tokens ?? null,
completionTokens: completion.usage?.completion_tokens ?? null,
claimCount: answer.claims.length,
}));
一个微妙的点:200只代表传输成功。如果Answer.parse拒绝了payload,将其与未知引用分类到不同类别。如果仪表盘把两者合并为"LLM error",就无法判断应该调整schema指令、检索边界还是provider选择。只有在知识库安全策略允许的情况下,才将原始响应保存在受保护的诊断存储中;日志可能泄露私有上下文,这正是OWASP的LLM指南要求团队检查的那种边界。
这个代码片段在客户端构造函数之上故意保持vendor中性。在评估时使用固定模型运行。自动路由在基线稳定后才有价值;过早启用它会让一次失败的验收测试更难归因,因为模型行为和路由选择都可能变化。
使用从私有知识库中提取的一个固定评估集,并移除secret和个人数据。向每个候选者发送相同的检索段落、问题、schema、temperature和token上限。记录原始token用量,但按已采纳答案及这些已采纳答案所附的成本来排名候选者。这是一个应用测试,不是通用模型基准。
没有工作负载就没有诚实的"最便宜"赢家。输入输出比不同,被拒绝的输出有成本,路由策略改变混合比例。你的 mileage 可能不同——尤其是当问题需要长检索段落但答案很短时。公平的比较是在相同样本上运行相同的验收测试,然后用网关或provider记录进行账单对账。
统一方案在这里有一个具体的运营优势:一个凭证和一张账单避免了key分散和跨独立仪表盘的月末对账。它的纯REST表面也保持了测试架与vendor SDK的独立性。这很有用,但不会抹平表格中显示的兼容性权衡。
第一个异议是provider深度。通用API不适用于产品依赖于兼容性契约未暴露的原生功能的场景。在这种情况下坚持使用直接provider,将其原生响应保留在遥测模型中,并接受额外的凭证和账单工作。我不确定兼容性层能否保留每个未来provider特定的控制;迁移前检查精确的请求表面是唯一站得住脚的回答。
第二个异议是治理。网关减少了集成蔓延,但也在数据路径上增加了另一个系统。团队在发送私有段落前必须检查保留策略、地域处理、访问控制和合同要求。对于严格监管的知识库,直接访问可能是更清晰的边界,即使运营变得不那么方便。
能力也有边界。不要仅仅因为相邻AI API共享一个基础URL就选择这条路来处理需要广泛地域可用性的实时语音会话、专用审核端点或转录。对于本文的文本问答任务,测试范围仍然更窄:候选者能否返回已采纳的结构化答案、暴露足够的token和成本证据以供对账、并让团队在不改写评估测试的情况下切换模型?
这些异议是决策的特征,不是脚注。获胜的路由是那个已采纳答案行为能够经受回放、其遥测能解释拒绝原因、其运营边界是团队能够捍卫的路由。
https://vercel.com/docs/ai-gateway
https://platform.openai.com/docs/api-reference
https://docs.anthropic.com/en/api/overview
https://owasp.org/www-project-top-10-for-large-language-model-applications/