ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

自建LLM API网关:统一多模型接入、密钥管理与流式转发的工程实践

自建LLM API网关:统一多模型接入、密钥管理与流式转发的工程实践 自打去年开始密集接入各家大模型 API 之后我发现自己一直在重复做一件很蠢的事情每换一家服务商就要写一套新的对接代码处理一套新的鉴权方式还得单独维护一套密钥配置。更烦的是项目里不同模块需要的模型还不一样有的要便宜 quick 模型有的要长上下文有的要 streaming 输出结果配置散落得到处都是改一处就要翻半天代码。于是我一咬牙花了两周时间做了一个开源项目LLM API Gatewayllm-proxy-tk。简单说它就是一个统一的大模型 API 网关让你用一套 OpenAI 兼容的接口去访问任意多家大模型服务商包括密钥管理、流式转发、限流熔断、成本统计这些乱七八糟的事全部在网关上搞定。这篇文章不打算写那种一本正经的 README 复述而是想把我在设计和实现这个网关时踩过的坑、想明白的道理、以及最终沉淀下来的方案完整讲一遍。如果你也在被多模型接入的问题折磨或者正打算自己写一个类似的网关工具这篇文章应该能帮你少走不少弯路。1. 为什么我非要自己造一个 LLM API 网关先交代一下背景。我手上的一个业务系统需要同时接入来自三家服务商的模型国内某家的快速响应模型用于聊天机器人OpenAI 的模型用于复杂推理还有一家开源模型的托管服务用于批量文本分类。最开始我图省事直接在业务代码里分别封装了三个 client结果不到一个月就出问题了。1.1 散装接入带来的三个真实痛点第一个痛点是密钥管理彻底失控。三个服务商的 API Key 直接写在项目配置文件里后来换了人维护又不小心把配置提交到了 Git 历史里虽然及时发现并删除了但那种后背发凉的感觉相信不少人懂。而且每把 Key 都有独立的配额和计费月底对账的时候要跑去三个控制台分别拉账单再手动汇总效率极低。第二个痛点是接口协议各有各的脾气。三家虽然都号称兼容 OpenAI 格式但有的需要额外传api-version参数有的流式返回的字段名跟标准不同有的超时行为特别诡异。业务代码里被迫塞满了各种if ... else ...分发逻辑越改越乱。第三个痛点是流量治理完全缺失。给外部客户开放 API 的时候我根本没办法控制某个调用方能不能访问、每分钟最多调多少次、并发超过多少就熔断。一旦有人写了个死循环调用的脚本服务商那边先限流的是我然后整个业务跟着遭殃。1.2 网关方案解决的恰恰是 LLM 生态最碎的环节后来我调研了社区里现成的网关方案发现有两个极端要么是 Kong、APISIX 这种通用 API 网关功能很强但完全不了解 LLM 的协议细节对接大模型还得自己写一堆插件要么是某些商业平台的闭源网关好用是真好用但没法私有化部署数据要过第三方。于是我想明白了一个道理LLM API Gateway 的核心价值不在于做一个四层转发而在于在模型层做语义级别的抽象和治理。它应该理解什么是 chat completion什么是 embedding什么是流式响应并且在这些语义之上实现计费、限流、缓存、回退等能力。通用网关做不了这些因为它不懂模型的语言。1.3 同类开源方案里我看到的空白GitHub 上确实有几个相关的开源项目比如 LiteLLM、OpenRouter 的客户端库等。LiteLLM 更多是一个 Python SDK提供了统一的调用接口但它不是一个独立的网关进程没法做到让整个团队的任意语言微服务都通过 HTTP 来访问。还有一些项目做得很重依赖数据库、消息队列、Redis 一套全上对小团队来说部署成本太高了。我的定位很明确做一个人均几十 KB 内存占用的轻量级网关单二进制文件部署默认用 SQLite 存配置不强制依赖任何外部中间件但保留扩展能力。目标用户就是中小团队、独立开发者、以及不想被某一个云厂商绑定的技术负责人。这个定位直到现在在开源社区里依然比较稀缺。2. llm-proxy-tk 的设计思路与核心架构项目代号里 tk 是 tool-kit 的缩写因为我希望它不仅仅是一个代理而是一整套围绕 LLM 调用的工具集合。整体架构上我取舍了很久最后选择了控制面极简、数据面直连的设计路线。2.1 控制面与数据面分离轻量也能有清晰边界网关分为两条链路。控制面负责管理配置包括服务商接入信息、模型路由规则、访问令牌、限流策略这些通过一个 Web 控制台和 REST API 暴露数据面负责实际的模型请求转发它只做一件事收到客户端的 OpenAI 兼容请求按路由规则找到一个合适的上游模型转发请求把响应特别是流式响应原样传回。为什么强调数据面要原样传回因为很多网关喜欢在中间做协议转换比如把上游的非标准字段清洗成标准字段。出发点是好的但实际运行中我发现字段重写会破坏流式响应里增量内容的连续性而且一旦上游服务商更新了字段语义网关这边的转换逻辑就会悄悄出错。所以我的做法是默认透传只在明确配置了映射规则时才做转换。2.2 核心模块拆分每个组件只做一件事整个项目现在拆成了六个模块模块职责关键依赖proxy-coreHTTP 服务、请求路由、流式转发FastAPI、httpxprovider-registry服务商接入模板、模型能力声明pydantickey-vaultAPI Key 的加密存储与脱敏展示cryptographyroute-manager模型路由规则、权重分配、故障回退SQLitemeter-core调用计费、Token 统计、配额控制sqlite-utilsadmin-web可视化控制台Vue3、Element Plus模块之间通过事件总线做异步通信比如一次请求完成之后proxy-core发出一个request_completed事件meter-core监听到之后异步记录用量和费用这样转发路径上不会因为要写数据库而增加额外延迟。2.3 为什么坚持 OpenAPI 兼容层优先在设计对外接口时我坚定不移地选择了OpenAI 兼容格式作为唯一的第一方 API。原因很现实OpenAI 的接口格式已经成为大模型时代的HTTP 基本语义几乎所有开源工具、SDK包括 LangChain、LlamaIndex原生支持 OpenAI client只要我的网关提供一份 OpenAI 兼容的 base_url这些东西开箱即用。这比我自己发明一套更优雅的 API 格式有价值得多。代价是有的OpenAI 协议本身的某些设计我并不认同比如它在流式响应的事件格式里塞了太多业务字段解析起来不够清爽。但为了兼容性我选择忍受。这是典型的把复杂度留给实现者把简洁留给使用者的取舍。2.4 一个请求的完整生命周期把一次请求跑通的全过程写出来大家应该就能理解架构里各部分的协作关系了。客户端向网关发送POST /v1/chat/completions请求头里带网关颁发的访问令牌。proxy-core先从key-vault校验令牌有效性解析出调用方身份和所属项目。route-manager根据请求体里的model字段查路由表拿到上游服务商、具体模型名、超时设置、是否启用缓存等元数据。如果启用了语义缓存且请求命中了缓存直接返回缓存的响应不触发上游调用。如果没命中缓存把请求转发到上游服务商同时启动限流器检查配额。收到上游响应后如果开了流式则把 SSE 事件流按块转发给客户端如果没开等完整响应后再返回。请求结束后异步写入计费记录和调用日志。整个流程里最容易出错的是第 6 步流式转发的细节我在后面单独用一节来展开。3. 从零搭建网关版本选型、配置编写与基础功能演示这一部分是最有实操价值的我尽量按着你拿到代码之后一步步怎么跑起来的顺序来写。先说环境再说配置最后跑几个真实请求看效果。3.1 环境准备Python 3.11 Docker Compose项目后端是 Python 写的要求 Python 3.11 及以上因为用到了tomllib解析 TOML 配置3.11 才进标准库。如果你不想在本地装 Python 环境我提供了 Docker Compose 编排文件一条命令就能把所有服务拉起来。# 克隆项目 git clone https://github.com/yourname/llm-proxy-tk.git cd llm-proxy-tk # 本地开发模式 python -m venv .venv source .venv/bin/activate pip install -e .[dev] uvicorn llm_proxy_tk.main:app --reload --port 8080 # 或者使用 Docker Compose docker compose up -d启动之后打开http://localhost:8080/admin就能看到控制台页面。默认管理员账号密码首次启动时会打印在日志里记得第一时间修改。3.2 配置文件详解服务员商、路由与缓存网关的配置文件默认叫gateway.toml支持热加载。我发现 TOML 比 YAML 更适合做配置嵌套层级多了之后 YAML 的对齐真的让人崩溃TOML 的方括号分区方式一目了然。[gateway] listen 0.0.0.0:8080 max_request_size_mb 10 [providers.openai] base_url https://api.openai.com/v1 api_key_env OPENAI_API_KEY # 推荐用环境变量注入不直接写文件 models [gpt-4o, gpt-4o-mini] [providers.azure_openai] base_url https://xxx.openai.azure.com/ api_key_env AZURE_OPENAI_API_KEY api_version 2024-06-01 models [gpt-4o] [providers.local_vllm] base_url http://localhost:8000/v1 api_key_env models [qwen2.5-7b-instruct] [routes.default] strategy round_robin targets [ { provider openai, model gpt-4o-mini, weight 8 }, { provider local_vllm, model qwen2.5-7b-instruct, weight 2 }, ] [cache] enabled true ttl_seconds 300 max_entries 10000 similarity_threshold 0.95这里有一个非常典型的场景默认路由把 80% 的流量转发给 OpenAI 的快速便宜模型20% 的流量转发给本地部署的开源模型。这么做有两个用意一是降本二是万一 OpenAI 服务不稳定本地模型可以自动接管一部分请求。3.3 接入服务商的正确姿势环境变量管理密钥配置里故意把 API Key 写成api_key_env让网关从环境变量里读取真实密钥。这样做的主要原因是防止密钥泄露到 Git 仓库。在 Docker 部署时可以通过.env文件注入但记得把.env加进.gitignore如果是 k8s 环境直接挂载 Secret 作为环境变量即可。启动之后在控制台的密钥管理页面可以看到 API Key 以sk-***abc的形式脱敏展示真实值只在创建时展示一次。密钥在数据库里是加密存储的加密密钥保存在本地文件的vault.key里后续可以做 KMS 对接。3.4 跑通第一次请求从 curl 到 SDK网关启动后先创建一个访问令牌。拿到令牌后用最朴素的 curl 验证连通性curl http://localhost:8080/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer tk_live_xxxxxxxx \ -d { model: quick-chat, messages: [{role: user, content: 你好介绍一下你自己}], stream: false }我故意在请求里用了model: quick-chat而不是实际模型名这样就能验证路由规则的语义映射是否生效。网关会去 routes 表里找到名为quick-chat的逻辑模型然后按负载均衡策略转发给真实的上游模型。如果是 Python 项目直接换 base_url 就能用from openai import OpenAI client OpenAI( base_urlhttp://localhost:8080/v1, api_keytk_live_xxxxxxxx ) response client.chat.completions.create( modelquick-chat, messages[{role: user, content: Hello}] ) print(response.choices[0].message.content)4. 流式响应、并发控制与语义缓存网关最核心的三大技术难点能够转发普通的请求只是及格线真正的硬骨头全在这节里。这三块做不好网关就是个玩具。4.1 流式响应转发的机制与坑SSE 事件流转发的完整逻辑大模型接口基本都支持stream: true的 SSE 流式返回。网关这层做流式转发跟普通 HTTP 转发最大的不同是上游返回的响应头已经到达后body 还是一个持续打开的数据流你不能等它全部结束再返回给客户端必须边收边传。我用httpx.AsyncClient的stream模式实现。核心代码如下async def proxy_streaming_response(upstream_response): async with upstream_response.aiter_lines() as lines: for line in lines: if line.startswith(data:): # 转发数据行 yield f{line}\n\n elif line.strip() data: [DONE]: yield data: [DONE]\n\n这里有一个特别隐蔽的坑Socket 的絮凝Nagle算法。实现第一版的时候我直接在 FastAPI 里把流式响应 yield 给客户端结果发现响应内容是一段一段卡出来的延迟高得离谱。查了半天发现 Python 的 Uvicorn 默认在 Windows 上没禁用 Nagle小数据包会被攒到一起才发出去。解决办法是在启动命令里加上--no-server-header和 socket 优化参数或者在 nginx 反代层把tcp_nodelay on打开。另一个坑是错误响应也可能出现在流式过程中。上游可能在已经开始了数据流之后突然抛出一个错误比如触发了内容审核但此时连接不能随便断。我的做法是把错误转换成 SSE 格式里的一个错误事件尽可能让客户端 SDK 能感知到发生了什么而不是直接掐断连接导致客户端永远卡在等待中状态。4.2 并发控制从固定窗口到令牌桶早期版本我用的是固定窗口限流1 分钟窗口内最多请求 N 次。跑了一段时间后发现一个极端情况某调用方在第 59 秒时突发大量请求把配额打满紧接着下一秒窗口重置又立刻涌入一批导致上游在分钟级边界上承受双倍压力。后来换成了令牌桶算法。令牌桶的逻辑可以这样理解有一个桶每秒钟往里放几个令牌桶最多攒到一定量每个请求来的时候要从桶里拿走一个令牌如果桶空了就拒绝或排队。这样既能允许突发流量桶里攒下来的令牌又能限制平均速率。class TokenBucket: def __init__(self, rate_per_second: float, capacity: int): self.rate rate_per_second self.capacity capacity self.tokens capacity self.last_refill time.monotonic() async def acquire(self) - bool: now time.monotonic() self.tokens min(self.capacity, self.tokens (now - self.last_refill) * self.rate) self.last_refill now if self.tokens 1: self.tokens - 1 return True return False限流的维度也要想清楚。我在网关上支持三层限流全局维度整个网关每秒最多多少请求、项目维度每个项目每秒多少请求、模型维度某个上游模型每秒多少请求。三层都通过同一个令牌桶管理器实现只是 key 的粒度不同。4.3 语义缓存embedding 相似度判断与成本节省很多网关的缓存都是精确 key 匹配也就是说只有请求体完全一致才会命中。但 LLM 调用里用户的 prompt 往往只有细微差别比如多打了个空格、换了个标点、改了个同义词精确匹配的命中率其实很低。我实现了一种低成本但非常实用的语义缓存。原理是请求进来之后对原始 prompt 做一个 embedding这个小模型默认走本地all-MiniLM-L6-v2大概只有 80MB跑在 CPU 上毫无压力然后计算向量与缓存库中已有条目的余弦相似度。相似度超过配置阈值默认 0.95就认为语义一致直接复用缓存里的完整响应。这套方案实测下来能省大概 20% 到 40% 的重复请求开销尤其适合 RAG 场景里有大量用户问相似问题的情况。缺点也很明显多了一层 embedding 计算延迟大约 20 到 50 毫秒。对于延迟敏感的场景可以通过配置只对某些路由开启缓存。4.4 成本统计与配额控制我不希望月底对账的时候再去各个平台拉账单所以网关注册了每一次调用的 token 用量和费用预估。上游返回的usage字段里包含 prompt tokens 和 completion tokens网关按服务商配置里写的单价算出费用存到 SQLite 里。控制台提供一个统计看板按项目、按模型、按日期多维度汇总。配额控制方面除了上述的限流还支持按 token 数控制每个项目每月最多用多少 token超出直接拒绝并返回 429。这个能力对对外开放 API 的团队非常有用避免出现客户刷爆你的账单的事故。5. 实测中的坑从流式超时到数据竞争逐个复盘排错过程工具做出来之后我自己在生产环境跑了两周平均每天处理大概 20 万次请求。这段时间暴露出了不少在单元测试里根本测不出来的问题一个一个说。5.1 流式响应触发的静默截断问题第一天上线就收到用户反馈生成长文的时候经常只输出一半就停了客户端没有任何报错就像文章被突然咬掉一截。检查网关日志发现网关这边确实收到了上游的完成事件data: [DONE]但客户端根本没收到。问题出在代理超时设置上。我用httpx.AsyncClient默认的timeout30s对于短输出够用但长文生成时SSE 流里每两个数据块之间的间隔完全取决于模型生成速度可能 30 秒都没吐出下一个字。网关这边把这种大间隔误判为超时主动断开了连接。而上游并不知道你断了还在继续生成等它生成完了准备发[DONE]的时候发现连接没了于是这个完成事件就丢了。修复方式是把timeout设置改成httpx.Timeout(connect10.0, readNone, write10.0, pool10.0)读取超时设成无限让连接只受系统级 keepalive 管理。同时网关内部加了一个心跳检测每隔 60 秒向上游发送一个注释行作为 SSE 心跳避免客户端那边因为长时间没有消息而误判。5.2 数据竞争并发请求数量统计完全不准确我在做配额控制的时候最初是把当前并发数存在 Python 进程的内存里用asyncio.Lock保护。单机部署没问题但我准备多开几个副本横向扩展时发现每个进程的计数是独立的总并发数完全失真。后来把并发计数的存储迁移到了 SQLite 的WAL模式下。SQLite 在 WAL 模式下面可以多个进程同时读写的时候串行。虽然性能上限不如 Redis但中小规模的网关根本打不到那个瓶颈。用 SQLite 做计数还有一个好处重启不丢数据。具体实现上每次请求开始和结束时用INSERT和DELETE记录一张active_requests表当前并发数就是这张表的COUNT(*)。这套方案简单粗暴但非常可靠而且天然支持多副本部署。5.3 密钥泄露的补救机制优雅的强制轮换流程运营期间遇到过一次报警某个项目的一位开发者把 API 令牌误提交到了公开仓库几分钟内就被扫描机器人盯上了。幸好网关的控制台支持即时吊销令牌我立刻吊销了那把密钥然后给该开发者签发了一枚新密钥全程不到一分钟。事后我反思光有吊销还不够于是加了一个强制轮换功能管理员可以在控制台设置某个项目的令牌有效期到期后自动下发新令牌旧令牌进入 72 小时宽限期宽限期内仍能通过但会记录警告日志。这个机制让团队在发生疑似泄露时可以不慌不忙地分批处理而不是半夜被叫起来救火。5.4 上游限流错配涂层防御还是直接拒绝有一次上游服务商直接返回 429 限流错误但客户端看到的是 502。原因很简单我的网关把上游的 429 当成了上游故障走了熔断逻辑返回给用户 502 Bad Gateway。这对用户来说完全不透明他们无法区分是你自己的服务有问题还是模型供应商限流了。修复方案网关对不同状态码做精细化处理。上游返回 429 时网关解析响应里的Retry-After头带上同样的 429 状态码返回给客户端并在响应体里附上说明信息只有连续多次 429 或 5xx 时才触发熔断并切换备用模型。6. llm-proxy-tk 与其他开源网关的对比为什么还在持续迭代写这篇文章之前我又重新看了一遍 LiteLLM、OpenRouter 和 Kong AI Gateway 的最新文档确保自己的定位在社区里依然成立。6.1 四款网关工具的横向对比特性llm-proxy-tkLiteLLMKong AI GatewayOpenRouter部署形态单二进制/容器Python SDK/Proxy重型网关托管服务学习成本低中高极低多租户支持内置需扩展有有流式转发优化优秀良好一般优秀语义缓存内置无需插件无私有化部署完全支持支持支持不支持自定义路由策略支持权重回退简单路由支持插件支持坦率讲Kong AI Gateway 在插件生态和企业级能力上确实遥遥领先但部署上手成本是一个不低的门槛。LiteLLM 作为一个 SDK 非常出色但如果你想把它当独立网关给多语言团队用还是得自己再包一层服务。OpenRouter 的体验最好可数据要经过他们的服务器对某些行业这就是硬伤。6.2 我自己的定位和持续迭代方向llm-proxy-tk 的护城河不在功能多而在轻量、可私有化、面向 LLM 语义深度优化。接下来的迭代重点有几个方向一是支持更多的 embedding 模型供应商让语义缓存的选择更灵活二是把 Web 控制台的权限体系做得更细支持 RBAC 角色管理三是计划补上多活部署时的配置同步能力目前配置存在本地 SQLite 里多副本之间还没法自动同步需要借助共享存储或外部数据库。6.3 哪些用户适合用这个网关如果你的团队满足下面任意一条我觉得 llm-proxy-tk 是值得试一试的项目里已经接了两家以上的大模型 API每次切换都痛苦不堪。需要给公司内部多个业务线统一发放模型访问权限并分别统计用量。想控制在各个模型上的花费但不想被某个云厂商的计费账单绑架。正在做 RAG 或知识库类应用用户提问高度相似想通过缓存降低调用成本。反过来如果你只需要访问一家模型且只有一个后端服务在用那直接用官方 SDK 就好了网关反而多了一层无效的网络跳转。7. 部署上线前必须确认的八件事送给第一次用网关的人最后本着博客要能给读者省时间的原则我整理了一份部署前检查清单。这是我踩完坑之后一条条总结出来的每一条背后都有真实的教训撑腰。API Key 一律走环境变量或密钥管理服务不要写进配置文件更不要写进 Docker 镜像的 layer 里。对外只暴露网关的端口上游服务商的信息包括模型列表和 base_url不能让外部客户端直接看到。开启访问日志并配置轮转网关会产生大量 JSON 日志磁盘爆炸的惨案我已经看到过太多次了。先配好全局限流再对外服务否则一旦你的 API 被刷上游账单会教你做人。流式请求的客户端超时要调长建议至少 5 分钟否则长文章生成到一半就会被客户端掐断。给测试环境单独建一个项目用独立的令牌和独立的配额避免测试数据污染生产统计。监控三个核心指标网关自身的请求延迟、上游响应延迟、429/5xx 错误率。这三个指标能覆盖 90% 的故障场景。升级前先备份 SQLite 文件这个文件里存了全部配置和统计是网关的记忆丢了就只能从头再来。我在实际运维中还发现网关最好部署在离业务服务近的位置比如同一个 K8s 集群内这样网络跳数最少延迟损耗也最小。如果用 Docker Compose 部署记得把网关和业务服务放在同一个 Docker 网络里走容器名互联而不是通过宿主机端口转发。这个项目开源到现在收到了不少 issue 和 PR有人提过加 WebSocket 支持有人想要 gRPC 转 HTTP 接 OpenAI 格式也有在 Kubernetes 环境里做 Ingress 集成的需求。这些方向我都会观察社区呼声再决定优先级。对我来说一个工具能真正被人在生产环境用起来比自己闭门造车设计一堆高级功能有价值得多。如果你也遇到了多模型接入的各种破事欢迎来项目里提 issue 或者直接提 PR咱们一起把这套工具做得更好用。
返回列表