调用 DeepSeek API 返回 401,第一反应往往是“密钥错了”。但在实际工程中,401 的根因分布很广:密钥没加载、环境变量没注入、请求头拼错、代理吞掉了 Authorization、容器里读到了空值。下面按调用链顺序拆解排查路径。
一、先确认 401 的响应体
不同网关对 401 的返回格式不同。先看响应体里有没有 invalid_api_key、authentication_error 或类似字段。如果响应体是 HTML 或网关自定义 JSON,说明请求可能还没到 DeepSeek 服务端,而是被中间层拦截。这一步能快速区分“密钥问题”和“链路问题”。
二、密钥加载:从源头查起
1. 环境变量是否真的存在
本地开发常见错误是 .env 文件写了但没被加载。Node.js 项目要确认 dotenv 在读取密钥之前执行;Python 项目要确认 load_dotenv() 在 os.getenv 之前调用。
最小验证:
import os
key = os.getenv("DEEPSEEK_API_KEY")
print(repr(key))
用 repr 而不是直接 print,可以暴露前后空格、换行符和 None。如果输出 None,说明变量没注入;如果输出带引号或 \n,说明值被污染。
2. CI/CD 中的变量作用域
GitHub Actions 里,env 定义在 job 级别还是 step 级别,作用范围不同。如果密钥定义在某个 step 的 env 下,另一个 step 读不到。GitLab CI 要检查变量是否被标记为 protected,非保护分支的流水线拿不到。
3. 容器部署的注入方式
Kubernetes 中通过 Secret 注入时,确认 envFrom 或 valueFrom 的 key 名与代码读取的变量名完全一致。大小写敏感。如果 Secret 里是 deepseek_api_key,代码读 DEEPSEEK_API_KEY,结果就是空字符串。
三、密钥格式:容易被忽略的细节
拿到密钥后,检查三点:
- 是否包含首尾空格或换行;
- 是否被 shell 转义(如
$被展开); - 是否误用了其他服务的密钥。
在 shell 中导出变量时,用单引号避免特殊字符被解释:
export DEEPSEEK_API_KEY='sk-...'
如果密钥从文件读取,注意文件末尾的换行符。很多语言读取文件时会保留 \n,直接拼进请求头会导致认证失败。
四、请求头构造:SDK 与手写 HTTP 的差异
1. 使用官方 SDK
如果使用 OpenAI 兼容 SDK,确认 base_url 指向 DeepSeek 的端点,且 api_key 参数传入了正确值。部分 SDK 在 api_key 为空时不会报错,而是发出不带认证头的请求,最终由服务端返回 401。
2. 手写 HTTP 请求
手写请求时,Authorization 头的格式必须是:
Authorization: Bearer
常见错误包括:漏掉 Bearer 前缀、Bearer 和密钥之间没有空格、把密钥放在 x-api-key 等其他头里。用 curl 做最小验证:
curl -X POST https://api.deepseek.com/chat/completions \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"}]}'
如果 curl 成功而代码失败,问题在代码的请求构造;如果 curl 也 401,问题在密钥或网络链路。
五、代理与网关:认证信息是否透传
企业内网常通过代理访问外部 API。代理可能:
- 剥离 Authorization 头;
- 要求额外的代理认证;
- 缓存了旧的认证结果。
排查时先绕过代理直连,确认密钥本身有效。如果必须走代理,检查代理配置是否保留了原始请求头。Nginx 作为反向代理时,默认会转发 Authorization,但如果有 proxy_set_header 覆盖,可能丢失。
六、分场景检查清单
本地开发:.env 是否加载 → 变量值是否干净 → SDK 初始化是否传参 → 请求头是否正确。
CI/CD:变量作用域 → 是否 protected → 是否在正确的 step 中可用 → 日志中是否意外打印了密钥(注意脱敏)。
容器部署:Secret 是否存在 → key 名是否匹配 → 容器内 env 是否可见 → 是否被 entrypoint 覆盖。
七、最小化验证代码
排除业务代码干扰,用最短路径验证密钥:
import os, requests
key = os.getenv("DEEPSEEK_API_KEY")
assert key, "DEEPSEEK_API_KEY is empty"
resp = requests.post(
"https://api.deepseek.com/chat/completions",
headers={"Authorization": f"Bearer {key}"},
json={"model": "deepseek-chat", "messages": [{"role": "user", "content": "ping"}]},
timeout=10,
)
print(resp.status_code, resp.text[:200])
这段代码只依赖环境变量和 requests。如果它返回 200,说明密钥和网络正常,问题在业务代码的封装层;如果仍 401,逐项检查密钥来源和网络出口。
八、工程建议
- 在应用启动时做一次密钥存在性校验,空值直接 fail fast,避免运行到请求阶段才暴露。
- 日志中永远不要打印完整密钥,只打印前几位和后几位。
- 对 401 做单独的错误分类,与 429、5xx 区分处理,便于监控告警。
- 代理场景下,在请求发出前打印实际请求头(脱敏后),确认 Authorization 存在。
401 本身不复杂,复杂的是调用链上的每一层都可能悄悄改变认证信息。按“密钥加载 → 格式 → 请求头 → 代理”的顺序逐层验证,通常能在几分钟内定位根因。