平时使用 AI,我们通常是在聊天窗口里输入问题,再等待回答。
如果想把 AI 能力接入自己的程序,就需要通过 API 与模型交互。今天,我用 Node.js 写了一个简单示例:向 DeepSeek 发送一句 hello,然后把模型的回答输出到终端。
这个示例代码不多,却串起了几个很实用的知识点:
- 如何初始化 Node.js 项目;
- 如何安装和使用第三方依赖;
- 如何通过环境变量管理 API Key;
- 如何使用 ES Module;
- 如何通过
async/await获取异步请求的结果。
下面按照“搭建项目 → 配置环境 → 调用模型 → 理解原理”的顺序,整理这次学习的内容。
一、先理解我们要做什么
这次程序的执行流程可以概括为:
启动 Node.js 程序
↓
读取环境变量
↓
创建 API 客户端
↓
发送对话消息
↓
等待模型返回结果
↓
在终端输出回答
这里需要区分两个角色:
- Node.js:运行 JavaScript 程序的环境。
- 大模型 API:接收请求、生成内容并返回结果的远程服务。
我们的程序负责组织请求、发送请求和处理响应,模型的生成过程发生在远程服务中。
二、初始化项目并安装依赖
先创建一个项目目录,进入目录后执行:
npm init -y
这条命令会生成 package.json,用于记录项目名称、依赖、脚本等信息。
需要注意:npm init -y 的作用是初始化 npm 项目,并不直接决定项目属于前端还是后端。
接着安装依赖:
npm install openai dotenv
也可以使用 pnpm:
pnpm add openai dotenv
选择一种包管理工具,在项目中统一使用即可。
这两个依赖分别解决不同的问题:
| 依赖 | 作用 |
|---|---|
openai | 提供调用模型接口的客户端 |
dotenv | 读取 .env 文件,将配置加载到环境变量 |
安装多个包时,包名之间用空格分隔:
pnpm add openai dotenv
不要写成 openai/dotenv,那不是同时安装这两个包的写法。
pnpm 为什么能节省空间?
可以先这样理解:
多个项目可能依赖相同版本的包,pnpm 通过共享存储和链接机制复用依赖文件,减少重复存储。
它并不只是简单地“安装一次,再给所有项目创建软链接”,内部还涉及内容寻址存储和硬链接等机制。入门阶段先掌握它解决了什么问题即可。
三、用环境变量管理 API Key
调用模型 API 通常需要 API Key,它相当于访问服务的凭证。
如果直接把密钥写进代码:
const apiKey = '这里是真实密钥';
代码一旦被提交或分享,密钥也可能一起泄露。
更合适的做法是,把配置放到 .env 文件中,代码只负责读取配置。
1. 创建 .env
在项目目录中创建:
DEEPSEEK_API_KEY=替换成你的真实密钥
DEEPSEEK_API_BASE=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
这里的 deepseek-chat 沿用学习示例。实际运行时,请将 DEEPSEEK_MODEL 改为服务方当前支持的模型名称。
.env 使用 变量名=变量值 的形式,每个配置单独占一行。
变量名通常使用大写,这是命名惯例,并非强制语法。
2. 配置 .gitignore
创建 .gitignore:
node_modules/
.env
它的作用是告诉 Git:不要跟踪这些尚未被纳入版本管理的文件。
需要注意:
.gitignore 不会加密文件,也不会自动删除已经提交过的密钥。
如果需要让其他人知道项目有哪些配置,可以提交一个不包含真实密钥的 .env.example:
DEEPSEEK_API_KEY=
DEEPSEEK_API_BASE=https://api.deepseek.com
DEEPSEEK_MODEL=
3. 加载环境变量
在代码中调用:
import dotenv from 'dotenv';
dotenv.config();
随后就可以读取:
process.env.DEEPSEEK_API_KEY
完整的数据流是:
.env 文件
↓ dotenv.config()
process.env
↓
程序中的客户端配置
其中,process 是 Node.js 提供的全局对象,用于访问当前进程的信息;process.env 保存环境变量。
还有一个容易忽略的细节:dotenv.config() 默认从当前工作目录寻找 .env,不一定从脚本所在目录寻找。
因此,运行示例前应先进入项目目录。
四、理解 .mjs 和 ES Module
示例使用了下面这种导入语法:
import dotenv from 'dotenv';
import { OpenAI } from 'openai';
这是 ES Module,简称 ESM,是 JavaScript 的模块化方案。
模块化可以帮助我们把代码拆分到不同文件,也方便使用第三方提供的功能。
在 Node.js 中,明确使用 ESM 的常见方式有两种。
第一种:使用 .mjs 后缀。
index.mjs
第二种:使用 .js 后缀,并在 package.json 中配置:
{
"type": "module"
}
本次示例直接使用 index.mjs。
五、编写一次完整的模型调用
项目结构如下:
demo1/
├── .env
├── .env.example
├── .gitignore
├── index.mjs
└── package.json
在 index.mjs 中编写:
import dotenv from 'dotenv';
import { OpenAI } from 'openai';
async function main() {
// 1. 加载环境变量
dotenv.config();
const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_API_BASE;
const model = process.env.DEEPSEEK_MODEL;
// 2. 检查必要配置
if (!apiKey || !baseURL || !model) {
throw new Error('请检查 .env 中的密钥、API 地址和模型名称');
}
// 3. 创建客户端
const client = new OpenAI({
apiKey,
baseURL,
});
console.log('程序开始运行');
// 4. 发送请求并等待结果
const result = await client.chat.completions.create({
model,
messages: [
{
role: 'user',
content: '用三句话解释什么是生成式 AI。',
},
],
});
// 5. 读取并输出回答
const content = result.choices[0]?.message?.content;
if (!content) {
throw new Error('本次响应没有返回可输出的文本');
}
console.log(content);
console.log('程序结束');
}
// 6. 处理运行过程中的异常
main().catch((error) => {
console.error('调用失败:', error.message);
process.exitCode = 1;
});
在项目目录中运行:
node index.mjs
配置正确、请求成功时,终端会依次输出:
程序开始运行
模型生成的回答……
程序结束
六、为什么使用 OpenAI SDK,却调用 DeepSeek?
第一次看到下面的代码时,很容易产生疑问:
const client = new OpenAI({
apiKey,
baseURL,
});
类名叫 OpenAI,为什么请求的是 DeepSeek?
原因是 DeepSeek 提供了兼容 OpenAI 格式的接口,因此可以复用相应的客户端。
可以把客户端理解成“帮助我们组织和发送请求的工具”。具体访问什么服务,还要看配置。
| 配置项 | 解决的问题 |
|---|---|
apiKey | 使用什么凭证访问? |
baseURL | 请求发送到哪里? |
model | 使用哪个模型? |
所以,看到 SDK 的名称时,还需要结合 baseURL 和 model 判断实际调用的服务。
七、请求参数和返回结果怎么看?
核心调用是:
const result = await client.chat.completions.create({
model,
messages: [
{
role: 'user',
content: 'hello',
},
],
});
其中:
model:指定模型。messages:发送给模型的对话消息列表。role:表示消息的角色。content:表示消息的内容。
这次使用的 user 表示用户消息。
请求成功后,下面这段代码用于读取回答:
result.choices[0].message.content
可以分层理解:
result 整个响应对象
choices 候选回答列表
choices[0] 第一条候选回答
message 这条回答对应的消息
content 消息中的文本内容
输入是 messages,输出则从响应对象中读取。
八、async/await 到底做了什么?
API 请求需要经过网络,结果不会在调用的瞬间就准备好。
客户端方法返回的是 Promise,我们通过 await 等待它完成:
const result = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: 'hello' }],
});
可以这样理解:
先发出请求,等请求成功返回,再把响应赋值给
result,继续执行当前函数后面的代码。
async 的作用
async 用来声明异步函数:
async function main() {
// ...
}
也可以写成异步箭头函数:
const main = async () => {
// ...
};
这两种形式都可以组织本次示例的入口逻辑。
需要记住:调用 async 函数会得到一个 Promise。
await 会让整个程序停下来吗?
不会。它会暂停当前异步函数的后续执行,其他可以执行的代码仍然能够运行。
看一个不需要网络的例子:
async function main() {
console.log('1. 开始');
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
console.log('3. 等待结束');
}
main();
console.log('2. 函数外的代码');
输出顺序是:
1. 开始
2. 函数外的代码
3. 等待结束
执行到 await 时,main 暂时等待,函数外面的代码继续执行。等 Promise 完成后,main 才继续输出最后一行。
因此,同步和异步不能简单理解为“快”和“慢”,它们描述的是执行与等待的方式。
为什么要捕获错误?
网络请求可能失败,例如配置不正确、网络异常或服务返回错误。
当等待的 Promise 被拒绝时,await 会抛出异常。
示例通过:
main().catch((error) => {
console.error('调用失败:', error.message);
process.exitCode = 1;
});
统一处理入口函数中的失败,让程序给出明确提示。
九、用 nodemon 提高调试效率
开发时,如果每次修改代码都需要手动重启,会比较繁琐。
nodemon 可以监听文件变化,并自动重启程序。
安装为开发依赖:
npm install --save-dev nodemon
运行:
npx nodemon index.mjs
需要留意:如果程序启动后会立即请求模型,那么每次自动重启也可能再次发起请求。
十、几个容易混淆的概念
| 容易产生的理解 | 更准确的理解 |
|---|---|
npm init -y 就是创建后端项目 | 它用于初始化 npm 项目 |
.env 会自动进入 process.env | 本例需要通过 dotenv.config() 加载 |
.gitignore 能保护所有已提交的密钥 | 它主要影响尚未被跟踪的文件 |
await 会阻塞整个程序 | 它暂停当前异步函数的后续执行 |
| 异步代码一定比同步代码慢 | 同步、异步描述执行方式,与快慢不是同一个概念 |
main 是必须使用的函数名 | 它是常见命名约定,可以换成其他名称 |
| 调用一次模型就是 Agent | 本例完成单次调用;Agent 通常还涉及工具使用和多步任务执行 |