ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Node.js + Express 从零搭建可接 AI 的 API 服务实战指南

2026/10/7 12:18:40 拓冰建站 浏览量
Node.js + Express 从零搭建可接 AI 的 API 服务实战指南 API 服务这个东西听起来像是后端老手的专属领域但这两年我越来越明显地感觉到一个变化身边做前端的、做数据的、甚至做运营的朋友都开始自己搭 API 服务了。原因很直接——AI 能力已经变成了一个个 HTTP 接口你想把大模型接进自己的小工具、小页面、小自动化流程里绕不开发一个请求、拿一个结果这件事。而 Node.js 加 Express 这套组合恰好是前端背景的人上手成本最低的路径同一门 JavaScript同一个 npm 生态不用切换语言心智。这篇要聊的就是怎么从零把一个能跑、能扩展、能接 AI 的 API 服务搭起来。我会把整个过程拆成几个真实的阶段环境怎么准备、项目骨架怎么设计、路由和中间件怎么写、AI 接口怎么接进来、出错怎么排查。中间会穿插我自己踩过的坑比如 Node 版本装错导致的原生模块编译失败、请求体解析顺序写反导致接口一直返回空、以及 AI 接口超时该怎么兜底。适合有基础 JavaScript 语法、但没怎么写过服务端的朋友也适合写过一点后端但想系统梳理一遍的人。1. 先把运行环境这件事做扎实很多人搭 API 服务卡在第一步不是不会写代码而是环境没弄干净。Node.js 的版本管理是重灾区我见过太多人系统里同时躺着三四个版本node -v和实际运行时的版本对不上最后报一堆莫名其妙的错。1.1 Node.js 版本选择与安装路径先说版本。截至我写这篇的时候Node.js 的 LTS长期支持版本是 20.x 系列22.x 也已经进入 LTS 轨道。我的建议很明确生产项目一律用 LTS不要碰 Current 版本。Current 版本虽然新特性多但生命周期短很多原生模块native addon还没跟上编译适配你装个依赖就可能遇到node-gyp编译失败。安装方式上我强烈建议不要直接用系统包管理器比如 Ubuntu 的apt install nodejs。原因很简单系统源里的 Node 版本往往落后好几个大版本而且升级麻烦。正确的做法是用版本管理工具Linux 和 macOS 上用nvmWindows 上用nvm-windows或者直接下官方安装包。# Linux / macOS 安装 nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 重新加载 shell 配置 source ~/.bashrc # 安装并切换到 Node 20 LTS nvm install 20 nvm use 20 nvm alias default 20装完之后一定要验证三件事node -v输出v20.x.xnpm -v能正常输出版本号which node指向的是 nvm 管理的路径而不是/usr/bin/node。第三点最容易被忽略如果你之前用 apt 装过 Nodewhich node可能还是指向系统路径这时候 nvm 装的版本根本没生效。提示如果你在 Ubuntu 上遇到error installing 24.21.0: node.js v24.21.0 is not yet released这类报错基本可以确定是 nvm 的版本列表缓存过期了执行nvm cache clear再重试即可。这类报错跟你的代码毫无关系纯粹是工具链的问题。1.2 包管理器与项目初始化npm 是默认选择但如果你在意安装速度可以考虑 pnpm。不过对于小项目我建议先用 npm少一层抽象少一个坑。初始化项目就是标准的npm init -y然后装核心依赖mkdir ai-api-demo cd ai-api-demo npm init -y npm install express cors dotenv npm install -D nodemon这里解释一下每个依赖的作用别小看这一步很多人装完都不知道自己装了什么依赖作用是否必需expressWeb 框架处理路由和中间件必需cors处理跨域请求头前后端分离时必需dotenv从 .env 文件加载环境变量强烈建议nodemon开发时文件变动自动重启开发环境建议dotenv这个包我要单独强调。API 服务迟早要接第三方接口而第三方接口的密钥绝对不能硬编码在代码里。用.env文件管理配合.gitignore排除是最基本的工程习惯。我见过有人把密钥直接写进app.js然后推到公开仓库第二天就收到账单提醒这种事真的不少。1.3 目录结构别等代码乱了再重构小项目最容易犯的错就是所有代码堆在一个文件里。一开始确实爽app.js写个两百行就能跑但等你接了三个 AI 接口、加了日志、加了错误处理这个文件会膨胀到你自己都不想看。我的建议是从一开始就分目录哪怕每个目录只有一个文件ai-api-demo/ ├── src/ │ ├── routes/ # 路由定义 │ │ └── ai.js │ ├── services/ # 业务逻辑比如调用 AI 接口 │ │ └── aiService.js │ ├── middlewares/ # 自定义中间件 │ │ └── errorHandler.js │ └── app.js # Express 应用组装 ├── .env # 环境变量不提交 ├── .env.example # 环境变量模板提交 ├── .gitignore └── package.json这个结构的好处是职责清晰路由只管哪个 URL 对应哪个处理函数service 只管具体怎么调 AI中间件只管请求前后的通用逻辑。等你哪天要换 AI 供应商只改 service 层就行路由和中间件完全不用动。2. Express 应用骨架从能跑到跑得稳环境弄好之后接下来是把 Express 应用搭起来。这一步看起来简单但里面有几个顺序问题写反了就会出各种诡异现象。2.1 中间件注册顺序为什么不能乱Express 的中间件是按注册顺序依次执行的这个特性决定了顺序极其重要。我见过最典型的错误是把express.json()写在路由注册之后结果所有 POST 请求的req.body都是空对象排查半天以为是前端没传数据。正确的顺序是这样的const express require(express); const cors require(cors); require(dotenv).config(); const app express(); // 1. 基础解析中间件必须最先注册 app.use(express.json({ limit: 1mb })); app.use(express.urlencoded({ extended: true })); // 2. 跨域处理 app.use(cors()); // 3. 简单的请求日志 app.use((req, res, next) { console.log([${new Date().toISOString()}] ${req.method} ${req.path}); next(); }); // 4. 业务路由 app.use(/api/ai, require(./routes/ai)); // 5. 404 处理放在所有路由之后 app.use((req, res) { res.status(404).json({ error: Not Found, path: req.path }); }); // 6. 全局错误处理必须放在最后 app.use(require(./middlewares/errorHandler)); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(API server running on http://localhost:${PORT}); });这里有几个细节值得展开。express.json({ limit: 1mb })里的 limit 是请求体大小限制默认是 100kb。如果你要传图片的 base64 或者长文本给 AI100kb 很容易超超了会直接返回 413 错误。但也不能设太大否则容易被恶意请求撑爆内存1mb 到 5mb 是比较合理的区间。express.urlencoded({ extended: true })是处理表单提交的extended: true表示用qs库解析支持嵌套对象。如果你只处理 JSON 请求这行可以不加但加上没坏处。2.2 错误处理中间件的四个参数Express 的错误处理中间件有个硬性规定必须接收四个参数(err, req, res, next)少一个 Express 就不会把它当成错误处理器。这个规则坑过无数人包括我自己。// src/middlewares/errorHandler.js module.exports (err, req, res, next) { const status err.status || 500; const message err.message || Internal Server Error; console.error([ERROR] ${status} - ${message}); if (status 500) { console.error(err.stack); } res.status(status).json({ error: message, ...(process.env.NODE_ENV development { stack: err.stack }) }); };注意最后那个展开运算符只在开发环境返回堆栈信息。生产环境暴露堆栈是安全隐患攻击者能从中推断出你的目录结构、依赖版本甚至数据库类型。2.3 异步路由的坑为什么你的 try-catch 没生效Express 4.x 有个历史遗留问题它不会自动捕获异步函数里抛出的错误。也就是说如果你写app.get(/api/test, async (req, res) { throw new Error(出错了); // 这个错误不会被错误中间件捕获 });这个错误会变成未处理的 Promise rejection进程可能直接崩溃而你的错误中间件根本收不到。解决办法有两个要么每个异步路由都手动 try-catch要么写一个包装函数。// 包装异步路由自动捕获错误 const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; // 使用 app.get(/api/test, asyncHandler(async (req, res) { throw new Error(出错了); // 现在能被正确捕获 }));这个asyncHandler是我每个项目都会写的工具函数十几行代码省掉无数重复的 try-catch。Express 5.x 据说会原生支持异步错误捕获但 5.x 还没正式稳定现阶段还是自己包一层最稳妥。3. 接一个 AI 接口进来从请求到响应骨架搭好之后最有价值的部分来了——把 AI 能力接进来。这里我用一个通用的思路来讲不管你用的是哪家的大模型服务流程都是类似的构造请求、发送、处理响应、处理异常。3.1 密钥管理与 .env 的正确用法第一步永远是密钥管理。在项目根目录建一个.env文件PORT3000 AI_API_KEYyour_api_key_here AI_API_BASEhttps://api.example.com/v1 AI_MODELyour_model_name NODE_ENVdevelopment然后建一个.env.example把值留空这个文件提交到仓库让协作者知道需要配哪些变量PORT3000 AI_API_KEY AI_API_BASE AI_MODEL NODE_ENVdevelopment.gitignore里必须包含.env。这一步没有商量余地。注意.env文件里的值不要加引号除非值本身包含空格或特殊字符。我见过有人写AI_API_KEYsk-xxx结果引号被当成值的一部分传给了接口导致鉴权失败排查了半天。3.2 用原生 fetch 还是 axiosNode.js 18 之后内置了fetch所以小项目完全可以不装 axios。但fetch有几个需要注意的地方它不会自动抛错HTTP 404、500 都算成功的响应需要手动检查response.ok它没有内置超时需要配合AbortController。// src/services/aiService.js async function callAI(prompt, options {}) { const { timeout 30000 } options; const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); try { const response await fetch(${process.env.AI_API_BASE}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.AI_API_KEY} }, body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: user, content: prompt }] }), signal: controller.signal }); if (!response.ok) { const errText await response.text(); const error new Error(AI 接口返回 ${response.status}: ${errText}); error.status response.status 500 ? 502 : response.status; throw error; } const data await response.json(); return data.choices?.[0]?.message?.content ?? ; } finally { clearTimeout(timer); } } module.exports { callAI };这段代码里有几个关键设计。AbortController配合setTimeout实现超时控制30 秒是个比较合理的默认值——大模型生成长文本确实可能超过 10 秒但超过 30 秒基本就是网络或服务端问题了。finally里清除定时器防止内存泄漏。错误状态码的映射也值得说一下。AI 服务返回 500对客户端来说其实是上游服务出错映射成 502Bad Gateway更准确。如果 AI 服务返回 401密钥无效那就原样透传因为这是配置问题不是网关问题。3.3 路由层怎么写才干净service 层负责怎么调路由层负责接收什么、返回什么。两者分开之后路由代码会非常清爽// src/routes/ai.js const express require(express); const router express.Router(); const { callAI } require(../services/aiService); const asyncHandler (fn) (req, res, next) Promise.resolve(fn(req, res, next)).catch(next); router.post(/chat, asyncHandler(async (req, res) { const { prompt } req.body; if (!prompt || typeof prompt ! string) { const err new Error(prompt 字段必填且必须是字符串); err.status 400; throw err; } if (prompt.length 4000) { const err new Error(prompt 长度不能超过 4000 字符); err.status 400; throw err; } const result await callAI(prompt); res.json({ success: true, data: result }); })); module.exports router;参数校验放在路由层业务调用放在 service 层这是我认为最舒服的分工。校验逻辑包括类型检查typeof prompt ! string和长度限制后者很重要——大模型接口通常有 token 上限超了会返回 400 错误与其让上游报错不如在入口就拦住。4. 联调、测试与那些让人抓狂的报错代码写完只是开始真正花时间的是联调和排错。这一节我把常见的几类问题整理出来都是我自己或者身边朋友真实遇到过的。4.1 用 curl 和 REST 客户端做最小验证服务跑起来之后第一件事不是打开前端页面测而是用最原始的方式验证接口本身通不通curl -X POST http://localhost:3000/api/ai/chat \ -H Content-Type: application/json \ -d {prompt: 用一句话解释什么是 API}如果这一步就失败问题一定在服务端跟前端无关。常见返回和对应原因返回可能原因排查方向404 Not Found路由路径写错检查app.use的挂载路径和 router 内的路径拼接400 Bad Request请求体格式错误检查 Content-Type 和 JSON 格式401 Unauthorized密钥无效或未传检查 .env 加载和 Authorization 头500 Internal Error代码抛异常看服务端控制台堆栈502 Bad Gateway上游 AI 服务异常检查 AI_API_BASE 和网络连通性curl验证通过之后再用 Postman 或类似工具测最后才接前端。这个顺序能帮你快速定位问题出在哪一层。4.2 跨域问题的本质与解法跨域报错是前端联调时最常见的拦路虎。浏览器控制台会显示类似Access to fetch at ... has been blocked by CORS policy的错误。这个问题的本质是浏览器出于安全考虑默认禁止网页向不同源协议、域名、端口任一不同的地址发请求除非服务端明确返回允许的响应头。cors中间件就是干这个的。开发阶段可以直接app.use(cors())允许所有来源但生产环境一定要限制app.use(cors({ origin: [https://your-frontend.com], methods: [GET, POST], allowedHeaders: [Content-Type, Authorization] }));有个细节要注意CORS 预检请求OPTIONS 方法是浏览器自动发的不需要你手动处理cors中间件会自动响应。但如果你在它之前注册了别的中间件拦截了 OPTIONS 请求预检就会失败。所以cors要尽量往前放。4.3 超时、重试与降级AI 接口调用失败是常态不是异常。网络抖动、上游限流、模型过载都会导致失败。一个健壮的服务必须考虑这些。超时前面已经用AbortController处理了。重试要谨慎——不是所有错误都值得重试。4xx 错误参数错误、鉴权失败重试多少次都一样只有 5xx 和网络超时才值得重试。而且重试要加退避不能立刻重发async function callAIWithRetry(prompt, maxRetries 2) { let lastError; for (let i 0; i maxRetries; i) { try { return await callAI(prompt); } catch (err) { lastError err; // 4xx 不重试 if (err.status err.status 500) throw err; if (i maxRetries) { const delay Math.pow(2, i) * 1000; // 1s, 2s await new Promise(r setTimeout(r, delay)); } } } throw lastError; }指数退避的意思是第一次失败等 1 秒第二次等 2 秒第三次等 4 秒。这样能避免在服务端已经过载时雪上加霜。降级策略则取决于业务。如果是聊天场景AI 挂了可以返回服务繁忙请稍后再试如果是批处理任务可以把失败的请求记录下来稍后重跑。关键是不要让一个 AI 接口的故障拖垮整个服务。5. 让服务更耐用的几个工程习惯代码能跑之后接下来是让它跑得久。这一节聊的都是小项目容易忽略、但迟早会付出代价的地方。5.1 环境变量校验启动时就失败别等运行时.env少配一个变量服务照样能启动但第一次调用接口时才报错。这种延迟失败很折磨人。更好的做法是在启动时校验// src/config.js const required [AI_API_KEY, AI_API_BASE, AI_MODEL]; const missing required.filter(key !process.env[key]); if (missing.length 0) { console.error(缺少必需的环境变量: ${missing.join(, )}); process.exit(1); } module.exports { port: parseInt(process.env.PORT || 3000, 10), aiApiKey: process.env.AI_API_KEY, aiApiBase: process.env.AI_API_BASE, aiModel: process.env.AI_MODEL, nodeEnv: process.env.NODE_ENV || development };process.exit(1)表示非正常退出配合进程管理工具如 pm2、systemd能实现自动重启和告警。启动即失败比运行到一半才失败要好得多。5.2 日志别只用 console.logconsole.log在开发时够用但生产环境你需要的是结构化日志——带时间戳、带级别、带请求上下文。小项目不用上重型日志库但至少要做到分级const logger { info: (msg, meta {}) console.log(JSON.stringify({ level: info, time: new Date().toISOString(), msg, ...meta })), error: (msg, meta {}) console.error(JSON.stringify({ level: error, time: new Date().toISOString(), msg, ...meta })) };JSON 格式的日志好处是可以直接被日志收集系统解析方便按字段搜索。比如你想查所有调用 AI 接口耗时超过 5 秒的请求结构化日志一条查询就能搞定纯文本日志只能靠 grep 硬找。5.3 请求限流保护自己也保护上游API 服务暴露在公网迟早会遇到异常流量。可能是有人恶意刷也可能是你自己的前端出了 bug 疯狂重试。限流是最基本的防护。小项目可以用内存实现一个简单的滑动窗口限流const requestCounts new Map(); function rateLimit(maxRequests 60, windowMs 60000) { return (req, res, next) { const key req.ip; const now Date.now(); const record requestCounts.get(key) || { count: 0, resetAt: now windowMs }; if (now record.resetAt) { record.count 0; record.resetAt now windowMs; } record.count; requestCounts.set(key, record); if (record.count maxRequests) { return res.status(429).json({ error: 请求过于频繁请稍后再试 }); } next(); }; } app.use(/api/, rateLimit(60, 60000));这个实现有个内存泄漏隐患requestCounts只增不减。生产环境需要定期清理过期记录或者直接用express-rate-limit这类成熟库。但理解原理很重要不然你连限流参数该设多少都没概念。提示限流阈值要根据你的 AI 接口配额来定。如果上游给你每分钟 100 次调用额度那你的限流就应该设在 100 以下留出余量。别等上游把你封了才想起来限流。5.4 优雅关闭别让请求半路夭折服务重启时如果有正在处理的请求直接process.exit()会让这些请求全部失败。优雅关闭的意思是收到关闭信号后停止接收新请求等正在处理的请求完成再退出。const server app.listen(PORT, () { console.log(Server running on port ${PORT}); }); process.on(SIGTERM, () { console.log(收到关闭信号停止接收新请求...); server.close(() { console.log(所有请求处理完毕进程退出); process.exit(0); }); // 兜底10 秒后强制退出 setTimeout(() { console.error(强制退出仍有请求未完成); process.exit(1); }, 10000); });这个模式在容器化部署时尤其重要。容器编排系统发送停止信号后会等待一段时间再强制杀掉进程优雅关闭能利用这段时间把请求处理完。6. 从单文件到可维护项目演进的真实路径最后聊一个很多人关心的问题这个小项目接下来怎么长大。我不建议一上来就上微服务、上消息队列、上各种重型架构那是过度设计。但有几个演进方向是自然的、值得提前留好接口的。6.1 多 AI 供应商的抽象一开始你可能只接一家 AI 服务但很快就会有这家限流了换一家这个任务用便宜模型那个任务用贵模型的需求。这时候 service 层如果写死了某家的请求格式改起来就很痛苦。更好的做法是定义一个统一的接口// src/services/providers/base.js class AIProvider { async chat(prompt, options) { throw new Error(必须实现 chat 方法); } } // src/services/providers/openaiCompatible.js class OpenAICompatibleProvider extends AIProvider { constructor({ apiKey, baseUrl, model }) { super(); this.apiKey apiKey; this.baseUrl baseUrl; this.model model; } async chat(prompt, options {}) { // 具体的请求实现 } }这样上层调用只认provider.chat()换供应商只需要换一个实例。这个抽象不用一开始就做但当你接第二家的时候就该动手了。6.2 什么时候该上数据库小项目用内存存数据没问题但一旦涉及用户历史记录调用日志配额统计内存就不够了——重启就丢。这时候该上数据库。选型上如果只是存日志和简单记录SQLite 是最省事的选择一个文件搞定不用单独部署服务。如果数据量大、需要并发写再考虑 PostgreSQL 或 MySQL。别一上来就上 MongoDB除非你的数据结构真的非常不固定。6.3 部署前必须检查的清单服务要上线之前我会过一遍这个清单.env已配置且未提交到仓库NODE_ENVproduction已设置CORS 已限制为具体域名不是*错误响应不包含堆栈信息限流已启用日志输出到文件或日志系统不是只打控制台进程管理工具已配置pm2 / systemd / 容器编排健康检查接口已提供比如GET /health返回 200健康检查接口虽然简单但很重要。负载均衡器和容器编排系统靠它判断实例是否可用。实现就几行app.get(/health, (req, res) { res.json({ status: ok, uptime: process.uptime() }); });uptime返回进程运行秒数能帮你判断服务是不是刚重启过。我在实际项目里最大的体会是搭 API 服务这件事难点从来不在写代码而在处理各种边界情况——网络会断、上游会挂、用户会传奇怪的数据、流量会突然暴涨。把这些边界情况一个个处理好服务才算真正能用。而 AI 接口的接入本质上就是给这个服务加了一个会失败的外部依赖你对它的容错设计决定了整个服务的可靠性。