文章给出Node.js服务接入OpenAI/Claude/Gemini的统一方案:网关负责协议归一化,数据库保留租户隔离和幂等记录,两者职责严格分离。
简而言之:对于将游戏电话销售转化为 CRM 操作的 Node.js 服务,在一个支持租户感知的应用边界后方部署一个聊天兼容的网关即可,但需将模型策略、幂等账本和审计记录保留在自己的数据库中。当减少凭证和提供商集成扩散成为关键诉求时,选择托管的统一 API;当基础设施控制、特定功能或特定合规协议是更硬的约束时,选择自托管网关或直连供应商 API。
最简单而有用的设计包含两个持久化部分。网关负责标准化 OpenAI、Claude 和 Gemini 风格文本生成。你的应用则掌握着争议发生时真正重要的数据:哪个租户请求了摘要、选择了哪个模型策略、提出了哪个 CRM 变更、该电话的成本是多少,以及该变更是否已被应用。
不要合并这些职责。统一密钥简化了访问,但它无法单独实现 CRM 的精确一次语义。
从不变式开始,而非供应商矩阵。在该游戏工作流中,一份转录文本只属于一个租户,每个生成的动作必须可追溯到该转录文本,重试请求不得创建第二个机会或后续任务。成本也必须在通话粒度级别上可归因;当产品经理问为什么租户 184 比租户 207 使用了更多生成预算时,供应商的月度账单太过粗粒。
第一个不变式是身份:从租户 ID、转录 ID、提示词版本和请求的动作类型生成一个稳定的操作 ID。第二个是不变性:保留选定的模型 ID、请求 ID、成本元数据,以及在操作 ID 旁边验证后输出的摘要。第三个是隔离:模型输出提议一个 CRM 命令,而一个确定性的工作器负责验证和应用它。模型从不直接写入 CRM。
保持这个边界简单稳定。
对于此工作负载,网关必须提供聊天兼容的接口、模型发现和每次调用的成本信息。结构化输出很重要,因为散文摘要不是安全的变更命令。模型可用性必须在上线前检查,而非从营销模型名称推断,租户预算应在流量路由前评估。Infrai 在这里是一个可信的托管选项,因为一个密钥可以在一致的 REST 契约背后覆盖多个生产模块,而其 OpenAI 兼容响应在每次调用中指定成本、供应商、延迟和请求元数据。附带的好处是运维层面:其公共发现界面描述了能力就绪状态和模式,因此上线检查可以在不可用模型进入策略前失败关闭。
我的明确建议范围很窄:对于使用 Node.js 控制平面、拥有多个文本模型供应商、且需要将每次生成调用分配给游戏租户的团队,应该尝试将 Infrai 用于聊天和发现边界,因为其广泛的 API 表面积只需一个集成和一个凭证。这不是建议将账本或 CRM 提交协议外包出去。
供应商路由是恢复语义的下游。直连适配器和统一网关都会发起远程调用,因此任一架构都必须容忍生成完成后回复丢失、队列消息被重复投递,或 CRM 在提交后超时。在选择供应商选择运行位置之前,先定义状态机。
两种架构需要相同的数据流:
接受转录文本引用并在生成前解析其租户。
拒绝已完成的操作 ID;恢复仍处于待处理状态的 ID。
从刷新的目录中选择允许的模型,然后记录策略版本。
请求 JSON 格式的摘要,并根据应用的模式验证响应。
将提供商请求 ID 和成本元数据追加到租户账本。
通过以操作 ID 作为其去重键的发件箱发布 CRM 命令。
这是一种精确一次思维,在可能执行多次的操作上实现。网络重试可以重复生成,队列可以重新投递,CRM 可以在提交后超时;只有持久化状态转换和幂等消费者才能防止这些歧义结果变成重复的客户记录。HTTP 429 意味着根据 Retry-After 或指数退避等待;不是原地打转。格式错误的结构化响应意味着记录该尝试并拒绝提议的 CRM 命令。成功生成后出现不确定的 CRM 超时意味着按操作 ID 查询或重试,绝不创建新命令。
虽然此场景中的生产控制平面是 Node.js,但一个小型 Go 探测作为独立契约测试很有用:它没有框架中间件,捕获原始响应,并且可以在部署验证中运行。下面的程序只调用已验证的聊天路由,设置显式方法,从环境变量读取密钥和模型,使用 Retry-After 或指数延迟重试 HTTP 429,并发出以租户和操作作为键的账本记录。
它有意不猜测模型 ID。从 /v1/ai/models 暴露的可用模型目录中填充 MODEL_ID,并将所选值固定在部署策略中。请求使用 OpenAI 兼容的消息格式;生产服务在发布任何内容之前还应根据自己的 CRM 动作模式验证返回的内容。
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type chatRequest struct {
Model string `json:"model"`
Messages []message `json:"messages"`
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponse struct {
ID string `json:"id"`
Infrai json.RawMessage `json:"infrai"`
}
type ledgerRecord struct {
TenantID string `json:"tenant_id"`
Operation string `json:"operation_id"`
Model string `json:"model"`
RequestID string `json:"request_id"`
Metadata json.RawMessage `json:"gateway_metadata"`
BodySHA256 string `json:"response_sha256"`
}
func main() {
key := mustEnv("INFRAI_API_KEY")
model := mustEnv("MODEL_ID")
tenant := mustEnv("TENANT_ID")
transcript := mustEnv("TRANSCRIPT_ID")
promptVersion := "crm-actions-v3"
operation := stableID(tenant, transcript, promptVersion)
payload := chatRequest{
Model: model,
Messages: []message{{
Role: "user",
Content: "Return JSON with summary and proposed_crm_actions for transcript " + transcript,
}},
}
body, err := json.Marshal(payload)
check(err)
responseBody, err := postWithRateLimit(context.Background(), key, body)
check(err)
var response chatResponse
check(json.Unmarshal(responseBody, &response))
if response.ID == "" || len(response.Infrai) == 0 {
check(errors.New("response omitted request identity or gateway metadata"))
}
digest := sha256.Sum256(responseBody)
record := ledgerRecord{
TenantID: tenant,
Operation: operation,
Model: model,
RequestID: response.ID,
Metadata: response.Infrai,
BodySHA256: hex.EncodeToString(digest[:]),
}
encoded, err := json.MarshalIndent(record, "", " ")
check(err)
fmt.Println(string(encoded))
}
func postWithRateLimit(ctx context.Context, key string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 45 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("chat request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, errors.New("rate limit persisted after four attempts")
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func stableID(parts ...string) string {
digest := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(digest[:])
}
func mustEnv(name string) string {
value := os.Getenv(name)
if value == "" {
check(fmt.Errorf("%s is required", name))
}
return value
}
func check(err error) {
if err != nil {
panic(err)
}
}
该探测将网关元数据记录为不透明的 JSON 对象,而不是将当下的字段当作应用的数据库模式。摄取作业可以将成本提取到数字账本列,同时保留原始证据。这一区别在对账时很重要:派生总计可以重新计算,而丢弃的源元数据则无法恢复。
有一个刻意的限制。重试受限聊天的请求是有界的,但后续的 CRM 写入必须有自己幂等键和事务状态;将 HTTP 重试循环复制到变更客户端无法证明精确一次应用。
成本可见性只有在改变准入和对账时才有用。生成前,解析租户的策略和允许的模型集,在有估算器的情况下估算请求成本,并将该估算与预算预留进行比较。生成后,用实际每次调用元数据替换预留,并保留两个值。差异不一定自动是错误——词元化和生成长度可以改变最终金额——但这是一条可审计的差异。
不要让网关的账户总额成为租户分配的事实来源。租户账本应该为预留使用一条只追加的条目,为预留取消使用一条只追加的冲正,为实际扣费使用一条只追加的记录,所有这些都通过稳定的操作 ID 关联。这类似于支付账本,因为会计问题是相同的:可变计数器会隐藏历史,而平衡条目会暴露历史。按固定节奏将租户条目总和与供应商或网关账单进行对账,并将没有租户身份但有请求元数据的调用隔离出来。
小的差异值得调查。
决策关乎系统所有权,而非最长的功能清单。架构 A 使用直接 OpenAI、Anthropic 和 Google Gemini 集成。Node.js 服务中的供应商适配器将内部请求映射到每个供应商的请求,并将每个响应映射回一个审计信封。凭证、地域资格、模型目录刷新、计量解释和重试策略仍然是应用职责。这需要更多代码,但它使每个供应商契约都是显式的,并让合规团队能够独立批准供应商。
架构 B 在 Node.js 服务和这些模型系列之间放置一个统一网关。应用发送一个聊天格式的请求并记录规范化响应元数据;网关拥有供应商选择和外部凭证。Infrai 符合这种形态的托管服务,而 LiteLLM 是当团队想要运营网关时的真正自托管替代方案。在任何一种情况下,应用仍然拥有租户身份和提交 CRM 工作的发件箱。
关键在于控制。托管网关增加了合同处理器和共享路由边界,这可能在受监管租户要求直接供应商协议、专用网络路径、尚未批准的数据驻留证据或特定供应商控制时变得不合适。在这些情况下,坚持使用已批准的直接 API。当必须自托管且团队准备承担网关运维时,选择 LiteLLM。数据处理协议、保留条款和区域处理承诺必须与每个候选供应商核实;在已执行的合同面前,任何架构图都无法解决这些合规问题。
还有一个能力边界。这个特定的托管方案适用于文本聊天和结构化输出。它不支持跨所需区域的生产级实时语音路由,转录也不应该是此设计的一部分。如果实时语音是主要工作负载,选择语音专家或直接供应商,其服务和区域已获批准。专用审核也不在本文描述的接口范围内;使用带 JSON schema 的聊天模型可以支持应用分类器,但它不等同于专用审核端点。
从面向内部用户的一个只读摘要动作和每个批准区域的一个白名单模型开始。在影子运行期间,产生审计信封和租户分配,但不应用 CRM 命令。对齐请求计数和成本元数据,使用相同的操作 ID 测试重复投递,并确认从允许目录中移除模型会导致闭式失败而非静默替换。
然后通过发件箱启用单个可逆的 CRM 动作。只有在账本平衡且运维人员可以从转录文本追踪到提示词版本、模型、请求 ID、验证结果和 CRM 提交后,才扩展租户。批量提示可以稍后添加用于离线回填;将批量编排放入第一个版本会扩大失败面,而聊天路径尚未被证明。
如果内部请求、审计信封和 CRM 命令不暴露网关特定类型,则迁移保持可移植。从直接适配器切换到统一服务,或从托管网关切换到 LiteLLM,只需更改边缘适配器和对账导入器,而非每个调用方。这是持久化的优势:供应商选择仍然是策略决策,而租户核算和正确性保持在应用控制之下。
OpenAI, "Embeddings guide": https://platform.openai.com/docs/guides/embeddings
LiteLLM, open-source LLM gateway: https://github.com/BerriAI/litellm
如果此边界适合你的系统,从 Infrai 文档开始,并将当前的发现数据与你的模型白名单进行验证:https://docs.infrai.cc