ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js + Express 搭建 AI API 服务:从选型到上线的工程实践

Node.js + Express 搭建 AI API 服务:从选型到上线的工程实践 1. 为什么我选 Node.js Express 来搭这个 API 服务1.1 从能跑起来到能扛住的选型逻辑很多人第一次搭 API 服务脑子里第一反应是 Python 的 Flask 或者 FastAPI毕竟写起来确实短平快。但我这次从零搭一个面向 AI 能力的 API 服务最终选了 Node.js Express 这套组合原因不是跟风而是几个很实际的考量。第一AI 类 API 服务的核心特征是大量 I/O 等待——你要等大模型返回、等数据库查询、等第三方接口回调。这种场景下 Node.js 的事件循环模型天然占优单线程异步非阻塞不需要为每个请求开线程内存占用和并发表现都很舒服。第二前端和脚本层如果也用 JavaScript整个项目就是一套语言调试链路短不用在两种运行时之间来回切换心智。第三Express 虽然老但它的中间件生态成熟到几乎不用自己造轮子鉴权、限流、日志、跨域这些都有现成方案。这里要澄清一个常见误解Express 并不是过时它只是足够稳定。对于中小型 API 服务它的性能瓶颈通常不在框架本身而在你的数据库查询和外部调用上。我实测过一个简单的 Express 服务单机处理纯 JSON 响应能轻松跑到每秒几千请求这个量级对绝大多数小项目完全够用。选型对比我整理成了一张表方便你按自己的场景判断方案上手速度并发模型生态成熟度适合场景Node.js Express快事件循环异步极高I/O 密集、AI 代理层Python FastAPI快异步/多进程高数据科学、模型推理Go Gin中协程中高高并发、低延迟Java Spring Boot慢线程池极高大型企业系统1.2 环境准备里最容易被忽略的两个细节搭环境这一步看起来是下载安装下一步下一步但实际踩坑最多。我重点说两个。第一个是 Node.js 版本。现在主流是 LTS 版本写这篇文章时 20.x 是长期支持版。为什么强调 LTS因为奇数版本比如 21、23是实验性的生命周期短很多依赖包不会专门适配。你在服务器上装了个非 LTS 版本过几个月发现某个包编译不过排查半天才发现是版本问题非常浪费时间。安装方式我推荐用版本管理工具比如 nvm而不是直接下安装包这样以后切版本一条命令搞定。第二个是 npm 源的问题。默认源在国内访问有时候会很慢装依赖卡住不动。可以换成国内镜像源速度提升非常明显。但要注意换源之后如果遇到某些包版本对不上先切回官方源试试别一上来就怀疑代码。# 查看当前 node 和 npm 版本 node -v npm -v # 如果用了 nvm安装并切换到 LTS nvm install --lts nvm use --lts # 查看当前 npm 源 npm config get registry # 换成国内镜像示例 npm config set registry https://registry.npmmirror.com提示换源是加速手段不是必须。如果你的网络访问官方源很顺畅保持默认反而更省心避免镜像同步延迟导致的版本差异。1.3 项目初始化别小看 package.json 的每一个字段初始化项目就一行npm init -y但生成的package.json里每个字段都值得你花两分钟看一眼。main指向入口文件scripts是你以后天天要用的命令别名type字段决定了你用 CommonJS 还是 ES Module 语法——这个如果一开始没定好后面改起来会牵一发动全身。我的建议是新项目直接用 ES Module在package.json里加type: module因为import/export语法更现代和前端代码风格统一。但要注意一旦用了 ESM__dirname这类 CommonJS 的全局变量就不能直接用了需要用import.meta.url转换这是新手最容易卡住的地方。// ESM 下获取当前文件目录的正确写法 import { fileURLToPath } from url; import { dirname } from path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);装依赖的时候dependencies和devDependencies要分清楚。运行时真正需要的Express、数据库驱动放前者只在开发时用的nodemon、测试框架放后者。这不只是整洁问题生产环境部署时用npm install --production可以跳过开发依赖镜像体积和安装时间都能省下来。2. 把 API 服务的骨架一层层搭起来2.1 入口文件到底该写多薄很多教程的入口文件index.js里塞满了路由、中间件、数据库连接几百行堆在一起。我强烈建议入口文件保持薄——它只做三件事创建应用实例、挂载中间件和路由、启动监听。业务逻辑全部拆到别的目录去。为什么这么强调因为入口文件是你以后排查问题的第一站。如果它只有几十行你一眼就能看清请求进来之后经过了哪些处理如果它几百行每次加功能都要在文件里翻半天改错一处可能影响全局。这就是所谓的关注点分离听起来像套话但真到了线上出问题的时候你会感谢当初把结构拆清楚的自己。// index.js —— 保持薄 import express from express; import routes from ./routes/index.js; import { requestLogger } from ./middlewares/logger.js; import { errorHandler } from ./middlewares/errorHandler.js; const app express(); const PORT process.env.PORT || 3000; app.use(express.json()); app.use(requestLogger); app.use(/api, routes); app.use(errorHandler); app.listen(PORT, () { console.log(服务已启动监听端口 ${PORT}); });2.2 目录结构按职责分而不是按文件类型分新手常见的目录结构是controllers/、services/、models/这样按类型分。项目小的时候没问题但一旦功能多起来你改一个功能要在三四个目录之间跳来跳去。我更推荐按业务模块分每个模块内部再分自己的 controller、service、model。src/ ├── modules/ │ ├── chat/ # 对话相关 │ │ ├── chat.controller.js │ │ ├── chat.service.js │ │ └── chat.routes.js │ └── user/ # 用户相关 │ ├── user.controller.js │ ├── user.service.js │ └── user.routes.js ├── middlewares/ ├── utils/ └── index.js这样做的直接好处是删掉一个功能直接删一个文件夹不会留下孤儿代码。团队协作时两个人分别负责不同模块几乎不会改到同一个文件冲突概率大幅降低。2.3 路由设计URL 是给未来的自己看的路由路径的设计有个朴素原则看 URL 就知道这个接口干什么。用名词表示资源用 HTTP 方法表示动作。GET /api/chat/messages是获取消息列表POST /api/chat/messages是发一条新消息语义清晰。版本号要不要加我的经验是只要这个服务有可能被外部调用就从第一天加上/api/v1/。等以后接口有破坏性变更时你可以平滑地推出 v2老客户端继续用 v1不用半夜起来改线上代码。这个前缀成本几乎为零但省下的麻烦是实打实的。// routes/index.js import { Router } from express; import chatRoutes from ../modules/chat/chat.routes.js; import userRoutes from ../modules/user/user.routes.js; const router Router(); router.use(/v1/chat, chatRoutes); router.use(/v1/user, userRoutes); export default router;2.4 中间件请求流水线上的关卡中间件是 Express 的灵魂理解它你就理解了 Express 的一切。你可以把一次请求想象成一件包裹在流水线上传递中间件就是流水线上的各个关卡日志关卡记录包裹信息鉴权关卡检查通行证限流关卡控制流量最后业务关卡处理包裹。关键点是执行顺序。app.use()的注册顺序就是执行顺序谁先注册谁先执行。所以日志中间件要放在最前面错误处理中间件要放在最后面。这个顺序搞反了会出现日志里看不到错误或者错误处理抓不到异常的诡异现象。// middlewares/logger.js export function requestLogger(req, res, next) { const start Date.now(); res.on(finish, () { const duration Date.now() - start; console.log(${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms); }); next(); }注意next()这个调用它是放行的意思。如果你在中间件里忘了调next()请求就会卡在那里客户端一直转圈直到超时。这是新手最常见的 bug 之一排查时优先检查中间件有没有漏掉next()。3. 接入 AI 能力从能调通到调得好3.1 调用大模型 API 的基本姿势搭 API 服务的一大动机就是把 AI 能力封装成自己的接口。这样前端不用直接持有模型厂商的密钥也方便你做统一的限流、缓存和日志。调用大模型 API 本质上就是发一个 HTTP POST 请求把提示词和参数传过去拿回生成的文本。这里有个安全红线必须强调API 密钥绝对不能写死在代码里更不能提交到代码仓库。正确做法是用环境变量。本地开发用.env文件并且把.env加进.gitignore生产环境用部署平台的环境变量配置。我见过太多因为密钥泄露被人刷爆账单的案例这个坑一定要提前避开。// modules/chat/chat.service.js export async function callLLM(prompt) { const apiKey process.env.LLM_API_KEY; if (!apiKey) { throw new Error(缺少 LLM_API_KEY 环境变量); } const response await fetch(https://api.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify({ model: your-model-name, messages: [{ role: user, content: prompt }], temperature: 0.7 }) }); if (!response.ok) { const errText await response.text(); throw new Error(模型调用失败: ${response.status} ${errText}); } const data await response.json(); return data.choices[0].message.content; }3.2 超时、重试与降级让服务稳下来的三件套直接调外部 API 最大的风险是不可控。对方可能慢、可能挂、可能返回格式变了。如果你不做任何防护对方一抖动你的服务就跟着雪崩。所以必须加三样东西。超时控制是第一道防线。用AbortController给请求设一个上限比如 30 秒。超过就主动断开别让请求无限期挂着占资源。重试机制是第二道。网络抖动导致的失败重试一次往往就好了。但要注意只对可重试的错误重试比如超时、5xx对 4xx 这种参数错误重试没意义只会浪费配额。重试次数也别太多2 到 3 次足够并且每次间隔要递增退避策略避免把对方打得更惨。降级方案是第三道。当模型实在调不通时返回一个友好的兜底响应而不是把 500 错误直接甩给用户。哪怕只是返回服务繁忙请稍后再试体验也比报错强。async function fetchWithTimeout(url, options, timeoutMs 30000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } }注意重试一定要配合幂等性考虑。如果这个接口有副作用比如扣费、写库盲目重试可能造成重复操作。查询类接口重试最安全。3.3 参数调优temperature 和 max_tokens 到底怎么设调模型接口时两个参数最影响结果temperature和max_tokens。temperature控制随机性范围一般 0 到 2。值越低输出越确定、越保守适合做分类、抽取、代码生成这类要准的任务值越高输出越发散、越有创意适合写文案、头脑风暴。我的经验是需要稳定复现的场景设 0 到 0.3需要创意的场景设 0.7 到 1.0。别一上来就拉满输出会飘得没法用。max_tokens控制返回的最大长度。设太小回答会被截断设太大既浪费配额又增加延迟。要根据你的实际业务预估比如做摘要输出通常比输入短很多设个 500 到 1000 就够做长文生成才需要设到几千。还有一个容易忽略的点上下文长度限制。每个模型都有最大 token 数输入加输出不能超过这个上限。如果你把一整篇长文档塞进去很可能直接报错。稳妥做法是在发送前估算 token 数超了就做截断或分段处理。3.4 把 AI 调用封装成可复用的服务层直接在路由处理函数里写调用逻辑是最容易失控的做法。正确姿势是抽出一个 service 层路由只负责收参数、调服务、返结果具体怎么调模型、怎么处理错误全在 service 里。这样做的好处是可测试、可替换。哪天你想从 A 模型换成 B 模型只改 service 一个文件路由和控制器完全不用动。而且 service 层可以单独写单元测试不用起整个 HTTP 服务。// modules/chat/chat.controller.js import { callLLM } from ./chat.service.js; export async function handleChat(req, res, next) { try { const { prompt } req.body; if (!prompt || typeof prompt ! string) { return res.status(400).json({ error: prompt 参数缺失或类型错误 }); } const reply await callLLM(prompt); res.json({ success: true, data: reply }); } catch (err) { next(err); // 交给统一错误处理 } }4. 上线前必须处理的那些脏活4.1 统一错误处理别让异常裸奔一个没做错误处理的服务出问题时用户看到的是堆栈信息你看到的是满屏 500。统一错误处理中间件的作用就是把所有异常收拢到一个地方统一格式化输出同时记录日志。Express 的错误处理中间件有个特点它必须接收四个参数(err, req, res, next)少一个都不会被识别为错误处理器。这个细节很多人不知道写了三个参数发现不生效排查半天。// middlewares/errorHandler.js export function errorHandler(err, req, res, next) { console.error([ERROR] ${req.method} ${req.originalUrl}, err.message); const status err.status || 500; res.status(status).json({ success: false, error: status 500 ? 服务器内部错误 : err.message }); }注意最后返回给用户的信息500 错误不要暴露内部细节只说服务器内部错误即可具体原因写进日志。这既是安全考虑也避免用户看到一堆看不懂的技术术语。4.2 输入校验永远不要相信客户端客户端传来的任何数据都可能是脏的缺字段、类型不对、超长、带特殊字符。如果你不校验直接用轻则报错重则被注入攻击。校验要放在业务逻辑之前越早拦截越好。校验分两层格式校验字段在不在、类型对不对和业务校验值合不合理、有没有权限。格式校验可以用现成的库也可以手写简单的判断。手写的话记住几个要点字符串要检查类型和长度数字要检查是不是 NaN对象要检查是不是 null。function validatePrompt(prompt) { if (typeof prompt ! string) return prompt 必须是字符串; if (prompt.trim().length 0) return prompt 不能为空; if (prompt.length 4000) return prompt 长度超过限制; return null; }4.3 日志与监控出问题时你能看到什么日志不是打印点东西那么简单。好的日志要能回答三个问题谁在什么时候调了什么接口、结果如何、花了多久。我前面写的requestLogger中间件就是干这个的记录方法、路径、状态码、耗时。但光有请求日志还不够关键操作也要打点。比如调用模型失败时要记录失败原因和请求参数注意脱敏别把用户隐私写进日志。这些日志在排查问题时就是你的黑匣子。监控方面小项目不用上重型方案但至少要有个健康检查接口。部署平台或负载均衡会定期来探活返回 200 就认为服务正常。这个接口要足够轻别在里面查数据库否则数据库一慢健康检查也跟着挂反而误判服务不可用。// 健康检查 app.get(/health, (req, res) { res.json({ status: ok, timestamp: Date.now() }); });4.4 部署与进程守护让服务活着本地node index.js跑起来只是第一步真正上线要考虑进程挂了怎么办、服务器重启后服务会不会自动起来、日志往哪写。进程守护工具比如 pm2能解决前两个问题。它会在进程崩溃时自动重启也能配置开机自启。用起来很简单几条命令搞定。但要注意用了守护工具之后日志默认由它接管你要配置日志轮转否则日志文件会越滚越大最后把磁盘撑爆。# 全局安装 pm2 npm install -g pm2 # 启动服务并命名 pm2 start index.js --name my-api # 查看状态 pm2 status # 配置开机自启 pm2 startup pm2 save环境变量在生产环境要通过平台配置不要依赖.env文件。很多部署平台都有专门的环境变量设置界面填进去就行。这样密钥不会跟着代码走换环境也不用改代码。5. 我在这个项目里踩过的几个真实坑5.1 端口占用那个让人抓狂的 EADDRINUSE第一次启动服务报EADDRINUSE: address already in use意思是端口被占了。原因通常是上一次的服务没关干净或者别的程序占了这个端口。解决办法有两个换端口或者找到占用进程杀掉。排查命令很简单Linux 和 macOS 用lsof -i :3000看谁占着Windows 用netstat -ano | findstr :3000。找到进程号之后处理掉。但更优雅的做法是把端口做成可配置的通过环境变量传入这样本地和线上可以用不同端口避免冲突。const PORT process.env.PORT || 3000;5.2 异步错误没被捕获Express 的经典陷阱Express 4 有个坑异步函数里抛出的错误不会被自动传给错误处理中间件。也就是说你在async函数里throwExpress 根本不知道请求会一直挂着直到超时。解决办法有两种一是每个异步处理函数都用try/catch包起来手动调next(err)二是写一个包装函数自动帮你捕获。我推荐后者代码更干净。// 包装异步处理函数自动捕获错误 const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; // 使用 router.post(/chat, asyncHandler(handleChat));这个坑我踩过不止一次尤其是从同步代码改到异步代码的时候特别容易忘。记住一句话只要处理函数是 async 的就要考虑错误捕获。5.3 JSON 解析失败客户端传了非法数据express.json()中间件在解析请求体时如果客户端传的不是合法 JSON会直接抛错。默认情况下这个错误会返回一个 HTML 格式的错误页对 API 服务来说很不友好。你可以在错误处理中间件里专门判断这类错误返回统一的 JSON 格式。export function errorHandler(err, req, res, next) { if (err.type entity.parse.failed) { return res.status(400).json({ success: false, error: 请求体不是合法 JSON }); } // ... 其他错误处理 }5.4 跨域问题前端联调时的拦路虎前端在本地开发时域名端口和后端不一样浏览器会拦截跨域请求。开发阶段最简单的办法是后端开 CORS允许特定来源访问。但要注意生产环境不要用*通配符放开所有来源要明确列出允许的域名否则等于把接口暴露给任何人。import cors from cors; app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(,) || http://localhost:5173, methods: [GET, POST], credentials: true }));把允许的来源也做成环境变量本地和线上配置不同的值这样一套代码两边都能用。5.5 依赖版本漂移昨天还好好的今天就崩了package.json里的版本号前面有^或~意思是允许安装兼容的新版本。这本来是为了方便更新但有时候某个依赖发了个小版本引入了不兼容的改动你的服务就莫名其妙崩了。解决办法是提交 lock 文件package-lock.json它锁定了每个依赖的确切版本。部署时用npm ci而不是npm install前者严格按照 lock 文件安装保证每次部署的依赖完全一致。这个习惯能帮你省掉大量环境不一致的玄学问题。6. 这套骨架还能怎么长搭完这个基础版本其实只是起点。往后的扩展方向很多我按优先级说几个。加缓存是最快见效的优化。AI 调用又慢又贵如果同样的请求重复出现缓存结果能省下大量时间和配额。简单的内存缓存比如 node-cache就能应付小流量流量大了再上 Redis。加限流是保护自己的必要手段。按 IP 或按用户限制单位时间内的请求数防止被恶意刷。express-rate-limit这个中间件几行配置就能用起来。加鉴权是走向正式服务的关键一步。用 JWT 做无状态鉴权用户登录后拿到 token后续请求带上 token 验证身份。这样你才能区分不同用户做精细化的配额管理。加数据库让服务有状态。用户信息、对话历史这些都需要持久化。选型上关系型数据库PostgreSQL、MySQL适合结构化数据文档型数据库MongoDB适合灵活的对话记录。加测试是长期维护的保障。至少给核心的 service 层写单元测试给主要接口写集成测试。测试不是为了好看而是让你以后改代码时敢改知道改完没破坏原有功能。我个人在实际操作中的体会是别一上来就追求大而全。先把最小可用的版本跑通能对外提供一个稳定的接口然后再根据真实需求一点点加。很多项目死在想太多、做太少上骨架搭起来、跑起来、用起来比什么都重要。这套 Node.js Express 的骨架我从零搭到能对外服务一个下午就够了剩下的时间都花在打磨细节和踩坑上——而这些细节恰恰是决定服务能用还是好用的分水岭。
返回列表