1. 从一个真实痛点说起:为什么我们需要LLM Gateway
过去一年,我帮三四个团队做过大模型应用的落地,几乎每一家都踩过同一个坑:项目刚开始的时候,业务代码里直接写死一个模型厂商的SDK,调通就上线。等到第二个月,老板说“换个更便宜的模型试试”,或者“这个场景用国产模型合规一点”,整个代码库就开始遭殃——改调用方式、改参数格式、改返回解析、改错误处理,牵一发动全身。更别提后面还要加限流、加计费、加审计、加缓存,每加一个能力都要在业务代码里再糊一层。
这就是LLM Gateway(大模型网关)要解决的核心问题。你可以把它理解成传统微服务架构里API网关在大模型场景下的“特化版本”:所有对模型的请求不再由业务代码直接发起,而是统一打到网关,由网关负责路由、鉴权、限流、缓存、计费、日志、降级。业务侧只认一个统一的接口,背后接的是OpenAI、Claude、通义、文心、本地Ollama还是vLLM,业务代码完全不用关心。
这篇文章我想聊的不是“怎么装一个开源网关”这种操作手册,而是把LLM Gateway这件事拆开揉碎:它到底解决什么问题、核心模块怎么设计、参数怎么算、坑在哪里。适合正在做大模型应用开发、准备把demo推向生产、或者被多模型接入折磨过的同学。看完你应该能自己判断:我的项目现在到底需不需要一个网关,需要的话该怎么做。
2. LLM Gateway到底在解决什么问题
2.1 多模型接入的“巴别塔”困境
先说最直观的问题:接口不统一。OpenAI的Chat Completions是一套格式,Claude的Messages API是另一套,通义千问、文心一言、智谱各有各的字段命名和鉴权方式。就连同一个厂商,不同版本之间参数都可能变。业务代码如果直接对接,等于把“厂商差异”这个复杂度泄漏到了整个系统里。
我见过最夸张的一个项目,业务代码里有一个if-else链,根据模型名字走不同的调用分支,足足两百多行。这种代码的维护成本极高,加一个新模型就要动核心逻辑,测试回归范围巨大。
LLM Gateway的第一层价值就是协议归一化:对外暴露一套统一的、兼容OpenAI格式的接口(这是事实标准,几乎所有客户端和框架都支持),对内做协议转换。业务侧永远只发一种请求,网关负责翻译成各家厂商能听懂的“方言”。
2.2 治理能力:限流、计费、审计、缓存
接口统一只是入门,真正让网关在生产环境不可替代的是治理能力。这里我列几个实际项目里最刚需的:
- 限流与配额:大模型调用是按Token烧钱的,一个死循环或者一次爬虫攻击可能几分钟烧掉几百块。网关可以在入口做QPS限流、Token配额、按用户/按租户的额度控制。
- 计费与成本归因:哪个业务线、哪个用户、哪个功能消耗了多少Token,必须能算清楚。网关是唯一能拿到全量请求的地方,天然适合做成本统计。
- 审计与合规:谁在什么时候问了什么、模型答了什么,需要留痕。金融、医疗这类场景这是硬要求。
- 缓存:相同或相似的Prompt可以直接命中缓存,省下真金白银。这就是热搜里提到的redis缓存治理在大模型场景的典型应用。
- 降级与容灾:主模型挂了自动切备用模型,或者高峰期把非核心请求路由到更便宜的模型。
这些能力如果散落在业务代码里,每个团队都要重复实现一遍,而且实现质量参差不齐。收敛到网关,一次做好,全公司复用。
2.3 它和传统API网关的区别
很多人会问:我直接用Spring Cloud Gateway或者Nginx不行吗?热搜里还有个词叫springcloud网关 path=/api 开头,说明不少人在用传统网关做路由。
答案是:传统网关能做一部分,但不够。传统网关擅长的是HTTP层的路由、鉴权、限流,它不理解“Token”这个概念,不理解流式响应(SSE)的特殊性,不理解Prompt和Completion的语义。LLM Gateway需要在传统网关能力之上,增加语义层的处理:
| 能力维度 | 传统API网关 | LLM Gateway |
|---|---|---|
| 路由 | 按路径/Header | 按模型名/能力/成本策略 |
| 限流 | 按QPS | 按QPS + Token数 + 并发数 |
| 计费 | 按请求数 | 按输入/输出Token分别计价 |
| 缓存 | URL级缓存 | Prompt语义级缓存 |
| 响应 | 完整响应 | 支持SSE流式透传与改写 |
| 容灾 | 服务级熔断 | 模型级降级与重试 |
所以我的建议是:LLM Gateway通常部署在传统网关之后,作为专门处理大模型流量的一个独立服务。两者是互补关系,不是替代关系。
3. 核心模块拆解与设计思路
3.1 统一接入层:协议适配器怎么设计
统一接入层的核心是适配器模式。每个模型厂商对应一个Adapter,Adapter负责三件事:请求转换、响应转换、错误码映射。
请求转换是把统一的内部格式翻译成厂商格式。这里有个设计要点:内部格式建议直接采用OpenAI的Chat Completions格式,因为生态最全,客户端、SDK、评测工具都认它。你不需要自己发明一套格式,那只会增加学习成本。
响应转换要处理流式和非流式两种情况。流式(SSE)是难点,因为不同厂商的流式分片格式不一样,有的按字符,有的按Token,有的还会在中间插入心跳。网关需要把各家的流式响应重新组装成统一的SSE格式再吐给客户端。
错误码映射同样重要。厂商A返回429表示限流,厂商B可能返回rate_limit_exceeded,网关要统一映射成标准错误码,业务侧才能写统一的错误处理逻辑。
# 适配器接口的简化示意 class BaseAdapter: def transform_request(self, unified_req: dict) -> dict: """统一格式 -> 厂商格式""" raise NotImplementedError def transform_response(self, vendor_resp: dict) -> dict: """厂商格式 -> 统一格式""" raise NotImplementedError def transform_stream_chunk(self, chunk: str) -> str: """流式分片转换""" raise NotImplementedError def map_error(self, vendor_error) -> GatewayError: """错误码归一化""" raise NotImplementedError提示:适配器一定要做成可插拔的。新接一个模型应该是“新增一个文件”,而不是“修改核心代码”。这是判断网关架构好坏的一条硬标准。
3.2 路由与负载均衡:不只是随机挑一个
路由策略决定了请求打到哪个模型。最基础的是按模型名路由,但生产环境往往需要更复杂的策略:
- 按成本路由:简单任务走便宜的小模型,复杂任务走大模型。可以基于Prompt长度、是否包含特定关键词、或者让一个轻量分类器先判断。
- 按可用性路由:主模型健康检查失败时自动切备用。
- 按租户路由:VIP客户走专属通道,普通用户走共享池。
- 加权负载均衡:同一个模型部署了多个实例(比如本地vLLM集群),按权重分发。
这里有个容易忽略的点:路由决策要可观测。每次请求走了哪条路由、为什么这么走,都要记录。否则出了问题根本没法排查。
3.3 缓存治理:Redis怎么用才不亏
缓存是大模型网关里性价比最高的模块之一。热搜里的redis缓存治理用在这里非常贴切。但大模型缓存和传统缓存有个本质区别:它不是精确匹配,而是语义匹配。
精确缓存很简单:把Prompt的哈希值作为key,命中就返回。适合那些完全重复的请求,比如系统提示词固定的场景。
语义缓存复杂一些:把Prompt做Embedding,在向量库里找相似度超过阈值的缓存结果。这能命中“换个说法但意思一样”的请求。但要注意阈值设置——设太高命中率低,设太低会返回不准确的答案。我的经验是相似度阈值设在0.92到0.95之间比较稳妥,具体要看业务对准确性的容忍度。
# Redis缓存key的设计示例 # 精确缓存 llmgw:cache:exact:{sha256(prompt+model+params)} # 语义缓存索引(配合向量库) llmgw:cache:semantic:{embedding_id} # 缓存TTL设置建议 # 事实类问答:24小时 # 时效性内容:1小时 # 代码生成:7天(代码变化慢)注意:缓存一定要考虑多租户隔离。A公司的缓存结果绝不能返回给B公司,这是数据安全问题。key里必须带租户ID。
3.4 限流与配额:Token级别的精细控制
传统限流按QPS算,但大模型场景下QPS没有意义——一个请求可能消耗10个Token,也可能消耗10000个Token。所以限流必须做到Token级别。
实现思路是:请求进来时先估算Token数(可以用tiktoken这类库),检查是否超过配额,超过就拒绝;请求完成后用实际消耗的Token数做结算。这里有个细节:流式响应的Token数是逐步产生的,需要在流式过程中实时累加,一旦超过配额要能中断。
配额维度通常有这几层:
- 全局配额:整个网关的总预算
- 租户配额:每个业务方的额度
- 用户配额:每个终端用户的额度
- 模型配额:某个昂贵模型的专属额度
这四层是叠加的,任何一层超了都要拒绝。实际实现时可以用Redis的原子操作做计数器,配合滑动窗口算法。
4. 实操落地:从零搭一个最小可用网关
4.1 技术选型与部署架构
如果你要自己搭,我推荐的技术栈是:Python + FastAPI + Redis + PostgreSQL。FastAPI的异步特性适合处理流式响应,Redis做缓存和限流计数器,PostgreSQL存审计日志和计费数据。
部署架构上,我建议分三层:
- 接入层:Nginx或云负载均衡,做TLS终止和第一层DDoS防护
- 网关层:LLM Gateway服务,可以水平扩展多个实例
- 存储层:Redis集群 + PostgreSQL主从
网关层必须无状态,所有状态放Redis,这样才能随意扩缩容。这一点在流量波动大的场景下特别重要。
4.2 关键配置参数怎么定
参数配置是很多人头疼的地方。我列几个关键参数和我的经验值:
| 参数 | 说明 | 建议值 | 依据 |
|---|---|---|---|
| 请求超时 | 单次请求最长等待 | 非流式60s,流式300s | 大模型生成慢,尤其长文本 |
| 连接池大小 | 到上游模型的连接数 | 每实例50-100 | 取决于上游限流 |
| 重试次数 | 失败重试 | 2次 | 太多会放大故障 |
| 重试退避 | 重试间隔 | 指数退避,基数500ms | 避免雪崩 |
| 缓存TTL | 缓存有效期 | 1-24小时 | 按内容时效性 |
| 语义缓存阈值 | 相似度阈值 | 0.92-0.95 | 平衡命中率与准确性 |
| 限流窗口 | 滑动窗口大小 | 60s | 与业务配额周期对齐 |
超时这个参数特别值得说。非流式请求如果设太短,长文本生成会被误杀;设太长,故障时资源被占住。我的做法是分级超时:根据请求的max_tokens动态计算超时时间,比如timeout = 10 + max_tokens * 0.05秒,这样短请求快速失败,长请求有足够时间。
4.3 流式响应的透传与改写
流式响应是LLM Gateway最容易出bug的地方。核心难点在于:你既要透传上游的流,又要在中间做处理(比如计费、内容审核、格式转换),还不能破坏流的实时性。
我的实现方案是用异步生成器:网关从上游拿到流后,逐块处理,处理完立即yield给下游,不做缓冲。这样用户感知的延迟和直连几乎一样。
async def stream_proxy(request, adapter): """流式代理的核心逻辑""" upstream_stream = await adapter.call_stream(request) token_count = 0 async for chunk in upstream_stream: # 1. 转换格式 unified_chunk = adapter.transform_stream_chunk(chunk) # 2. 累计Token用于计费 token_count += estimate_tokens(unified_chunk) # 3. 实时检查配额 if token_count > request.quota_remaining: yield error_chunk("quota_exceeded") break # 4. 立即透传,不缓冲 yield unified_chunk # 5. 流结束后异步结算 await settle_billing(request, token_count)提示:流式场景下,客户端断开连接要能正确传播到上游。否则用户关了页面,上游还在傻傻生成,白白烧钱。FastAPI里可以通过监听
request.is_disconnected()来实现。
4.4 审计日志与成本归因
审计日志要记什么?我的清单是:请求ID、租户ID、用户ID、模型名、输入Token数、输出Token数、耗时、状态码、缓存命中情况、路由决策。这些字段缺一不可,尤其是路由决策,排查问题时全靠它。
成本归因的关键是单价表。每个模型每百万Token的输入输出价格要维护成配置,定期更新。计费时用输入Token * 输入单价 + 输出Token * 输出单价算出来。这里要注意不同厂商的计价单位可能不同,有的按千Token,有的按百万Token,统一换算成百万Token再算。
日志写入建议异步化,不要阻塞主请求链路。可以用消息队列缓冲,后台消费者批量写库。
5. 常见问题与排查技巧实录
5.1 流式响应中断或卡顿
这是最高频的问题。排查思路按顺序来:
- 检查Nginx配置:Nginx默认会缓冲响应,必须关掉。
proxy_buffering off;和proxy_cache off;是必须的。这个坑我踩过,调了半天代码,最后发现是Nginx在缓冲。 - 检查超时设置:流式请求的
read timeout要设长,否则生成到一半连接被掐。 - 检查网关的缓冲逻辑:确认代码里没有意外的
await asyncio.sleep或者同步IO阻塞了事件循环。 - 检查上游:有时候是模型厂商那边的问题,用curl直连对比一下。
5.2 Token计数不准导致计费偏差
Token计数不准通常有三个原因:一是用了不匹配的tokenizer,比如用GPT-2的tokenizer去算GPT-4的Token;二是流式场景下分片边界处理不当,把半个Token算重了;三是没算上系统提示词和函数调用的Token。
解决办法:用官方推荐的tokenizer,流式场景下先拼接完整再计数(或者用增量计数但做好去重),计费时把系统提示词也算进去。另外建议定期对账,拿网关统计的数字和厂商账单对比,偏差超过5%就要查。
5.3 缓存命中率低
缓存命中率低,先分清是精确缓存还是语义缓存的问题。精确缓存命中率低,说明请求重复度本来就低,这是正常的。语义缓存命中率低,通常是阈值设太高,或者Embedding模型不适合你的领域。
我的调优步骤是:先看日志里相似请求的实际相似度分布,再定阈值。如果大部分相似请求的相似度在0.88左右,那阈值设0.95就永远命中不了。另外,缓存key要排除掉随机性参数,比如temperature、seed,这些参数不同但语义相同的请求应该能命中同一个缓存。
5.4 多租户场景下的数据串扰
这是最危险的问题,一旦发生就是事故。排查要点:缓存key、日志、计费记录里是否都带了租户ID;向量库的检索是否做了租户过滤;Redis的key前缀是否隔离。
我的做法是在网关入口就把租户ID注入到请求上下文,后续所有模块都从这个上下文取,不允许从请求体里临时解析。这样能避免某个模块忘了带租户ID的情况。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 流式卡顿 | Nginx缓冲/超时 | 检查proxy_buffering |
| 计费偏差大 | tokenizer不匹配 | 核对tokenizer版本 |
| 缓存不命中 | 阈值过高/key含随机参数 | 看相似度分布 |
| 数据串扰 | 租户ID未隔离 | 检查所有存储层 |
| 上游429频繁 | 限流配置过松 | 调低并发/加退避 |
| 内存持续增长 | 流未正确关闭 | 检查生成器生命周期 |
6. 一些实操心得和踩坑记录
先说一个我自己的教训。早期做网关的时候,我把限流做在了业务层,结果每个业务团队实现的限流逻辑都不一样,有的按用户,有的按IP,有的干脆没做。后来统一收到网关,才发现之前有大量重复请求根本没被拦住。限流这件事,越靠近入口做越有效,这是血的教训。
第二个心得是关于降级策略的。很多人以为降级就是“主模型挂了切备用”,但实际场景更复杂。比如高峰期主模型响应慢,你是继续等还是切备用?我的做法是设置动态阈值:当主模型的P99延迟超过正常值的2倍时,自动把部分流量切到备用。这个策略要配合监控告警,不能全自动,否则可能误判。
第三个是关于成本优化的。网关是唯一能看到全量请求的地方,所以它也是做成本优化的最佳位置。我做过一个简单的优化:对长度小于50 Token的简单问答,自动路由到便宜的小模型,成本直接降了60%,而用户几乎无感知。这种优化只有网关层能做,业务层做不了。
最后说一个架构上的建议:网关不要做业务逻辑。我见过有人在网关里加内容审核、加意图识别、加RAG检索,结果网关变得极其臃肿,改一处影响全局。网关的职责边界要清晰:接入、治理、路由、计费,就这四件事。业务逻辑放业务层,网关只做“管道”。
关于后续扩展,如果你的团队规模上来了,可以考虑把网关拆成控制面和数据面:控制面管配置(模型列表、路由规则、配额策略),数据面管实际请求转发。控制面改动不影响数据面,数据面可以独立扩缩容。这是大型系统的标准做法,小团队先用单体网关跑起来,等真的遇到瓶颈再拆也不迟。