Node.js 接入大模型 API(fetch 与 SDK)
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 项目里 messages、model 这些字段都有类型提示,写错字段名 IDE 直接标红,不用等到运行时才发现拼错了 mesages。代价是多了一个依赖,包体积和你能控制的细节都变少了——比如你想自定义重试次数、想在超时前后插入自己的埋点日志,SDK 也支持通过 client.chat.completions.create(params, { maxRetries: 5, timeout: 30_000 }) 这种第二参数传配置,但如果你想要的定制逻辑更复杂(比如按错误类型走不同的降级模型),有时候手写 fetch 反而更直接可控。
两种方式对比
| 维度 | 原生 fetch | openai 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 内测。