从零调用大模型 API:用 Node.js 理解环境变量、模块化和 async/await

5 阅读8分钟

平时使用 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 通常还涉及工具使用和多步任务执行