← 返回资讯

Node.js 接入大模型 API(fetch 与 SDK)

2026-06-15

Node.js 接入大模型 API 可选原生 fetch(Node 18+ 内置)或 openai npm 包。两者都支持 OpenAI Chat Completions 格式——改 baseURL 即可切换平台,无需重写业务逻辑。

环境准备

# Node 18+,内置 fetch,无需额外安装 HTTP 库
node -v

# 安装 openai SDK(可选,推荐生产使用)
npm install openai

将 API key 写入 .env 或系统环境变量,绝不硬编码进代码:

export OPENAI_API_KEY="sk-xxx"
export OPENAI_BASE_URL="https://api.lidayun.com/v1"

如果你是本地开发,不想每次开新终端都手动 export,装个 dotenv 更省心:

npm install dotenv

然后在入口文件最上面第一行加 import "dotenv/config";,Node 启动时就会自动把 .env 里的键值对塞进 process.env。这里有个真实踩过的坑:dotenv/config 必须在所有业务代码之前 import,如果你把它放在其他 import 语句后面,前面那些模块里如果已经读取了 process.env.OPENAI_API_KEY,拿到的会是 undefined——因为 ES Module 的 import 是静态提升的,但模块顶层代码的执行顺序是按书写顺序来的,dotenv/config 没执行到,环境变量自然还没写进去。记得把 .env 加进 .gitignore,这是最容易被忽略但代价最大的一步:一旦 key 提交进 git 历史,即使后来删除,只要仓库公开过,就得当作已泄露处理,直接去平台后台吊销重新生成,改代码没用。

方式一:原生 fetch

适合不想引入 npm 依赖、或已有自己的 HTTP 工具链的场景:

// chat.mjs  (Node 18+)
const API_KEY = process.env.OPENAI_API_KEY;
const BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";

async function chat(messages, model = "gpt-4o-mini") {
  const res = await fetch(`${BASE_URL}/chat/completions`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model,
      messages,
      max_tokens: 1024,
    }),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`API error ${res.status}: ${err.error?.message ?? res.statusText}`);
  }

  const data = await res.json();
  return data.choices[0].message.content;
}

// 使用示例
const reply = await chat([{ role: "user", content: "用 JS 写一个防抖函数" }]);
console.log(reply);

拆一下这段代码里容易忽略的细节。第一,res.ok 只检查 HTTP 状态码是不是 2xx,它不会告诉你响应体里到底装的是什么——所以判断失败之后一定要再 res.json() 一次去拿 error.message,很多人图省事直接 throw new Error(res.statusText),结果线上报错永远是一句”Bad Request”,什么用都没有,真正有用的错误原因(比如”该模型不支持这个参数”)都在响应体里,不解析出来等于白扔。第二,res.json().catch(() => ({})) 这个 catch 不是可有可无的装饰——如果服务端返回的不是合法 JSON(比如网关超时时吐出一段 HTML 错误页),直接 res.json() 会再抛一次异常,把原本的 4xx/5xx 错误吞掉换成一个”Unexpected token ’<’“,反而更难排查,加个 catch 兜底能保证你至少看到原始的 HTTP 状态码。第三,max_tokens: 1024 限制的是输出长度而不是输入长度,如果你的回答经常被硬生生截断(结尾没有标点、句子断在半中间),第一反应不是加大这个值,而是先确认是不是达到了模型的上下文总长度上限——遇到过一次线上问题,日志里报的是 context_length_exceeded,排查了半天才发现是历史对话消息没做裁剪,一直往 messages 数组里塞,塞到第几十轮就爆了,跟 max_tokens 完全没关系。

你运行这段脚本,正常情况下终端会原样打印出一段带代码块的 JS 防抖函数文本;如果卡住十几秒后报 TypeError: fetch failed,先排查是不是 BASE_URL 写错了域名或者本地网络没法出海——国内直连大多数海外模型的官方接口会被墙,这也是为什么很多人会中转到国内可达的服务节点。

方式二:openai npm 包(推荐)

openai 包支持 TypeScript 类型提示、自动重试与流式,只需将 baseURL 指向目标平台:

// chat-sdk.mjs
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
});

async function chat(messages, model = "gpt-4o-mini") {
  const resp = await client.chat.completions.create({
    model,
    messages,
    max_tokens: 1024,
  });
  return resp.choices[0].message.content;
}

console.log(await chat([{ role: "user", content: "解释 Promise 和 async/await" }]));

用 SDK 比裸写 fetch 省心的地方,不只是少写几行 HTTP 代码。client.chat.completions.create 内部帮你处理了三件麻烦事:一是遇到 429(限流)和部分 5xx 错误时会按指数退避自动重试几次,你不用自己写重试循环;二是超时和连接异常有统一的错误类型 APIError,不用你自己去判断 res.ok;三是 TypeScript 项目里 messagesmodel 这些字段都有类型提示,写错字段名 IDE 直接标红,不用等到运行时才发现拼错了 mesages。代价是多了一个依赖,包体积和你能控制的细节都变少了——比如你想自定义重试次数、想在超时前后插入自己的埋点日志,SDK 也支持通过 client.chat.completions.create(params, { maxRetries: 5, timeout: 30_000 }) 这种第二参数传配置,但如果你想要的定制逻辑更复杂(比如按错误类型走不同的降级模型),有时候手写 fetch 反而更直接可控。

两种方式对比

维度原生 fetchopenai SDK
依赖无(Node 18+)npm install openai
流式支持需手动解析 SSE内置 stream: true
自动重试需自行实现内置指数退避
TypeScript需自定义类型完整类型定义
适用场景轻量脚本 / Edge Runtime生产应用

流式输出(stream)

对话型应用建议开启流式,让用户即时看到输出。用 SDK 的流式接口:

// stream.mjs
const stream = await client.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "写一首关于代码的短诗" }],
  stream: true,
});

for await (const chunk of stream) {
  const delta = chunk.choices[0]?.delta?.content ?? "";
  process.stdout.write(delta);   // 逐 token 打印,不换行
}
console.log();  // 输出完成后换行

SSE 原理与浏览器端渲染见流式输出 SSE:原理与各语言实现

如果你坚持用原生 fetch 而不装 SDK,流式就得自己解析 SSE 了,不算难但细节容易踩坑:

// stream-fetch.mjs  (不依赖 openai 包)
async function chatStream(messages, onDelta) {
  const res = await fetch(`${BASE_URL}/chat/completions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ model: "gpt-4o-mini", messages, stream: true }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split("\n");
    buffer = lines.pop(); // 最后一行可能不完整,留到下一轮拼接

    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const payload = line.slice(6);
      if (payload === "[DONE]") return;
      const json = JSON.parse(payload);
      const delta = json.choices[0]?.delta?.content ?? "";
      if (delta) onDelta(delta);
    }
  }
}

await chatStream(
  [{ role: "user", content: "写一首关于代码的短诗" }],
  (delta) => process.stdout.write(delta)
);

这段代码里最容易漏掉的是 buffer = lines.pop() 这一行。SSE 是按字节流分块推送的,一个完整的 data: {...} 行有可能被拆成两个网络包,如果你直接对每个 chunk 单独 split("\n") 再逐行 JSON.parse,运气不好会遇到 Unexpected end of JSON input——因为收到的这一块数据行还没结束,JSON 是不完整的。正确做法就是维护一个 buffer,把每次读到的数据先拼接起来,按换行切分后,最后一行(很可能不完整)不处理,留到下一次读取时再拼上去,只有确认是完整一行才解析。decoder.decode(value, { stream: true }) 里的 { stream: true } 也不是摆设,它告诉解码器”这不是最后一块,可能有跨块的多字节 UTF-8 字符(比如中文)被从中间切断,先缓存半个字符,等下一块来了再拼完整”,去掉这个参数在中文流式输出时偶尔会看到乱码或缺字。

并发请求与限流控制

批量处理场景(比如一次性给几百条用户评论做摘要)不能无脑 Promise.all 把所有请求同时甩出去,大概率会先撞到平台的 QPS 限制,成片收到 429。用 p-limit 控制并发数:

npm install p-limit
// batch.mjs
import pLimit from "p-limit";

const limit = pLimit(5); // 同时最多 5 个请求在飞行中

const tasks = comments.map((text) =>
  limit(() => chat([{ role: "user", content: `总结这条评论:${text}` }]))
);

const results = await Promise.all(tasks);

并发数怎么定?没有万能值,取决于平台给你账号核定的 QPS/RPM 配额和单条请求的平均耗时——如果配额是 60 RPM(每分钟 60 次),单条请求平均 3 秒,理论上并发 3 左右就能跑满配额又不超限;实测建议先设保守一点(比如 3-5),观察日志里 429 出现的频率,没有再逐步调高,比一上来就设 50 然后被限流打回原形要省事。批量任务里还要留一个”部分失败不影响整体”的容错,Promise.all 只要有一个 reject 就会让整批全部失败,实际项目更常用 Promise.allSettled,把失败的那几条记下来单独重试,而不是几百条里错一条就全部作废重跑。

超时控制

大模型接口偶尔会因为排队或者模型本身推理慢而迟迟不返回,如果不设超时,一个卡住的请求可能会让调用方一直挂起等待。用 AbortController 显式控制:

async function chatWithTimeout(messages, ms = 20_000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(`${BASE_URL}/chat/completions`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ model: "gpt-4o-mini", messages }),
      signal: controller.signal,
    });
    return await res.json();
  } finally {
    clearTimeout(timer); // 请求正常结束也要清掉定时器,否则内存里留着悬空的 timer
  }
}

超时时间设多长合适?看场景:面向用户的实时对话,体验上等 10-20 秒是极限,超过用户大概率已经关页面了;后台批量任务对时延不敏感,可以放到 60 秒甚至更长,换取更高的成功率。踩过的一个坑是 clearTimeout 忘记写在 finally 里——如果只在成功分支里清定时器,一旦请求抛出非超时类的异常(比如网络断了),这个 setTimeout 还是会在 20 秒后触发 controller.abort(),但这时请求早已经结束,abort() 调用不会报错但纯属浪费,量大的话会有一堆孤儿定时器堆在事件循环里。

错误处理

import { APIError } from "openai";

async function chatSafe(messages) {
  try {
    return await chat(messages);
  } catch (e) {
    if (e instanceof APIError) {
      if (e.status === 429) {
        console.error("限流,请稍后重试");
      } else if (e.status === 401) {
        console.error("API key 无效,检查环境变量");
      } else {
        console.error(`API 错误 ${e.status}:`, e.message);
      }
    }
    throw e;
  }
}

e.status === 429 只是第一层判断,实际线上更值得关心的是 429 之后怎么恢复,而不只是打个日志了事。加一层带指数退避的重试,429 和网络类错误值得重试,401(key 无效)、400(请求参数本身错了)重试多少次都没用,纯属浪费时间:

async function chatWithRetry(messages, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await chat(messages);
    } catch (e) {
      const retriable = e instanceof APIError && (e.status === 429 || e.status >= 500);
      if (!retriable || attempt === maxRetries) throw e;

      const delay = Math.min(1000 * 2 ** attempt, 8000); // 1s, 2s, 4s...,封顶 8s
      console.warn(`第 ${attempt + 1} 次失败(${e.status}),${delay}ms 后重试`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

为什么要指数退避而不是固定间隔重试?如果限流的原因是你的请求量短时间内超过了平台配额,固定 1 秒后立刻重试大概率还是撞在同一个限流窗口里,等于白等;退避时间逐次翻倍,给平台端的配额窗口留出恢复空间,成功率明显更高。Math.min(..., 8000) 这个封顶也有必要,不封顶的话重试几次后延迟会拉到几十秒,用户等待体验反而比直接报错更差——这时候该做的是把错误抛给上层,让业务代码决定是提示用户”服务繁忙请稍后再试”还是切换到备用模型,而不是让程序在这里死等。

成本怎么估

调用量上来之后,光靠感觉判断”这个月大概要花多少钱”很容易翻车。估算方法很直接:token 消耗 = 输入 token 数 + 输出 token 数,中文场景下一个汉字大约算 1.5-2 个 token(跟具体分词器有关,不同模型的 tokenizer 不完全一样,这里给的是经验区间,精确值以你实际调用后返回的 usage 字段为准),乘以对应模型的单价,就是这次调用的成本。每次响应里其实都带着真实数字,别猜,直接读:

const resp = await client.chat.completions.create({ model: "gpt-4o-mini", messages });
console.log(resp.usage);
// { prompt_tokens: 42, completion_tokens: 128, total_tokens: 170 }

把这个 usage 字段落到日志或者数据库里,按天按用户聚合,才能算出真实的月度成本曲线,而不是等账单出来才发现某个功能悄悄把成本堆高了。具体到某个模型每 1000 token 多少钱,各平台价格会调整,认准调用方所在平台的官方定价页为准,这篇不列具体数字以免过时误导。

常见问题

Node.js 版本低于 18 能用内置 fetch 吗? 不能,需升级到 Node 18+ 或安装 node-fetch。建议直接升级,Node 18 已是 LTS,生产环境不应低于此版本。

在 Next.js / Express 中怎么用? 直接在 API route 或 controller 中 await chat(messages) 即可;如果是 Next.js Edge Runtime,用原生 fetch 方案(openai SDK 在 Edge 中有部分限制)。

如何防止 API key 暴露在前端? 永远不要在浏览器端直接调用大模型 API。在服务端(Next.js API route / Express 路由)中转,前端只请求自己的后端接口。


更多接入方案见大模型 API 接入完全指南接入教程专题。Python 版本的完整示例参考Python 调用大模型 API 完整示例。需要统一多模型入口?申请力达云聚合 API 内测