
1. 为什么我选 Node.js Express 来搭这个 API 服务1.1 从零搭 API 服务先想清楚三件事很多人一上来就纠结用哪个框架、装哪个版本的运行时结果环境还没配好就卡住了。我自己的习惯是先把三件事想明白这个 API 服务给谁用、要暴露几个接口、数据从哪来。想清楚这三件事技术选型基本就定了。这次的小项目目标很明确搭一个能对外提供接口的服务前端或者其他系统能通过 HTTP 请求拿到数据同时这个服务要能接上大模型的能力做一个简单的 AI 问答接口。说白了就是一个中间层——把请求接进来转发给大模型再把结果整理好返回去。为什么选 Node.js 而不是 Python 或者 Java原因很实际。第一JavaScript 生态里处理 HTTP 请求的库非常成熟Express 几乎是零学习成本第二Node.js 天生异步非阻塞处理这种请求进来、等大模型返回、再吐出去的 IO 密集型场景特别合适第三前后端可以共用一套语言调试的时候不用来回切换思维。对于一个小项目来说少一个语言就少一层心智负担。1.2 Express 和 Fastify 到底怎么选热词里同时出现了 Express 和 Fastify这俩确实是现在 Node.js 圈子里讨论最多的两个 Web 框架。我两个都用过说说真实感受。Express 的优势是老和稳。它的中间件生态极其丰富你遇到的大部分问题网上都能搜到现成的中间件或者解决方案。它的写法也最直观一个app.get(/path, handler)就完事了新手看两眼就能上手。缺点是它对异步错误的处理不够友好性能在高并发下不如 Fastify。Fastify 的优势是快和现代。它的序列化机制做了大量优化官方 benchmark 里吞吐量通常是 Express 的两三倍。它还内置了 JSON Schema 校验接口参数校验不用再额外引库。缺点是对新手来说概念稍微多一点插件体系需要花点时间理解。我的选择是小项目、学习为主、要快速出结果用 Express。因为你要的是从零搭起来的成就感而不是一上来就被插件生命周期绕晕。等这个项目跑通了再换 Fastify 重构一遍你会对两者的差异有非常深的体会。这个思路我在好几个项目里都用过先跑通再优化比一开始就追求最优解要高效得多。1.3 整体架构长什么样这个 API 服务的架构其实很简单我用大白话描述一下数据流向客户端发一个 HTTP 请求过来Express 接收到之后先经过一层中间件做日志记录和参数校验然后路由把请求分发到对应的处理函数。处理函数里如果需要调用大模型就把请求转发给大模型的 API拿到结果后整理成统一的 JSON 格式返回给客户端。整个链路里有两个关键点一是错误处理大模型接口可能超时、可能返回格式不对这些都要兜住二是密钥管理调用大模型需要 API Key这个东西绝对不能硬编码在代码里也不能提交到代码仓库。我见过太多人把 Key 直接写在app.js里然后传到公开仓库结果被人扫到盗刷。这个坑一定要避开后面我会讲具体怎么做。2. 环境准备Node.js 安装与版本选择的那些坑2.1 Node.js 版本怎么选别盲目追新热词里有一条特别典型error installing 24.21.0: node.js v24.21.0 is not yet released or is not available。这个报错我太熟悉了就是版本号写错了或者写了一个还没正式发布的版本。Node.js 的版本分三类LTS长期支持版、Current当前版、Nightly每夜构建版。生产环境和学习项目一律选 LTS。LTS 版本经过充分测试稳定性和兼容性都有保障。Current 版本会包含最新特性但可能有坑。Nightly 就别碰了那是给 Node.js 核心开发者用的。截至我写这篇内容的时候Node.js 20.x 和 22.x 都是 LTS 系列。热词里提到的ubuntu安装node.js 20是个很稳的选择。我自己的机器上装的是 20.x LTS跑了大半年没出过问题。怎么查当前有哪些 LTS 版本直接去 Node.js 官网的下载页上面会明确标注 LTS 字样。或者用命令行工具nvmNode Version Manager来管理nvm ls-remote --lts就能列出所有 LTS 版本。2.2 Ubuntu 上安装 Node.js 的两种方式如果你用的是 Ubuntu安装 Node.js 有两条路用系统包管理器apt或者用nvm。用apt装最简单sudo apt update sudo apt install nodejs npm但这种方式装出来的版本往往比较旧因为 Ubuntu 仓库里的 Node.js 更新不及时。你装完一查node -v可能还是 12.x 或者 14.x跑现代项目会各种报错。所以我更推荐用nvmcurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash装完 nvm 之后重新打开终端然后nvm install 20 nvm use 20 nvm alias default 20最后一行是把 20 设为默认版本这样每次新开终端都自动用 20。装完验证一下node -v npm -v能正常输出版本号就说明成功了。注意用 nvm 装完之后如果node -v提示找不到命令多半是 shell 配置文件没加载。检查一下~/.bashrc或~/.zshrc里有没有 nvm 的初始化脚本没有的话手动加上再source一下。2.3 初始化项目与依赖安装环境好了之后建一个项目目录初始化 npmmkdir ai-api-demo cd ai-api-demo npm init -y-y是跳过交互式提问直接生成默认的package.json。然后装 Expressnpm install express如果你要调用大模型还需要一个 HTTP 客户端。Node.js 18 以后内置了fetch可以直接用不用额外装axios。但如果你用的是更早的版本就装一个npm install axios再装一个开发时自动重启的工具省得每次改代码都手动 CtrlC 再重启npm install -D nodemon然后在package.json的scripts里加一行scripts: { start: node app.js, dev: nodemon app.js }这样开发时用npm run dev上线时用npm start。3. 核心代码实现从 Hello World 到 AI 接口3.1 最小可运行服务长什么样先写一个最基础的 Express 服务确保环境没问题const express require(express); const app express(); const PORT process.env.PORT || 3000; app.use(express.json()); app.get(/, (req, res) { res.json({ message: API 服务已启动 }); }); app.listen(PORT, () { console.log(服务运行在 http://localhost:${PORT}); });保存为app.js然后npm run dev。打开浏览器访问http://localhost:3000看到{message:API 服务已启动}就说明成功了。这里有几个细节值得说。app.use(express.json())这行是必须的它的作用是解析请求体里的 JSON 数据。没有这行你后面接收 POST 请求的req.body会是undefined这个坑我踩过不止一次。process.env.PORT || 3000是为了部署时能通过环境变量指定端口本地开发默认 3000。3.2 设计一个 AI 问答接口接下来加一个真正有用的接口接收用户的问题调用大模型返回答案。先定义接口格式。我习惯用 POST请求体长这样{ question: 什么是 RESTful API }返回体{ code: 0, data: { answer: ... }, message: success }这种统一返回格式的好处是前端处理起来简单不用为每个接口写不同的解析逻辑。code为 0 表示成功非 0 表示出错message放错误信息。路由代码大概是这样app.post(/api/ask, async (req, res) { const { question } req.body; if (!question || typeof question ! string) { return res.status(400).json({ code: 400, data: null, message: question 参数缺失或类型错误 }); } try { const answer await callLLM(question); res.json({ code: 0, data: { answer }, message: success }); } catch (err) { console.error(调用大模型失败:, err.message); res.status(500).json({ code: 500, data: null, message: 服务内部错误 }); } });注意这里的参数校验。typeof question ! string这个判断很有必要因为用户可能传数组、传对象、传数字如果不校验后面调用大模型时可能报奇怪的错。热词里有人搜javascript判断数据类型这就是一个典型场景。3.3 调用大模型接口的正确姿势callLLM这个函数是核心。不同的大模型厂商接口格式略有差异但大体逻辑是一样的发一个 POST 请求带上 API Key 和消息内容拿到返回结果。以常见的对话补全接口为例async function callLLM(question) { const apiKey process.env.LLM_API_KEY; const apiUrl process.env.LLM_API_URL; if (!apiKey) { throw new Error(未配置 LLM_API_KEY 环境变量); } const response await fetch(apiUrl, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify({ model: your-model-name, messages: [ { role: system, content: 你是一个乐于助人的助手。 }, { role: user, content: question } ], 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; }这里有几个关键点。API Key 从环境变量读取绝对不写死在代码里。超时处理fetch默认没有超时如果大模型接口卡住你的服务也会一直挂着。可以加一个AbortController来做超时控制const controller new AbortController(); const timeout setTimeout(() controller.abort(), 30000); const response await fetch(apiUrl, { // ...其他配置 signal: controller.signal }); clearTimeout(timeout);30 秒是个比较合理的值大模型生成较长内容时可能需要这么久。如果设太短正常请求也会被中断。提示热词里出现过 api error: 400 this models maximum context length is 1048576 tokens 这类报错本质是输入内容超过了模型的最大上下文长度。解决办法是在调用前对输入做长度截断或者用支持更长上下文的模型。别指望模型能处理无限长的输入。3.4 环境变量管理与密钥安全前面反复提到 API Key 不能硬编码具体怎么做在项目根目录建一个.env文件LLM_API_KEY你的密钥 LLM_API_URLhttps://api.example.com/v1/chat/completions PORT3000然后装dotenvnpm install dotenv在app.js最顶部加一行require(dotenv).config();这样process.env.LLM_API_KEY就能读到.env里的值了。最关键的一步把.env加到.gitignore里确保它不会被提交到代码仓库。node_modules/ .env我见过有人把.env提交上去结果 Key 泄露被人盗刷了几千块。这个教训太贵了一定要记住。如果团队协作可以提供一个.env.example文件里面只写变量名不写真实值其他人复制一份改成自己的。4. 常见问题排查与实战避坑指南4.1 启动就报错先看这几处新手搭 API 服务最常见的报错就那么几类。我整理了一个速查表遇到问题先对照着看报错信息可能原因解决办法Cannot find module express依赖没装在项目目录执行npm installEADDRINUSE: address already in use端口被占用换端口或杀掉占用进程req.body是 undefined没加express.json()在路由前加app.use(express.json())SyntaxError: Unexpected tokenJSON 格式错误检查请求体是否是合法 JSON401 UnauthorizedAPI Key 错误或缺失检查.env配置和环境变量加载ETIMEDOUT网络超时检查网络增加超时时间端口占用这个问题特别常见。你上次跑的服务没关干净这次再启动就报EADDRINUSE。Linux 或 Mac 上可以这样查lsof -i :3000找到 PID 之后kill -9 PID干掉它。或者干脆在代码里换个端口比如 3001。4.2 异步错误处理Express 的经典陷阱Express 有一个很坑的地方它不会自动捕获异步函数里抛出的错误。看这段代码app.get(/test, async (req, res) { throw new Error(出错了); });这个错误不会被 Express 的错误处理中间件捕获而是会导致请求挂起客户端一直等不到响应。解决办法有两种。第一种是每个异步路由都包一层 try-catch就像我前面callLLM那样。第二种是写一个包装函数const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; app.get(/test, asyncHandler(async (req, res) { throw new Error(出错了); }));然后在所有路由后面加一个错误处理中间件app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ code: 500, data: null, message: 服务内部错误 }); });这个中间件有四个参数Express 靠参数个数来识别它是错误处理中间件少一个都不行。这个细节很多人不知道写了三个参数结果不生效。4.3 大模型接口调用的那些坑调用大模型接口除了前面说的超时和上下文长度还有几个坑值得说。返回格式不稳定。有些模型返回的内容里会带 markdown 代码块标记或者前后有多余的空格换行。如果你要把结果直接展示给用户最好做一下清洗。如果要把结果解析成 JSON那更要做容错处理因为模型不一定每次都返回合法 JSON。并发限制。大部分大模型接口都有 QPS 限制你并发发太多请求会被限流。小项目里可以在服务端加一个简单的队列或者用p-limit这类库控制并发数。费用问题。大模型接口是按 token 计费的输入和输出都算。如果你的接口对外开放一定要加频率限制不然被人恶意刷接口账单会很感人。可以用express-rate-limitnpm install express-rate-limitconst rateLimit require(express-rate-limit); const limiter rateLimit({ windowMs: 60 * 1000, max: 20, message: { code: 429, message: 请求过于频繁请稍后再试 } }); app.use(/api/, limiter);这段配置表示每个 IP 每分钟最多 20 次请求。对于个人项目来说够用了。4.4 日志与调试出问题时怎么快速定位服务跑起来之后出问题是必然的。关键是要能快速定位。我的做法是加一个简单的请求日志中间件app.use((req, res, next) { const start Date.now(); res.on(finish, () { const duration Date.now() - start; console.log(${req.method} ${req.path} ${res.statusCode} ${duration}ms); }); next(); });这样每次请求都会打印方法、路径、状态码和耗时。如果某个接口突然变慢日志里一眼就能看出来。对于大模型调用我还会把请求参数和返回结果的关键信息打出来注意不要打完整的 API Key。这样出问题时能快速判断是请求发错了还是返回解析错了。提示生产环境不要用console.log打太多东西性能会有影响而且日志文件会爆炸。可以用winston或pino这类日志库支持分级和文件轮转。5. 从能跑到好用几个提升服务质量的小改动5.1 加一个健康检查接口服务部署上去之后你怎么知道它是不是活着加一个健康检查接口app.get(/health, (req, res) { res.json({ status: ok, timestamp: Date.now() }); });这个接口不查数据库、不调外部服务就是单纯返回一个 ok。负载均衡或者监控系统会定期来探活如果返回非 200 就认为服务挂了自动摘掉或者重启。5.2 统一错误码设计前面用了code: 0表示成功但错误码不能只有 500。我一般会设计一套简单的错误码错误码含义HTTP 状态码0成功200400参数错误400401未授权401429请求过于频繁429500服务内部错误500503依赖服务不可用503这样前端拿到响应后先看code再决定怎么处理。比单纯看 HTTP 状态码要清晰。5.3 用 PM2 让服务稳定运行开发时用nodemon但生产环境不能这么跑。进程挂了要能自动重启最好还能开机自启。PM2是 Node.js 生态里最常用的进程管理工具npm install -g pm2 pm2 start app.js --name ai-api pm2 save pm2 startuppm2 save保存当前进程列表pm2 startup配置开机自启。之后用pm2 logs ai-api看日志pm2 restart ai-api重启服务pm2 status看运行状态。我自己的小项目基本都是这套组合Node.js Express PM2简单够用维护成本低。5.4 后续可以怎么扩展这个 API 服务跑通之后能扩展的方向很多。比如加一个对话历史功能把每次问答存到数据库里下次请求时带上历史上下文实现多轮对话。或者加一个流式输出接口用 Server-Sent Events 把大模型的生成过程实时推给前端体验会好很多。再进一步可以把多个大模型接口封装成统一的调用层根据问题类型自动路由到不同的模型。热词里提到的多 AI 协作就是这个思路。不过这些都属于锦上添花先把基础版本跑稳再逐步迭代。我在实际做这类小项目的时候最大的体会是别一上来就追求完美架构。先让服务能跑起来能返回正确结果然后再考虑错误处理、日志、限流这些。很多新手卡在设计一个完美的架构上结果一行代码没写。跑通再优化这个顺序不能反。