你在 Node.js 里第一次调用大模型 API,终端直接返回 401 Unauthorized,或者流式输出只打出半句话就停住。这类问题看起来像网络抖动,实际往往是参数填错。读完这篇,你能拿到一个依赖最少的最小示例,并知道每个关键参数填错的后果。
场景与前置条件
这篇解决的是 Node.js 调用大模型 API 时最常见的两类问题:认证头配错,以及流式响应解析不完整。不涉及 Python,不涉及框架集成。你需要先有一个可用的大模型 API Key,并且已经在千木控制台创建过密钥。这个 Key 前缀是 sk_live_,只在创建时显示一次,丢了只能重新生成。
先说环境限制,避免你做到一半发现版本不对。示例基于 Node.js 18 以上版本,因为代码直接使用全局 fetch。18 以下需要额外安装 node-fetch 或 axios,行为略有差异。模型调用名使用 deepseek-v3.2,这个名称必须从模型广场详情页复制,不要根据展示名自己拼版本号。OpenAI 兼容端点是 /chat/completions,后文所有示例都围绕它展开。
环境准备
不需要安装任何 npm 包。创建一个新目录,在目录里放一个 package.json,将 type 设置为 module,这样可以使用 top-level await。示例文件命名为 index.mjs 也可以直接运行。如果你坚持用 CommonJS,把 import 改成 require 就行,但本教程不这么写。
API Key 要放进环境变量,不要在代码里硬编码。硬编码会让密钥跟着代码一起进入 Git 历史,排查问题时也容易在日志里泄露。Linux 或 macOS 终端里执行 export,Windows PowerShell 里用 $env: 赋值。代码里通过 process.env 读取环境变量,这是唯一推荐做法。Base URL 只有一个,示例里已经写死,你不需要修改,尤其注意不要写成带 api 子域名的形式。这个地址的根路径是 /api/v1,后面接 /chat/completions 才是对话端点。
最小可跑示例
先跑一个非流式请求,拿到完整回复就退出。把下面代码保存为 index.mjs,然后 node index.mjs。
javascript
const baseUrl = 'https://www.silvamux.com/api/v1';
const apiKey = process.env.SILVAMUX_API_KEY;
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'deepseek-v3.2',
messages: [
{ role: 'user', content: '用一句话解释 Node.js 事件循环' }
],
stream: false
})
});
if (!response.ok) {
const errText = await response.text();
console.error('请求失败', response.status, errText);
process.exit(1);
}
const data = await response.json();
console.log(data.choices[0].message.content);
代码不长,但里面几个参数不能填错。model 必须使用平台规范后的调用名,这里填 deepseek-v3.2。如果你按上游厂商的命名习惯写成别的版本号,会收到 400 或 gateway_error。stream 填 false 表示这次要一次性拿完整响应,不要传字符串 'false',布尔值和字符串在类型检查时会不一样。messages 数组的第一个元素必须带 role,role 只能是 user、assistant、system 其中之一。Authorization 头部的 Bearer 后面有一个空格,少了这个空格,服务端会认为没有认证信息。
关键在 baseUrl 和 apiKey 两行。baseUrl 已经是完整的根路径,后面拼接 /chat/completions 时不要重复加 /api/v1。apiKey 从环境变量读取,如果读到 undefined,fetch 也会发出请求,但那会变成 401。跑之前先 echo 一下环境变量,确认不是空值。
逐步扩展
真实业务里很少用非流式请求,因为长回复会让人等很久。流式输出能边生成边显示,体验好得多。在最小示例上加一个流式版本,顺便加 30 秒超时控制。超时用 AbortController,超时后 fetch 会被中止,而不是一直挂着。
javascript
const baseUrl = 'https://www.silvamux.com/api/v1';
const apiKey = process.env.SILVAMUX_API_KEY;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'deepseek-v3.2',
messages: [{ role: 'user', content: '写一段关于初秋傍晚的场景描写' }],
stream: true
}),
signal: controller.signal
});
if (!response.ok || !response.body) {
clearTimeout(timer);
console.error('请求失败', response.status);
process.exit(1);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = 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 trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const json = trimmed.slice(5).trim();
if (json === '[DONE]') continue;
try {
const chunk = JSON.parse(json);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) process.stdout.write(delta);
} catch {
// 忽略半行数据
}
}
}
clearTimeout(timer);
console.log('\n流结束');
这个版本和非流式的差别集中在三点。第一,stream 变成 true,服务端会返回 SSE 格式的增量数据。第二,用 response.body.getReader() 逐块读取,而不是 response.json() 一次性解析。第三,每一行 SSE 都可能包含空 choices,甚至最后可能没有 data: DONE。所以代码没有用 DONE 作为唯一结束条件,而是循环到 done 为 true 才跳出。这个细节在 Node.js 里特别容易错。
坑在这里:如果你只判断 DONE 就退出循环,可能会漏掉最后一条内容,或者在一个只带 usage 的 chunk 上卡住。正确做法是继续读取直到流结束。TextDecoder 的 { stream: true } 参数也不要省,它能避免多字节字符被截断。超时时间 30000 毫秒可以按需调整,但建议保留,否则请求一旦挂起,Node 进程不会自动退出。
排错
下面三个报错是 Node.js 调用大模型 API 时最高频的,按状态码区分。
401 Unauthorized
报错文本:HTTP 状态码 401,终端通常会打印 401 Unauthorized,响应体里 error.type 多为 gateway_error,code 字段按网关策略变化。
直接原因:请求头里的 Authorization 值不对。最常见的是拼成了 Authorization: apiKey,缺少 Bearer 前缀,或者 Bearer 后面没有空格,又或者 API Key 本身少了 sk_live_ 前缀。
修法:先检查环境变量是否有值,用 console.log(process.env.环境变量名) 打出来看是否为 undefined。再看 fetch 头部是不是写成了 Bearer ${apiKey}。最后去密钥页重新生成一个,因为旧 Key 可能已经丢失。
400 Bad Request
报错文本:状态码 400,响应体同样包含 error.message 和 type 字段,message 会因模型名不同而变化。
直接原因:model 参数填错,或者 stream 传了字符串 'false' 或 'true'。模型调用名必须来自平台模型广场,不能自己根据展示名拼接版本号。比如写 deepseek 或 deepseek-chat 都可能不是平台规范名。
修法:打开模型广场详情页,复制稳定的 alias,也就是调用名。示例里用的 deepseek-v3.2 是可直接复制的名称。stream 改成布尔值 true 或 false。
流式输出中断
报错文本:有时没有报错,只是终端只显示了半句话,进程就退出了;有时一直挂住不结束。
直接原因:客户端用 data: DONE 判断结束,但服务端可能在末尾返回一个只带 usage、choices 为空的 chunk,甚至省略 DONE。Node.js 读到这种 chunk 后如果不继续读,就会提前结束或卡死。
修法:把结束条件从"收到 DONE"改成"read() 返回 done 为 true"。在循环里忽略空 choices 和 DONE,但不要 break。这样无论服务端是否发送 DONE,客户端都能等到真正的流结束。这个修法也适用于其他 OpenAI 兼容端点。
常见问题
问:Node.js 版本必须是 18 吗?
不一定。Node.js 18 以上内置 fetch,所以示例能直接跑。18 以下可以安装 node-fetch@3 或 axios,但你需要把 fetch 调用替换成对应库的请求方法,响应体的流式读取方式也不同。如果只是做最小验证,建议用 Node.js 18 或 20,避免工具链干扰。
问:API Key 放在环境变量里,重启终端后就不见了?
是的,用 export 设置的环境变量只在当前终端会话有效。Windows 上重启后也会消失。如果要持久化,可以把 export 写进 .bashrc 或 .zshrc,但不要把这些文件提交到 Git。生产环境建议用密钥管理服务,而不是写在项目的 .env 文件里再硬编码进前端。
问:为什么模型名复制过来还是报 400?
因为展示名和调用名可能不是一回事。有些模型展示为"DeepSeek V3.2",但调用名是 deepseek-v3.2。必须从模型广场详情页的"调用名"或"模型 ID"字段复制,不要根据页面标题自己拼。拼错任何一个字符,请求都会在网关层被拦截。
以上示例在千木SilvaMux的 OpenAI 兼容端点上验证,模型调用名为 deepseek-v3.2。模型调用名和错误码字段以开发者文档为准。
