从零搭建 NLP Demo:用 ES6 模块化 + DeepSeek API 构建你的第一个 AI 应用
本文首发于稀土掘金,作者:hushu。未经授权禁止转载。
📖 本文概要
最近在系统学习 NLP 实战开发,从一个极简的 Demo 入手,把「Prompt 驱动大模型做 NLP 任务」这件事拆解清楚。这个项目代码只有 3 个文件、不到 40 行 ,但背后涉及的工程思想------模块化架构、ES6 语法特性、API 调用链路、关注点分离------是每一个 AI 应用开发者都必须掌握的。本文用 CodeGraph 静态分析工具把项目结构完全可视化,带你一窥究竟。
读完你将收获:
- 一个可运行的 NLP Demo 项目骨架
- ES6 模块化的核心语法及使用场景
- OpenAI SDK 调用 DeepSeek API 的完整流程
- 软件工程中「单一职责」与「关注点分离」的落地实践
- 用 CodeGraph 理解代码结构的思维方式
一、项目全景:3 个文件,13 个符号,10 条关系边
先把整个项目的骨架摊开来看。启动 CodeGraph 索引后,得到一张精确的结构图:
css
nlp-demo/
├── client.mjs ← 6 个符号,最底层
├── completion.mjs ← 4 个符号,中间层
└── main.mjs ← 3 个符号,最上层
模块依赖关系 (由 CodeGraph codegraph_trace 和 codegraph_callers 给出):
arduino
main.mjs ─── import { getCompletion } ──→ completion.mjs
│
completion.mjs ─── import client ──→ client.mjs
│
client.mjs ─── import { OpenAI } ──→ openai (npm 依赖)
这个依赖方向是单向的、自上而下的:上层调用下层,下层不感知上层。这是一条铁律------依赖倒过来就会产生循环引用,项目稍微变大就不可维护。课程开篇第一句话 "有哪些东西可以模块化?" 问的就是这个。
二、逐层拆解:从螺丝钉到发动机
2.1 底层:client.mjs --- 管「连谁」
javascript
import { OpenAI } from 'openai'
import dotenv from 'dotenv'
dotenv.config()
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_API_BASE_URL,
})
export default client
这里藏着三个工程实践:
① 配置外部化。 DEEPSEEK_API_KEY 和 DEEPSEEK_API_BASE_URL 不硬编码在代码里,而是从 .env 文件通过 dotenv 注入。这样切换环境(开发 / 测试 / 生产)只改配置文件,不用动代码。更关键的是,API Key 这类敏感信息绝不能提交到 Git------.env 写进 .gitignore,这才是生产级习惯。
② export default 语义。 一个模块只能有一个 default export。这里把 client 实例作为默认导出,因为其他模块只需要「用这个客户端发请求」,不需要知道它怎么构造的。这就是封装。
③ SDK 抽象。 用的是 OpenAI 官方的 openai npm 包,但实际调的是 DeepSeek 的 API。SDK 提供统一的接口规范------chat.completions.create() 这个方法签名在 OpenAI、DeepSeek、Moonshot 之间通用,换模型只改 baseURL 和 apiKey,业务代码零修改。这就是「面向接口编程」的威力。
2.2 中间层:completion.mjs --- 管「做什么」
javascript
import client from './client.mjs'
export async function getCompletion(prompt) {
const response = await client.chat.completions.create({
model: process.env.DEEPSEEK_MODEL,
messages: [
{ role: 'user', content: prompt }
]
})
return response.choices[0].message.content
}
export async function getImage(prompt) {
// 待实现
}
这一层是课程的核心------Prompt 做 NLP 任务的落地代码。
getCompletion 函数做的事情用一句话描述:把人类自然语言的指令(prompt)发给大模型,拿回结构化的回答 。传统 NLP 要做分类、实体识别、摘要等,需要分别训练模型;现在一个 getCompletion(prompt) 全干了------你写什么 prompt,它就做什么任务。这就是「Prompt Engineering」的本质。
注意几个细节:
async/await。 网络请求是异步的,await让异步代码写起来像同步代码,极大降低心智负担。这是 ES2017 的特性,课程虽然没展开讲,但已经是现代 JS 基础设施。exportvsexport default。 这里用的是命名导出(export async function),因为一个模块里可能有多个任务函数------getCompletion、getImage,将来还能加getEmbedding、getSummary。和client.mjs的单一默认导出形成对比,两种导出模式的使用场景一目了然。response.choices[0].message.content。 这是 OpenAI 兼容 API 的标准响应格式。choices是数组(可以一次返回多个候选回答),取第 0 个,拿message.content就是 AI 回复的纯文本。
2.3 顶层:main.mjs --- 管「怎么触发」
javascript
import { getCompletion } from './completion.mjs'
async function main() {
const prompt = '用一句话解释什么是模块化编程'
const response = await getCompletion(prompt)
console.log(response)
}
main()
入口文件只有 6 行有效代码,无比简洁。这就是课程讲的「单点入口」------程序的启动逻辑集中在一处,不散落。
这里 prompt 是硬编码的示例,真实场景下它可以来自:
- HTTP 请求的 body(Express/Koa 接口)
- 命令行参数(CLI 工具)
- 消息队列消费(后台任务)
- 用户输入流(聊天机器人)
入口层不关心 prompt 具体怎么来的,它只负责「拿到 prompt → 调用完成任务 → 输出结果」这个编排。这是路由/编排层的职责。
三、ES6 语法:让 JS 成为企业级语言的能力
课程在 readme 里专门列了 ES6(ECMAScript 2015)的四个关键特性。它们不是孤立的知识点------这个项目本身就是 ES6 语法的实战练习场。
3.1 let / const:告别变量提升的噩梦
在 ES6 之前,JS 只有 var。var 有两个致命问题:
javascript
// 问题一:变量提升(hoisting)
console.log(x) // undefined,不报错!
var x = 10
// 问题二:没有块级作用域
if (true) {
var y = 20
}
console.log(y) // 20,在块外部依然能访问!
let 和 const 解决了这两个问题:块级作用域 让变量的生命周期限制在 {} 之内,暂时性死区 让声明前访问直接报错。const 更进一步------简单数据类型(字符串、数字)不可重新赋值,复杂类型(对象、数组)可以修改内部属性但不能指向新的内存地址。这能防止意外覆盖,提高代码安全性。
在本项目中,client 实例用 const 声明,因为程序运行期间不需要更换客户端;prompt 和 response 也用 const,语义清晰------这些值不会被重新赋值。
3.2 解构赋值:一行代码干三行的事
main.mjs 里虽然没有直接出现解构语法,但注释里给了清晰的示例:
javascript
// 传统写法
let name = obj.name
let city = obj.city
// 解构赋值
let { name, city } = { name: '姚明', city: '北京' }
对象解构按属性名 匹配,数组解构按顺序匹配:
javascript
// 数组解构 + rest 操作符
let [coach, ...players] = ['范甘迪', '姚明', '麦迪', '穆托姆博', '弗朗西斯']
console.log(coach) // '范甘迪'
console.log(players) // ['姚明', '麦迪', '穆托姆博', '弗朗西斯']
这不仅仅是语法糖------解构一次提取,后续直接使用变量 ,比每次通过属性链访问(obj.name)性能更好,尤其在循环和频繁访问的场景下差异明显。
3.3 Rest / Spread:... 的双面人生
同一个 ... 运算符,根据上下文有两种含义:
javascript
// Rest:收集剩余元素(在赋值左侧 / 函数参数位)
let [first, ...rest] = arr
// Spread:展开可迭代对象(在赋值右侧 / 函数调用位)
let merged = [...teamA, ...teamB]
Pro tip:spread 展开是浅拷贝 ,嵌套对象/数组的内部引用仍然共享。需要深拷贝时得用 structuredClone() 或 JSON.parse(JSON.stringify())。
3.4 模块化:import / export 体系
回顾 CodeGraph 的模块依赖图,三种导出方式在这个项目里各司其职:
| 语法 | 使用场景 | 本项目示例 |
|---|---|---|
export default |
模块只有一个主要输出 | client.mjs 导出 client 实例 |
export(命名导出) |
模块输出多个工具函数 | completion.mjs 导出 getCompletion、getImage |
import { ... } from |
按需引入命名导出 | main.mjs 只引入 getCompletion |
import ... from |
引入默认导出 | completion.mjs 引入 client |
默认导出可以随意命名 ,import x from './client.mjs' 中的 x 叫什么都可以,因为它对应的是那个唯一的 export default。命名导出则必须用花括号按原名引入 (除非用 as 别名),这种约束让模块间的契约更明确。
四、API 调用链:从 Prompt 到 Response 的五步流转
把 CodeGraph codegraph_trace main → getCompletion 的结果展开,可以看到一次 API 调用的完整生命周期:
php
┌─────────────────────────────────────────────┐
│ ① dotenv.config() │
│ 读取 .env → 注入 process.env │
│ DEEPSEEK_API_KEY / BASE_URL / MODEL │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ② new OpenAI({ apiKey, baseURL }) │
│ 创建指向 DeepSeek 的客户端实例 │
│ SDK 内部处理鉴权、请求序列化、重试逻辑 │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ③ main() 构造 prompt 字符串 │
│ '用一句话解释什么是模块化编程' │
│ → 这就是 NLP 任务的「输入」 │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ④ client.chat.completions.create({ │
│ model: 'deepseek-v4-flash', │
│ messages: [{role:'user', content}] │
│ }) │
│ HTTP POST → https://api.deepseek.com │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ ⑤ response.choices[0].message.content │
│ 从响应 JSON 中提取纯文本回复 │
│ 返回给调用方 → console.log 输出 │
└─────────────────────────────────────────────┘
这个流程值得记住的原因在于:市场上一半以上的 AI 应用,都是这个模式的变体。 无论你做的是聊天机器人、文档问答还是智能客服,核心都是「拿用户输入 → 拼 prompt → 调 API → 解析回传」。把握住这条链路,你就理解了 AI 应用的骨架。
五、设计思想:课程里没明说但贯穿始终的东西
5.1 单一职责原则(SRP)
css
client.mjs → 只负责「建立连接」
completion.mjs → 只负责「定义任务」
main.mjs → 只负责「编排流程」
每个模块只有一个理由被修改。想换模型?改 client.mjs。想加新任务类型?改 completion.mjs。想改入口逻辑?改 main.mjs。三者独立演化,互不干扰。
5.2 关注点分离(Separation of Concerns)
鉴权、业务、路由------三个关注点分属三个文件。这和 MVC 的 Model-View-Controller 是一个思路,只是在 AI 应用的语境下,分离的维度变成了:
- 基础设施层(client)--- SDK 配置、鉴权、网络
- 领域逻辑层(completion)--- Prompt 构造、API 调用、响应处理
- 应用编排层(main)--- 路由、参数校验、结果输出
5.3 可复用性
getCompletion(prompt) 是一个高度可复用的「积木」。今天在 main.mjs 里调用它,明天可以在 Express 路由里调用,后天可以在定时任务脚本里调用------函数签名不依赖运行环境,只依赖一个 string 入参,这就是好的抽象。
5.4 配置外部化
.env 文件的存在,让这个项目具备了「开发环境」和「生产环境」自由切换的能力。这是 12-Factor App 的第三因子------「配置存储在环境变量中」------在这个小 Demo 里就已经落地了。
六、动手实战:复现这个项目的 5 个步骤
如果你也想跑起来这个 Demo,按以下步骤操作:
Step 1:初始化项目
bash
mkdir nlp-demo && cd nlp-demo
npm init -y
Step 2:安装依赖
bash
npm install openai dotenv
Step 3:配置环境变量
创建 .env 文件(记得加入 .gitignore):
env
DEEPSEEK_API_KEY=你的DeepSeek-API-Key
DEEPSEEK_API_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-v4-flash
Step 4:创建三个模块文件
按上面的代码分别创建 client.mjs、completion.mjs、main.mjs。
Step 5:运行
bash
node main.mjs
如果一切顺利,控制台会输出 DeepSeek 对「什么是模块化编程」的一句话解释。
⚠️ 注意 :
package.json中如果"type": "commonjs",.mjs扩展名仍然可以让 Node.js 以 ES Module 模式处理这些文件。这是课程的一个教学细节------.mjs后缀显式声明模块类型,避免和 CommonJS 混淆。
七、用 CodeGraph 理解代码:一种新的学习方式
传统学代码的方式是「打开文件 → 按顺序读 → 在脑子里拼关系」。CodeGraph 给了另一种路径:先看关系,再看细节。
bash
# 初始化索引
codegraph init -i
# 看整体结构
codegraph_files # → 3 个文件,清晰的分层
# 查符号定义位置
codegraph_search getCompletion # → completion.mjs:3
# 追踪完整调用链
codegraph_trace main → getCompletion # → 2 跳,完整路径
这套工具把代码变成了可查询的知识图谱------谁调谁、谁依赖谁、改一个符号会影响哪些文件------全部结构化地展现在眼前。建议你在学习任何新项目时都先用它扫一遍,建立全局认知再深入细节,效率会高很多。
八、总结
这篇文章围绕一个不到 40 行的 NLP Demo,展开了七个维度的深度分析:
| 维度 | 核心收获 |
|---|---|
| 项目结构 | 3 层架构:client → completion → main |
| ES6 语法 | let/const、解构、rest/spread、模块化 |
| API 链路 | 5 步流转:配置 → 客户端 → prompt → 调用 → 解析 |
| 设计原则 | 单一职责、关注点分离、可复用、配置外部化 |
| 工程实践 | .env 管理敏感信息、SDK 抽象、async/await |
| 工具方法 | CodeGraph 先看关系再看细节的学习路径 |
| 实战步骤 | 5 步跑通第一个 NLP Demo |
这个 Demo 很小,但它是一颗种子。Prompt 驱动大模型做 NLP 任务这个范式,从这里出发,可以长出聊天机器人、智能客服、文档分析、代码生成------所有 AI 应用的本质,都是「拿输入 → 拼 prompt → 调 API → 输出结果」这个循环的不同包装。
真正的模块化思想,不是把代码拆成多个文件就完事了------而是让每一层都对变化保持开放,对修改保持封闭。这个 3 文件的 NLP Demo,就是这种思想的微型范本。
参考资源
如果这篇文章对你有帮助,欢迎点赞、收藏、转发。有问题欢迎在评论区交流讨论!