做AI推理网关这件事,说白了就是一句话:当你的大模型后端从一两个变成七八个,调用入口必须有一个统一的路由架构,把流量按策略分到最合适的推理服务上。这篇是“大模型推理优化系列”的第一篇,我会把AI推理网关的路由架构和策略实践拆开讲,包括怎么分层、怎么设计策略、怎么处理流式中断这些细节,适合正在做模型治理、推理成本控制,或者打算从单模型直连走向多模型统一入口的团队参考。
去年上半年我们线上模型只有两个,一个自建的vLLM服务,一个第三方的接口。业务代码直接调SDK,倒也不乱。到年底模型服务涨到八个,Ollama上还跑着qwen2.5-7b这类本地小模型,调用层就彻底失控了:有些请求明明该走便宜模型却走了贵的,有些模型升级了业务方还在用旧接口,有些后端无响应时所有调用一起超时。最终我决定在业务和推理服务之间加一层AI推理网关,把路由决策从业务代码里捞出来,统一管理。这篇文章记录的就是这个过程。
1. 推理网关要解决的真实问题:模型变多之后调用层先乱了
1.1 从“两个模型”到“八个模型”的失控过程
一开始真没想过要引入网关。一个vLLM集群管住了大部分线上对话,一个外部API负责高难度任务,业务方在自己的服务里写死两套调用逻辑,顶多封装一个公共类,看起来挺干净。
转折点出现在“性价比”三个字上。有人开始接轻量模型处理简单问答,有人把文本分类任务切到本地Ollama上的开源模型,再后来多模态、长上下文、代码生成各自找到了专属后端。线上模型服务从两个变成八个,每个服务的调用方式、鉴权方式、超时要求都不一样。团队里每个人都在自己的代码里维护一份模型清单,你去问某个模型现在能不能用,没有人能立刻答上来。
失控的典型表现是:A服务通过vLLM的Python SDK调用模型,B服务直接用Ollama的HTTP接口,C服务又接了一版厂商的官方SDK。三个服务各自封装,没有一个统一的地方能看清所有模型服务的健康状态和调用量。新模型上线、旧模型下线变成一次高危变更,因为影响面根本理不清。
1.2 散落在业务代码里的路由逻辑就是技术债
我见过最典型的“路由代码”长这样:
def chat(messages): last = messages[-1]["content"] if "复杂" in last or "推理" in last: return call_vllm(messages) elif "简单" in last or "闲聊" in last: return call_ollama(messages) else: return call_api(messages)这段代码看着能跑,实际上问题很大。判断依据是拍脑袋写出来的关键词,模型稍微升级一下,这个规则就废了,但谁也不知道该什么时候改、改成什么。今天新接入一个模型,所有相关服务都要跟着改一遍,路由逻辑从一份复制成了三四份,改漏一个就出事。
更麻烦的是没法观测。每个模型每天处理多少请求、花了多少token、失败率多高,这些数据散落在各个服务的日志里,想统计得人肉捞日志。等到月底算推理成本的时候,财务拿着账单过来,我们连哪部分钱花在哪个模型上都说不清楚。
1.3 推理网关的定位:入口、策略执行点、观测点
给业务和推理服务之间加一层网关,核心不是“转发”这个动作,而是把三件事集中在一层解决:
- 流量入口:所有模型调用统一走一个地址、一套协议,业务方不用关心后端是vLLM还是API。
- 策略执行点:路由规则、限流、鉴权、熔断、降级全部下沉到网关,业务代码里不再有路由判断。
- 观测点:每个请求经过网关时统一记录耗时、token数、成功失败状态,成本和性能数据一口出。
一个比较贴近的类比是:以前每个业务方自己开车去不同的车站,现在统一到一个枢纽站换乘。多跳一跳,但是每个站点的状态、每辆车的班次都被集中管理起来。引入网关初期会有少量转发开销,但从治理角度看,这笔开销非常值得。
2. 路由网关的分层架构设计:接入、策略、转发各管一段
2.1 接入层要做的协议归一化
网关接入层最核心的一件事是统一协议。目前几乎所有的推理服务都兼容OpenAI的Chat Completions格式,vLLM、Ollama、各类外部API全都能接收这种格式的请求,所以直接把OpenAI兼容格式作为网关的内部标准格式是成本最低的选择。
接入层要做的事情包括:
- 鉴权:校验调用方的API Key或内部token,区分租户和业务线。
- 参数校验:检查必填字段、约束temperature范围、确认max_tokens不超过上限。
- 模型名映射:业务方传过来的model名字是逻辑名,网关映射成实际后端,业务方不需要知道后端地址。比如业务方传
model="chat-smart",网关映射到vllm-main;传model="chat-cheap",映射到ollama-local。 - 额外字段剥离:比如内部用的trace_id、租户id不能透传给外部API,需要在接入层剥掉。
2.2 策略层要组织的是“规则+候选集+打分”,不是if-else
很多人实现路由时,第一反应就是写if-else,这是最容易被代码腐化的做法。网关策略层应该围绕“规则、候选集、选择器”三个概念组织:
- match:描述什么请求命中这条路由规则,比如路径前缀、模型名、请求头里的租户标识。
- candidates:命中规则后,有哪些后端可以作为候选。
- selector:从候选集中挑出最终目标的方式,可以是加权随机、最小负载、分数排序。
一套典型的YAML路由配置长这样:
routes: - name: chat_default match: path_prefix: /v1/chat/completions candidates: - upstream: vllm-main weight: 80 - upstream: public-api weight: 20 selector: weighted_random这套配置的逻辑是:所有Chat Completion请求,80%流量走自建vLLM,20%流量走外部API。路由规则和代码分离,改权重不需要改代码,热更新配置就能生效。规则一多,还可以加优先级字段,从上到下匹配,第一条命中就先走第一条。
2.3 转发层与连接管理
转发层承担的是“把请求送到后端”的脏活,每个后端类型要有对应的适配器。vLLM适配器要处理好max_model_len和stream参数;Ollama适配器要注意本地接口和OpenAI兼容接口的差异;外部API适配器要关注上游限流头、账户余额这类因素。
连接管理是转发层比较容易忽略的点。如果每次请求都新建一条TCP连接,再做TLS握手,在高并发下延迟会明显抬高。网关内部一定要给每个后端维护连接池,复用HTTP长连接。实测下来连接复用能降低30%左右的请求延迟,同时对后端的连接数也更稳定,不容易把推理服务打挂。
2.4 一次请求在网关里的完整生命周期
| 阶段 | 做什么 | 关键点 |
|---|---|---|
| 1 | 接收HTTP请求 | 网关入口统一地址 |
| 2 | 鉴权和限流 | 校验调用方身份和配额 |
| 3 | 参数校验和归一化 | 模型名映射、字段规整 |
| 4 | 路由匹配 | 找到命中的路由规则 |
| 5 | 候选打分 | 选出最终目标后端 |
| 6 | 转发上游 | 通过适配器和连接池发送请求 |
| 7 | 响应处理 | 透传SSE流或聚合完整响应 |
| 8 | 指标记录 | 记录耗时、token、失败原因 |
| 9 | 返回业务 | 统一响应格式返回给调用方 |
整个过程对业务方是透明的,业务方只需要和一个域名交互。
3. 路由策略的核心维度:能力、成本、延迟、优先级怎么组合
3.1 能力约束先行
模型路由本质上是一个多目标决策。能力是硬约束,成本、延迟、负载是软目标。如果一个请求要求function calling,而候选模型根本不支持,那不管它多便宜、多快,都不能选。
给每个上游维护一份能力元数据,至少包含以下字段:
supports_tool_calling:是否支持函数调用supports_vision:是否支持图片输入max_context_length:最大上下文长度output_limit:最大输出长度quantization:量化精度,比如FP16、INT8、AWQ
路由开始之前,先根据元数据把不满足硬约束的候选过滤掉,再进入后续打分。这一步没做好的话,后面所有优化都是空中楼阁。
3.2 成本与负载怎么平衡
过滤完能力约束之后,如果候选集里还剩多个后端,就需要权衡成本和负载。
自建vLLM集群的边际成本很低,GPU已经买了,电费和运维都是固定的;外部API按token计费,每千token一个价,流量大了之后账单很吓人;本地Ollama模型几乎零边际成本,但能力弱一些,适合低优先级的简单任务。
动态权重是更细致的做法。网关不停统计每个后端的队列长度、每分钟token消耗,负载高的后端自动降低权重,负载低的后端多分流量。外部API还可以做配额管理,设置每分钟token上限和月度预算,超过限额之后直接把流量全切到内部模型,避免账单失控。
3.3 延迟敏感度决定路由偏好
同一个模型服务,交互式对话和离线批处理对延迟的要求完全不同。
- 网页对话框:用户等着回复,要求首token延迟尽量低,TTFT(首token时间)最好控制在800ms以内。
- 离线推理任务:凌晨跑一批文本分类,延迟不是首要指标,成本才是。
- 数据标注:要求模型输出质量稳定,延迟可以放宽。
所以在路由策略里应该带上场景标签。偏重延迟的场景优先选快的后端,偏重成本或质量的场景优先选对应的后端。我们线上就把请求头里增加了一个X-Scene字段,网关根据场景做不同维度的打分,效果比单一策略好很多。
3.4 多维度打分路由的实现思路
多维度打分是目前比较通用的做法,核心思路是:先做硬性过滤,再做加权求和。一个简化的打分函数:
def score_candidate(candidate, ctx): # 硬性过滤 if ctx.need_tool_calling and not candidate.supports_tool_calling: return -1 if candidate.health == "down": return -1 if ctx.max_context_length > candidate.max_context_length: return -1 # 软目标打分 score = 0.0 score += candidate.quality_rank * 10 # 质量分越高越好 score += (100 - candidate.current_load) * 0.2 # 负载越低越好 if candidate.cost_class <= ctx.max_cost_class: score += 15 # 满足成本约束加分 if ctx.latency_sensitive and candidate.ttft_p95 > 1200: score -= 30 # 延迟超标扣分 return score选出分数最高的候选作为转发目标。注意,分数本身没有绝对意义,只有排序价值。权重不要硬编码在代码里,应该做成配置项,最好还能通过AB实验来调,否则调参一次发版一次,维护成本也很高。
4. 流式输出与中断处理:SSE 和 abort 在网关层远比想象中麻烦
4.1 网关转发SSE的技术要点
大模型对话普遍用SSE(Server-Sent Events)做流式输出。一个典型的SSE事件长这样:
data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"delta":{"content":"你好"}}]} data: [DONE]每个事件以data:开头,以空行结尾,最后发一个[DONE]表示结束。网关转发SSE时,必须做到边收边推,绝不能等上游全部生成完再一次性返回,否则用户感知的延迟会非常差。
还有几个细节容易踩坑:
- 不要把整个SSE流缓存在内存里再转发,模型输出可能很长,内存会扛不住。
- 有些后端会周期性发心跳注释行
:keep-alive,网关要么透传,要么过滤,但不要影响事件解析。 - 网关配置超时必须区分读超时和写超时,SSE场景下写超时要宽松很多,因为连接虽然空闲,但用户还在等数据。
4.2 客户端断开后必须终止上游请求
这是流式场景里代价最大的坑。用户在网页上点了一个“停止生成”,前端调用AbortController.abort(),连接断开了。但如果网关没有把断开信号传递给上游,上游大模型会继续生成token,GPU继续算,token继续烧,月底账单上的钱就是这么流走的。
网关在转发流式响应时,必须持续检测客户端连接状态,一旦发现客户端断开,要立刻向上游发起终止请求。用Python写转发逻辑时,需要在一个循环里不断检查request.is_disconnected():
@app.post("/v1/chat/completions") async def relay_chat_completion(request: Request): payload = await request.json() upstream = route_to_upstream(payload) async with httpx.AsyncClient(timeout=600) as client: async with client.stream("POST", upstream, json=payload) as resp: async for line in resp.aiter_lines(): if await request.is_disconnected(): await resp.aclose() break yield line + "\n\n"注意不要在检测到客户端断开后直接什么都不做就break,最好先关闭上游响应流,让上游感知到连接终止。如果你的网关用的是其他语言,核心逻辑是一样的:收到abort信号时,向上游请求对象调用cancel或者close,而不是听之任之。
4.3 流式限流与重试边界
流式请求的限流策略和非流式不一样。流式请求一旦发出,用户已经看到前半段内容了,这时候重试会导致内容重复,体验极其糟糕。
所以网关对流式请求的重试必须严格限制在“首包之前”。也就是说,只有连接建立阶段失败、或者超过首包超时时间还没有收到第一个事件,才允许重试;一旦收到第一个数据块,后面任何失败都只能中断连接,不能重试。
限流维度上,除了常规的QPS,还需要关注并发连接数和token速率。大模型推理服务的并发能力非常有限,网关需要给每个后端设置并发上限,超出上限的请求排队或直接快速失败。流式连接断开的一瞬间,如果有大量客户端同时abort,还会形成一波“断开风暴”打到上游,网关最好加个保护,控制同一瞬间向上游发终止请求的数量。
5. 故障转移与熔断降级:后端集群不健康时的兜底方案
5.1 健康检查的层次
网关路由的前提是知道哪些后端是健康的。健康检查不能只停留在“端口通”这个层面。
| 层次 | 检查方式 | 能发现的问题 | 不能发现的问题 |
|---|---|---|---|
| L4 TCP | 端口连通性 | 进程挂掉 | 服务假死、死锁 |
| L7 HTTP | GET /health 返回200 | 应用层是否存活 | 模型是否能实际推理 |
| 业务级 | 发一个极小推理请求 | 模型是否可推理 | 显存OOM、队列堆积 |
最怕的就是服务进程还在,健康检查也返回200,但实际推理时因为显存不足或者并发打满,每个请求都超时。这种情况下只做TCP检查基本等于没有防护。建议至少做到HTTP层,每个后端定期探活;关键后端可以加业务级探测,用一个极短的prompt周期性验证模型能正常返回。
5.2 熔断器三态及其参数设定
熔断器是故障转移里最经典的机制。它的状态有三个:
| 状态 | 含义 | 网关行为 |
|---|---|---|
| closed | 正常状态 | 请求正常转发 |
| open | 熔断状态 | 不再转发到该后端,直接走降级 |
| half-open | 试探状态 | 放少量请求进去验证后端是否恢复 |
参数上我一般先给一组推荐值,再根据实际压测调整:连续失败3次进入open状态,open状态持续30秒,30秒后进入half-open,half-open状态放2个试探请求,试探成功则回到closed,失败则重新进入open。这组参数不是银弹,如果你的后端启动恢复特别慢,open窗口要加长;如果失败信号非常可靠,阈值可以调小一些。
5.3 超时分级与降级策略
超时如果不分级,所有请求卡在同一个超时值上,网关很容易把自己拖死。推荐分成三档:
- 连接超时:2-3秒,连不上的后端直接跳过。
- 首包超时:5-10秒,大模型加载prompt可能比较慢,但首次数据迟迟不来多半有问题。
- 整体空闲超时:流式过程中超过30-60秒没有新数据,主动断开,避免僵尸连接一直占着资源。
降级策略上,比较实用的是语义降级。主模型失败后,根据业务允许的程度,降级到本地小模型或者外部兜底API。降级不是返回一个错误,而是返回一个尽量合理的结果,同时在响应头里加一个X-Fallback: true标记,业务方可以根据这个标记判断本次回复质量。比如vLLM集群挂了,低优先级的闲聊请求直接切到Ollama本地模型,用户几乎无感知。
5.4 故障场景速查表
| 故障场景 | 网关行为 | 业务方感知 |
|---|---|---|
| 上游连接超时 | 尝试下一个候选后端 | 可能感觉稍慢 |
| 上游返回5xx | 熔断计数,切换备胎 | 基本无感 |
| 客户端中途断开 | 终止上游流式请求 | 无感知 |
| 所有候选都不可用 | 返回503并附带说明 | 明确失败 |
| 外部API配额耗尽 | 全部切到内部模型 | 可能质量下降 |
这张表可以直接拿去做联调时的测试用例,让每个故障场景真的走一遍,比看文档有效得多。
6. 一次真实的路由切换落地:配置、指标与踩坑
6.1 初始架构和改造目标
我们当时的后端组合很有代表性:一个自建的vLLM集群作为主力,跑qwen2.5-7b;一个Ollama本地服务,跑轻量模型;一个外部API兜底处理复杂任务。改造前的痛点是,三个后端分别被不同服务用不同方式调用,没有一个统一入口。
改造目标定得很朴素:业务方只认网关一个地址;路由策略要可配置、可热更新;任何单一后端故障都要自动切走,不让业务方感知。
6.2 路由配置示例
这是当时简化后的核心配置:
upstreams: vllm-main: type: vllm endpoint: http://vllm-cluster:8000 capability: max_context: 32768 supports_tool_calling: true health: kind: http url: /health interval: 10s ollama-local: type: ollama endpoint: http://ollama:11434 capability: max_context: 8192 supports_tool_calling: false health: kind: http url: /health interval: 15s public-api: type: openai endpoint: https://api.example.com/v1 quota: tokens_per_minute: 100000 monthly_budget_usd: 2000 routes: - name: chat_main match: path_prefix: /v1/chat/completions candidates: - vllm-main - ollama-local - public-api selector: score_based fallback: public-api这个配置表达的信息是:所有Chat Completion请求都进这条路由;候选集是三个后端;选择器用分数排序;如果所有候选都失败,最后兜底走到public-api。
6.3 灰度切流过程
网关上线不能一把梭,需要分步走。我们分了四步:
- 旁路模式:网关先开启,但不接管真实流量。复制线上请求打到网关,观察路由策略命中的结果是不是符合预期。这一步能发现大部分路由规则配错的问题。
- 小流量验证:把5%-10%的流量切到网关,重点观察流式转发、abort处理是否正常。
- 按业务线切换:优先切延迟不敏感的业务线,交互类业务放后面。每条业务线独立回滚。
- 全量切换:所有流量统一走网关,业务方的模型调用代码收敛为一个HTTP请求。
灰度过程中的监控面板必须包含三类指标:网关本身的错误率、各后端健康状况、token消耗成本趋势。没有这些指标,灰度就是在盲飞。
6.4 效果指标与经典踩坑记录
切换完成后的体感变化很明显:路由规则变更从几天一次发版变成秒级热更新,后端故障转移从人工介入的分钟级变成自动处理的秒级,成本也因为低优先级任务被分流到本地模型而明显下降。
踩过的坑也值得记一笔:
| 坑 | 后果 | 解决方案 |
|---|---|---|
| 网关超时设得比上游短 | 上游还在算,网关先断了,返回空响应 | 网关超时要大于上游最长输出时间 |
| 健康检查只做TCP | 后端假死,照样路由过去,请求全超时 | 增加HTTP和业务级探测 |
| 流式请求做了重试 | 用户看到两段重复内容 | 只在首包前重试 |
| 客户端abort没有传给上游 | 前端断开了,GPU还在烧token | 检测断开立即终止上游流 |
我自己的一个习惯是,网关上线后必须安排专人盯一周指标,重点看路由命中分布和失败原因。不要以为加上网关就万事大吉,路由策略要基于真实流量持续调,尤其是权重和熔断参数,前两周基本每天都要微调一次。
网关只是推理优化的第一站,后续还有prompt缓存、动态批处理、模型并行这些更深的优化可以做,但干净统一的路由架构是所有优化能够落地的地基。如果你们也正在做类似的改造,我建议先从小范围灰度开始,把策略维度想清楚再动手,不要一上来就堆一堆功能。