在大模型 API 对接初期,很多开发者为了快速跑通 Demo,往往会把 API Key、接口地址、模型名称、请求逻辑、业务提示词全部写进同一个 JavaScript 文件中。这样做短期看似方便,但一旦项目进入迭代阶段,问题会很快暴露:更换模型需要通篇搜索代码,迁移项目时需要手动拆分逻辑,密钥硬编码还可能因误提交 Git 仓库造成额度被盗刷。
更合理的做法,是从一开始就按照工程化思路拆分代码。本文基于 Node.js ESM 模块规范,使用 OpenAI Node SDK 对接 DeepSeek API,完成一个可复用、可维护、可扩展的轻量 LLM 调用项目。DeepSeek 官方文档显示,其 API 采用兼容 OpenAI/Anthropic 的调用格式,开发者可以通过修改配置,使用 OpenAI SDK 或兼容 OpenAI API 的软件进行访问。
本文示例重点不在复杂框架,而在最基础、最容易长期复用的项目分层:配置层、客户端层、请求封装层和业务入口层。通过这种方式,后续无论是接入文本摘要、信息抽取、分类判断,还是迁移到其他兼容 OpenAI 格式的大模型,都能减少重复改造成本。
一、为什么不建议继续使用单文件写法
很多初学者第一次写大模型调用代码时,代码结构大致如下:在文件顶部写入密钥,中间初始化客户端,下面直接写 Prompt,最后调用接口并输出结果。这种方式适合验证 API 是否可用,但并不适合作为项目基础。
主要问题有三类。
第一,配置和业务强耦合。API Key、Base URL、模型名称都写在业务代码里,后续一旦更换模型或切换环境,就需要修改源码。开发环境、测试环境、生产环境很难保持隔离。
第二,安全风险较高。密钥硬编码是非常常见的安全问题,一旦代码上传到 GitHub、Gitee 或公司内部仓库,密钥就可能泄露。OpenAI Node SDK 文档也特别提醒,不应在浏览器端暴露密钥,因为用户可以检查前端代码并提取凭证。
第三,复用成本较高。如果项目后续需要在多个模块中调用大模型,例如评论摘要、客服问答、标签生成、文本分类等,每个文件都重新初始化客户端,会造成代码重复,也不利于统一处理错误、超时和日志。
因此,更推荐使用模块化结构,将“配置读取”“客户端创建”“请求方法”“业务任务”拆开管理。
二、项目结构设计:按职责拆分,而不是按文件堆叠
本文采用 ESM 模块化方式组织项目。Node.js 官方文档说明,开发者可以通过 .mjs 文件扩展名,或在 package.json 中设置 "type": "module",让 Node.js 将 JavaScript 文件解释为 ES Module。
推荐项目结构如下:
deepseek-llm-demo/
├── .env
├── .env.example
├── .gitignore
├── package.json
├── config.mjs
├── client.mjs
├── completions.mjs
└── main.mjs
各文件职责如下:
.env 存放本地敏感配置,不提交仓库
.env.example 提供配置模板,方便团队成员复制
.gitignore 忽略 node_modules、.env 等文件
package.json 项目依赖与启动命令
config.mjs 读取并校验环境变量
client.mjs 创建全局唯一 LLM 客户端
completions.mjs 封装通用文本调用方法
main.mjs 编写具体 NLP 业务逻辑
相比原来的四文件结构,这里新增了 config.mjs。它的作用是集中校验环境变量,避免程序运行到接口调用阶段才发现密钥为空、模型名缺失或 Base URL 配错。工程项目中,配置校验越早发生,排查成本越低。
三、初始化项目与安装依赖
首先新建项目目录并初始化:
mkdir deepseek-llm-demo
cd deepseek-llm-demo
npm init -y
安装核心依赖:
npm install openai dotenv
OpenAI 官方 JavaScript/TypeScript SDK 提供了便捷的 REST API 调用能力,安装命令为 npm install openai,并支持 Chat Completions API。([GitHub][2]) 当前 OpenAI Node SDK 对运行环境有版本要求,官方文档列出的 Node.js 支持范围为 Node.js 20 LTS 或更高版本,因此不建议继续使用较旧的 Node.js 14 作为新项目基础。
然后修改 package.json:
{
"name": "deepseek-llm-demo",
"version": "1.0.0",
"description": "A modular Node.js ESM demo for DeepSeek API calls",
"type": "module",
"scripts": {
"start": "node main.mjs"
},
"dependencies": {
"dotenv": "^16.4.7",
"openai": "^5.0.0"
}
}
这里设置 "type": "module" 后,项目中的 .js 文件也会按 ESM 解析。不过为了示例更清晰,本文仍统一使用 .mjs 后缀。
四、环境变量配置:不要把密钥写进代码
创建 .env 文件:
DEEPSEEK_API_KEY=sk-替换为你的真实密钥
DEEPSEEK_API_BASE_URL=https://api.deepseek.com
DEEPSEEK_API_MODEL=deepseek-v4-flash
LLM_TIMEOUT_MS=30000
DeepSeek 官方文档中,OpenAI 格式的 Base URL 为 https://api.deepseek.com,模型包含 deepseek-v4-flash 和 deepseek-v4-pro。文档还显示,deepseek-chat 与 deepseek-reasoner 将于 2026 年 7 月 24 日 15:59 UTC 废弃,并分别对应 deepseek-v4-flash 的非思考模式与思考模式。
再创建 .env.example,方便团队成员知道需要哪些配置:
DEEPSEEK_API_KEY=
DEEPSEEK_API_BASE_URL=https://api.deepseek.com
DEEPSEEK_API_MODEL=deepseek-v4-flash
LLM_TIMEOUT_MS=30000
创建 .gitignore:
node_modules
.env
.DS_Store
npm-debug.log
.env 一定不要提交到仓库。.env.example 可以提交,因为里面不包含真实密钥。
五、配置层 config.mjs:统一读取并校验环境变量
很多项目出问题,并不是接口不可用,而是环境变量没有正确读取。比如 .env 文件路径不对、变量名拼写错误、dotenv.config() 执行顺序不对,都会导致 process.env 中取不到值。
创建 config.mjs:
// config.mjs
import dotenv from "dotenv";
dotenv.config();
const requiredEnvNames = [
"DEEPSEEK_API_KEY",
"DEEPSEEK_API_BASE_URL",
"DEEPSEEK_API_MODEL"
];
for (const name of requiredEnvNames) {
if (!process.env[name]) {
throw new Error(`Missing required environment variable: ${name}`);
}
}
export const llmConfig = Object.freeze({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_API_BASE_URL,
model: process.env.DEEPSEEK_API_MODEL,
timeout: Number(process.env.LLM_TIMEOUT_MS || 30000)
});
这一步有两个好处:第一,环境变量只在一个地方读取;第二,如果配置缺失,程序启动时就会直接报错,而不是等到请求接口时才返回模糊异常。
六、客户端层 client.mjs:创建全局唯一客户端实例
接下来封装客户端。OpenAI Node SDK 官方示例使用默认导入方式,即 import OpenAI from "openai",并通过 apiKey 创建客户端实例。
创建 client.mjs:
// client.mjs
import OpenAI from "openai";
import { llmConfig } from "./config.mjs";
const client = new OpenAI({
apiKey: llmConfig.apiKey,
baseURL: llmConfig.baseURL,
timeout: llmConfig.timeout,
maxRetries: 2
});
export default client;
这里不建议在每个业务文件里反复 new OpenAI()。客户端初始化属于基础设施逻辑,应该全局复用。后续如果要增加代理、超时时间、重试次数、请求日志,也只需要修改这个文件。
需要注意的是,ESM 中默认导出 export default 每个模块只能有一个。如果同一个文件写多个默认导出,会直接触发语法错误。多个函数或变量应该使用命名导出。
七、请求封装层 completions.mjs:让业务代码不关心底层接口
创建 completions.mjs:
// completions.mjs
import client from "./client.mjs";
import { llmConfig } from "./config.mjs";
export async function getCompletion(prompt, options = {}) {
const {
systemPrompt = "你是一个严谨的中文 NLP 助手,请按用户要求输出结果。",
temperature = 0.2,
responseFormat
} = options;
const requestBody = {
model: llmConfig.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt }
],
temperature
};
if (responseFormat === "json") {
requestBody.response_format = { type: "json_object" };
}
const response = await client.chat.completions.create(requestBody);
const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error("Empty response from LLM");
}
return content.trim();
}
export async function getJsonCompletion(prompt, options = {}) {
const content = await getCompletion(prompt, {
...options,
responseFormat: "json"
});
try {
return JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse JSON response: ${content}`);
}
}
这层代码的价值在于“屏蔽底层细节”。业务入口不需要知道模型参数怎么传,也不需要重复写 client.chat.completions.create()。后续如果需要加入流式输出、统一日志、失败重试、请求耗时统计,都可以在这里扩展。
八、业务入口 main.mjs:用 Prompt 实现三类轻量 NLP 任务
接下来编写入口文件 main.mjs。这里演示三类常见任务:电商评论信息抽取、批量评论摘要、文本主题匹配。
// main.mjs
import { getCompletion, getJsonCompletion } from "./completions.mjs";
async function extractReviewInfo() {
const review = `
我需要一盏漂亮的卧室灯,这款灯具有额外的储物功能,价格也不算太高。
我很快就收到了它。在运输过程中,我们的灯绳断了,但是公司很乐意寄送了一个新的。
几天后就收到了。这款灯很容易组装。我发现少了一个零件,于是联系了他们的客服,
他们很快就给我寄来了缺失的零件!在我看来,Lumina 是一家非常关心顾客和产品的优秀公司!
`;
const prompt = `
请从下面的评论中抽取信息。
要求:
1. 严格输出 JSON,不要输出解释文字。
2. 字段 sentiment 只能是 "正面"、"负面" 或 "中性"。
3. 字段 angry 为布尔值 true 或 false。
4. 无法判断的信息填 "未知"。
JSON 字段:
{
"sentiment": "",
"angry": false,
"product": "",
"company": ""
}
评论文本:
\`\`\`
${review}
\`\`\`
`;
const result = await getJsonCompletion(prompt, {
systemPrompt: "你是一个信息抽取助手,只输出合法 JSON。"
});
console.log("评论信息抽取结果:");
console.log(result);
}
async function summarizeReviews() {
const reviews = [
"这个熊猫公仔是我给女儿的生日礼物,她很喜欢,去哪都带着。公仔很软,超级可爱,面部表情也很和善。但是相比于价钱来说,它有点小,同价位能买更大的,快递提前一天送达。",
"电动牙刷是牙医推荐的,电池续航强,但刷头尺寸太小。50美元价位性价比还可以,不过替换原装刷头比较贵,通用平替更划算。"
];
console.log("\n批量评论摘要:");
for (const review of reviews) {
const prompt = `
请总结下面这条商品评论,不超过30个汉字。
要求突出商品优点、缺点或购买体验,不要添加原文没有的信息。
评论:
\`\`\`
${review}
\`\`\`
`;
const summary = await getCompletion(prompt, {
temperature: 0.3
});
console.log("-", summary);
}
}
async function matchTopics() {
const topics = ["美国国家航空航天局", "地方政府", "员工满意度"];
const text = "NASA员工满意度95%排名公共部门第一,社保管理局满意度仅45%。";
const prompt = `
请判断主题列表中的每个主题是否属于文本话题。
输出要求:
1. 严格输出 JSON。
2. results 数组长度必须与主题列表一致。
3. match 为 1 表示相关,0 表示不相关。
主题列表:
${JSON.stringify(topics)}
文本:
\`\`\`
${text}
\`\`\`
输出格式:
{
"results": [
{ "topic": "主题名称", "match": 1 }
]
}
`;
const result = await getJsonCompletion(prompt, {
systemPrompt: "你是一个文本分类助手,只输出合法 JSON。"
});
console.log("\n主题匹配结果:");
console.log(result);
}
async function main() {
await extractReviewInfo();
await summarizeReviews();
await matchTopics();
}
main().catch((error) => {
console.error("程序执行失败:", error.message);
process.exit(1);
});
运行项目:
npm run start
如果配置正确,控制台会依次输出评论抽取结果、批量摘要结果和主题匹配结果。
九、Prompt 设计要点:不要只写“帮我总结一下”
大模型可以处理自然语言任务,但如果 Prompt 太随意,输出就会不稳定。尤其在需要程序解析结果的场景中,Prompt 必须尽量结构化。
在信息抽取任务中,建议明确字段、类型、枚举值和缺省值。例如 sentiment 只能从“正面、负面、中性”中选择,angry 必须是布尔值,未知内容统一填“未知”。这样可以减少模型自由发挥。
在摘要任务中,建议同时给出长度限制和摘要重点。比如“不超过30个汉字”“突出商品优点、缺点或购买体验”“不要添加原文没有的信息”。这比单纯写“总结一下”更稳定。
在分类任务中,如果后续需要程序读取结果,优先让模型输出 JSON,而不是自然语言解释。虽然“0/1逗号分隔”更短,但一旦模型多输出一句解释,解析逻辑就会失败。JSON 输出更适合工程集成。
十、常见踩坑与修复建议
第一个坑是 Base URL 写错。DeepSeek 官方 OpenAI 格式 Base URL 示例为 https://api.deepseek.com,如果你使用的是第三方兼容网关,才需要根据对应平台文档判断是否添加 /v1。Base URL 不匹配时,最常见的表现是 404、路径错误或请求无法命中目标接口。
第二个坑是 ESM 与 CommonJS 混用。如果项目没有设置 "type": "module",同时文件后缀也不是 .mjs,直接写 import 可能导致模块解析错误。Node.js 官方文档明确说明,.mjs 扩展名和 package.json 中的 "type": "module" 都可以作为启用 ESM 的显式标记。
第三个坑是环境变量读取顺序错误。dotenv.config() 必须在读取 process.env 之前执行。将这一步放进 config.mjs 后,可以避免在多个文件里重复调用,也能减少顺序错误。
第四个坑是把密钥写进前端代码。大模型 API Key 应只存在于服务端环境中,不应暴露在浏览器、移动端包体或公开仓库里。生产环境更推荐使用云平台 Secret Manager、CI/CD 环境变量或容器密钥注入。
第五个坑是过度相信模型输出格式。即使设置了 JSON 输出,也建议在代码中保留 JSON.parse 异常处理,并在失败时记录原始返回内容,方便排查 Prompt 或模型兼容问题。
十一、后续扩展方向:从 Demo 走向可上线服务
当项目从本地脚本演进为线上服务后,可以继续补充以下能力。
首先是流式输出。对于客服对话、长文本生成、代码解释等场景,流式输出可以明显改善用户等待体验。当前示例为了便于理解,采用的是非流式调用。
其次是日志与监控。建议记录请求耗时、模型名称、任务类型、Token 消耗和错误信息,但不要记录 API Key,也不要直接记录用户隐私文本。
第三是并发控制。批量摘要或批量抽取时,不建议一次性发起过多请求,可以使用队列或并发限制工具控制请求数量,避免触发限流。
第四是多模型配置管理。如果团队后续需要同时维护 DeepSeek、通义千问、智谱等多个兼容 OpenAI 格式的调用入口,也可以把 TreeRouter 作为大模型 API 聚合接入层使用,用于集中配置不同供应商的 Base URL、模型名与密钥,减少重复对接和切换成本;但业务侧仍应保留本文示例中的配置校验、错误处理、日志记录和权限边界。
第五是类型校验。在 Node.js 项目中,可以引入 Zod、Valibot 等 Schema 校验库,对模型返回的 JSON 做二次校验,避免字段缺失或类型错误影响后续业务流程。
十二、总结
从单文件 Demo 到模块化 LLM 项目,真正提升的不是代码行数,而是项目的可维护性和可迁移性。本文将 DeepSeek API 调用拆分为配置层、客户端层、请求封装层和业务入口层,并通过评论抽取、批量摘要、主题匹配三个示例展示了轻量 NLP 任务的落地方式。
这套结构适合三类场景:个人开发者快速学习大模型 API 调用;中小团队构建内部文本处理工具;企业在正式接入复杂 Agent 或 RAG 系统前,先验证基础模型能力。它不依赖复杂框架,也不绑定单一业务逻辑,只要目标模型兼容 OpenAI 接口格式,就可以通过修改环境变量完成较低成本的迁移。
大模型项目的工程化,往往不是从复杂架构开始,而是从不硬编码密钥、不重复初始化客户端、不把所有逻辑塞进一个文件开始。把这些基础做好,后续扩展流式输出、重试机制、日志监控、批量任务和多模型接入,都会更加顺畅。




