这篇文章记录完整的排查路径。它不只是一次 API Key 问题更是一次关于Node.js 环境变量优先级的实战复盘。先看这个 RAG 小项目在做什么项目使用 ESM入口文件一开始就加载了 dotenvimport dotenv/config; import { CheerioWebBaseLoader } from langchain/community/document_loaders/web/cheerio; import { RecursiveCharacterTextSplitter } from langchain/textsplitters; import { MemoryVectorStore } from langchain/classic/vectorstores/memory; import { ChatOpenAI, OpenAIEmbeddings } from langchain/openai;后面的主链路可以概括为网页 URL - CheerioWebBaseLoader 提取指定段落 - RecursiveCharacterTextSplitter 递归切分 - OpenAIEmbeddings 生成向量 - MemoryVectorStore 建库与检索 - ChatOpenAI 根据检索片段回答问题这里的RecursiveCharacterTextSplitter配置了chunkSize: 400和chunkOverlap: 100。它会优先按中文句末标点切分无法自然切开时才继续尝试更细的边界。网页抓取和文档切分都成功了日志显示“文档分割完成共 9 个 chunks”。所以故障范围已经可以缩小问题发生在第一笔 embedding 请求而不是 loader 或 splitter。这个 TypeError 为什么容易把人带偏报错位置在依赖内部embeddings.push(batchResponse[j].embedding);表面上看是 LangChain 对null做了数组访问。很多人会立刻怀疑chunk 是不是空了embedding 模型名是不是写错了LangChain 版本是不是不兼容这些方向不能说完全没有可能但先改代码只会扩大变量。更有价值的问题是batchResponse为什么会是null答案要到 API 的原始响应里找而不是停在 SDK 的最后一层报错里。直接请求 embedding 接口看到真实响应我写了一个只发一条 embedding 请求的最小检查脚本。注意日志只打印状态和结果形状绝不打印完整 Key。import dotenv/config; const base process.env.OPENAI_BASE_URL.endsWith(/) ? process.env.OPENAI_BASE_URL : ${process.env.OPENAI_BASE_URL}/; const response await fetch(${base}embeddings, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY}, }, body: JSON.stringify({ model: process.env.EMBEDDINGS_MODEL_NAME, input: test, }), }); const body await response.json(); console.log({ status: response.status, hasEmbedding: typeof body.data?.[0]?.embedding?.[0] number, error: body.error?.message ?? body.msg ?? null, });原始响应不是 OpenAI 兼容的 embedding 结构而是{ code: 0, msg: 旧转发链路已关闭, data: null }这就解释了依赖内部的TypeErrorSDK 期待data是向量数组网关却返回了null并且错误地使用了 HTTP200。也就是说HTTP 成功并不代表业务请求成功。根因.env被已存在的环境变量覆盖了我原本以为老师给的.env没有被读取但index.mjs的第一行明明写了import dotenv/config;关键在于 dotenv 的默认行为只填充不存在的变量不覆盖当前进程已经存在的变量。环境变量大致可以这样理解我的.env中是可用的百炼兼容地址和对应 Key但 Windows 的用户环境变量里残留了旧的OPENAI_BASE_URL与OPENAI_API_KEY。Node 启动时先继承了它们dotenv 发现同名变量已经存在就保留旧值。于是代码实际请求的是旧网关不是.env中的服务。不要猜比较“文件值”和“实际生效值”下面这个检查可以快速确认是否发生覆盖。它不会输出 Key 本文只比较是否一致import fs from node:fs; import dotenv from dotenv; const fileEnv dotenv.parse(fs.readFileSync(.env)); await import(dotenv/config); for (const key of [ OPENAI_BASE_URL, OPENAI_API_KEY, MODEL_NAME, EMBEDDINGS_MODEL_NAME, ]) { console.log(${key}: ${fileEnv[key] process.env[key]}); }如果OPENAI_BASE_URL或OPENAI_API_KEY输出false就不要继续改 LangChain 代码了。先处理配置来源。在 PowerShell 中也可以查看当前会话Get-ChildItem Env:OPENAI_*查看用户级持久配置[Environment]::GetEnvironmentVariable(OPENAI_BASE_URL, User) [Environment]::GetEnvironmentVariable(OPENAI_API_KEY, User)解决方案先让当前终端不再继承旧值为了不影响其他项目可以先在当前 PowerShell 窗口清空旧变量Remove-Item Env:OPENAI_BASE_URL -ErrorAction SilentlyContinue Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue node src/index.mjs这只影响当前终端。关闭窗口后持久环境变量仍会重新被继承。如果确认旧变量已经不再被任何项目使用再到 Windows 的“编辑账户的环境变量”中删除用户变量里的OPENAI_BASE_URL OPENAI_API_KEY删除后要完整重启 VS Code、终端或其他运行 Node 的编辑器。原因很简单已经启动的父进程会保留启动时的环境即使你删掉了 Windows 用户变量旧进程创建的新终端也可能继续继承旧值。第二类错误401 才是真正的 API Key 问题清理旧网关后错误可能变成401 Incorrect API key provided code: invalid_api_key这个错误和前面的TypeError完全不是一回事现象请求实际到达的位置优先排查方向data: nullSDK 内部 TypeError已关闭或不兼容的代理网关OPENAI_BASE_URL被覆盖、服务响应格式401 invalid_api_key已到达目标模型服务Key 是否复制完整、过期、撤销或属于错误的服务商429 insufficient_quota已通过认证账户余额、额度或消费上限model_not_found/403已到达目标模型服务模型名称、项目权限、组织配置错误信息是排错路线图。不要把所有错误都归结为“Key 没连上”。要不要在代码里强制覆盖dotenv 支持import dotenv from dotenv; dotenv.config({ override: true });它可以让.env覆盖当前进程环境变量。但这不一定适合所有项目部署平台、CI 和容器经常故意通过运行环境注入生产 Key此时强制覆盖反而可能让本地.env覆盖生产配置。更稳妥的习惯是本地练习项目把 Key 放进.env并确保.env已写进.gitignore。不要在 Windows 用户环境变量中长期放同名的项目专用 Key。排错时打印“是否存在、是否一致、长度或指纹”不要打印密钥原文。对第三方兼容网关额外检查响应是否真的是 OpenAI 兼容格式。结语这次排错最大的收获不是“删掉两个环境变量”而是建立了一个顺序先定位出错阶段 - 再看原始 API 响应 - 比较 .env 与 process.env - 最后才判断 Key、额度或模型权限RAG 的 loader、切分、向量化和检索看起来是一条业务链实际每一段都依赖配置和