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" 字样。或者用命令行工具nvm(Node 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,跑现代项目会各种报错。
所以我更推荐用nvm:
curl -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 初始化项目与依赖安装
环境好了之后,建一个项目目录,初始化 npm:
mkdir ai-api-demo cd ai-api-demo npm init -y-y是跳过交互式提问,直接生成默认的package.json。然后装 Express:
npm install express如果你要调用大模型,还需要一个 HTTP 客户端。Node.js 18 以后内置了fetch,可以直接用,不用额外装axios。但如果你用的是更早的版本,就装一个:
npm install axios再装一个开发时自动重启的工具,省得每次改代码都手动 Ctrl+C 再重启:
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 model's maximum context length is 1048576 tokens" 这类报错,本质是输入内容超过了模型的最大上下文长度。解决办法是在调用前对输入做长度截断,或者用支持更长上下文的模型。别指望模型能处理无限长的输入。
3.4 环境变量管理与密钥安全
前面反复提到 API Key 不能硬编码,具体怎么做?
在项目根目录建一个.env文件:
LLM_API_KEY=你的密钥 LLM_API_URL=https://api.example.com/v1/chat/completions PORT=3000然后装dotenv:
npm 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 install |
EADDRINUSE: address already in use | 端口被占用 | 换端口,或杀掉占用进程 |
req.body是 undefined | 没加express.json() | 在路由前加app.use(express.json()) |
SyntaxError: Unexpected token | JSON 格式错误 | 检查请求体是否是合法 JSON |
401 Unauthorized | API 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-limit:
npm 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 | 成功 | 200 |
| 400 | 参数错误 | 400 |
| 401 | 未授权 | 401 |
| 429 | 请求过于频繁 | 429 |
| 500 | 服务内部错误 | 500 |
| 503 | 依赖服务不可用 | 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 协作"就是这个思路。不过这些都属于锦上添花,先把基础版本跑稳,再逐步迭代。
我在实际做这类小项目的时候,最大的体会是:别一上来就追求完美架构。先让服务能跑起来,能返回正确结果,然后再考虑错误处理、日志、限流这些。很多新手卡在"设计一个完美的架构"上,结果一行代码没写。跑通再优化,这个顺序不能反。