调用 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 本身不复杂,复杂的是调用链上的每一层都可能悄悄改变认证信息。按“密钥加载 → 格式 → 请求头 → 代理”的顺序逐层验证,通常能在几分钟内定位根因。