把大模型调用从业务代码里拆出去:一个 OpenAI 兼容 Provider 层的 Node.js 实现

最近很多团队接入大模型时,第一版代码通常长这样:业务函数里直接写 baseURLapiKeymodel,然后在 Dify、Cursor、Chatbox 或自研后台里各配一遍。刚开始能跑,后面一旦要换模型、切备用入口、统计费用、排查 model_not_found,问题就会集中爆出来。

更稳的做法是把模型调用从业务代码里拆成一个 Provider 层:业务只说“我要一个文本模型”或“我要一个便宜的摘要模型”,Provider 层负责选择实际模型、拼接地址、隐藏 Key、记录日志和归类错误。

这篇用 Node.js 写一个最小实现。重点不是框架,而是把几个容易踩坑的点固定下来:Base URL 不硬编码在业务里,模型名以后台为准,API Key 不下发到前端,错误可以被定位。

先把三个地址分清楚

以一个 OpenAI 兼容服务为例,常见会同时出现三类地址:

地址适合放在哪里
https://api.vectorengine.cn服务根地址,用来识别服务域名
https://api.vectorengine.cn/v1大多数工具里的 OpenAI 兼容 Base URL
https://api.vectorengine.cn/v1/chat/completions自己写 curlfetchrequests 时直接请求的完整接口

如果你在工具里已经填写了 /v1,通常不要再把 /chat/completions 也拼进去;如果你在代码里直接请求完整接口,就不要再由 SDK 额外拼一次路径。很多“接口明明通了但工具里报错”的问题,都出在这里。

如果只是想做一次多模型入口的小额测试,可以把向量引擎作为一个 OpenAI 兼容 provider 加进配置里,试用入口:https://178.nz/awa。实际 API Key、可用模型名和额度状态仍然以后台显示为准。

Provider 配置不要散落在业务里

先准备一份独立配置。这里把 provider、模型别名和真实模型名拆开:

// providers.js
export const providers = {
  vectorengine: {
    name: "vectorengine",
    baseURL: "https://api.vectorengine.cn/v1",
    apiKey: process.env.VECTORENGINE_API_KEY,
    timeoutMs: 30_000
  }
};

export const modelMap = {
  "chat-default": {
    provider: "vectorengine",
    model: process.env.CHAT_DEFAULT_MODEL || "deepseek-chat"
  },
  "summary-cheap": {
    provider: "vectorengine",
    model: process.env.SUMMARY_MODEL || "qwen-plus"
  }
};

业务代码以后只引用 chat-default,不直接知道真实模型名。后面要把摘要模型换掉,只改配置,不改几十处业务代码。

写一个最小 chat 调用函数

下面这个函数只做三件事:取 provider、发请求、把错误转成业务能读懂的结构。

// llmClient.js
import { providers, modelMap } from "./providers.js";

export async function chat(alias, messages) {
  const target = modelMap[alias];
  if (!target) {
    throw new Error(`unknown_model_alias:${alias}`);
  }

  const provider = providers[target.provider];
  if (!provider?.apiKey) {
    throw new Error(`provider_api_key_missing:${target.provider}`);
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), provider.timeoutMs);

  try {
    const res = await fetch(`${provider.baseURL}/chat/completions`, {
      method: "POST",
      signal: controller.signal,
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${provider.apiKey}`
      },
      body: JSON.stringify({
        model: target.model,
        messages,
        temperature: 0.3
      })
    });

    const data = await res.json().catch(() => ({}));

    if (!res.ok) {
      throw normalizeProviderError(res.status, data, target);
    }

    return {
      provider: target.provider,
      model: target.model,
      content: data.choices?.[0]?.message?.content ?? "",
      usage: data.usage ?? null
    };
  } finally {
    clearTimeout(timer);
  }
}

function normalizeProviderError(status, data, target) {
  const raw = JSON.stringify(data);
  const message = data?.error?.message || data?.message || raw;

  const err = new Error(message);
  err.status = status;
  err.provider = target.provider;
  err.model = target.model;

  if (status === 401 || /api key|unauthorized/i.test(message)) {
    err.code = "invalid_api_key";
  } else if (/model/i.test(message) && /not|不存在|invalid/i.test(message)) {
    err.code = "model_not_found";
  } else if (status === 429 || /rate|limit|quota/i.test(message)) {
    err.code = "rate_limited";
  } else {
    err.code = "provider_request_failed";
  }

  return err;
}

注意这里的 fetch(${provider.baseURL}/chat/completions) 是在 baseURL 已经到 /v1 的前提下拼接。如果你配置里放的是完整请求地址,就不要再这样拼。

给业务层暴露一个干净接口

业务接口可以只接收用户问题,不暴露 provider、Key 和真实模型名:

// server.js
import express from "express";
import { chat } from "./llmClient.js";

const app = express();
app.use(express.json());

app.post("/api/ask", async (req, res) => {
  try {
    const question = String(req.body.question || "").trim();
    if (!question) {
      return res.status(400).json({ error: "question_required" });
    }

    const result = await chat("chat-default", [
      { role: "system", content: "回答要简洁,优先给可执行步骤。" },
      { role: "user", content: question }
    ]);

    res.json({
      answer: result.content,
      provider: result.provider,
      model: result.model,
      usage: result.usage
    });
  } catch (err) {
    res.status(err.status || 500).json({
      error: err.code || "internal_error",
      provider: err.provider,
      model: err.model,
      message: err.message
    });
  }
});

app.listen(3000, () => {
  console.log("LLM proxy listening on http://localhost:3000");
});

这层代理有几个好处:

  • 前端拿不到 API Key。
  • 业务只依赖模型别名,不依赖某个 provider 的真实模型 ID。
  • 失败时能看到是 Key 错、模型名错、额度限制,还是上游请求失败。
  • 后面要加缓存、审计日志、费用统计、限流,都有一个统一入口。

上线前做一组小额验收

不要一上来就把所有业务流量切过去。先用一组最小请求做验收:

检查项通过标准
Key 是否可用最小请求返回 200,且不是空回答
Base URL 是否正确工具侧填 /v1 能通,代码侧完整路径能通
模型名是否正确使用后台展示的模型 ID,不凭记忆填写
错误是否可归类至少能区分 invalid_api_keymodel_not_foundrate_limited
日志是否脱敏日志里不出现完整 API Key、用户隐私和原始长文本
费用是否能追踪每次请求记录 provider、model、业务场景、usage

验收通过后,再把真实业务按场景迁移:客服问答、摘要、代码生成、内部知识库,不要全部共用一个模型别名。不同场景的延迟、上下文长度和成本要求不一样,混在一起会很难排查。

常见问题

1. 为什么不直接在前端调用模型接口?

因为 API Key 会暴露。即使做了域名限制,也很难控制滥用、日志、额度和用户输入。生产环境更适合走后端代理。

2. Base URL 到底填根地址还是 /v1

多数 OpenAI 兼容工具填到 /v1。如果是自己写 HTTP 请求,通常直接请求完整的 /v1/chat/completions。关键是不要重复拼路径。

3. 模型名能不能随便写成 gpt-4deepseek 这种?

不要凭感觉写。模型名应以 provider 后台展示为准。很多 model_not_found 不是接口坏了,而是模型 ID 不匹配。

4. 后面要支持多个 provider 怎么办?

继续扩展 providersmodelMap 即可。业务层仍然只使用别名,比如 chat-defaultsummary-cheapcode-helper

小结

多模型接入最怕的不是第一天跑不通,而是跑通以后到处散落 Key、Base URL 和模型名。把调用收敛到 Provider 层之后,业务代码会干净很多,排错也会从“猜哪里错了”变成“看错误分类和日志”。

我的建议是:先把地址、Key、模型名、错误分类和日志脱敏这五件事做扎实,再谈更复杂的路由、降级和成本优化。这样后面换模型、接新工具、做团队用量统计,都不会变成一次大返工。


憨厚的圣诞树
1 声望0 粉丝