官方 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 才需要
新建项目一律用 ESM,在 package.json 里加一行 "type": "module"。SDK 本身是双格式发布,两种都能跑,但 ESM 的 import 语法和顶层 await 写起来顺手得多。老项目继续用 require 也完全没问题,只是别在同一份文件里混着写。
判断标准很简单:文件里出现 import 就用 ESM 规则解析,出现 require 就用 CommonJS 规则解析,两套规则混用必然报错,而且报错信息通常指向别的地方,排查起来很浪费时间。
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 就能跑。四个参数各有各的用处,值得说清楚:
baseURL 指向 https://xyuapi.top/v1,末尾别多写斜杠,SDK 会自己拼上 /chat/completionstimeout 单位是毫秒,120000 就是两分钟,思考型模型首字延迟经常十几秒,默认值容易误杀maxRetries 是 SDK 内置的自动重试,只对 429 和 5xx 生效,401、404 不会重试apiKey 从环境变量读,任何情况下都不要写死在源码里拿到响应后 resp.choices[0].message.content 就是正文。choices 是数组是因为接口支持一次返回多个候选答案,n 参数大于 1 时才会用到,日常取第 0 个就够。
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,国内直连不降智不限速,横评多个模型时不用来回换账号和域名。
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,那就用原生 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 的即时性破坏最明显。
打开开发者工具,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 转发时,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 不对就是不对,模型名写错就是写错,直接抛出让人看见比默默重试五次强。
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 掉,否则每次取消都会在服务端留下一个跑着的请求。这类幽灵请求不会立刻暴露问题,但在并发量上来之后会明显拖慢整个服务。
不用自己造轮子,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,不兜会炸在运行时。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
开发阶段用 npx tsx index.ts 直接跑,不用先编译。skipLibCheck 打开能省掉一大半第三方库的类型噪音。
strict 一定要开。AI 接口返回的字段几乎全是可选的,content 可能是 null,usage 可能在流式响应里压根不存在,不开 strict 这些坑会一路带到线上。让编译器先骂你一遍,比让用户先骂你便宜得多。
浏览器直连 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 一起 abort,否则每个断连都留一个幽灵请求for await 会抛异常,已经写出去的 SSE 补一条 data: {"error":"..."} 再 res.end()res.end():浏览器会一直挂着等连接关闭,前端 loading 永不消失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-pro | 0.031 | 对话 |
| gemini-3-pro-preview | 0.05 | 对话 |
| gemini-3.1-pro-preview | 0.09 | 对话 |
| NanoBanana-Pro | 0.27 | 生图 |
| claude-sonnet-4-5-thinking | 0.09 | 思考 |
| claude-opus-4-5-thinking | 0.12 | 思考 |
| claude-sonnet-4-6-thinking | 0.2 | 思考 |
| claude-opus-4-6-thinking | 0.25 | 思考 |
| claude-sonnet-4-7-thinking | 0.2 | 思考 |
| claude-opus-4-7-thinking | 0.25 | 思考 |
| grok-4.1 | 0.05 | 对话 |
| grok-4.5 | 0.09 | 对话 |
| grok-4.6 | 0.09 | 对话 |
| deepseek-v3.2-thinking | 0.049 | 思考 |
| deepseek-r1-thinking | 0.049 | 思考 |
| deepseek-v4-flash-thinking | 0.05 | 思考 |
| deepseek-v4-pro-thinking | 0.09 | 思考 |
| gpt-5.5 | 0.2 | 对话 |
| gpt-5.5-pro-thinking | 0.2 | 思考 |
| gpt-5.3-pro | 0.3 | 对话 |
| gpt-image-2-pro | 0.25 | 生图 |
| kimi-k2.5 | 0.09 | 对话 |
| kimi-k2.6 | 0.09 | 对话 |
最低 7 元起充,用多少扣多少。日调用量上千次的话,无限卡套餐比单次扣费划算得多。
deepseek-v3.2-thinking(0.049 元/次),上线前再拿 claude-opus-4-6-thinking 复检关键路径