Node 基础
🎯 学习目标
- 使用 Node 20+、npm/pnpm 管理依赖与脚本
- 掌握 ESM(
import)、fetch、async/await与.env配置- 能读懂第二章 Lab、Next.js API Route 与第三章 MCP 示例
- 理解流式
ReadableStream与 SSE 解析的基本写法
引言
非主线选修,但本课程 第二章 API 实战、Next.js 流式前端、第三章 MCP Server(TypeScript/Node) 均大量出现 Node。有 Python 背景的同学建议 45–60 分钟 速览本节,然后边做 实战 · 流式 API 边查。
全栈常见分工:Node/Next.js 暴露 /api/chat、SSE 推流到浏览器;Python 跑 RAG 索引与 LangGraph(见 Python 基础)。
章节正文
第 1 步:环境与包管理
# 建议 Node 20 LTS(与 VitePress、MCP SDK 一致)
node -v # v20.x
npm -v
# 用 nvm / fnm 切换版本(推荐)
# nvm install 20 && nvm use 20
mkdir my-agent-api && cd my-agent-api
npm init -y
npm pkg set type=module # 启用 ESM,下文 import 才能用
npm install openai dotenv
npm install -D typescript @types/node # 可选,MCP/大型项目建议 TS| 工具 | 说明 |
|---|---|
| npm | Node 自带,本课程默认 |
| pnpm | 省磁盘、monorepo 友好,命令与 npm 类似(pnpm add) |
package.json | 依赖、scripts、是否 "type": "module" |
node_modules/ | 安装产物,勿提交 Git |
常用脚本:
{
"type": "module",
"scripts": {
"chat": "node chat.mjs",
"dev": "node --watch chat.mjs"
}
}第 2 步:ESM 与 CommonJS 区别
本课程 新示例一律 ESM:
// chat.mjs 或 package.json 含 "type": "module" 的 chat.js
import OpenAI from 'openai'
import 'dotenv/config'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))旧项目可能是 CommonJS(require)。MCP TypeScript SDK 为 ESM-only(见 3.3),新建 MCP Server 请直接用 ESM + "type": "module"。
第 3 步:环境变量与配置
根目录 .env(勿提交 Git):
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
LLM_PROVIDER=openai读取:
import 'dotenv/config'
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('Missing OPENAI_API_KEY')
const model = process.env.OPENAI_MODEL ?? 'gpt-4o-mini'Next.js 中 服务端 用 process.env;客户端只能读 NEXT_PUBLIC_ 前缀变量——永远不要把 API Key 暴露到浏览器。
第 4 步:异步、fetch 与 OpenAI SDK
与 2.1 API 与本地部署 对齐:
import OpenAI from 'openai'
import 'dotenv/config'
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
})
const response = await client.chat.completions.create({
model: process.env.OPENAI_MODEL ?? 'gpt-4o-mini',
messages: [
{ role: 'system', content: '你是简洁的中文技术助手。' },
{ role: 'user', content: '用一句话解释 Token。' },
],
max_tokens: 200,
})
console.log(response.choices[0].message.content)
console.log('usage:', response.usage)原生 fetch(无 SDK、调试 curl 等价物):
const res = await fetch(`${process.env.OPENAI_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello' }],
}),
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`upstream ${res.status}: ${text}`)
}
const data = await res.json()
console.log(data.choices[0].message.content)第 5 步:Next.js App Router API Route
Lab 1 的标准形态(服务端代理 Key):
// app/api/chat/route.js
import OpenAI from 'openai'
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
export async function POST(req) {
try {
const { messages } = await req.json()
const completion = await client.chat.completions.create({
model: process.env.OPENAI_MODEL ?? 'gpt-4o-mini',
messages,
temperature: 0.7,
})
return Response.json({
reply: completion.choices[0].message.content,
usage: completion.usage,
})
} catch (err) {
console.error(err)
return Response.json({ error: 'upstream_failed' }, { status: 502 })
}
}前端只请求 /api/chat,不接触 Key。
第 6 步:流式输出与 SSE 解析
2.4 SSE 与流式输出 核心模式:
const res = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: '写一首短诗' }],
stream: true,
})
for await (const chunk of res) {
const delta = chunk.choices[0]?.delta?.content
if (delta) process.stdout.write(delta)
}Next.js 流式 Response(简化示意):
export async function POST(req) {
const { messages } = await req.json()
const upstream = await fetch(url, {
method: 'POST',
headers: { /* ... */ },
body: JSON.stringify({ model: 'gpt-4o-mini', messages, stream: true }),
})
const stream = new ReadableStream({
async start(controller) {
const reader = upstream.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
// 解析 data: {...} 行,提取 delta.content 再 enqueue 给前端
controller.enqueue(value)
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' },
})
}AbortController 停止生成:
const ac = new AbortController()
fetch('/api/chat', { method: 'POST', body, signal: ac.signal })
// 用户点「停止」:ac.abort()第 7 步:文件系统与 JSON
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
const configPath = path.join(process.cwd(), 'config.json')
const raw = await readFile(configPath, 'utf8')
const config = JSON.parse(raw)
config.version = 2
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')MCP / 工具开发常用 同步 读小文件(启动时加载 schema):
import { readFileSync, existsSync } from 'node:fs'
if (existsSync('./tools.json')) {
const tools = JSON.parse(readFileSync('./tools.json', 'utf8'))
}第 8 步:错误处理与 Provider 抽象
export async function chatWithRetry(client, params, { retries = 1 } = {}) {
for (let i = 0; i <= retries; i++) {
try {
return await client.chat.completions.create(params)
} catch (err) {
const status = err.status ?? err.response?.status
if (status === 429 && i < retries) {
await new Promise((r) => setTimeout(r, 2000 * (i + 1)))
continue
}
if (status === 401) throw new Error('Invalid API key')
throw err
}
}
}多 Provider 配置(与 2.1 一致):
// config/models.mjs
export const providers = {
openai: {
baseURL: 'https://api.openai.com/v1',
apiKey: process.env.OPENAI_API_KEY,
defaultModel: 'gpt-4o-mini',
},
ollama: {
baseURL: 'http://localhost:11434/v1',
apiKey: 'ollama',
defaultModel: 'qwen2.5:7b',
},
}
export function createClient(name = 'openai') {
const p = providers[name]
if (!p?.apiKey) throw new Error(`Missing provider: ${name}`)
return new OpenAI({ apiKey: p.apiKey, baseURL: p.baseURL })
}第 9 步:TypeScript 与 MCP 开发提示
MCP Server(3.3–3.4)常用 TypeScript + ESM:
// tsconfig.json 片段
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"strict": true,
"outDir": "dist"
}
}要点:
- MCP SDK 仅 ESM;
package.json需"type": "module" - stdio 传输:日志必须写
stderr,禁止污染 stdout(会破坏 JSON-RPC) - 开发期可用
npx @modelcontextprotocol/inspector调试工具列表
Cursor / Claude Code 注册 MCP 见 .cursor/mcp.json 或 ~/.cursor/mcp.json(第九章)。
第 10 步:本课程常见 npm 包速查
| 包 | 章节 | 用途 |
|---|---|---|
openai | 2.x | Chat、Embedding |
dotenv | 2.x | 本地 .env |
next | Lab 1 | App Router、API Route |
@langchain/core 等 | 6.x | LangChain.js |
@modelcontextprotocol/sdk | 3.x | MCP Server |
zod | 2.6 / 3.x | 结构化输出、工具参数校验 |
ai(Vercel AI SDK) | 6.7 | React 流式 UI 封装 |
动手练习
npm init+ ESM,安装openai、dotenv,写chat.mjs完成一次非流式对话。- 实现
POST本地脚本模拟 Lab:readline读用户输入,维护messages[]多轮历史。 - 为 chat 增加
stream: true,终端逐字输出;加AbortController,5 秒后自动 abort 试一次。 - 写
createClient(process.env.LLM_PROVIDER),切换openai与ollama,业务代码不变。 - (可选)用 TypeScript 写一个最小 MCP tool,返回
{ content: [{ type: 'text', text: 'pong' }] }。
常见问题
Q:import 报 ERR_REQUIRE_ESM?
在 package.json 加 "type": "module",或把文件改为 .mjs。
Q:Next.js 里 fetch 报证书或代理问题?
企业网络需配置 HTTP_PROXY;本地 Ollama 用 http://127.0.0.1:11434/v1。
Q:和 Python 后端怎么拆?
Next.js 只做 BFF(Backend for Frontend):鉴权、SSE、聚合;RAG 检索调 Python FastAPI POST /internal/rag/query。Key 只存在于服务端环境变量。
Q:npm 还是 pnpm?
个人练习 npm 即可;本仓库 ai-news 站点用 npm。团队 monorepo 可统一 pnpm。
本节小结
跑通本课程 Node 侧需要:Node 20+、ESM、dotenv、OpenAI SDK 或 fetch、async/await、Next.js Route Handler、流式 ReadableStream 与基础错误重试。MCP 与 Agent IDE 章节在此基础上扩展 TypeScript 与 stdio 约束——边做 Lab 边查即可。