本地部署大模型的应用周期里,最容易被跳过却又最重要的一环就是API鉴权。我见过太多团队把基于Ollama、vLLM跑起来的本地大模型API直接开给前端或者内部工具,模型也确实能跑通,可等到有人在内网扫到接口、白嫖了一堆算力,或者月底根本分不清哪个业务线消耗了多少Token时才反应过来——本地大模型API调用鉴权这层如果不补,后面所有可视化、计量、权限控制、业务扩展都无从谈起。
这篇文章就围绕“鉴权”这个最容易偷懒的环节展开。我会先用实际场景说明为什么本地模型服务必须做鉴权,再把静态Key、签名、JWT这些主流方案的选型逻辑讲清楚,然后给出一套可以直接抄的Python网关实现,最后把鉴权后续的可视化监控和多业务扩展路径一并串起来。不管你现在是自己在开发机上玩大模型,还是已经在公司GPU服务器上部署了推理服务,这篇内容应该都能帮你少踩几个坑。
1. 本地模型API,为什么必须把鉴权这层补上
1.1 不鉴权可能遇到的三种真实情况
很多人的第一反应是“我的服务只在内网跑,不鉴权也没事”。这类想法我一开始也有过,直到真正吃过亏才改变。先说三个我见过或者亲身踩过的场景。
第一个场景是端口扫描。只要模型服务监听了0.0.0.0,不管它在办公网还是云上内网,都会被人用扫描工具探测到。Ollama默认监听127.0.0.1,但为了给局域网里的同事用,很多人会主动把OLLAMA_HOST改成0.0.0.0:11434。一旦改了这个配置,任何能访问到这台机器的人都可以直接调用/api/generate,甚至还能通过/api/pull往你的服务器上拉模型。算力会被白白消耗,更麻烦的是别人能看到你服务器上跑着哪些模型。
第二个场景是调用方混乱。团队内部两三个同学调试时确实不需要复杂鉴权,但业务一旦铺开,前端、后端、数据分析、算法都在连同一个接口,问题就来了。某个同学离职后,他电脑里存的API地址和密钥还在被旧脚本调用;某个业务线偷偷用另一个团队的模型额度;出了线上事故后,想定位是哪边调用方的问题,结果日志里只有IP和User-Agent,根本无法定位到具体的人和具体应用。
第三个场景是管理接口和控制面暴露。推理服务为了易用性,往往会暴露一些管理类接口。Ollama有拉取模型、删除模型的接口,vLLM依赖外部模型加载器,但如果代理层直接开放了模型管理能力,不说恶意攻击,光是有人误操作把线上模型删了、覆盖了,就够你喝一壶。鉴权要管的不仅是“谁能调用”,还要管“谁能改配置”。
1.2 鉴权在本地模型服务中到底承担什么职责
鉴权这个词在本地大模型场景里并不只是“校验一个Key”那么简单。拆开看,它至少承担四层职责。
第一层是身份识别,也就是确认“你是谁”。每次请求必须能对应到一个人、一个应用或者一个业务线。没有这层,后面做的所有限流、配额、审计都是无源之水。第二层是访问控制,也就是“你能做什么”。不同角色能访问的模型不一样,有的Key只能访问轻量模型,有的Key可以调用千亿参数大模型,有些路径比如模型管理接口则完全不对普通调用方开放。
第三层是行为审计。每次请求从哪个Key来、调了哪个模型、消耗了多少Token、耗时多久、成功还是失败,这些信息必须能被追溯。单纯做日志还不够,日志要围绕“API Key+模型+调用方”这几个维度结构化存储,否则事故复盘时很难回答“到底谁在什么时间干了什么”。第四层是计量和成本分摊。本地部署模型也有成本,电力、GPU折旧、运维人力都是钱。没有鉴权体系就没有计量口径,后续做业务扩展时连“哪个业务线消耗了多少资源”都说不清楚,更别提预算管控了。
所以,鉴权在本地大模型API场景里不是一个可选项,它是把模型能力从“技术demo”推向“业务服务”的那道基础门槛。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:模型服务、网关层与鉴权模型怎么搭配
2.1 常见本地推理服务的鉴权支持情况
先看一个直接的问题:主流本地推理框架自身有没有鉴权能力?我基于自己的使用经验整理了一个对照表。
| 推理服务 | 是否默认开启鉴权 | 是否支持多用户 | 是否支持配额管理 | 适合的默认暴露范围 |
|---|---|---|---|---|
| Ollama | 否 | 否 | 否 | 本机或受信任局域网 |
| vLLM | 可配置静态Key | 否 | 否 | 内部受控网络 |
| LocalAI | 可配置Bearer Token | 弱 | 否 | 内部受控网络 |
| Xinference | 有简单Key | 弱 | 部分场景可配置 | 内部受控网络 |
| llama.cpp server | 可配置简单Key | 否 | 否 | 本机调试为主 |
从这张表能看出,不管是Ollama还是vLLM,它们更多聚焦在“怎么把推理性能做好、把OpenAI兼容接口做得可靠”,而不是“怎么帮你管好一群人和一批应用”。vLLM的--api-key参数只能提供一个全局静态Key,内部十个人用同一个Key,出问题照样分不清是谁。Ollama目前开源版本没有Token体系,改OLLAMA_HOST绑定外网后,只要端口可达就能调用。
这也是为什么本地模型服务一旦要共享,就必须在模型前面加一层网关,把鉴权、审计、限流这些事收敛在网关里做,而不是指望推理服务本身来解决。
2.2 网关应该放在哪一层:轻量反代还是成熟API网关
网关放在模型服务和调用方之间,所有请求都经过它做鉴权和转发。这个小而关键的架构决策,直接决定了后续扩展的舒适度。
如果只是给团队内部五六个人用,最务实的方案是写一个轻量反向代理服务,用FastAPI或者Flask都行。它的核心逻辑很简单:校验API Key,然后反向代理到上游的Ollama或vLLM。这个方案维护成本极低,但能覆盖大部分内部需求。
如果公司已经有微服务体系和API网关基础设施,比如Kong、Apache APISIX这类组件,那就没必要重复造轮子。APISIX的key-auth、limit-count、response-rewrite插件组合起来,已经能实现密钥校验、限流、响应头改写,团队对这类系统本身就有运维经验,直接接入模型服务即可。
我个人的选型经验是:不要一上来就上重型网关。本地大模型场景一开始往往只有一两个模型服务,调用方也少,此时用Nginx加auth_request模块或者一个200行的Python小服务反而最灵活。等到调用方超过几十个、需要带管理后台、需要给不同业务线分配不同配额时,再切换到成熟网关或者自研控制面也不迟。
2.3 静态Key、HMAC签名和JWT怎么选
同样叫鉴权,不同方案解决的是不同层面的问题。
静态API Key是最基础也最常见的做法。调用方在Header里带一个Authorization: Bearer sk-xxx,网关查表确认Key存在且未过期即可放行。它的优点是实现简单、调试方便,特别适合内部服务。
HMAC签名则是在API Key之上增加了防篡改和防重放能力。调用方把请求方法、路径、时间戳、请求体放在一起做摘要签名,网关用同一把密钥验证。好处是即使请求在途中被截获,攻击者也没法随意篡改请求体;坏处是签名逻辑对调用方不够友好,每次生成签名都要写额外代码,前端联调时会比较痛苦。这种方案一般用于对外开放的API,或者对安全性要求更高的B端场景。
JWT是另一种思路。网关不直接存储密钥,而是通过验证JWT的签名来确认用户身份。JWT里能带上用户ID、模型权限范围、过期时间等信息。如果公司已经有统一登录系统,用OAuth2授权后发放JWT会比较顺畅。但对本地模型服务这种相对封闭的场景来说,引入JWT通常意味着还要搭建或对接一套身份认证服务,初期会偏重。
我的建议是:范围控制在团队内部时,先用静态API Key起步;需要开放给合作方或者外部开发者时,再在静态Key基础上叠加HMAC签名组件;需要在公司统一账号体系内做用户自服务时再考虑JWT。起步阶段不要把鉴权模型设计得太重,否则会被流程本身拖住。
3. 落地实现:写一个带鉴权的本地模型API网关
3.1 运行架构与一次完整请求的鉴权链路
为了让后面代码部分容易理解,先描述一次完整请求是怎么走通鉴权的。假设团队在GPU服务器上部署了Ollama(监听127.0.0.1:11434)和一个OpenAI兼容的vLLM服务(监听127.0.0.1:8000),这两个服务都不直接暴露给外部。对外暴露的是一个新建的Python网关,监听0.0.0.0:8080。
调用方发起请求后,链路是这样的:
- 客户端把API Key放在请求头
Authorization: Bearer sk-xxx中,请求打到网关的/v1/chat/completions路径。 - 网关从请求头里提取Key,查内存或数据库中的Key表,校验Key是否存在、是否过期、是否被禁用。
- 校验通过后,网关读取该Key对应的模型白名单和配额元数据,把请求转发给上游的vLLM或Ollama。
- 上游推理完成后返回响应,网关在返回过程中记录日志、增加指标计数、计算耗时。
- 如果校验失败,网关直接返回401或403,不触发任何上游推理请求。
这套逻辑的核心思路是:鉴权判断只发生在网关入口,上游模型服务永远不知道网关背后的密钥体系是什么。这样就算上游服务本身存在漏洞,攻击者也无法直接触达,因为网络层面已经把上游隔离在内网了。
3.2 目录结构和依赖准备
我推荐的工程结构不必复杂,单体文件能扛住初期使用,后续再拆也来得及:
text复制gateway/
├── main.py # FastAPI入口,含路由和转发逻辑
├── auth.py # 鉴权函数,从Header提取Key并校验
├── config.py # 配置项,比如上游地址、Key表路径
├── audit.py # 结构化审计日志
└── requirements.txt
依赖其实很少:fastapi、uvicorn、httpx。其中httpx负责异步转发,兼容流式响应。启动命令建议用uvicorn main:app --host 0.0.0.0 --port 8080。如果预期的并发请求量不低,可以把workers设为2到4个,但要注意后面我会提到的内存问题。
3.3 鉴权校验函数的核心代码
关键点在于Key的存放方式。初期可以放在环境变量或本地JSON文件里,但我强烈建议不要存明文,而是存sha256哈希值。这样即使配置文件泄露,攻击者也无法直接逆向得到可用的Key。
python复制import hashlib
import json
import os
from fastapi import Request, HTTPException
from fastapi.security.utils import get_authorization_scheme_param
# 这里演示从 JSON 文件加载 Key 配置
# 配置结构: {"sk-hash-value": {"name": "frontend", "enabled": true}}
KEY_FILE = os.getenv("KEY_FILE", "./keys.json")
def _load_keys():
if not os.path.exists(KEY_FILE):
return {}
with open(KEY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
def _hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
async def verify_api_key(request: Request):
# 支持两种 Header 写法
# 1. Authorization: Bearer sk-xxxx
# 2. X-API-Key: sk-xxxx
auth_header = request.headers.get("authorization", "")
scheme, raw_key = get_authorization_scheme_param(auth_header)
if scheme.lower() != "bearer" or not raw_key:
raw_key = request.headers.get("x-api-key", "")
if not raw_key:
raise HTTPException(status_code=401, detail="Missing API Key")
keys = _load_keys()
key_hash = _hash_key(raw_key)
key_info = keys.get(key_hash)
if not key_info:
raise HTTPException(status_code=403, detail="Invalid API Key")
if not key_info.get("enabled", True):
raise HTTPException(status_code=403, detail="API Key disabled")
# 把解析出的 Key 信息附加到 request.state 上
# 后面的路由处理函数可以直接读取
request.state.api_key = raw_key
request.state.key_name = key_info.get("name", "unknown")
request.state.key_meta = key_info
return key_info
这里有一个细节值得说明:为什么不用header: str = Header(...)这种FastAPI依赖注入方式?因为Authorization头本身是有标准格式的,直接用字符串匹配容易忽略大小写问题。get_authorization_scheme_param是FastAPI自带的解析器,能正确处理Bearer前缀,省得自己手动去切。
Key表的JSON结构大致长这样:
json复制{
"b2a1f4d8c0a5d0e5..." : {
"name": "frontend-app",
"created_at": "2025-01-01T00:00:00Z",
"expires_at": "2025-12-31T00:00:00Z",
"enabled": true,
"models": ["qwen2.5:32b", "deepseek-v3"],
"plan": "pro"
}
}
models字段若为空数组或["*"]表示允许所有模型,否则只允许列表内的模型。这个设计后面对接配额管理很有用。
3.4 反向代理转发与白名单控制
接下来是转发核心。这里最需要小心的是路径匹配和流式响应。
python复制import httpx
from fastapi import FastAPI, Request, APIRouter, Depends
from fastapi.responses import StreamingResponse, JSONResponse
app = FastAPI(title="LLM API Gateway")
UPSTREAM_OPENAI = os.getenv("UPSTREAM_OPENAI", "http://127.0.0.1:8000/v1")
UPSTREAM_OLLAMA = os.getenv("UPSTREAM_OLLAMA", "http://127.0.0.1:11434")
# Ollama 只允许放行这些路径,禁止管理类接口
OLLAMA_ALLOWED_PATHS = {
"/api/chat",
"/api/generate",
"/api/embed",
"/api/tags",
"/api/ps"
}
client = httpx.AsyncClient(timeout=None)
def _clean_headers(request: Request) -> dict:
headers = {k: v for k, v in request.headers.items() if k.lower() not in {
"host", "content-length", "authorization", "x-api-key"
}}
return headers
async def _proxy(url: str, request: Request):
headers = _clean_headers(request)
body = await request.body()
upstream_request = client.build_request(
request.method,
url,
headers=headers,
content=body
)
upstream_response = await client.send(upstream_request, stream=True)
return upstream_response
@app.api_route("/v1/{path:path}", methods=["POST", "GET"])
async def proxy_openai(path: str, request: Request, _=Depends(verify_api_key)):
url = f"{UPSTREAM_OPENAI}/{path}"
upstream_response = await _proxy(url, request)
return StreamingResponse(
upstream_response.aiter_raw(),
status_code=upstream_response.status_code,
headers={
k: v for k, v in upstream_response.headers.items()
if k.lower() not in {"content-encoding", "content-length", "transfer-encoding"}
}
)
为什么要把content-length和authorization删掉再转发?原因有两个:authorization如果直接转发给上游,上游可能以为请求已经经过鉴权,但实际上它根本不需要知道客户端身份;content-length删掉则是因为客户端在POST较大请求体时,网关读入body后可能需要重新计算长度,直接让它以chunked方式转发更省心。
Ollama的路径需要单独处理,因为它的原生路径和OpenAI兼容路径不是一套体系:
python复制@app.api_route("/api/{path:path}", methods=["POST", "GET"])
async def proxy_ollama(path: str, request: Request, _=Depends(verify_api_key)):
full_path = f"/api/{path}"
if full_path not in OLLAMA_ALLOWED_PATHS:
return JSONResponse(status_code=403, content={"detail": "Path not allowed"})
url = f"{UPSTREAM_OLLAMA}{full_path}"
upstream_response = await _proxy(url, request)
return StreamingResponse(
upstream_response.aiter_raw(),
status_code=upstream_response.status_code,
headers={
k: v for k, v in upstream_response.headers.items()
if k.lower() not in {"content-length", "transfer-encoding"}
}
)
这个白名单是必须加的。因为Ollama的/api/pull、/api/delete这类管理接口如果暴露出去,等于任何人只要拿到了一个能访问内网网关的权限,就能往服务器上拉模型或者删模型,这比单纯调用模型的风险严重得多。
3.5 流式响应场景下的日志埋点
大模型API最常见的调用方式其实是流式返回,客户端像打字机一样不断接收输出。如果采用“等全部响应收完再记日志”的方式,客户端会感觉延迟翻倍,这不可接受。正确的做法是在流式迭代器结束之后再记录完整日志。
python复制import time
import json
import logging
logger = logging.getLogger("gateway")
async def _proxy_and_log(url: str, request: Request):
start = time.time()
key_name = getattr(request.state, "key_name", "unknown")
model = request.query_params.get("model", "") or request.state.key_meta.get("models", "")
upstream_response = await _proxy(url, request)
status_code = upstream_response.status_code
stream = upstream_response.aiter_raw()
async def gen():
nonlocal status_code
try:
async for chunk in stream:
yield chunk
except Exception:
status_code = 500
logger.exception("upstream stream error")
raise
finally:
latency_ms = (time.time() - start) * 1000
log_entry = {
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"request_id": request.headers.get("x-request-id", ""),
"api_key": key_name,
"client_ip": request.client.host if request.client else "",
"method": request.method,
"path": request.url.path,
"status": status_code,
"latency_ms": round(latency_ms, 2),
"model": model,
}
logger.info(json.dumps(log_entry, ensure_ascii=False))
return StreamingResponse(gen(), status_code=status_code)
我在实际项目中习惯把request_id也打进去。调用方在请求头里带一个UUID,网关在转发时透传,这样出问题时能在网关日志和上游推理日志之间串联上下文。
3.6 联通性自测与curl验证
代码写完先别急着接业务,用curl把链路打通。正常情况下,不带Key的请求应该返回401:
bash复制curl -i http://127.0.0.1:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"qwen2.5:7b","messages":[{"role":"user","content":"hi"}]}'
预期结果是401或者403,而不是真的去调模型。
带正确Key的请求,应该能正常返回:
bash复制curl -i http://127.0.0.1:8080/v1/chat/completions \
-H "Authorization: Bearer sk-dev-abc" \
-H "Content-Type: application/json" \
-d '{"model":"qwen2.5:7b","messages":[{"role":"user","content":"hi"}]}'
我个人建议在正式接入前用OpenAI SDK实测一遍,因为很多网关对普通curl没问题,但对SDK的默认行为兼容性不足。比如OpenAI SDK会优先使用Authorization头,而且可能会发送user、stream_options等额外参数,你的网关需要能把这些参数原样透传。代码里因为用了request.stream()和完整Header透传,这些场景基本能被覆盖。
4. 可视化:让鉴权过程和流量消耗“被看见”
4.1 可视化不只是看图表,更重要的是看异常
本地大模型API网关做可视化的目的,不是画一张好看的大屏扔到公司展厅,而是让运维和开发能快速回答几个问题:现在谁在调用模型?哪个Key调用量异常上涨?有没有人在尝试用不存在的Key爆破?上游模型服务是不是已经开始变慢了?
如果没有可视化,这些问题只能靠人肉翻日志。而日志在没有指标的情况下很难快速聚合。鉴权链路可视化真正要盯的是四个维度:已校验通过的调用量、鉴权失败次数、平均响应延迟、异常分布。其中鉴权失败次数是最容易被忽视但最有价值的指标。它能在攻击者真正打穿服务之前就暴露问题。
4.2 结构化的JSON日志是第一层可视化基础
很多团队做可视化时第一反应是引入Grafana和Prometheus,但在为网关添加监控前,日志格式是否结构化其实更需要先做好。我推荐每行一条JSON,字段固定,这样日志采集端能自动解析并按字段过滤。
上文代码中的日志输出格式本身已经适合接入日志系统。实际运维时可以把这份日志交给三种后端之一:最简单的是Loki+Grafana,轻量、部署容易;如果公司已经有ELK,那直接对接Elasticsearch也行;如果是云原生环境,云厂商自带日志服务也可以凑合用。
用Loki的方案时,在Promtail配置里加一个job即可:
yaml复制scrape_configs:
- job_name: llm_gateway
static_configs:
- targets: [localhost]
labels:
job: llm-gateway
__path__: /var/log/llm_gateway/*.log
注意代码日志要写到文件而不是标准输出,否则没有__path__让Promtail去收集。我习惯在Python的logging配置里同时挂两个handler,一个输出到控制台便于本地调试,一个写到/var/log/llm_gateway/gateway.log便于远端采集。
4.3 Prometheus指标暴露与Grafana面板
日志适合做排障追溯,但做实时告警和趋势图还是Prometheus+Grafana更顺手。网关可以暴露一个/metrics端点,把核心指标通过Prometheus协议暴露出去。
用prometheus-client库实现三个核心指标:
python复制from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
from fastapi.responses import Response
API_REQUEST_COUNT = Counter(
"llm_gateway_requests_total",
"Total requests handled by gateway",
["api_key", "path", "status"]
)
API_LATENCY = Histogram(
"llm_gateway_request_duration_seconds",
"Request latency in seconds",
["api_key", "path"]
)
AUTH_FAILED_COUNT = Counter(
"llm_gateway_auth_failed_total",
"Total auth failed requests"
)
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
然后在路由代理函数里打点:
python复制API_REQUEST_COUNT.labels(
api_key=request.state.key_name,
path=request.url.path,
status=str(status_code)
).inc()
在Grafana里可以做这样一个面板:X轴是时间,Y轴是QPS,按api_key分组。查询表达式类似这样:
text复制sum by (api_key) (rate(llm_gateway_requests_total[1m]))
另一个值得加的面板是鉴权失败趋势。如果用AUTH_FAILED_COUNT这个Counter,配合increase函数可以算出最近十分钟新增了多少次鉴权失败。如果某个时间段失败次数异常飙升,排查一下是不是有同事把旧Key写死在脚本里,还是有人在扫端口。
我个人的实战心得是,不要把注意力只放在“模型响应延迟”上。网关本身的延迟、鉴权失败次数、上游429错误率都是一样重要的信号。尤其是模型服务没有限流时,某一个调用方的疯狂请求会把整张GPU卡打满,其他业务线全部变慢。这个现象只有通过可视化面板按Key拆解后才能一眼发现。
4.4 配置告警的三个关键阈值
可视化不仅仅是被动看,还要配合主动告警。我建议至少设置三类告警。
第一类叫“鉴权失败异常”。正常情况下,内部系统鉴权失败率极低。如果某个Key在一分钟内连续失败超过10次,多半是凭证被写死在某个脚本里,也可能是有人在扫密钥。设一个正则表达式匹配不到的请求就算一次失败,超过阈值就通知到运维群。
第二类叫“调用量突增”。以小时为单位对比调用量,如果某个Key的调用量比过去7天同一时段平均值高出3倍以上,触发告警。这个告警主要防的是某个定时任务写错循环、或者有同学把压测脚本接到生产环境上。
第三类叫“上游错误率”。当上游推理服务返回502、503或429的比例超过5%时,说明模型服务已经到瓶颈了。这种告警的目的是及早发现资源不足,避免把故障拖到用户投诉。
5. 业务扩展:从“几个人用”到“多业务线接入”
5.1 密钥管理从配置项走向后台系统
团队小的时候,Key写在JSON文件里完全没问题。可当接入方超过二十个,配置文件的维护本身就是灾难。今天我新增了一个Key,既要更新服务器文件,又要重启服务,还要通过IM告诉对方别用错;明天有人离职了,我要把对应Key禁用,同样要改配置。
业务扩展的第一步是把密钥管理从配置文件挪到后台管理。最低成本的方案是维护一张数据库表,网关每次请求时查询数据库判断Key是否有效。如果不想给网关增加数据库连接负担,可以加一层Redis缓存,按Key哈希做短时间缓存,比如5秒。
密钥表的设计可以参考下面的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| app_name | string | 应用名 |
| owner | string | 负责人 |
| key_hash | string | API Key的sha256哈希 |
| enabled | bool | 是否启用 |
| expires_at | datetime | 过期时间 |
| allowed_models | json | 允许调用的模型列表 |
| allowed_paths | json | 允许访问的路径列表 |
| plan | string | 套餐标识,比如free/pro/enterprise |
| created_at | datetime | 创建时间 |
| last_used_at | datetime | 最近一次使用时间 |
密钥管理界面哪怕内部用,也要支持创建Key、禁用Key、设置过期时间、查看Key最近用量。这里最容易被忽略的就是“禁用”操作。一旦出现Key疑似泄露,管理员要在几分钟内把它失效,而不是去改配置文件然后重启网关。
5.2 多租户模型:用户、应用与密钥的三层关系
当业务线多起来,密钥模型就不能是扁平的“一个Key对应一个调用方”。更合理的结构是三层关系:用户(负责人)下面挂应用,应用下面挂多个密钥。
举例来说,算法部的小王是一个用户,他的应用叫“智能问答机器人”,这个应用下面有三个Key,分别用于开发环境、测试环境、生产环境。三个Key可以单独禁用,但不影响小王这个账号的其他应用。
这个设计的好处是权限隔离和故障隔离更清晰。某个应用的Key泄露了,只需禁用一个Key;某个应用需要提升配额,不用影响其他应用。而且后续做成本分摊时,成本可以精确归集到应用这一层,财务看报表时非常清晰。
现实中有团队一上来就做到用户级,反而把简单事情搞复杂了。用户、应用、Key这三级关系确实要预留,但初期不需要做完整的注册、审核流程,可以先在配置里手动维护,等接入方多了再逐步自动化。
5.3 配额管理和限流:防止一个业务线拖垮整张卡
本地模型服务最典型的问题就是资源争抢。没有配额机制时,某个调用方发起长文本批量推理,可能把GPU算力全部占满,其他业务线延迟飙升。
网关层可以实现的配额维度很多,包括每分钟请求数、每分钟Token数、最大并发数、单次请求最大上下文长度。最容易见效的是单Key每分钟请求数限制。用简单的令牌桶算法就能实现:
python复制import time
from collections import defaultdict
class SlidingWindowRateLimiter:
def __init__(self, limit: int, window_seconds: int = 60):
self.limit = limit
self.window_seconds = window_seconds
self._usage = defaultdict(list) # key -> [timestamp, timestamp, ...]
def allow(self, key: str) -> bool:
now = time.time()
ts_list = self._usage[key]
# 清理窗口外的记录
while ts_list and ts_list[0] <= now - self.window_seconds:
ts_list.pop(0)
if len(ts_list) >= self.limit:
return False
ts_list.append(now)
return True
这个方案有几个问题必须说明:如果网关是多进程运行,内存变量不共享,需要把计数器挪到Redis;令牌桶的粒度可以按秒也可以按分钟,建议先用分钟级,因为大模型请求本身耗时较长,秒级限制意义不大。
比QPS限制更贴近大模型场景的是Token配额。上游OpenAI兼容接口返回的响应里通常带有usage字段,网关可以在流式结束前最后一块chunk中提取该字段并累加到Key的当日消耗里。这里牵涉到流式解析,实现相对复杂。务实的做法是先做按次调用配额,等有精确计量需求再上Token配额。
5.4 审计、权限与模型路由的渐进式演进
当模型不止一个时,业务扩展还会带来模型路由的需求。有的请求适合走Ollama的小模型,便宜快速;有的请求需要走vLLM的大模型,质量更高但成本高。网关可以在鉴权后增加一层路由逻辑,根据调用方所属的套餐、业务优先级、当前模型服务的负载来做决策。
审计方面,网关日志要保留足够周期的数据,至少30天。原因在于本地模型API属于企业内部资产,如果业务方质疑某个月账单不对,我们要能回放当时的请求记录。审计日志和监控日志可以参考同一份JSON,但审计日志需要追加不可变存储,不能随意清理。
权限控制则要做到路径级。前面白名单只是封掉了Ollama的管理接口,但更规范的做法是让Key自带的allowed_paths决定它能访问哪些路径。比如数据分析业务只能调用/v1/chat/completions,算法训练任务只能调用/v1/embeddings。静态Key在网关里虽然做了校验,但模型服务本身也知道路径权限的事,最好在网关层就拒绝不在范围内路径。
下表是我推荐的演进路径参考:
| 阶段 | 用户规模 | 采用方案 | 重点观察指标 |
|---|---|---|---|
| 第一阶段 | 5-20人,单一模型 | 静态API Key + 轻量网关 | 鉴权失败次数、调用量 |
| 第二阶段 | 20-100个应用,多模型 | 密钥管理后台 + Redis限流 | 按应用的成本分解、并发延迟 |
| 第三阶段 | 对外提供API或公司级平台 | 成熟网关 + OAuth2/Signature | 签约方计量、稳定性SLA |
6. 常见问题与避坑实录
6.1 防止管理接口被代理层意外放行
这是我见过最频发的问题。开发者在网关里写了一个通用的反向代理函数,把/api/{path:path}这种通配路径直接转发到Ollama,结果/api/pull也被代理出去了。有人拿到网关的访问权限后,直接往服务器上拉了一个七八GB的大模型,GPU磁盘被塞满,最终拖垮了同机上的其他服务。
解决办法就是路径白名单。哪怕需要支持Ollama的未来新接口,白名单列表宁可加得慢一点,也不要一开始就全量通配。每次加接口前先确认这个接口是推理面还是控制面,控制面一律不允许通过业务网关访问,而是走单独的运维通道。
6.2 API Key不能出现在URL和日志里
有一个常见的错误是把API Key放在Query参数中,比如?api_key=sk-xxx。这样做最危险的地方在于,几乎所有Web服务器、负载均衡器、云日志服务都会默认记录完整URL,Key会随着请求日志被持久化保存。一旦日志系统权限控制不到位,等于把钥匙挂在保险柜外面。
更应该杜绝的是在前端代码里写死Key。本地模型服务即使部署在公司内网,前端页面也可能通过CDN加载,任何能打开浏览器控制台的人都能直接看到请求头里的Key。正确的做法是前端调用公司后端服务,由后端在服务端持有Key并调用模型网关。
6.3 流式响应网关的常见异常排查
如果你在实现网关过程中发现流式输出时好时坏,或者客户端收到的内容不完整,可以先判断是不是Content-Length头没删掉。上游返回的响应长度是原始响应的长度,经过网关转发时如果继承了content-length,但实际流式内容因为中断比声明的长度短,客户端就会一直等待直到超时。
我在代码里加了从响应头中剔除content-length的逻辑,就是处理这个问题的。另外一个常见问题是超时设置。给上游服务发请求时,httpx如果不设置timeout=None,默认5秒超时会让大模型长思考场景频繁中断。大模型流式输出的时间经常超过一两分钟,所以务必把Timeout设置为None或者一个很大的值。
6.4 上线前的安全自查清单
我把平时给团队检查用的清单直接放在这里。对照着走一遍,能省去后续很多麻烦。
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 上游服务监听地址 | 检查Ollama/vLLM启动参数 | 只监听127.0.0.1,不对外暴露 |
| 管理接口是否代理 | 测试/api/pull、/api/delete等路径 |
网关返回403 |
| 无Key访问是否拒绝 | 不带Header调用 | 返回401或403 |
| Key能否单独禁用 | 禁用某个Key后调用 | 返回403 |
| 日志中的Key脱敏 | 查看访问日志 | 只显示Key名称,不显示完整Key |
| 流式响应完整性 | 用OpenAI SDK流式调用 | 无截断、无超时 |
| 限流是否生效 | 短时间连续发送请求 | 响应429 |
| 请求ID贯穿 | 调用方传入request-id后查日志 | 能关联网关和上游日志 |
