2694 字
13 分钟
企业内部 DeepSeek API 中转网关:用 OpenResty 隐藏密钥并实现 user_id 隔离
2026-06-05

一、需求#

整个网关要满足的目标可以概括为一句话:让员工能用 DeepSeek API,但既拿不到真实 Key,也无法相互影响或伪造身份。 拆开来是五条:

  1. 隐藏真实 Key。 真实 DeepSeek Key 只能存在于服务器上,员工抓包、看代码都不应该看到它。员工持有的是可单独吊销的内部令牌。
  2. 防止滥用。 单个员工的请求要能按人限流,避免一个人把账号级配额或余额占满,连累其他人。
  3. 可审计、可归因。 每一笔调用都要能追溯到具体的人,便于成本分摊和异常排查。
  4. 多用户隔离。 同一账号下不同员工之间,缓存与调度要相互隔离,且身份不可伪造。
  5. 不改变员工的使用习惯。 员工继续用标准 OpenAI / Anthropic SDK,只换 base_url 和 key,不需要改业务代码。

需要特别说明的是第 4 点。DeepSeek 的对话接口本身是无状态的——完整对话历史装在每次请求的 messages 里,服务端不保存会话,所以不会出现不同用户上下文被串号的情况。真正会”相互影响”的是账号级资源:并发是按账号计算的,与用哪个 Key 无关,超限会返回 HTTP 429;余额也是共享的。DeepSeek 为此提供了 user_id 参数,用于内容安全隔离、KVCache 隔离(隐私)和调度隔离。我们的网关要做的,就是在中转层把这个 user_id 强制写成已鉴权的身份,让员工既无法省略、也无法伪造。


二、使用配置清单#

软硬件环境#

  • 服务器:RHEL 系 Linux(RHEL 9 / Rocky 9 / Alma 9),x86_64,部署在 192.168.x 内网
  • 网关软件:OpenResty(nginx + LuaJIT + lua-nginx-module + lua-cjson 一体打包,版本约 1.29.x)
  • 上游:DeepSeek API(https://api.deepseek.com,Anthropic 格式为 https://api.deepseek.com/anthropic
  • 认证方式:HTTP Bearer,请求头 Authorization: Bearer <key>

为什么是 OpenResty 而不是发行版自带 nginx#

发行版自带的 nginx 的标准源里没有 Lua 模块——dnf search nginx 只能看到 brotli、mail、stream 等模块。而我们改写请求体注入 user_id 必须依赖 access_by_lua。给现成 nginx 编译挂载 Lua 动态模块需要与 nginx 版本、LuaJIT 严格匹配,极易踩坑。而OpenResty 是独立发行版,把这套东西全部打包好,开箱即用,是更稳妥的选择。

关键配置文件清单#

文件作用权限
/usr/local/openresty/nginx/secrets/deepseek.confmap 把真实 Key 定义成 $deepseek_api_key 变量chmod 600,root 可读
/usr/local/openresty/nginx/secrets/tokens.conf内部令牌 → 员工标识的映射($client_namechmod 600
/usr/local/openresty/nginx/ssl/proxy.crt / proxy.key员工 ↔ 代理之间的 TLS 证书私钥 600
/usr/local/openresty/nginx/conf/nginx.conf主配置:白名单、限流、日志、access_by_lua 注入逻辑

网关内置的能力清单#

  • 端点白名单:默认拒绝,仅放行 /chat/completions/completions/models/anthropic//beta//v1/;敏感的 /user/balance(查余额)被自动屏蔽
  • 按人限流limit_req / limit_conn$client_name 为键
  • 审计日志:JSON 格式,记录 client、IP、URI、状态码、请求/响应字节、上游耗时
  • 流式支持proxy_buffering off,SSE 逐 token 实时返回
  • DNS 稳定性:用静态 upstream 块在启动时解析一次,规避内网无法访问公共 DNS 的问题
  • user_id 注入access_by_lua 强制覆盖,兼容 OpenAI 与 Anthropic 两种格式

三、操作细节#

第 1 步:打通到 DeepSeek 的网络(DNS 坑)#

第一次跑起来就遇到 502,且日志里 upstream_time 为空——说明 Nginx 根本没从上游拿到响应,失败在”连接/解析上游”这一步。根因是nginx未配置公共 DNS解析,且内网服务器通过网关解析 api.deepseek.com 超时。

解法有两种,我们选了更稳的第一种:用 resolver 来解析域名,运行期不依赖网关DNS解析:

/usr/local/openresty/nginx/conf/nginx.conf
resolver 223.5.5.5 119.29.29.29 valid=300s ipv6=off;

排查时的关键命令:

tail -50 /var/log/nginx/error.log # 看具体报错
nslookup api.deepseek.com # 系统 DNS 能否解析
curl -v https://api.deepseek.com/models -H "Authorization: Bearer sk-真实key" # 验证出网+TLS

第 2 步:安装 OpenResty#

确认系统版本后,按官方方式添加 RHEL 9 系的仓库并安装:

sudo dnf install -y wget
wget https://openresty.org/package/rhel/openresty2.repo
sudo mv openresty2.repo /etc/yum.repos.d/openresty.repo
sudo dnf check-update
sudo dnf install -y openresty
/usr/local/openresty/bin/openresty -v # 应显示 openresty/1.29.x

OpenResty 是独立安装,会与自带 nginx 抢 80 端口,所以要修改 openresty 的默认配置文件,删除监听80端口的 server块

sudo vim /usr/local/openresty/nginx/conf/nginx.conf
sudo systemctl enable --now openresty

几个路径差异要记住:二进制在 /usr/local/openresty/nginx/sbin/nginx,默认配置在 /usr/local/openresty/nginx/conf/nginx.conf,服务名是 openresty。由于配置里 secrets 和 ssl 都用绝对路径,原有目录可直接沿用。

第 3 步:核心配置与 user_id 注入#

下面是主配置的关键部分。鉴权、端点校验、user_id 注入都在 access_by_lua_block 里完成,转发前用真实 Key 覆盖 Authorization

/usr/local/openresty/nginx/secrets/deepseek.conf
# 用 map 把真实 key 定义成一个变量,集中管理、便于轮换
map $host $deepseek_api_key {
default "sk-your-deepseek-api-key";
}
/usr/local/openresty/nginx/secrets/tokens.conf
# 内部令牌 → 员工/团队标识(标识用于限流与日志归因)
# 令牌建议用 `openssl rand -hex 16` 生成,前缀方便辨识
map $http_authorization $client_name {
default ""; # 不在清单 → 视为非法
"Bearer [token]" "[username]"; #备注
}
/usr/local/openresty/nginx/conf/nginx.conf
115 collapsed lines
http {
include /usr/local/openresty/nginx/secrets/deepseek.conf; # $deepseek_api_key
include /usr/local/openresty/nginx/secrets/tokens.conf; # $client_name
map $uri $endpoint_allowed {
default 0;
~^/chat/completions$ 1;
~^/completions$ 1;
~^/models$ 1;
~^/anthropic/ 1;
~^/beta/ 1;
~^/v1/ 1;
}
limit_req_zone $client_name zone=req_per_user:10m rate=20r/m;
limit_conn_zone $client_name zone=conn_per_user:10m;
log_format llm_audit escape=json
'{'
'"time":"$time_iso8601","client":"$client_name","ip":"$remote_addr",'
'"method":"$request_method","uri":"$uri","status":$status,'
'"req_bytes":$request_length,"resp_bytes":$body_bytes_sent,'
'"upstream_time":"$upstream_response_time"'
'}';
upstream deepseek {
server api.deepseek.com:443;
keepalive 32;
}
resolver 223.5.5.5 119.29.29.29 valid=300s ipv6=off;
server {
listen 9443;
#listen 443 ssl;
server_name [所在服务器内网IP];
#ssl_certificate /etc/nginx/ssl/proxy.crt;
#ssl_certificate_key /etc/nginx/ssl/proxy.key;
#ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/llm_audit.log llm_audit;
client_max_body_size 20m;
client_body_buffer_size 1m;
location / {
limit_req zone=req_per_user burst=20 nodelay;
limit_conn conn_per_user 10;
access_by_lua_block {
local client = ngx.var.client_name
if client == nil or client == "" then
ngx.status = 401
ngx.header.content_type = "application/json"
ngx.say('{"error":{"message":"invalid internal token"}}')
return ngx.exit(401)
end
if ngx.var.endpoint_allowed ~= "1" then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say('{"error":{"message":"endpoint not allowed"}}')
return ngx.exit(403)
end
local method = ngx.req.get_method()
local ctype = ngx.req.get_headers()["content-type"] or ""
if method == "POST" and ctype:find("application/json", 1, true) then
ngx.req.read_body()
local body = ngx.req.get_body_data()
-- 大 body 被写到临时文件时,get_body_data() 返回 nil,需读文件
if not body then
local fname = ngx.req.get_body_file()
if fname then
local fh = io.open(fname, "rb")
if fh then body = fh:read("*a"); fh:close() end
end
end
if body and #body > 0 then
local cjson = require "cjson.safe"
local data = cjson.decode(body)
if not data then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say('{"error":{"message":"invalid JSON body"}}')
return ngx.exit(400)
end
-- 强制覆盖为已鉴权身份,客户端传什么都不算数
if ngx.var.uri:find("^/anthropic") then
if type(data.metadata) ~= "table" then data.metadata = {} end
data.metadata.user_id = client -- Anthropic 格式
else
data.user_id = client -- OpenAI 格式
end
ngx.req.set_body_data(cjson.encode(data))
end
end
}
proxy_pass https://deepseek;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name api.deepseek.com;
proxy_set_header Host api.deepseek.com;
proxy_set_header Authorization "Bearer $deepseek_api_key";
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
default_type application/json;
}
}
}

这里有三个细节值得强调。

  • 一是大请求体兜底:当 body 超过 client_body_buffer_size 被写到临时文件时,get_body_data() 会返回 nil,必须用 get_body_file() 读文件,否则像 100KB 这种请求会注入失败。
  • 二是lua-cjson 的空数组坑:decode 后无法区分空数组 [] 和空对象 {},re-encode 时空表默认输出成 {};LLM 请求体里几乎不会出现 tools:[] 这类空数组,影响很小,但属于已知风险。
  • 三是永远不要开 proxy_cache:给 LLM 接口开响应缓存且缓存键不含用户维度,才是真正会导致跨用户泄露的操作。

第 4 步:验证注入是否成功#

注入发生在请求体里、随后被转发,肉眼看不到。最可靠的方式是让 Lua 把改完的 body 打到日志(验证完即删):

/usr/local/openresty/nginx/conf/nginx.conf
ngx.req.set_body_data(cjson.encode(data))
ngx.log(ngx.ERR, "USERID_INJECT client=", client, " body=", cjson.encode(data)) -- 临时

然后用一组测试覆盖三个场景,其中最关键的是”伪造被覆盖”

# 客户端故意伪造 user_id:"hacker" → 日志里应被改成令牌对应的 alice
curl -s http://[ip]:[port]/chat/completions \
-H "Authorization: Bearer int_alice_xxx" -H "Content-Type: application/json" \
-d '{"model":"deepseek-v4-flash","user_id":"hacker","messages":[{"role":"user","content":"hi"}]}'

安全红线:验证时绝不能把 proxy_pass 指向任何外部回显服务(httpbin 之类),因为代理会带上真实 Key。要看离开代理前的字节,只能用监听 127.0.0.1 的本地回显 server。

第 5 步:员工接入(Anthropic 方式)#

员工开发一般使用vscode等IDE工具配合claude code扩展,只须在安装claude code插件后,在用户目录根下的 .claude/settins.json 配置文件即可调用DeepSeek API内网中转网关

claude code配置文件:

  • Windows: C:\Users\[username]\.claude\settings.json
  • Linux: /home/[username]/.claude/settings.json
.claude/settings.json
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "[token]",
"ANTHROPIC_BASE_URL": "http://[ip]:[port]/anthropic",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-pro[1M]",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "deepseek-v4-pro",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "deepseek-v4-pro",
"ANTHROPIC_MODEL": "deepseek-v4-pro"
},
"includeCoAuthoredBy": false
}

员工不需要、也无法设置 user_id——即便在 extra_body 里传了也会被代理覆盖,这正是设计目的。流式只需加 stream=True,工具调用、JSON 模式等参数都在请求体里,代理原样透传。


四、实现效果#

上线后,对照最初的五条需求逐一达成:

  • 密钥隐藏:真实 Key 只存在于服务器上 chmod 600 的文件里。员工抓包只能看到自己的内部令牌;某人离职或令牌泄露,只需删掉 tokens.conf 里对应一行并 reload,即可单独吊销,不影响其他人。
  • 滥用受控:限流以 $client_name 为键,单个员工的突发流量被挡在自己的桶里,不会消耗他人配额,从而把账号级的并发与余额风险拆解到个人维度。
  • 可审计:每笔调用都以 JSON 落日志,可按 client 维度统计用量、做成本分摊,也能快速定位异常调用方。
  • 多用户隔离:网关在转发前强制写入 user_id,员工无法省略或伪造。借此拿到 DeepSeek 的 KVCache 隔离(不同员工缓存互不干扰)、内容安全隔离,以及在账号扩容后按 user_id 单独限并发的能力。
  • 零侵入接入:员工继续用标准 OpenAI / Anthropic SDK,仅改 base_url 与 key,流式、工具调用、JSON 模式全部照常。

更重要的是一个认知层面的结论:对话上下文不会在用户之间串联。API 无状态、Nginx 每请求隔离、keepalive 连接严格一问一答,三层共同保证了这一点;唯一需要主动规避的,是绝不给 LLM 接口开响应缓存。

整套方案的最终形态,是一台内网服务器上的 OpenResty 网关:对内提供一个统一的 HTTPS 入口和一套可吊销的令牌体系,对外用单一真实 Key 与 DeepSeek 通信,中间完成鉴权、限流、审计、身份注入与格式适配。后续若要做按 token 计费的精确额度控制,可在日志侧统计 usage 字段,或把鉴权升级为 auth_request + 后端数据库的动态管理。


五、文章引用#

说明:本文所述配置中的令牌、域名、Key 均为示例,部署时请替换为实际值;限流阈值(rate=20r/mburstlimit_conn)需按团队规模与预算调整。

企业内部 DeepSeek API 中转网关:用 OpenResty 隐藏密钥并实现 user_id 隔离
https://v0nl1.com/posts/enterprise-internel-deepseek-api-gateway/
作者
V0nl1
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0