面向LLM场景的API Gateway设计与实战:统一多模型接入

面向LLM场景的API Gateway设计与实战:统一多模型接入 这阵子一直在做多模型接入的事越做越觉得需要有个东西把“调谁家的模型”从业务代码里抽出来。原来每接一个大模型就要在服务里加一套SDK、改一遍请求体、调一次流式解析模型多了以后路由判断散落得到处都是线上排查一个问题要翻三个服务。最后我干脆写了个开源项目 llm-proxy-tk定位很简单面向LLM场景的API Gateway统一收敛所有上游模型调用对外暴露一个兼容OpenAI风格的接口对内负责路由、鉴权、限流、流式转发和成本统计。这个项目适合谁如果你在搭多模型聚合平台、企业内部AI网关或者只是想把几个模型接口统一给前端App用它都能直接参考。接下来我把当时的设计思路、核心模块、部署流程和踩坑过程完整写出来尽量少讲虚的能进代码的就进代码。1. 为什么要做LLM API Gateway而不是直接调SDK1.1 多模型接入时代第一痛点不是模型不强而是接口不统一现在市面上的语言模型服务接口风格五花八门。OpenAI用/chat/completions消息结构是messages数组Anthropic有自己的一套Messages APIsystem提示词要单独拆出来Google Gemini用的是contents和parts本地用vLLM部署的开源模型大部分是兼容OpenAI的但细节参数又未必完全一致。如果业务代码直接绑定某一家SDK后面想换模型或者做模型分流成本会高得离谱。打个比方就好比你租了两间户型完全不同的房子一间进门左手是灯开关另一间开关在阳台上。你不可能每次换房子都重新学一遍电闸位置最好的办法是装一个总控面板把所有开关统一收到门口。LLM API Gateway就是这个总控面板。具体到代码层面差异比想象中更碎。单说一个流式输出OpenAI返回的是data: {choices:[{delta:{content:...}}]}Anthropic的事件类型和字段名不一样Gemini又有一套自己的candidates结构。如果业务侧直接对接这些原始响应就得写一堆兼容分支。用网关把差异挡在外面业务只认一种结构这部分脏活就彻底隔离了。1.2 现成网关为什么不够解渴可能有人会问Nginx、Kong、APISIX这些传统API Gateway不是也能做反向代理吗为什么非要自己写它们确实擅长流量分发、路由匹配、负载均衡但问题是它们完全不懂LLM的语义。传统网关处理请求体基本是透传最多改改URL和Header不会去解析messages字段不知道streamtrue时应该走SSE事件流更没法帮你把Anthropic的请求转换成OpenAI格式。要在Kong或APISIX里硬做这些事得写大量自定义插件插件的调试和维护成本加起来比直接自研一个小服务还高。我最早也在Traefik上试着用中间件做模型路由做到“按模型名给不同上游加权”这一步时中间件已经膨胀成了一个小型业务系统。后来果断放弃决定用Python asyncio自研。理由很直接AI生态的工具链基本都是Python处理OpenAI等SDK的流式响应最顺手asyncio对高并发流式请求完全够用FastAPI自带的OpenAPI文档还能当调式面板用开发和排障都省心。1.3 llm-proxy-tk的整体定位与技术选型项目整体分成三层接入层、策略层、上游适配层。接入层对外暴露HTTP接口负责鉴权、限流、参数校验策略层做路由匹配、模型别名、负载均衡、失败重试上游适配层把标准请求翻译成各个厂商的请求格式再把上游响应统一转换成OpenAI风格返回给客户端。技术栈选的是FastAPI加httpx。为什么不是requests因为requests不支持异步在FastAPI里如果直接调用同步的requests会把事件循环堵死。遇到大并发时哪怕上游响应只要100毫秒事件循环一阻塞所有请求都会排队这不是网关应该有的表现。httpx的AsyncClient支持流式响应配合FastAPI的StreamingResponseSSE转发时不用等整个响应体下载完再吐给客户端而是一边收一边发首字延迟可以压得很低。2. 核心功能设计与拆解2.1 统一OpenAI风格接口层网关要收口就必须定一个大家都认识的入参结构。我选的基准是OpenAI的POST /v1/chat/completions。客户端只需要带一个本地的API Key请求体里model字段填业务侧的模型别名比如叫gpt4、fast-llm、claude-sonnet由网关把这些别名映射到真实上游模型。这个设计最大的好处是生态兼容。很多现成工具比如LobeChat、ChatGPT-Next-Web、各类插件和调试面板都支持自定义OpenAI接口地址。只要把base_url指到网关前端几乎不用改代码。很多AI应用测试过OpenAI官方接口把地址换成网关在内网的地址立刻就能跑通。接口层要做的不只是透传。OpenAI的请求体里有messages、temperature、max_tokens、stream等参数不同上游的字段名和约束不一样。网关需要做一层轻量级参数映射。比如Anthropic的max_tokens叫max_tokensGemini叫maxOutputTokens这些转换都交给适配器处理业务侧只认OpenAI约定。2.2 多上游路由与模型别名机制路由是网关最重要的能力之一。我在配置里用upstreams定义上游集群用routes定义模型别名与上游模型的映射关系。先看一个最小配置示例upstreams: - id: openai-upstream base_url: https://api.openai.com/v1 api_key_env: OPENAI_API_KEY models: - gpt-4o - gpt-4o-mini - id: local-vllm base_url: http://10.0.0.5:8000/v1 api_key_env: LOCAL_API_KEY models: - qwen2.5-72b-instruct routes: - alias: gpt4 upstream: openai-upstream model: gpt-4o - alias: fast-llm upstream: openai-upstream model: gpt-4o-mini - alias: local-qwen upstream: local-vllm model: qwen2.5-72b-instructalias是业务侧真正感知到的模型名model是上游的真实模型名。这个解耦是刻意设计的。模型厂商偶尔会下线旧型号、换新版本或者本地部署的模型需要从A机器迁移到B机器只要改网关配置业务不用动。更复杂的场景是同一模型别名对应多个上游做加权轮询。比如两家厂商都提供同类模型我想分散流量可以把一个alias指向一个包含多个上游的组在配置里写权重。网关在路由时会根据权重选一个上游失败时还能自动尝试下一个。这一步让“模型风险分散”从口头策略变成了可落地的东西。2.3 流式SSE转发与背压处理流式是LLM网关最容易被忽视、也最容易翻车的部分。很多LLM接口的默认行为是streamtrue只要开启上游就会持续返回text/event-stream格式的数据块。网关在转发这些数据时必须做到不缓冲、不重排、不断连。如果网关把整个上游响应读完再统一返回给客户端用户会感觉打字机效果变成了“等很久然后一次性吐字”体验崩掉。所以实现上我用的是httpx的stream模式读上游用FastAPI的StreamingResponse作为下游响应两个异步生成器之间直接透传数据块。中间不做大块缓存只保留必要的解析逻辑比如从SSE事件里提取data部分然后原样写到客户端。背压处理更现实。假设上游每秒输出几百个token而客户端是手机弱网消费速度跟不上。如果网关不做限制数据会在内存里堆积。我的做法是给中间队列设置一个水位线队列长度超过阈值时暂停从上游读取等客户端消费掉一部分再继续拉取。看起来不复杂但它能避免单个流式连接把整个网关内存打爆。2.4 鉴权、限流与成本统计网关的鉴权不需要做成复杂系统内置一个轻量级Key表就够了。客户端请求时带Authorization: Bearer sk-local-test-key网关先从配置里加载允许的Key列表校验通过再放行。限流我用令牌桶算法支持按Key维度限制每分钟请求数也支持按IP维度做兜底。成本统计是LLM网关的标配。同一个模型在不同上游的实时价格可能不同所以我按每次请求记录的usage字段来计算。先在上游配置里写上每个模型的单价请求结束拿到prompt_tokens和completion_tokens后把token数换算成金额成本 (prompt_tokens / 1_000_000) * input_price_per_million (completion_tokens / 1_000_000) * output_price_per_million比如配置里写文本模型输入每百万token 5美元、输出每百万token 15美元某次请求prompt消耗1000 tokens、completion消耗500 tokens那这次成本就是0.005美元加0.0075美元合计0.0125美元。网关把每次请求的成本连同上游ID、模型名、Key名、耗时一起输出成结构化日志同时暴露一个/metrics端点给Prometheus抓取。这样月底统计成本时不用翻原始账单直接查指标就行。3. 从零部署llm-proxy-tk的完整实操记录3.1 克隆项目并启动最小配置先讲最快跑通的方式。项目用Python编写推荐Python 3.10以上版本下面是本地开发模式的启动流程git clone https://github.com/yourname/llm-proxy-tk.git cd llm-proxy-tk python -m venv .venv source .venv/bin/activate pip install -r requirements.txt cp config.example.yaml config.yaml python -m llm_proxy_tk.server --config config.yaml启动成功后会打印一行日志显示网关监听在哪个端口以及当前可用的路由别名。这时候你可以先不配置真实上游用一个模拟接口或者本地vLLM服务体验一把。项目根目录的config.example.yaml里写了完整的注释照着改字段就行。如果不想在本地装Python环境也可以用Docker。项目里带了Dockerfile构建镜像后挂载配置文件运行和二进制服务一样方便。我自己的开发机通常两种混着用改动代码时在本地跑稳定后打镜像推到服务器。3.2 配置文件的每一项都代表什么很多人拿到配置模板先急着填密钥其实最值得花时间理解的是路由和上游的关系。我建议先按下面这个示例建立一个最小但完整的配置server: host: 0.0.0.0 port: 8080 auth: keys: - name: local api_key: sk-local-test-key quota_per_minute: 120 upstreams: - id: openai base_url: https://api.openai.com/v1 api_key_env: OPENAI_API_KEY timeout_read_seconds: 120 models: - gpt-4o - gpt-4o-mini prices: gpt-4o: input_per_million: 5.0 output_per_million: 15.0 routes: - alias: gpt4 upstream: openai model: gpt-4o - alias: fast-llm upstream: openai model: gpt-4o-miniserver.host和server.port控制监听地址生产环境一般绑内网IP外层再加Nginx或云负载均衡做统一入口。auth.keys下面配置的是本地Key列表name方便日志追踪quota_per_minute做分钟级限流。upstreams里每一个元素代表一个上游集群。base_url一定要带上版本路径比如OpenAI是https://api.openai.com/v1vLLM类型的服务通常是http://ip:8000/v1。真实密钥不写在配置文件里而是通过环境变量引用比如api_key_env: OPENAI_API_KEY启动时确保环境变量里存在这个值。这样密钥不会因为配置文件被误传到仓库而泄露。timeout_read_seconds单独拿出来说这个值直接影响流式请求默认只有5秒会害死人我一般直接设到120。routes是核心路由表alias是客户端使用的模型名upstream指向上游IDmodel是真实模型名。如果你想一个别名对应多个上游做负载均衡可以把它扩展成一个列表并给每个上游配置权重。3.3 业务侧如何接入网关起来后先用curl做一次非流式请求确认基础链路是通的curl http://127.0.0.1:8080/v1/chat/completions \ -H Authorization: Bearer sk-local-test-key \ -H Content-Type: application/json \ -d { model: gpt4, messages: [{role: user, content: 用一句话介绍API Gateway}], stream: true }如果配置正确返回内容会像标准的OpenAI流式响应一样一行行data:事件不断输出最后以data: [DONE]结束。把stream改成false则会返回完整的choices和usage结构。如果你用OpenAI官方Python SDK接入网关注重一个关键字base_url。把base_url指到网关地址后模型名填别名Key填网关Key示例代码如下from openai import OpenAI client OpenAI( api_keysk-local-test-key, base_urlhttp://127.0.0.1:8080/v1 ) resp client.chat.completions.create( modelgpt4, messages[{role: user, content: 你好}], ) print(resp.choices[0].message.content)也就是说业务侧完全不需要知道真实上游是谁。就算今天把gpt4的映射换成另一个厂商的模型只要请求和响应结构兼容业务代码一行都不用动。3.4 生产环境的进程守护与日志策略本地验证完不要直接nohup python挂后台至少要有一个进程守护。我推荐用Docker Compose配置如下services: llm-proxy-tk: build: . restart: always ports: - 8080:8080 env_file: - .env volumes: - ./config.yaml:/app/config.yaml command: [python, -m, llm_proxy_tk.server, --config, /app/config.yaml]restart: always保证进程挂了能自动拉起env_file用来统一管理所有上游密钥。日志方面我落地时直接让网关输出JSON格式每行是一条结构化日志。字段包括请求ID、路由别名、上游ID、模型名、返回码、耗时、prompt tokens、completion tokens、估算成本。目前项目内置了JSON日志开关线上环境必须打开否则默认的标准日志在采集时非常难解析。4. 我踩过的坑与排查技巧4.1 流式响应频繁断连罪魁祸首是超时配置早期版本我把httpx的默认超时设为5秒结果所有流式请求几秒后必定断掉。原因很典型LLM请求首字可能需要几十秒尤其当模型在做复杂推理或者排队时第一个token迟迟不来默认的总超时直接掐断了连接。实际就算首字到了后文如果间隔一长某些链路也会误判为超时。解决方法是把读超时放在120秒以上同时在网关上游的Nginx或云负载均衡上也要同步调大proxy_read_timeout。很多人只改了网关的配置结果网关和客户端之间的Nginx还是默认60秒照样断。这个坑排查起来最费时间因为客户端看到的是网关连接被重置但网关的日志显示上游一直是正常返回。4.2 上游返回格式不统一导致解析层炸掉做统一网关最烦的就是各厂商返回结构不一致。Anthropic的usage字段用的是input_tokens和output_tokensOpenAI是prompt_tokens和completion_tokens有些上游的流式事件里只有增量内容没有完整的usage还有些自建模型服务在流式结束时压根不输出usage。我的处理原则是所有字段解析都做空值保护。统一转换时先判断字段是否存在不存在就用0填充。成本统计也一样拿不到usage的请求不能直接抛异常否则一个上游的小差异会让整个网关崩溃。少量请求漏统计成本可以接受但服务不能挂。4.3 429与重试不加退避的重试是灾难多个业务共用同一个上游时上游很容易触发限流返回429。如果网关这时对所有请求都立刻重试会放大并发把429扩散成雪崩。我后来实现的是带抖动的指数退避并且只对429、502、503这类临时错误重试。对400、422这类客户端参数错误重试没有任何意义。更要注意的是流式请求只要已经向客户端吐出了部分数据就绝对不能重试否则客户端会收到两段不连续的内容等于把生成结果切成了两半。4.4 日志量爆炸与敏感信息脱敏网关是所有请求的必经之路如果把请求和响应的完整内容都打到日志里一台网关一天能写几十GB。更严重的是安全问题用户可能在prompt里输入姓名、邮箱、内部代码片段这些内容一旦进日志后续处理会非常麻烦。我的建议是生产环境默认不记录messages的具体字段只记录model、tokens、cost、状态码、耗时这些指标。需要排障时再临时开启debug采样按请求ID只记一个请求的完整body。不要图省事日志脱敏是上生产前就该做好的事。我整理了一份常见的排查速查表方便抄作业现象可能原因处理方式流式请求几秒后断开网关或负载均衡的read timeout过短调大到120秒以上请求返回405base_url少了/v1路径检查上游配置模型返回内容正常但无usage统计上游流式事件不携带usage解析时做空值填充上游偶发429触发限流指数退避重试带抖动日志里出现大量完整prompt日志级别配置过高调整为只记录元数据并发高时网关内存上涨背压未生效检查流式队列水位线设置做这个项目的过程中我最大的感受是LLM API Gateway的价值不在于转发本身而在于把“模型接入”这件事从业务代码里抽出来。现在团队新接一个模型付出一份配置而不是动一遍业务代码。项目后续我会逐步加入多租户配额、更细粒度的敏感信息识别以及给不同上游做自动健康检查。如果你也在做类似的事欢迎提issue一起讨论或者在部署时把配置文件发到项目讨论区我也能少走弯路。