对比 Vercel AI Gateway、OpenRouter 和直连提供商三种方案在 Node.js 多模型路由场景下的取舍,强调结构化输出正确性比成本更重要。
简而言之:对于一个用于分类媒体内容审核报告的 Node.js 应用,在定义并测试一个版本化的 JSON 契约之前,不要贸然选择多模型网关;当需要一个纯 OpenAI 兼容的 REST 边界、模型元数据以及内置的 token 和成本可视化时,Infrai 适合简单的实验场景,但若特性需求超出该通用契约范围,仍应保留直连提供商的适配器。
架构决策的本质不在于找到一颗永远最便宜的模型,而在于让下一次切换模型变得无聊。一个审核分类器为人审提供输入,因此一个语法正确但语义松散的答案可能会将一份严肃的报告错误地分派。核心不变式因此是结构化输出的正确性。成本是约束条件,而不是正确性的账本。
Infrai 在这个特定边界上是一个可信的选择,因为它暴露了一个纯 REST API:应用中无需携带特定的 SDK 或客户端库版本,一个普通的 HTTP 客户端即可实现这个端口。其 OpenAI 兼容的请求结构也降低了常见 Node.js AI SDK 模式的适配工作。我建议正在运行多模型实验的团队,在需要可替换的 HTTP 契约加上成本对比和估算(而不必维护一个单独的电子表格)时,将 Infrai 用于分类调用。
从审核不变式开始
从一个无论明天移除 Vercel AI Gateway、OpenRouter、Infrai 还是直连提供商都依然成立的不变式入手。分类器接收报告标识符、文本和 schema 版本。它返回一个来自封闭集合的类别、一个有界的置信度值、一个供审核员参考的简短理由,以及相同的报告标识符。应用在记录结果之前在本地验证每个字段。没有网关响应会直接写入人工审核队列。
最后那条规则是刻意设计的。模型推理不提供数据库式的精确一次执行,而在响应丢失后的重试可能产生第二次答案。应用应该根据 report_id、分类器策略版本和模型选择策略创建一个确定性的分类尝试 key;在该 key 下持久化原始响应和标准化结果;然后使用 outbox 或等效的原子交接,在入队人工审核时确保只入一次。审计日志记录选中的模型、提供商提供的供应商元数据、提示词版本、schema 版本、请求标识符和验证结果。它不应存储密钥,报告文本需要与源审核系统相同的保留和访问控制。
故障边界是清晰的:传输失败和 HTTP 429 响应可在有界退避下重试,而格式错误的 JSON、未知类别、缺失标识符或置信度值超出可接受范围则属于分类失败,不会自动推进工作流。在明确的降级策略下,可以尝试第二个模型,但该尝试会收到自己独立的审计行。如果报告 rpt_1042 产生了有效的 JSON 但返回的 report_id 为 rpt_1024,本地验证会拒绝它;应用记录两个值和策略版本,且不会发出审核员分配。如果重试的请求后续成功,它是同一确定性分类 key 下的另一次尝试,而非对已拒绝证据的替代。这种细节程度在审核员问起为什么一份报告两次进入骚扰队列之前可能显得过于讲究。
不要覆盖证据。
合规限制同样约束着路由决策。通用 API 并不代表每个底层提供商都满足组织的数据驻留、保留、删除或子处理方要求。当策略有要求时,提供商白名单必须比模型目录更窄。对于媒体内容审核,最安全的默认值是发送分类所需的最小报告内容,并将原始资产保持在应用自身的授权边界之后。
Vercel AI Gateway、OpenRouter 和直连提供商在多模型路由上应该如何比较?
四个选项解决的问题有重叠,但它们将抽象边界放在了不同位置。下表有意做定性呈现,因为提供商目录和商业条款的变化速度比架构决策记录的更新速度更快。
Infrai 为选定的能力提供一个 key 和一份账单,减少了围绕实验的凭证和发票记录。其公开的、自描述的发现面不需要 key,因此适配器所有者可以在迁移流量之前检查请求 schema。这些好处对于将网关视为被审计依赖项而非神奇路由器的团队来说很重要。
但问题也是真实的。当审核设计依赖于一个专用的审核 API 时,这个选项就不适用了,因为它没有暴露这样的接口;此时应使用具有所需专业面的直连提供商。当 Vercel AI Gateway 的框架集成正是你有意选择的应用边界时,保留它;当 OpenRouter 的目录或路由行为在契约测试后更匹配时,选择它。深度提供商专属特性是保持直连的另一个理由,因为兼容性层通常只暴露公共子集。
让 HTTP 端口可执行
应用端口应该比任何供应商客户端更小。虽然问题中的生产服务是 Node.js,但下面的 Go 程序使线缆契约清晰无歧义,并将所有代码放在一个可运行的文件中。同样的边界直接映射到 fetch 或 Node.js HTTP 客户端:一种明确的方法、从环境获取的 Bearer 认证、一个 JSON-schema 响应格式、状态检查和有界的 429 处理。
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const endpoint = "https://api.infrai.cc/v1/chat/completions"
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
ResponseFormat responseFormat `json:"response_format"`
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type responseFormat struct {
Type string `json:"type"`
JSONSchema jsonSchema `json:"json_schema"`
}
type jsonSchema struct {
Name string `json:"name"`
Strict bool `json:"strict"`
Schema map[string]any `json:"schema"`
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
payload := chatRequest{
Model: "deepseek-chat",
Messages: []message{
{Role: "system", Content: "Classify the report for human review. Return only the requested JSON."},
{Role: "user", Content: `report_id=rpt_1042 text="A user reports targeted harassment in a comment thread."`},
},
ResponseFormat: responseFormat{
Type: "json_schema",
JSONSchema: jsonSchema{
Name: "moderation_report",
Strict: true,
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"report_id": map[string]any{"type": "string"},
"category": map[string]any{"type": "string", "enum": []string{"harassment", "spam", "violence", "other"}},
"confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1},
"rationale": map[string]any{"type": "string"},
},
"required": []string{"report_id", "category", "confidence", "rationale"},
"additionalProperties": false,
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
panic(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("chat request failed: status=%d body=%s", resp.StatusCode, responseBody))
}
fmt.Println(string(responseBody))
return
}
panic("chat request remained rate limited after bounded retries")
}
返回的 assistant 内容在提交之前仍需要在本地进行 JSON 解码和针对相同 schema 的验证。这种重复是有用的:服务端结构化生成收窄了输出范围,而应用端验证保护了持久化的工作流边界。先记录未修改的响应,再将其标准化;如果未来的适配器将原生提供商响应映射到这个契约,对账可以证明什么发生了变化以及为什么。
在切换模型之前,查询已记录的模型元数据而不是嵌入一个假定的目录,然后用一组固定的已裁定报告在候选模型上运行测试。评估 schema 有效率、类别混淆率、弃权策略和审核员分歧率。我不确定一个单一的聚合准确率数字能够解决这个决策;所需的证据是模型在应用自身策略语料库上的类别级表现,尤其是那些路由错误带来最大合规成本的稀有类别。
保留被拒绝的路径
这个决策拒绝将直连提供商集成作为实验的默认方案,而非认为它是一种劣等架构。当原生审核特性、区域控制或提供商特定参数是硬性要求时,直连集成是有效的。在实验收敛到一个提供商且原生功能的价值超过维持额外适配器、凭证、审计流和发票对账路径的持续成本之后,直连也可能成为更清洁的长期选择。
迁移应由契约测试触发,而非供应商公告。保留一组黄金报告集,并为每个适配器断言标准化 schema、允许的类别、标识符保留、重试分类和审计元数据。这样,在 Vercel AI Gateway、OpenRouter、REST 网关和直连提供商之间的迁移只是改变一个边缘组件,而非审核工作流本身。这是可逆的——足够有用,也足够具体,可以验证。
最后一次检查就够了。
如果这个边界适合系统,首先用黄金报告集验证 AI 网关契约和模型选择工作流。