密钥该放环境变量,请求头只是密钥到达上游时的位置,两者不是二选一。MkSaaS 这类服务端应用应把 AI 接口密钥放在环境变量或密钥管理服务里,由服务端在发起请求时读取并拼进 Authorization(或上游要求的 x-api-key)请求头;密钥本身不要写进前端代码、NEXT_PUBLIC_ 前缀变量或提交进仓库。
鉴权错误通常来自三处,需要分开判断:进程里根本没读到变量、请求头字段名或值拼错、上游返回的 401/403 原文被业务层改写后误读成密钥失效。下面按读取、构造、收敛、最小验证、三段日志的顺序处理。
判断与边界:密钥存环境变量、只在服务端读取是默认做法;请求头是传输载体,不是存放位置。出现鉴权错误时先按“配置是否读到 → 请求头是否拼对 → 上游原始响应是什么”依次排查,不要先换密钥。本地能跑、部署报错时,优先查部署环境的变量注入和读取时机。若上游明确要求密钥放请求体或签名头,以该接口文档为准,需要结合环境确认。
确认环境变量在本地与部署环境都被读到
先确认变量放在哪个文件、什么时候被加载。常见约定是本地放 .env.local 或 .env,部署环境在平台的环境变量面板注入;前者由框架在启动时读取,后者由运行环境注入进程,构建期注入和运行时注入是两回事,需要结合项目实际确认。变量值末尾的空格、引号、换行都可能让上游判定密钥无效。
# 只列键名,不打印值
cut -d= -f1 .env.local | sort
# 确认运行时能读到,只输出是否存在与长度
node -e "const k=process.env.AI_API_KEY; console.log(k ? 'loaded len=' + k.length : 'missing')"部署环境不方便开终端时,可以临时加一个只返回布尔值的健康检查路由,输出 { aiKeyLoaded: true } 这类结果,确认后移除;不要在响应里返回密钥,也不要返回除长度以外的信息。
在服务端按固定顺序拼齐鉴权字段
把请求构造固定成“配置 → URL → 方法 → 请求头 → 请求体”的顺序,每个字段标注取值来源,改动时只改一处。
// 仅服务端执行,例如 route handler 或 server action
const cfg = getAiConfig(); // 来源:配置模块(环境变量)
const url = cfg.baseUrl.replace(/\/$/, '') + '/v1/chat/completions'; // 来源:配置 + 固定路径
const method = 'POST'; // 常量
const headers = {
'Content-Type': 'application/json', // 常量
'Authorization': `Bearer ${cfg.apiKey}`, // 来源:配置;上游若用 x-api-key,只改这一行
};
const payload = {
model: cfg.model, // 来源:配置
messages: [{ role: 'user', content: prompt }], // 来源:用户级数据
user: session.userId, // 来源:服务级/会话数据(可选)
};
const res = await fetch(url, {
method,
headers,
body: JSON.stringify(payload),
});要避免的写法:从请求体里读 apiKey,等于让客户端决定用哪把密钥;拼接时多出空格或换行,形成 Bearer xxx;同一请求里出现两个 Authorization 头;把鉴权头加到浏览器端请求里。上游要求的字段名和前缀不完全一致,接入前对照接口文档确认,不确定时先用最小请求试。
把密钥读取收敛到一个配置模块
让所有调用方都从同一个函数取值,避免同一进程里一处读文件、一处读 process.env,导致有的请求带上密钥、有的没有。
// config/ai.ts
export function getAiConfig() {
const baseUrl = process.env.AI_BASE_URL?.trim();
const apiKey = process.env.AI_API_KEY?.trim();
const model = process.env.AI_MODEL?.trim() || 'default-model'; // 默认值只给非敏感项
const missing = [
!baseUrl && 'AI_BASE_URL',
!apiKey && 'AI_API_KEY',
].filter(Boolean);
if (missing.length > 0) {
throw new Error(`[ai-config] 缺少环境变量: ${missing.join(', ')};请检查 .env.local 或部署平台变量注入`);
}
return { baseUrl, apiKey, model };
}错误提示里只写字段名和排查方向,不写变量值。密钥不设默认值,缺失时宁可启动或首次调用就失败,也不要带着空字符串去请求上游,那样只会拿到一条含义模糊的鉴权错误。配置模块只被服务端模块引用,前端组件不要间接 import 到它。
用一条最小请求单独验证鉴权
绕开业务逻辑,把参数降到最少,确认鉴权这一层是否通过。可以在本地终端直接发一条请求,也可以用独立的脚本文件:
# payload.json 内容示例
# {"model":"your-model","messages":[{"role":"user","content":"ping"}]}
curl -s -o resp.json -w '%{http_code}\n' \
-X POST "$AI_BASE_URL/v1/chat/completions" \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $AI_API_KEY" \
-d @payload.json观察点:401 一般是密钥缺失、格式不对或已失效;403 多半是权限、额度或来源限制;400 往往和 JSON 结构、model 字段有关;429 是限流,不属于鉴权问题。日志里记录 HTTP 状态码、上游 error.code 与 error.message、响应头里的 request-id(若有)、请求发出时间;密钥打码成前 4 位加末 4 位或哈希前缀,不记录明文。
按配置读取、请求头、上游响应三段日志定位
排查顺序固定为配置读取、请求头、上游响应,三段用同一个 request-id 或同一次调用串起来,能避免反复改代码试错。
- 配置读取段:记录变量名、是否存在、长度、读取来源(本地文件还是部署环境变量)、进程启动时间。常见错误形态是
missing,对应变量名拼写不一致、部署环境没有注入、或在构建期读取却期望运行时值。 - 请求头段:记录目标 URL、请求方法、请求头名称列表,Authorization 的值打码。常见错误形态是
Bearer undefined、值里带多余空格或换行、字段名拼错、前置转发层去掉或改写请求头。 - 上游响应段:记录状态码、错误码、错误原文、request-id。常见错误形态是上游返回 invalid api key,但打码后的尾部字符和当前环境变量对不上,说明用错了环境或沿用了旧密钥。
三段对比时,先确认配置段不是 missing,再看请求头段是否带上了正确前缀且只有一个鉴权头,最后才看上游原文。如果业务层包装过错误信息,先保留上游原始响应体再判断,不要只看自己抛出的鉴权失败提示。