从模块化到 Prompt 工程:我用 Node.js + LLM 复刻了传统 NLP 的流程
情感分类、信息提取、主题推断、文本总结------不到 100 行代码全部搞定
没有训练、没有标注、没有模型部署,只有写好一段自然语言指令
在传统的自然语言处理(NLP)开发中,即便是最简单的二分类任务,也需要经过数据清洗、特征工程、模型选型、训练调优、部署上线等一系列漫长流程。一个熟练的算法工程师团队,通常需要数天到数周才能交付一个可用的情感分析接口。
但今天,借助大语言模型(LLM)和 Prompt Engineering,我们可以在几分钟内构建出功能同样完整的 NLP 系统。本文基于一个真实的 Node.js 项目,完整展示如何用模块化的代码结构、ES6 现代语法以及精心设计的 Prompt,高效完成以下任务:
- 情感分类(正面 / 负面 / 愤怒检测)
- 信息提取(商品名、品牌名、缺失零件等)
- 主题推断(从长文本中自动归纳话题)
- 文本总结(控制长度、聚焦特定方面)
所有的代码和注释都来自你手中的文件,我会逐段分析其设计思想与实现细节。
一、项目架构:三文件模块化,高内聚低耦合
一个优秀的 AI 应用,首先应该是一个结构清晰、易于维护和复用的 Node.js 模块。项目分为三个核心文件:
| 文件 | 职责 | 导出内容 |
|---|---|---|
client.mjs |
封装 LLM 客户端(OpenAI 兼容接口) | 默认导出 client 对象 |
completions.mjs |
定义具体的 LLM 调用函数 | 命名导出 getCompletion、getImage |
main.mjs / main2.mjs |
程序入口,编排 Prompt 并执行 | 不导出,直接运行 |
这种分层的模块化设计带来了三个直接好处:
- 可读性与维护性 :每一层的职责单一,修改 LLM 配置只改
client.mjs,新增 Prompt 任务只改main文件。 - 复用性 :
getCompletion可以在任何地方被引入,无需重复编写chat.completions.create逻辑。 - 企业级协作:不同开发者可以同时修改不同模块,减少合并冲突。
1.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;
关键设计点:
- 使用
dotenv从.env文件读取 API 密钥和 Base URL,避免硬编码。 - 创建
OpenAI实例时,baseURL指向了 DeepSeek 或其他兼容服务,说明这套代码不局限于 OpenAI 官方,可以替换任意提供相同接口的 LLM。 - 默认导出 :一个模块只能有一个默认导出,这里把
client作为整个模块的核心资源暴露出去。
1.2 任务函数封装 (completions.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) {
// 预留:图像生成接口
}
设计亮点:
getCompletion是一个通用的 LLM 调用入口,接收任意 Prompt 字符串,返回模型的文本回复。- 模型名称也通过环境变量
DEEPSEEK_MODEL控制,方便切换不同模型(如从 DeepSeek-V3 换到 GPT-4)。 - 预留了
getImage函数,体现了可扩展性------未来可以轻松加入图像生成能力。 - 使用命名导出 (
export async function),允许多个任务函数共存。
二、ES6 语法:让 JavaScript 真正成为企业级语言
在 main2.mjs 的开头,有一段被注释掉的代码,正是对 ES6 核心特性的精彩演示。我们把它整理出来并深入分析。
2.1 解构赋值(Destructuring)
javascript
let { name, age } = { "name": "詹姆斯", "age": 20 };
console.log(name, age); // 詹姆斯 20
let obj = { "name": "姚明", "city": "上海" };
let { name, city } = obj;
传统写法需要 obj.name、obj.city 逐个访问,解构赋值让代码更简洁,同时性能更好(因为引擎可以直接绑定属性到变量)。在大型项目中,这种写法极大地提升了代码的可读性。
2.2 Rest 运算符与 Spread 运算符
javascript
let [coach, ...players] = ['范甘迪', '姚明', '麦迪', '穆托姆博', '弗朗西斯'];
console.log(coach); // '范甘迪'
console.log(players); // ['姚明', '麦迪', '穆托姆博', '弗朗西斯']
let [hrCoach, ...hrPlayers] = ['杰克逊', '科比', '费舍尔', '加索尔'];
let allPlayers = [...players, ...hrPlayers];
console.log(allPlayers);
// ['姚明', '麦迪', '穆托姆博', '弗朗西斯', '科比', '费舍尔', '加索尔']
...在赋值左边时是 Rest 运算符,用于收集剩余元素到一个数组。...在表达式右边时是 Spread 运算符,用于展开数组或对象。- 这个例子非常生动:通过 Rest 把教练和球员分开,再用 Spread 合并两支球队的球员名单。这种操作在处理动态数据时极为高效。
2.3 模块化导入导出
javascript
// 同时导入默认导出和命名导出
import client, { a, b } from "./client.mjs";
// 默认导出
export default client;
// 命名导出
export const a = 2;
export const b = 3;
这些注释清晰地解释了默认导出只能有一个,而命名导出可以有多个。在大型项目中,这种区分可以让模块的公共接口更加明确。
三、Prompt 实战:从情感分类到信息提取
main.mjs 和 main2.mjs 中包含了大量被注释掉但非常有价值的 Prompt 示例。下面按任务类型重新组织,每个示例都配有完整代码和输出说明。
3.1 情感分类(Sentiment Analysis)
3.1.1 基础二分类:正面 / 负面
javascript
const lamp_review_zh = `
我需要一盏漂亮的卧室灯,这款灯具有额外的储物功能,价格也不算太高。
我很快就收到了它。在运输过程中,我们的灯绳断了,但是公司很乐意寄送了一个新的。
几天后就收到了。这款灯很容易组装。我发现少了一个零件,于是联系了他们的客服,
他们很快就给我寄来了缺失的零件!在我看来,Lumina 是一家非常关心顾客和产品的优秀公司!
`;
const prompt = `
以下用三个反引号分隔的产品评论的情感是什么?
用一个单词回答:正面或负面
评论文本:\`\`\`${lamp_review_zh}\`\`\`
`;
const response = await getCompletion(prompt);
console.log(response); // 正面
分析:
- 指令非常直接:"用一个单词回答:正面或负面" 严格限制了输出格式,避免 LLM 输出冗长的解释。
- 使用三个反引号包裹评论文本,这是常见的分隔技巧,可以避免评论中的特殊字符(如换行、引号)干扰 Prompt 的解析。
- 结果返回
"正面",符合预期。
3.1.2 愤怒检测(是否表达愤怒)
javascript
const prompt = `
以下用三个反引号分割的产品评论是否表达了愤怒?
给出是或否的回答。
评论文本:\`\`\`${lamp_review_zh}\`\`\`
`;
// 输出:否
分析:
- 这是一个更细粒度的情感分析。传统方法需要额外标注"愤怒"类别并重新训练模型。而这里只需修改一句指令,同一个 LLM 就能完成新任务。
- 输出限制为"是或否",同样简洁。
3.1.3 提取多个情感词(逗号分隔)
javascript
const prompt = `
识别以下用三个反引号分隔的产品评论的作者表达的情感。
包含不超过5个项目。
将答案格式化为逗号分隔的单词列表。
评论文本:\`\`\`${lamp_review_zh}\`\`\`
`;
// 可能输出:满意, 感激, 高兴, 放心, 惊喜
分析:
- 不限制情感类型(正面/负面),而是让 LLM 自由提取评论中蕴含的情感词。这对于客户反馈的深度分析非常有用。
- "不超过5个项目" 可以防止输出过长,也迫使 LLM 聚焦于最显著的情感。
- "逗号分隔的单词列表"是一种简单的结构化输出,便于后续用
split(',')解析。
3.1.4 Few‑Shot 示例(注释中提到的"few shot")
虽然文件中没有完整写出,但根据注释提示,典型写法如下:
javascript
const prompt = `
将评论的情感分类为:正面、负面、中性。
示例:
评论:"非常好用" -> 正面
评论:"垃圾产品" -> 负面
评论:"一般般" -> 中性
现在请分类:
评论:"${someReview}"
`;
分析:
- Few‑Shot 是指给 LLM 提供少量示例(通常是 1~5 个),让它通过模仿来完成任务。当任务边界模糊或输出格式复杂时,Few‑Shot 可以显著提高准确率。
- 示例覆盖了正面、负面、中性三类,LLM 会学习这种映射关系,然后对未知评论进行分类。
3.2 信息提取(Information Extraction)
3.2.1 提取商品名和品牌名(JSON 格式)
javascript
const prompt = `
从评论文本中识别以下项目:
- 评论者购买的商品
- 制造该商品的公司
将你的响应格式以"物品(product)"和"品牌(brand)"为键的JSON对象。
如果信息不存在,请使用**未知**作为值。
评论文本:\`\`\`${lamp_review_zh}\`\`\`
`;
// 输出:{"product": "卧室灯", "brand": "Lumina"}
分析:
- 强制要求输出 JSON 对象,这是让 LLM 输出的结果可以被程序直接
JSON.parse()的关键。没有这一步,你将得到一段自然语言,需要再用正则表达式提取,既不稳定又低效。 - 增加 "如果信息不存在,请使用未知" 可以避免 LLM 胡乱编造(幻觉问题)。例如,如果评论中没有提到品牌,LLM 不会瞎猜一个,而是输出
"brand": "未知"。 - 这种技术可以用于自动抽取电商评论中的产品属性、价格、物流信息、售后评价等,实现非结构化数据到结构化数据的转换。
3.2.2 从长文本中提取主题(主题推断)
在 main2.mjs 中有一个 story_zh 长文本,描述了政府调查 NASA 和社会保障局的员工满意度。对应的 Prompt 如下:
javascript
const story_zh = `
在政府最近进行的一项调查中,要求公共部门的员工对他们所在部门的满意度进行评分。
调查结果显示,NASA 是最受欢迎的部门,满意度为 95%。
一位 NASA 员工 John Smith 对这一发现发表了评论,他表示:
"我对 NASA 排名第一并不感到惊讶。这是一个与了不起的人们和令人难以置信的机会共事的好地方。我为成为这样一个创新组织的一员感到自豪。"
NASA 的管理团队也对这一结果表示欢迎,主管 Tom Johnson 表示:
"我们很高兴听到我们的员工对 NASA 的工作感到满意。我们拥有一支才华横溢、忠诚敬业的团队,他们为实现我们的目标不懈努力,看到他们的辛勤工作得到回报是太棒了。"
调查还显示,社会保障管理局的满意度最低,只有 45%的员工表示他们对工作满意。
政府承诺解决调查中员工提出的问题,并努力提高所有部门的工作满意度。
`;
const prompt = `
确定以下给定文本中讨论的五个主题。
每个主题用一到两个单词概括。
输出时用逗号分隔。
给定文本:${story_zh}
`;
// 输出:NASA, 员工满意度, 政府调查, 管理团队, 社会保障局
分析:
- 主题推断是传统无监督学习(如 LDA - Latent Dirichlet Allocation)的典型任务,需要调参(主题数量、超参数)且输出是一堆概率分布词,可读性差。而这里直接用 Prompt 完成,输出可读性极高,并且可以指定主题数量("五个主题")和词长限制("一到两个单词")。
- 注意:LLM 在这里不仅做了聚类,还做了命名实体识别 (NASA、社会保障局)和抽象概念归纳(员工满意度、管理团队)。这展示了 LLM 强大的语言理解能力。
3.3 文本总结(Summarization)
3.3.1 基础总结:限制词汇数量
javascript
const prod_review_zh = `
这个熊猫公仔是我给女儿的生日礼物,她很喜欢,去哪都带着。
公仔很软,超级可爱,面部表情也很和善。但是相比于价钱来说,
它有点小,我感觉在别的地方用同样的价钱能买到更大的。
快递比预期提前了一天到货,所以在送给女儿之前,我自己玩了会。
`;
const prompt = `
您的任务是从电子商务网站上生成一个产品评论的简短摘要。
请对三个反引号之间的评论文本进行概括,最多30个词汇。
评论文本:\`\`\`${prod_review_zh}\`\`\`
`;
分析:
- "最多30个词汇" 是常见的长度控制技巧。LLM 会严格遵守,因为它是明确的数值约束。相比之下,如果说"简短一点",LLM 的"简短"可能因人而异。
- 这种摘要可以直接用于商品页面的短评展示、客服工单的自动摘要等。
3.3.2 聚焦特定方面(产品运输)
javascript
const prompt = `
您的任务是从电子商务网站上生成一个产品评论的简短摘要。
请对三个反引号之间的评论文本进行概括,最多30个词汇。
并且聚焦在产品运输上。
评论文本:\`\`\`${prod_review_zh}\`\`\`
`;
// 可能的输出:快递提前一天到货,买家在送给女儿前自己先玩了。
分析:
- 通过添加 "并且聚焦在产品运输上" ,我们要求 LLM 忽略其他信息(如价格、质量),只关注物流体验。这是可控文本生成的典型应用。
- 在电商客服分析中,可以批量抽取"物流差评"的共同原因(如"运输过程中灯绳断了"),而不被其他内容干扰。
3.3.3 多维度聚焦(运输 + 价格 + 质量)
javascript
const prompt = `
您的任务是从电子商务网站上生成一个产品评论的简短摘要。
请对三个反引号之间的评论文本进行概括,最多30个词汇。
并且聚焦在产品运输上,以及产品的价格和质量上。
评论文本:\`\`\`${prod_review_zh}\`\`\`
`;
// 输出:快递提前一天到货,价格偏高且公仔偏小,但柔软可爱。
分析:
- 可以同时聚焦多个方面,用"以及"或逗号连接即可。LLM 能够综合这些指令,输出一个融合了运输、价格、质量的摘要。
- 这种多维度控制非常灵活,可用于生成不同视角的摘要,供不同部门使用(物流部看运输摘要,产品部看质量摘要)。
3.3.4 批量处理四篇评论
在 main.mjs 末尾,有一个 for 循环:
javascript
const reviews = [review_1, review_2, review_3, review_4];
for(let review of reviews){
const prompt = `
你的任务是从电子商务网站上的产品评论提取相关信息。
请对三个反引号之间的评论文本进行概括,最多20个字符。
评论文本:\`\`\`${review}\`\`\`
`
const response = await getCompletion(prompt);
console.log(response, '\n');
}
解析:
- 四篇评论分别是:熊猫公仔 (软但价格偏高)、台灯 (灯绳断裂但客服好)、电动牙刷 (电池续航好但刷头太小)、搅拌机(价格波动大、质量下降)。
- 每篇评论都独立调用
getCompletion,输出极短的摘要(最多20个字符)。20个字符大约相当于"快递提前到,价格贵"这样的长度,适合作为标题或卡片提示。 - 这个循环展示了如何将 Prompt 工程应用到批量数据处理场景,例如分析过去一周的所有用户评论。
四、深度分析:为什么 Prompt 工程能颠覆传统 NLP?
根据 readme.md 中的笔记,传统 NLP 任务需要"熟练的机器学习人员数天到数周的时间",而 Prompt 方法只需要几分钟。这种差距的本质是什么?
4.1 从"算法设计"到"指令设计"
| 传统 ML 流程 | Prompt 流程 |
|---|---|
| 标注大规模数据集 | 无需标注 |
| 设计特征工程 | 无需特征 |
| 选择模型架构(CNN/RNN/Transformer) | 无需选型 |
| 训练、调参、验证 | 无需训练 |
| 部署模型服务 | 调用 LLM API |
| 处理 OOV(未登录词)问题 | LLM 泛化能力强 |
传统方法中,模型的知识完全来自训练数据,一旦遇到训练集外的表达方式就会失效。而 LLM 在海量语料上预训练后,已经具备了语言理解与推理的通用能力。我们只需要通过 Prompt 将这种能力引导到特定任务上。
4.2 Prompt 的本质:上下文学习(In-Context Learning)
LLM 并不是真正"理解"了任务,而是通过注意力机制从 Prompt 中提取模式。例如,当我们给出"评论:xxx -> 正面"的示例时,模型会在内部表示空间中调整权重分配,从而对下一个输入产生类似输出。这种能力称为 In-Context Learning。
4.3 局限性与应对策略
Prompt 方法并非万能:
- 输出格式不稳定:解决方案如文中所示,强制要求 JSON 格式并加入"如果信息不存在,使用未知"等兜底指令。
- Token 成本:长文本或 Few-Shot 示例会消耗更多 Token。对策是使用更小的模型或缓存常用结果。
- 确定性不足 :相同 Prompt 可能产生不同结果。可以通过设置
temperature=0提高确定性。
五、总结:你已具备构建企业级 LLM 应用的基础能力
通过分析这个项目,我们学到了:
- 模块化架构 :用
client.mjs封装 LLM 连接,用completions.mjs定义通用调用函数,用main.mjs编排业务逻辑。 - ES6 现代语法:解构赋值、Rest/Spread、模块化导入导出,让代码更简洁、更安全、更易于协作。
- Prompt 工程实战 :
- 情感分类(二分类 + 愤怒检测 + 多情感词提取 + Few-Shot)
- 信息提取(JSON 结构化输出)
- 主题推断(无监督话题发现)
- 文本总结(长度控制 + 多维度聚焦 + 批量处理)
- 深度理解:Prompt 的本质是上下文学习,它以极低的成本替代了传统 ML 的繁琐流程。
未来,"写 Prompt" 将和"写 SQL"一样,成为每一位开发者的基础技能。你不需要成为算法专家,也能利用 LLM 解决复杂的 NLP 问题。
现在,回到你的编辑器中,尝试修改 main.mjs 里的 review 数组,加入你自己的产品评论,看看 LLM 会给出怎样的摘要和情感分析。你已经在通往 AI 原生应用开发的路上了。