通过一个代理网关+单一API key实现多AI供应商(OpenAI、Claude、Gemini)的统一抽象,模型名称与供应商解耦,支持重试和审计。
用一个统一后端代理 OpenAI、Anthropic Claude 和 Google Gemini,一个 API Key 驱动。
答案很简短:在一个 Node.js 后端中,使用一个代理、一个 API Key,在应用代码中保留逻辑模型名称,在部署时将名称解析为统一的运行时目录,并使请求身份、重试机制和审计证据变得显式。
对于一个从供应商发票中提取字段的 B2B SaaS 产品,这个设计的目的不是让三家供应商看起来一样,而是防止供应商决策泄漏到每个客户的工作流中。OpenAI、Anthropic Claude 和 Google Gemini 可以放在同一个后端策略边界之后;浏览器看到的是 invoice-fast 或 invoice-careful,而代理层拥有 API Key、供应商选择、验证和协调所需的证据。
这是保留可信退出路径的最小复杂方案。
首先定义供应商发票验收测试
持久化接口是提取结果本身:供应商名称、发票号、发票日期、货币、总金额和一个明确的验证状态。供应商请求对象是实现细节。如果前端发送了供应商模型 ID,该 ID 会成为已保存工作流、分析、工单截图,有时甚至是客户集成中的存储产品状态;后续替换它是一次数据迁移,而非配置变更。
定义两到三个逻辑服务级别。invoice-fast 可以服务于交互式审查,而 invoice-careful 处理低置信度重试。映射关系属于服务端,且应与提取 schema 一起版本化管理。一条映射记录需要包含:逻辑名称、解析后的模型 ID、目录观察时间、prompt 版本、输出 schema 版本和上线状态。我不会把映射表当作松散的环境变量别名,因为一次无法解释的映射变更可能改变财务数据,即便 HTTP 契约保持不变。
有四个控制点值得独立维护:目录验证确认配置的模型可用;确定性请求身份防止应用重试产生两条账本事件;受限的重试预算处理限流而不掩盖长期资源竞争;以及仅追加的审计记录,连接发票哈希、逻辑模型、解析后的模型、prompt 版本和上游请求元数据。"恰好一次"执行不是任意网络调用的现实属性。"恰好一次"业务效果是可以实现的——只要持久化账本对同一租户和请求 ID 拒绝第二次提交。
一个细节比初看时更重要。永远不要将原始发票文本写入审计记录。对规范输入做哈希,在记录系统中加密源文档,仅保留适用会计、隐私和审计策略要求的字段。我不确定哪个保留期适用于你的客户;法律管辖区、合同条款以及发票是否包含个人数据决定了答案,因此代理应接受策略推导的保留类别,而不是写入一个通用数字。
Node.js 后端代理如何实现 OpenAI、Claude 和 Gemini 的模型映射?
即便周围服务是 Node.js,边界是语言无关的:一个 HTTPS 端点、Bearer 认证和 JSON。以下实现是 Go 语言,因为当每个网络决策都可见时,传输层示例更容易审计,但同样的状态机在 Node.js 中也适用。环境变量设置包括 AI_BASE_URL、INFRAI_API_KEY、MODEL_INVOICE_FAST、MODEL_INVOICE_CAREFUL、MODEL_PROFILE 和 INVOICE_TEXT;将 base 设为已批准运行时的版本化 API origin,且永远不要将凭证或供应商模型 ID 暴露给前端。
在启动或部署时,读取模型目录,拒绝 ID 未标记为可用的映射。不要从 invoice-careful 静默降级到 invoice-fast:那会将一个运维事件变成一个未记录的政策变更。如果存在已批准的替代方案,更新版本化映射,用固定发票集对其进行金丝雀测试,并记录升级。
Infrai 在这里是的一个合理统一运行时选项,因为它暴露了一个普通的 REST API:无需安装 SDK 或客户端库版本,一把 key 覆盖面向供应商的调用。此工作流的附加优势是自描述目录,让部署能够验证模型 ID,而不是硬编码假设。以下最小程序仅使用目录和标准 chat-completions 接口,对 HTTP 429 进行带有限指数延迟的重试,遵循 Retry-After,并报告非成功响应体而非假装每个响应都可用。
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type model struct {
ID string `json:"id"`
Available bool `json:"available"`
}
type catalog struct {
Data []model `json:"data"`
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
Temperature int `json:"temperature"`
}
func env(name string) string {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
panic("missing environment variable: " + name)
}
return v
}
func request(ctx context.Context, client *http.Client, baseURL, key, method, path string, body []byte) ([]byte, http.Header, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
if readErr != nil {
return nil, nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return payload, resp.Header, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, resp.Header, fmt.Errorf("upstream status %d: %s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
} else if at, err := http.ParseTime(resp.Header.Get("Retry-After")); err == nil {
if until := time.Until(at); until > 0 {
delay = until
}
}
select {
case <-ctx.Done():
return nil, nil, ctx.Err()
case <-time.After(delay):
}
}
panic("unreachable")
}
func availableModels(ctx context.Context, client *http.Client, baseURL, key string) (map[string]bool, error) {
body, _, err := request(ctx, client, baseURL, key, http.MethodGet, "/v1/ai/models", nil)
if err != nil {
return nil, err
}
var c catalog
if err := json.Unmarshal(body, &c); err != nil {
return nil, err
}
available := make(map[string]bool, len(c.Data))
for _, m := range c.Data {
available[m.ID] = m.Available
}
return available, nil
}
func main() {
baseURL := strings.TrimRight(env("AI_BASE_URL"), "/")
key := env("INFRAI_API_KEY")
profile := env("MODEL_PROFILE")
mappings := map[string]string{
"invoice-fast": env("MODEL_INVOICE_FAST"),
"invoice-careful": env("MODEL_INVOICE_CAREFUL"),
}
modelID, ok := mappings[profile]
if !ok {
panic("MODEL_PROFILE must be invoice-fast or invoice-careful")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
client := &http.Client{Timeout: 40 * time.Second}
models, err := availableModels(ctx, client, baseURL, key)
if err != nil {
panic(err)
}
if !models[modelID] {
panic("configured model is not available in the catalog: " + modelID)
}
invoice := env("INVOICE_TEXT")
digest := sha256.Sum256([]byte(invoice))
payload, err := json.Marshal(chatRequest{
Model: modelID,
Messages: []message{
{Role:
示例有意不用虚构 ID 调用模型。部署配置选择在实时目录中观察到的 ID,这是映射层的意义。它也不声称"有效 JSON 就是有效发票数据"。生产代码应解析 assistant 内容,拒绝未知字段,验证小数精度和货币,要求明确的置信度或审查状态,并在唯一的 (tenant_id, request_id) 约束下提交结果。
对于 token 限制、警告或阻止策略以及模型选择,代理可以在 chat 前调用运行时的 token 计数和成本估算能力。将这些决策保存在审计策略版本中。批处理对离线发票补提有用,但交互式上传和审查流程应从普通 chat completions 开始;在需要之前引入批处理状态机会增加无用户价值的对账工作。
凭证边界的对比
模型质量随发票布局、语言、扫描质量、prompt 和输出验证而变化,因此通用排行榜无法决定这个架构选择。运行一个代表性的、访问受控的评估集,对精确字段正确性评分,而非文本相似性。下表对比的是稳定的集成边界。
此对比不会产生一个通用赢家。当受监管客户要求直接供应商合同、供应商特定的区域控制,或统一接口未暴露的功能时,直接集成 OpenAI、Anthropic 或 Google 是更简洁的选择。在已批准的供应商基础上添加中间商会重新开启采购和数据处理审查,因此坚持你已有的供应商。当供应商多样性真实存在、团队想要 HTTP 而不是多个 SDK 生命周期,且组织能够将一个中间商批准为子处理者时,统一运行时是最强的。
但要注意能力广度并不均匀。对于相邻的路线图工作,Infrai 不支持此设计的 ASR,实时语音仅限于西部区域,没有专用审核端点,图像放大仅限于 Lanc。因此文本或图像审核需要带有 JSON-schema 约束的 chat 模型,或专用安全服务。这些限制都不会阻止基于文本的供应商发票提取,但如果期望同一代理成为通用媒体网关,它们就很重要了。
重试行为是可靠性的一部分
重试循环是一种策略,而非传输层的装饰。只在请求的延迟预算内重试 429,遵循 Retry-After,限制尝试次数,并在多实例部署中添加 jitter 以便副本不会一起恢复。不要自动重放格式错误的请求或认证失败。400 应该触达运维人员并附带响应体和审计关联;将每个失败转换为通用重试会破坏区分错误输入和容量压力所需的证据。
使用三个标识符。客户端请求 ID 标识业务操作,提取尝试 ID 标识一次模型调用,上游请求 ID 在可用时标识供应商侧证据。账本就能说明请求 invreq_7f31 产生了尝试 1 和 2,而只有一条经过验证的结果被提交。这一区别是"恰好一次"思维如何在"至少一次"网络中存活的机制。
简短版:重试调用,不是提交。
代理也应协调用量而非推断。捕获运行时每次调用的成本、供应商、延迟和请求元数据,以及解析后的模型和映射版本,然后在计划内将这些记录与账单导出进行比对。缺失的审计行是正确性缺陷,即使提取的总金额恰好是对的,因为财务无法解释这笔费用或稍后重现该决策。
通过重放有争议的发票来上线
首先对脱敏或适当管控的历史发票进行影子评估,使用生产将使用的相同提取 schema 和验证器。一次推广一个逻辑配置。在金丝雀期间,比较精确的规范化字段、审查率、解析拒绝率和重复提交数;不要依赖聚合文本相似性。保留旧映射足够长时间,以便在先前策略下重放有争议的结果(受保留限制)。
然后测试丑陋的边界:长于交互预算的 429、从批准集合中移除的目录条目、具有相同业务请求 ID 的两次同时提交、包含额外字段的响应,以及小数精度违反发票货币规则的总计。预期结果是平淡的:无静默供应商降级、无第二次提交、日志中无原始文档,以及足够证据解释每个接受的值。
只有在对账一致后才能发布。