JavaScript/Node.js调用AI API完整教程

环境与 SDK 安装

Node.js 版本要求

官方 openai SDK 4.x 用了原生 fetch 和 Web Streams,需要 Node 18 以上,直接上 20 LTS 或 22 LTS 最省心。老项目还在 Node 16 的,先升版本,别想着降级 SDK 硬扛。

node -v          # 确认 >= 18,建议 20+
mkdir ai-demo && cd ai-demo
npm init -y
npm i openai
npm i dotenv
npm i -D typescript tsx @types/node     # 用 TypeScript 才需要

选 CommonJS 还是 ESM

新建项目一律用 ESM,在 package.json 里加一行 "type": "module"。SDK 本身是双格式发布,两种都能跑,但 ESM 的 import 语法和顶层 await 写起来顺手得多。老项目继续用 require 也完全没问题,只是别在同一份文件里混着写。

判断标准很简单:文件里出现 import 就用 ESM 规则解析,出现 require 就用 CommonJS 规则解析,两套规则混用必然报错,而且报错信息通常指向别的地方,排查起来很浪费时间。

把密钥放进 .env

OPENAI_API_KEY=sk-你的小鱼API密钥

代码里用一行加载:

import 'dotenv/config';

.env 必须写进 .gitignore,这一条别省。Key 在 xyuai.cc 后台生成,最低充 7 元就能用,一旦跟着仓库推到公开托管平台,几分钟内就会被扫走刷爆额度。

最小可运行示例

完整代码

import 'dotenv/config';
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: 'https://xyuapi.top/v1',
  timeout: 120_000,
  maxRetries: 2,
});

async function chat(prompt, model = 'deepseek-v3.2-thinking') {
  const resp = await client.chat.completions.create({
    model,
    messages: [
      { role: 'system', content: '你是一个简洁的中文技术助手。' },
      { role: 'user', content: prompt },
    ],
  });
  return resp.choices[0].message.content;
}

console.log(await chat('用一句话解释 Node.js 的事件循环'));

存成 index.mjs,node index.mjs 就能跑。四个参数各有各的用处,值得说清楚:

拿到响应后 resp.choices[0].message.content 就是正文。choices 是数组是因为接口支持一次返回多个候选答案,n 参数大于 1 时才会用到,日常取第 0 个就够。

顶层 await 报错就是模块格式的问题

SyntaxError: Cannot use import statement outside a module

package.json 里没加 "type": "module",或者文件后缀是 .js 又被当成 CommonJS 解析了。两个解法:加 "type": "module",或者把后缀改成 .mjs。

换模型只改一个字符串

const models = ['gemini-2.5-pro', 'claude-sonnet-4-5-thinking', 'kimi-k2.6'];
for (const m of models) {
  console.log(m, '->', await chat('用一句话说明什么是幂等', m));
}

小鱼API 一个 Key 覆盖全系列模型,接入地址统一是 https://xyuapi.top/v1,国内直连不降智不限速,横评多个模型时不用来回换账号和域名。

流式输出与 SSE 解析

用 SDK 拿流最省事

async function streamChat(prompt, model = 'grok-4.5') {
  const stream = await client.chat.completions.create({
    model,
    messages: [{ role: 'user', content: prompt }],
    stream: true,
  });
  let out = '';
  for await (const chunk of stream) {
    const delta = chunk.choices?.[0]?.delta;
    if (delta?.content) {
      out += delta.content;
      process.stdout.write(delta.content);
    }
  }
  process.stdout.write('\n');
  return out;
}

chunk.choices 可能是空数组,最后一个带 usage 的包里就没有 choices,所以必须用可选链,不然直接 TypeError。

不用 SDK 时手写 SSE 解析

有些运行环境不方便引 SDK,那就用原生 fetch。注意 SSE 的分包边界会切在任意位置,一个 JSON 对象可能被拆成两半,必须自己缓冲:

async function rawStream(prompt) {
  const res = await fetch('https://xyuapi.top/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'deepseek-v3.2-thinking',
      messages: [{ role: 'user', content: prompt }],
      stream: true,
    }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);

  const reader = res.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop();                 // 末尾可能是半行,留到下一轮
    for (const line of lines) {
      const t = line.trim();
      if (!t.startsWith('data:')) continue;
      const payload = t.slice(5).trim();
      if (payload === '[DONE]') return;
      const text = JSON.parse(payload).choices?.[0]?.delta?.content;
      if (text) process.stdout.write(text);
    }
  }
}

两个关键点:buffer 必须留下最后一段不完整的行;TextDecoder 要带 { stream: true },否则一个中文字符正好被切成两半时会解出乱码。

把流推给前端

服务端把上游流包一层自己的 SSE 协议吐出去:

app.post('/api/chat/stream', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const stream = await client.chat.completions.create({
    model: 'grok-4.5',
    messages: req.body.messages,
    stream: true,
  });

  for await (const chunk of stream) {
    const text = chunk.choices?.[0]?.delta?.content;
    if (text) res.write(`data: ${JSON.stringify({ text })}\n\n`);
  }
  res.write('data: [DONE]\n\n');
  res.end();
});

res.flushHeaders() 不能忘,不加的话响应头会攒到第一块数据才发出去,前端拿到流的时间被推迟。另外别在中间套任何会做响应缓冲的中间件,压缩中间件是最常见的一个坑,它对 SSE 的即时性破坏最明显。

浏览器端绝不能硬编码 Key

为什么前端放 Key 等于公开

打开开发者工具,Network 面板里任何一个请求的 Authorization 头就是明文 Key。就算不抓包,打包后的 JS 里搜一下 sk- 也能直接找到。代码混淆没有用,还原成本是几分钟的事。Key 泄露之后别人拿你的额度跑他的一万条任务,账单全部算在你头上。

正确做法:后端做代理

浏览器调自己的后端,后端拿 Key 去调小鱼API:

import 'dotenv/config';
import express from 'express';
import OpenAI from 'openai';

const app = express();
app.use(express.json({ limit: '1mb' }));

const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,   // 只在服务端存在
  baseURL: 'https://xyuapi.top/v1',
  timeout: 180_000,
});

app.post('/api/chat', async (req, res) => {
  const { messages } = req.body;
  if (!Array.isArray(messages)) {
    return res.status(400).json({ error: 'messages 必须是数组' });
  }
  try {
    const r = await client.chat.completions.create({
      model: 'deepseek-v3.2-thinking',
      messages,
    });
    res.json({ content: r.choices[0].message.content });
  } catch (e) {
    console.error('upstream error:', e.status, e.message);
    res.status(502).json({ error: '上游调用失败' });
  }
});

app.listen(3000, () => console.log('http://localhost:3000'));

前端这边就干净了:

const resp = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ messages: [{ role: 'user', content: '你好' }] }),
});
const { content } = await resp.json();
console.log(content);

代理层顺手把该做的都做了:校验请求体长度、限制单用户调用频率、记录调用日志、在服务端统一改模型和提示词。这些事在前端一件都做不了。

还有一个容易忽略的点:代理层要过滤前端传来的 model 字段。如果原样透传,用户把 model 改成最贵的那个,成本就失控了。做法是在服务端维护一份允许的模型白名单,前端只能从里面挑。

Nginx 反代要关掉缓冲

前后端不同源、走 Nginx 转发时,SSE 会被 Nginx 攒一大块才吐给浏览器,流式效果直接消失:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 300s;
}

proxy_read_timeout 默认只有 60 秒,思考模型偶尔会超过,调到 300 秒更稳。

多轮对话状态管理

服务端管会话

把 messages 存 Redis 或者数据库,用会话 ID 取:

const sessions = new Map();   // 生产环境换成 Redis

function pushMessage(sessionId, message) {
  const list = sessions.get(sessionId) ?? [];
  list.push(message);
  if (list.length > 40) list.splice(0, list.length - 40);   // 保留最近 40 条
  sessions.set(sessionId, list);
  return list;
}

放在进程内存里只适合本地调试。线上多开进程或者重启服务,历史和状态就全丢了。

客户端管会话

纯前端场景更简单,浏览器只维护一个数组,每次整段发给后端:

let history = [];

async function ask(text) {
  history.push({ role: 'user', content: text });
  const resp = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages: history }),
  });
  const { content } = await resp.json();
  history.push({ role: 'assistant', content });
  return content;
}

要跨设备保留历史,就得把 history 落到后端。前端 localStorage 只能算临时缓存,用户清个浏览器数据就没了。这是很多人做聊天界面时最容易犯的错:看着能用,用户换个电脑打开发现对话全没了,体验直接崩掉。

上下文太长怎么办

历史无限堆下去迟早撑爆模型上下文,表现是报 400 或者回答突然变傻。三种处理:只留最近二十条;把早期对话摘要成一段话继续带;换长上下文模型,例如 gemini-3.1-pro-preview。小鱼API 按次计费,上下文拉长不加钱,做这件事主要是为了回答质量。

错误处理与重试

错误对象里有什么

SDK 抛出的都是 APIError 子类,关键字段是 status、code、type,直接打日志就能定位。和 Python 版本一样,错误要分类处理:能重试的重试,不能重试的立刻暴露,别用一个大 try-catch 把所有异常都吞掉,那样线上出问题日志里什么都看不到。

import OpenAI, {
  APIError,
  RateLimitError,
  APIConnectionTimeoutError,
} from 'openai';

带抖动的指数退避

const RETRYABLE_STATUS = new Set([408, 409, 429, 500, 502, 503, 504]);

function isRetryable(err) {
  if (err instanceof APIConnectionTimeoutError) return true;
  if (err instanceof RateLimitError) return true;
  if (err instanceof APIError && RETRYABLE_STATUS.has(err.status)) return true;
  return false;
}

async function withRetry(fn, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      const last = i === attempts - 1;
      if (last || !isRetryable(err)) throw err;
      const wait = Math.min(2 ** i, 30) * (500 + Math.random() * 500);
      console.warn(`第 ${i + 1} 次失败(${err.status ?? err.name}),${Math.round(wait)}ms 后重试`);
      await new Promise((r) => setTimeout(r, wait));
    }
  }
}

const reply = await withRetry(() =>
  client.chat.completions.create({
    model: 'deepseek-v4-flash-thinking',
    messages: [{ role: 'user', content: '你好' }],
  }),
);
console.log(reply.choices[0].message.content);

401 和 404 这类错误重试没有意义,Key 不对就是不对,模型名写错就是写错,直接抛出让人看见比默默重试五次强。

用 AbortController 主动掐断

const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 60_000);

try {
  const r = await client.chat.completions.create(
    { model: 'kimi-k2.6', messages: [{ role: 'user', content: '写首诗' }] },
    { signal: ac.signal },
  );
  console.log(r.choices[0].message.content);
} finally {
  clearTimeout(timer);
}

浏览器里用户点「停止生成」也是同一套机制,前端 abort 之后记得让后端也把上游请求 abort 掉,否则每次取消都会在服务端留下一个跑着的请求。这类幽灵请求不会立刻暴露问题,但在并发量上来之后会明显拖慢整个服务。

TypeScript 类型定义

直接用 SDK 导出的类型

不用自己造轮子,SDK 的类型已经覆盖了请求和响应:

import OpenAI from 'openai';
import type {
  ChatCompletionMessageParam,
  ChatCompletionChunk,
} from 'openai/resources/chat/completions';

定义自己的业务类型

业务层包一层自定义类型,前后端约定的字段就不会乱:

type Role = 'system' | 'user' | 'assistant' | 'tool';

interface ChatMessage {
  role: Role;
  content: string;
}

interface ChatResult {
  content: string;
  model: string;
  usage: {
    promptTokens: number;
    completionTokens: number;
    totalTokens: number;
  };
}

async function chat(
  messages: ChatMessage[],
  model = 'kimi-k2.6',
): Promise<ChatResult> {
  const r = await client.chat.completions.create({
    model,
    messages: messages as ChatCompletionMessageParam[],
  });
  return {
    content: r.choices[0].message.content ?? '',
    model: r.model,
    usage: {
      promptTokens: r.usage?.prompt_tokens ?? 0,
      completionTokens: r.usage?.completion_tokens ?? 0,
      totalTokens: r.usage?.total_tokens ?? 0,
    },
  };
}

usage 全部用 ?? 兜底,因为流式响应和部分模型不返回 usage,不兜会炸在运行时。

tsconfig 要点

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

开发阶段用 npx tsx index.ts 直接跑,不用先编译。skipLibCheck 打开能省掉一大半第三方库的类型噪音。

strict 一定要开。AI 接口返回的字段几乎全是可选的,content 可能是 null,usage 可能在流式响应里压根不存在,不开 strict 这些坑会一路带到线上。让编译器先骂你一遍,比让用户先骂你便宜得多。

三个最容易卡住的地方

CORS

浏览器直连 https://xyuapi.top/v1 会先发 OPTIONS 预检,而且不管通不通,你的 Key 已经暴露在请求头里了。所以浏览器永远只调自己的后端。后端对外暴露接口时再配 CORS 白名单:

import cors from 'cors';
app.use(cors({ origin: ['https://your-site.com'], methods: ['POST'] }));

别写 origin: '*',那等于谁都能调你的接口,白送的额度最容易被薅。如果接口需要带 Cookie 凭证,credentials 也必须显式开启,而且此时 origin 不能是通配符,浏览器会直接拒绝。

本地调试时前端跑在 5173、后端跑在 3000 算两个源,一样会触发 CORS。开发环境把 http://localhost:5173 加进白名单就行,别为了图省事直接放开全部。

超时

Node 的 http 客户端默认没有硬超时,SDK 默认放宽到十分钟。思考模型跑个几十秒很正常,但超过三分钟基本是上游卡住了。建议客户端 timeout 设 120 秒,再用 AbortController 兜一层主动取消,双保险。

流式中断

req.on('close', () => stream.controller?.abort());

上线前拿手机 4G 切 Wi-Fi 手动断几次,流式和中断处理都跑一遍,问题基本会自己蹦出来。流式这类交互的 bug 只在真实网络抖动下才复现,本地网络太稳反而测不出来。

上线前扫一遍这张表

检查项常见表现处理动作
Key 是否只在服务端打包产物里能搜到 sk-改成后端代理调用
是否限制可选模型用户能自己指定最贵的型号服务端维护模型白名单
流式是否被中间件缓冲前端一次性蹦出一大段关掉压缩,proxy_buffering off
断连是否释放上游请求并发一高整体变慢监听 close 事件 abort 上游
有没有按用户限流一个账号跑光整月额度按用户维度限次

这几条都属于「上线前五分钟能查、上线后要查半天」的类型。尤其是模型白名单,前端传什么就转发什么,是最容易被忽略的成本漏洞。

真实价格表与成本控制

按次计费到底省在哪

小鱼API 采用按次计费:一次对话请求算一次钱,输入 1 个字和输入 20 万字同一个价,输出多长也不影响单价。这意味着前端、后端、批处理脚本的调用成本是可预测的,不会因为用户贴了一篇长文档就费用飙升。响应的 usage 字段还能读,用来做日志分析或者质量监控都行,但它不参与算钱,和按 token 计费的平台逻辑完全不同。

真实价格表

模型单价(元/次)类型
gemini-2.5-pro0.031对话
gemini-3-pro-preview0.05对话
gemini-3.1-pro-preview0.09对话
NanoBanana-Pro0.27生图
claude-sonnet-4-5-thinking0.09思考
claude-opus-4-5-thinking0.12思考
claude-sonnet-4-6-thinking0.2思考
claude-opus-4-6-thinking0.25思考
claude-sonnet-4-7-thinking0.2思考
claude-opus-4-7-thinking0.25思考
grok-4.10.05对话
grok-4.50.09对话
grok-4.60.09对话
deepseek-v3.2-thinking0.049思考
deepseek-r1-thinking0.049思考
deepseek-v4-flash-thinking0.05思考
deepseek-v4-pro-thinking0.09思考
gpt-5.50.2对话
gpt-5.5-pro-thinking0.2思考
gpt-5.3-pro0.3对话
gpt-image-2-pro0.25生图
kimi-k2.50.09对话
kimi-k2.60.09对话

最低 7 元起充,用多少扣多少。日调用量上千次的话,无限卡套餐比单次扣费划算得多。

三个省钱的真实做法

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信