编程 自建大模型统一网关:多模型路由、故障转移与成本治理的工程实战

2026-07-23 02:41:45 +0800 CST views 8

自建大模型统一网关:多模型路由、故障转移与成本治理的工程实战

当你的应用同时挂着 OpenAI、Anthropic、Gemini、DeepSeek、Qwen 五家 Key 的时候,真正折磨你的不是模型能力,而是「每次换模型都要改一坨 SDK、每家签名都不一样、账单月底爆炸、某个厂商半夜 503 全场雪崩」。LLM Gateway 就是把这一地鸡毛收拢成「一个地址、一把钥匙、一套协议」的中间层。本文从协议碎片化的病根讲起,拆解一个生产级网关必须解决的六类工程问题,并用手写 Go 网关 + Python 语义缓存把路由、熔断、重试、成本记账跑通。

一、背景:我们是怎么被「模型碎片化」拖垮的

2026 年的 AI 应用早就不满足于「调一个 GPT 就完事」。一个稍微像样的产品,后端往往是这样一幅图景:

  • 闲聊、分类、抽取这类低价值请求,丢给便宜的小模型(DeepSeek-V3、Qwen、GLM);
  • 复杂推理、代码生成,才上 Claude / GPT 这类贵但强的模型;
  • 国内合规流量走国产厂商,海外流量走国际厂商;
  • 关键链路还要准备兜底:主力厂商限流或宕机时,能秒级切到备用厂商,不能让用户看到「502 Bad Gateway」。

听起来合理,落地就是一场灾难。根子在协议碎片化

  • OpenAI 用 /chat/completions,请求体是 messages
  • Anthropic 用 /messages,字段叫 system 而不是塞进 messages,且要求 x-api-key 头;
  • Gemini 是 contents + parts 结构,连思维链都要单独字段;
  • 国内厂商更是各家一套签名算法,有的要 Authorization: Bearer,有的要把时间戳拼进 query 再 HMAC。

结果就是:业务代码里很快长出一堆 if provider == "anthropic" { ... } else if provider == "gemini" { ... },密钥散落在十几个配置文件,计费逻辑和模型调用耦合在一起,谁也说不清「上个月那 8000 块钱到底烧在了哪些请求上」。

这正是 LLM Gateway 要解决的问题。它不是什么新概念——本质上就是传统 API Gateway 在 LLM 场景的特化:在应用和一堆异构模型之间,插一层统一协议 + 智能路由 + 弹性容错 + 成本治理的中间件。

1.1 这不是 PPT 概念,是 2026 年的真实热点

看几个有代表性的事实,证明这是当下真在发生的工程需求:

  • OmniRoute(github.com/diegosouzapw/OmniRoute):一个本地跑的 AI 网关,聚合 237 个 AI 服务商(其中 90+ 带免费额度),提供 13 种路由策略,并内置 RTK + Caveman 上下文压缩技术,号称能省 15%–95% 的 token。它用 npm / Docker / Desktop / ARM 多种形态分发,说明「开发者想把模型调度收口到本地」是刚需。
  • LiteLLM:开源顶流 LLM 网关,统一 100+ 模型提供商的调用为 OpenAI 格式,支持预算控制、限流、负载均衡、成本追踪、虚拟 Key。生产落地案例显示,把多模型接入、智能路由、故障转移、成本精细化管控做扎实后,可把调用成本压低 70%,可用性从 99.5% 拉到 99.99%
  • New API:31.9k Stars 的 One API 增强版,原生支持 15+ 国产服务商,是大量国内团队的实际选择。
  • 学术侧也在推进:FusionRoute(ICML 2026,CMU + Meta)提出了「专家路由 + 自我修正」的多 LLM 协作范式,说明路由策略本身正在从「规则硬编码」走向「模型驱动」。

行业数据也佐证了这点:KubeCon 2026 上披露,三分之二的生成式 AI 工作负载已经跑在 Kubernetes 上,K8s 正在从「基础设施」演变成「AI 操作系统」——而网关,就是这个操作系统里的流量调度内核。

所以,理解并亲手撸一个网关,不是玩具练习,是 2026 年后端/平台工程师的硬通货。

二、核心概念:一个生产级网关必须解决的六类问题

不要把网关想成一个「转发代理」就完了。真正生产级的 LLM 网关,要同时回答下面六个问题:

2.1 协议归一(Protocol Normalization)

对外只暴露一套 OpenAI 兼容的 REST 接口(业界事实标准),对内把各家协议翻译成对应厂商的格式。这意味着:业务侧永远只认 POST /v1/chat/completions + messages,换模型只需改一个字段,业务代码一行都不用动

2.2 智能路由(Intelligent Routing)

不是所有问题都值得上 GPT-4 级别的模型。路由维度至少有四种:

  • 成本优先:用历史单价把请求导向最便宜的及格模型;
  • 延迟优先:基于每个厂商的 EWMA(指数加权移动平均)延迟,选当前最快的;
  • 能力优先:代码生成走 Claude、长文摘要走便宜模型,按任务类型打标签路由;
  • 负载均衡:轮询 / 加权轮询,避免把流量全压在一家。

2.3 故障转移与熔断(Failover & Circuit Breaker)

单个厂商 503 / 429 / 超时是常态,不是异常。网关要能做:

  • 重试 + 退避:对幂等请求做有限次指数退避重试;
  • 跨厂商 Fallback:主力挂了,自动切到候选厂商(注意流式场景要更谨慎);
  • 熔断:某厂商连续失败达到阈值,直接「开路」30 秒,不再浪费请求,进入半开探测。

2.4 成本治理(Cost Governance)

这是网关最大的隐性价值。要能:

  • 网关层做 token 计量,而不是事后看厂商账单;
  • 虚拟 Key(Virtual Key)/ 团队 / 业务线 做预算和分摊;
  • 超预算直接 402 Payment Required 拒掉,防止「半夜账单刺客」。

2.5 可观测(Observability)

每一次调用都要留下:走的是哪家模型、耗时多少、花了多少 token、命中没命中缓存、失败原因是什么。没有这些,排障和成本优化就是盲人摸象。

2.6 安全(Security)

  • 真实厂商 Key 只存在网关内存/密钥管理里,绝不出网
  • 上游应用拿的是网关发的虚拟 Key;
  • 可选做 PII 脱敏、审计日志,满足合规。

这六条,就是网关的「功能边界」。下面我们用代码把它落出来。

三、架构分析:网关的分层设计

一个清晰的分层架构长这样(从上到下):

┌─────────────────────────────────────────────┐
│  接入层 (Edge)                               │
│  OpenAI 兼容 REST / SSE 流式 / 虚拟 Key 鉴权  │
├─────────────────────────────────────────────┤
│  路由层 (Router)                             │
│  成本 / 延迟 / 能力 / 轮询 策略 + 有序候选    │
├─────────────────────────────────────────────┤
│  弹性层 (Resilience)                         │
│  重试 · 退避 · 跨厂商 Fallback · 熔断         │
├─────────────────────────────────────────────┤
│  适配器层 (Adapters)                         │
│  OpenAI / Anthropic / Gemini / 本地 vLLM     │
├─────────────────────────────────────────────┤
│  治理层 (Governance)                         │
│  预算 · 配额 · 成本记账 · 分摊                │
├─────────────────────────────────────────────┤
│  缓存层 (Cache)                             │
│  精确缓存 · 语义缓存 · Prompt Cache           │
├─────────────────────────────────────────────┤
│  可观测层 (Observability)                    │
│  日志 · Metrics · Trace · 评测回放           │
└─────────────────────────────────────────────┘

设计哲学:接入层薄、适配器薄、路由和治理厚。业务方只跟接入层打交道;厂商差异被锁死在适配器层;真正体现技术含量的,是路由策略怎么选、熔断阈值怎么定、成本怎么归因。

下面我用 Go 手写一个最小但真实可用的网关核心,覆盖协议归一、路由、熔断、Fallback、成本记账。生产环境你大概率会站在 LiteLLM / OmniRoute 的肩膀上,但理解这套内核,你才知道它们到底在替你做什么。

四、代码实战:从零撸一个 Go 网关内核

4.1 领域模型与 Provider 适配器

先定义统一请求/响应,以及把异构厂商归一化的 Provider 接口:

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"time"
)

// 统一请求:业务方永远只发这一种结构
type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatRequest struct {
	Model    string    `json:"model"`
	Messages []Message `json:"messages"`
	Stream   bool      `json:"stream"`
}

type ChatResponse struct {
	ID      string `json:"id"`
	Model   string `json:"model"` // 回填上真实提供方,便于归因
	Choices []struct {
		Message Message `json:"message"`
	} `json:"choices"`
	Usage struct {
		PromptTokens     int `json:"prompt_tokens"`
		CompletionTokens int `json:"completion_tokens"`
		TotalTokens      int `json:"total_tokens"`
	} `json:"usage"`
}

// Provider:把不同厂商协议归一为统一接口
type Provider interface {
	Name() string
	Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
	CostPer1K(prompt, completion int) float64 // 每千 token 成本(美元)
}

OpenAIProvider 是一个具体的适配器,Anthropic / Gemini 只需在实现里把 ChatRequest 翻译成自家格式即可,对上完全透明:

type OpenAIProvider struct {
	name    string
	baseURL string
	apiKey  string
	costIn  float64 // 每千 input token 单价
	costOut float64 // 每千 output token 单价
	client  *http.Client
}

func (p *OpenAIProvider) Name() string { return p.name }

func (p *OpenAIProvider) CostPer1K(prompt, completion int) float64 {
	return float64(prompt)/1000*p.costIn + float64(completion)/1000*p.costOut
}

func (p *OpenAIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
	body, _ := json.Marshal(req)
	httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost,
		p.baseURL+"/chat/completions", bytes.NewReader(body))
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)

	start := time.Now()
	resp, err := p.client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("provider %s status %d: %s", p.name, resp.StatusCode, b)
	}
	var out ChatResponse
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	out.Model = p.name // 关键:标注真实来源,方便成本归因
	_ = start
	return &out, nil
}

4.2 熔断器:别让一个挂掉的厂商拖死全场

type Breaker struct {
	mu        sync.Mutex
	failures  int
	threshold int
	openUntil time.Time
}

func NewBreaker(threshold int) *Breaker {
	return &Breaker{threshold: threshold}
}

func (b *Breaker) Allow() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return time.Now().After(b.openUntil) // 还在熔断期就拒绝
}

func (b *Breaker) Success() {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.failures = 0
	b.openUntil = time.Time{}
}

func (b *Breaker) Fail() {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.failures++
	if b.failures >= b.threshold {
		b.openUntil = time.Now().Add(30 * time.Second) // 熔断 30s 后进入半开探测
	}
}

4.3 路由器:把候选厂商排个序

type Strategy int

const (
	StrategyCost Strategy = iota
	StrategyLatency
	StrategyRoundRobin
)

type Router struct {
	strategy Strategy
	rr       uint64
	latency  map[string]float64 // 各厂商 EWMA 延迟(秒)
	mu       sync.RWMutex
}

func (r *Router) Observe(name string, d time.Duration) {
	r.mu.Lock()
	defer r.mu.Unlock()
	v := d.Seconds()
	if old, ok := r.latency[name]; ok {
		r.latency[name] = old*0.7 + v*0.3 // EWMA 平滑
	} else {
		r.latency[name] = v
	}
}

func (r *Router) Order(providers []Provider) []int {
	n := len(providers)
	idx := make([]int, n)
	for i := range idx {
		idx[i] = i
	}
	switch r.strategy {
	case StrategyCost:
		sort.SliceStable(idx, func(a, b int) bool {
			// 用 1K prompt token 粗估单价排序
			return providers[idx[a]].CostPer1K(1000, 0) <
				providers[idx[b]].CostPer1K(1000, 0)
		})
	case StrategyLatency:
		r.mu.RLock()
		defer r.mu.RUnlock()
		sort.SliceStable(idx, func(a, b int) bool {
			return r.latency[providers[idx[a]].Name()] <
				r.latency[providers[idx[b]].Name()]
		})
	case StrategyRoundRobin:
		offset := int(atomic.AddUint64(&r.rr, 1)-1) % n
		idx = append(idx[offset:], idx[:offset]...)
	}
	return idx
}

4.4 网关编排:路由 + 熔断 + Fallback + 成本记账

type Budget struct {
	mu      sync.Mutex
	spent   float64
	limit   float64
	byModel map[string]float64
}

func (b *Budget) Record(name string, cost float64) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.spent += cost
	b.byModel[name] += cost
}

func (b *Budget) Allowed() bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.spent < b.limit
}

type Gateway struct {
	providers []Provider
	breakers  map[string]*Breaker
	router    *Router
	budget    *Budget
	log       *log.Logger
}

func (g *Gateway) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
	order := g.router.Order(g.providers)
	var lastErr error
	for _, i := range order {
		p := g.providers[i]
		if br := g.breakers[p.Name()]; br != nil && !br.Allow() {
			g.log.Printf("skip %s: breaker open", p.Name())
			continue
		}
		start := time.Now()
		resp, err := p.Chat(ctx, req)
		if err != nil {
			lastErr = err
			if br := g.breakers[p.Name()]; br != nil {
				br.Fail()
			}
			g.log.Printf("provider %s failed: %v; try next", p.Name(), err)
			continue
		}
		if br := g.breakers[p.Name()]; br != nil {
			br.Success()
		}
		// 成本记账 + 延迟观测
		cost := p.CostPer1K(resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
		g.budget.Record(p.Name(), cost)
		g.router.Observe(p.Name(), time.Since(start))
		g.log.Printf("served by %s, tokens=%d, cost=$%.4f",
			p.Name(), resp.Usage.TotalTokens, cost)
		return resp, nil
	}
	return nil, fmt.Errorf("all providers failed, last err: %v", lastErr)
}

4.5 接入层:OpenAI 兼容 HTTP 入口

func (g *Gateway) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
		if !g.budget.Allowed() {
			http.Error(w, "budget exceeded", http.StatusPaymentRequired)
			return
		}
		var req ChatRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()

		resp, err := g.Chat(ctx, req)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(resp)
	})
	return mux
}

业务方从此只需:

curl http://gateway:8080/v1/chat/completions \
  -H 'Authorization: Bearer <虚拟Key>' \
  -d '{"model":"auto","messages":[{"role":"user","content":"你好"}]}'

modelauto,网关按策略选厂商;要指定厂商就填 claude-3-7 / deepseek-v3业务代码彻底和厂商解耦

工程提示:上面是「非流式」骨架。生产环境务必支持 SSE 流式转发(用 http.Flusher 把上游 chunk 直接 pipe 给客户端),否则首 token 延迟会劝退用户。流式下 Fallback 要更谨慎——一旦开始吐字就不能换厂商,所以流式请求通常在「拿到第一个 chunk 之前」完成厂商选择。

五、性能优化:让网关又快又省的四把刀

网关自己要是成了瓶颈,前面全白搭。四个最值得投入的优化点:

5.1 流式转发 + 连接池

  • 网关到厂商用长连接http.TransportMaxIdleConnsIdleConnTimeout),避免每次握手;
  • 对外流式走 SSE,首 token 延迟从「等完整响应」降到「等第一个 chunk」;
  • 给厂商 client 设合理的 Timeout,但业务侧用 context 单独控制总超时,两者不要打架。

5.2 语义缓存:重复问题零成本命中

大量真实流量是高度重复的(FAQ、内部知识问答、评测 prompt)。精确缓存(同样 hash 命中)覆盖率低,真正香的是语义缓存:把问题 embedding 化,余弦相似度超过阈值就直接返回历史答案。

import numpy as np
from openai import OpenAI

client = OpenAI()
_cache = []  # [(embedding: np.ndarray, answer: str)]

def _embed(text: str) -> np.ndarray:
    r = client.embeddings.create(model="text-embedding-3-small", input=text)
    return np.array(r.data[0].embedding, dtype=float)

def _cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))

def ask(prompt: str, threshold: float = 0.92):
    q = _embed(prompt)
    for emb, ans in _cache:
        if _cosine(q, emb) >= threshold:   # 语义近似即命中
            return ans                     # 零推理成本
    ans = call_llm(prompt)                 # 真去调模型
    _cache.append((q, ans))
    return ans

阈值 0.92 是经验起点,太低会答非所问,太高退化成精确缓存。生产用向量库(pgvector / Milvus)替代内存列表,否则缓存本身会成瓶颈。语义缓存通常能吃掉 30%–60% 的重复流量。

5.3 上下文压缩:把 token 账单砍下来

OmniRoute 敢说省 15%–95%,靠的就是上下文压缩。几类手段:

  • Prompt Cache:Claude / Gemini / 部分 OpenAI 支持「前缀缓存」,稳定 system prompt 只计费一次;
  • 滑动窗口 / 自动摘要:超长对话历史,旧轮次先做摘要再拼回;
  • 结构化裁剪:RAG 场景只保留 top-k 片段,别把整本手册塞进 context。

5.4 地域就近 + 批处理

  • 海外模型走离用户近的接入点,国内模型走国内节点,别让一次调用绕地球半圈;
  • 可批量、非实时的任务(如批量抽取)做请求合并 / 异步队列,用便宜模型的闲时配额跑,进一步摊薄成本。

六、总结与展望:网关正在变成 AI 时代的「流量内核」

回到开头那个被五把 Key 折磨的场景。引入网关后,它的价值远不止「少写几行 SDK」:

  1. 业务解耦:模型是「能力池」里的资源,换厂商像换数据库连接池一样无感;
  2. 成本可控:网关层实时计量 + 预算拦截,月底不再有账单刺客;
  3. 韧性提升:熔断 + Fallback 把「单点 503」从「全场雪崩」降级为「轻微抖动」;
  4. 治理闭环:虚拟 Key、分摊、审计,让 AI 资源像云资源一样可被管理。

展望一下趋势,这个领域还在快速演化:

  • 路由策略模型化:FusionRoute 这类工作已经证明,「该用哪个模型」本身可以用一个小模型来决策,并结合自我修正做多 LLM 协作——规则硬编码的路由器会逐步被「路由模型」取代。
  • Agent 原生网关:随着 AI Agent 成为主流形态,网关要原生理解 tool-call、多轮规划、记忆存取,而不只是转发一次 chat。
  • 混合部署常态化:敏感数据走本地 vLLM / Ollama,通用能力走云端,网关负责在这之间无缝调度,数据合规和成本兼得。
  • 成为 AI 操作系统内核:当 2/3 的生成式 AI 负载跑在 K8s 上,网关就是这层「操作系统」里的调度器——它管的不只是流量,是算力、成本、合规与韧性的总和。

所以,无论你是后端、平台还是 AI 工程师,亲手理解并搭建一个 LLM 网关,都是 2026 年最值得投入的「底层能力」之一。站在 LiteLLM、OmniRoute 这些成熟项目肩上能让你快速上线,但只有自己撸过一遍路由、熔断、成本记账的内核,你才真正握住了 AI 基础设施的方向盘。


本文代码示例为最小可用骨架,生产环境请结合 LiteLLM / OmniRoute 等成熟方案,并补全流式 SSE、虚拟 Key 鉴权、向量化语义缓存与全链路 Trace。

推荐文章

乐观锁和悲观锁,如何区分?
2024-11-19 09:36:53 +0800 CST
任务管理工具的HTML
2025-01-20 22:36:11 +0800 CST
MCP test 25000
2026-07-22 13:51:25 +0800 CST
JavaScript设计模式:组合模式
2024-11-18 11:14:46 +0800 CST
Golang 几种使用 Channel 的错误姿势
2024-11-19 01:42:18 +0800 CST
MCP 2026 测试10000
2026-07-22 13:50:29 +0800 CST
Requests库详细介绍
2024-11-18 05:53:37 +0800 CST
赚点点任务系统
2024-11-19 02:17:29 +0800 CST
【SQL注入】关于GORM的SQL注入问题
2024-11-19 06:54:57 +0800 CST
程序员茄子在线接单