跳到主内容

一文搞懂 AI 网关:多模型动态路由、failover 与配额治理 ​

问题背景:为什么不能让业务直连各家大模型 ​

当系统只接一个大模型时,在业务代码里写死 endpoint 和 API Key 似乎最简单;但一旦进入多模型、多团队、多租户阶段,问题会迅速放大:

  • 成本不可控:高级模型被用于低价值任务,token 消耗月底才暴露。
  • 稳定性脆弱:单一供应商限流或宕机时,调用方没有统一 failover 能力。
  • 路由规则分散:A 团队按任务类型选模型,B 团队按租户等级选模型,逻辑重复且难审计。
  • 配额治理滞后:Key 级限额无法表达“某租户每天 10 万 token、每分钟 200 次请求”。

AI 网关正是位于业务服务与模型供应商之间的基础设施。它不替代模型能力,而是把协议适配、模型路由、failover、配额、鉴权、观测和成本核算收敛成统一控制面。

方案对比:SDK 直连、轻量代理与 AI 网关 ​

常见方案大致分三类:

  1. 业务 SDK 直连:接入最快,但路由、重试、配额散落在各服务中。
  2. 轻量反向代理:适合统一域名、密钥托管和日志审计,但缺乏租户级治理。
  3. 独立 AI 网关:在代理基础上增加动态路由、故障转移、配额计费、熔断和指标体系。

如果只有一个内部应用,轻量代理足够;如果要支持多业务线、多模型供应商和成本分摊,独立网关的长期收益更明显。

业务应用统一 OpenAI 协议鉴权与租户识别配额与限流动态路由failover 与熔断候选模型链高级模型标准模型低成本模型

核心设计:路由、failover 与配额如何协同 ​

建议把一次请求拆成四层:

  • 接入层:统一使用类 OpenAI Chat Completions 协议,降低业务改造成本。
  • 决策层:根据任务类型、租户等级、上下文长度、灰度标签和实时健康度选择模型。
  • 执行层:按候选链调用,遇到可重试错误执行 failover,并记录每次尝试。
  • 治理层:请求前做 QPS 与 token 预算检查,响应后按实际用量结算。

注意,配额不能只依赖供应商响应头。供应商限额是“你与供应商之间的约束”,而平台配额是“你与内部租户之间的约束”,两者必须分开。

Go 实战:实现一个可运行的最小 AI 网关 ​

下面示例使用 Go 标准库加内存配额实现,便于直接运行。生产环境可将 MemoryQuota 替换为 Redis Lua 脚本,将路由配置放入 etcd、Consul 或配置中心。

第一步:定义模型与路由配置 ​

go
package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

// ModelEndpoint 描述一个具体模型供应商端点
type ModelEndpoint struct {
	Name     string
	Vendor   string
	Priority int // 数值越小优先级越高
	Healthy  bool
}

// RouteRule 表示一条动态路由规则
type RouteRule struct {
	TaskType string // chat、summary、code 等
	Tier     string // free、pro、enterprise
	Models   []string
}

type ChatRequest struct {
	TenantID string
	Tier     string
	TaskType string
	Message  string
}

type ChatResponse struct {
	Model       string
	Content     string
	InputToken  int
	OutputToken int
}

第二步:实现动态路由与 failover ​

路由不只按静态配置选择,还要过滤不健康节点,并按优先级形成候选链。真实系统中还可以引入权重、延迟、区域和碳成本等因子。

go
type Router struct {
	mu        sync.RWMutex
	endpoints map[string]*ModelEndpoint
	rules     []RouteRule
}

func NewRouter() *Router {
	return &Router{endpoints: map[string]*ModelEndpoint{}}
}

func (r *Router) UpdateEndpoint(e ModelEndpoint) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.endpoints[e.Name] = &e
}

func (r *Router) Candidates(taskType, tier string) []*ModelEndpoint {
	r.mu.RLock()
	defer r.mu.RUnlock()

	var modelNames []string
	for _, rule := range r.rules {
		if rule.TaskType == taskType && rule.Tier == tier {
			modelNames = rule.Models
			break
		}
	}

	var candidates []*ModelEndpoint
	for _, name := range modelNames {
		if ep, ok := r.endpoints[name]; ok && ep.Healthy {
			candidates = append(candidates, ep)
		}
	}
	return candidates
}

var ErrNoHealthyModel = errors.New("no healthy model available")

// CallWithFailover 按候选链依次尝试,避免首个供应商故障导致整体失败
func (r *Router) CallWithFailover(ctx context.Context, req ChatRequest, call func(context.Context, *ModelEndpoint) (*ChatResponse, error)) (*ChatResponse, error) {
	candidates := r.Candidates(req.TaskType, req.Tier)
	if len(candidates) == 0 {
		return nil, ErrNoHealthyModel
	}

	var lastErr error
	for _, ep := range candidates {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		default:
		}

		resp, err := call(ctx, ep)
		if err == nil {
			return resp, nil
		}
		lastErr = err
		fmt.Printf("model %s failed: %v, try next\n", ep.Name, err)
	}
	return nil, fmt.Errorf("all models failed: %w", lastErr)
}

第三步:实现租户用量配额 ​

配额至少包含两类:短周期请求数限流和周期 token 预算。请求前预占、请求后结算,是防止超额调用的关键。

go
type Quota struct {
	DailyToken int64 // 每日 token 上限
	UsedToken  int64
	ResetAt    time.Time
}

type MemoryQuota struct {
	mu sync.Mutex
	m  map[string]*Quota
}

func NewMemoryQuota() *MemoryQuota {
	return &MemoryQuota{m: map[string]*Quota{}}
}

// Reserve 在调用模型前预占额度;生产环境建议使用 Redis Lua 保证原子性
func (q *MemoryQuota) Reserve(tenant string, estimate, dailyLimit int64) error {
	q.mu.Lock()
	defer q.mu.Unlock()

	item, ok := q.m[tenant]
	now := time.Now()
	if !ok || now.After(item.ResetAt) {
		item = &Quota{DailyToken: dailyLimit, ResetAt: now.Add(24 * time.Hour)}
		q.m[tenant] = item
	}

	if item.UsedToken+estimate > item.DailyToken {
		return fmt.Errorf("tenant %s quota exceeded: used=%d limit=%d", tenant, item.UsedToken, item.DailyToken)
	}
	item.UsedToken += estimate
	return nil
}

// Settle 根据供应商返回的真实 token 修正预估值
func (q *MemoryQuota) Settle(tenant string, estimate, actual int64) {
	q.mu.Lock()
	defer q.mu.Unlock()

	if item, ok := q.m[tenant]; ok {
		item.UsedToken += actual - estimate
		if item.UsedToken < 0 {
			item.UsedToken = 0
		}
	}
}

第四步:组装网关处理链路 ​

go
type Gateway struct {
	router *Router
	quota  *MemoryQuota
}

func (g *Gateway) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
	// 简单按消息字符数估算输入 token,实际可用分词器精确计算
	estimate := int64(len(req.Message)) + 256
	if err := g.quota.Reserve(req.TenantID, estimate, 100000); err != nil {
		return nil, err
	}

	resp, err := g.router.CallWithFailover(ctx, req, func(ctx context.Context, ep *ModelEndpoint) (*ChatResponse, error) {
		// 这里替换为真实 HTTP 调用,并设置超时、追踪和请求体大小限制
		return &ChatResponse{
			Model:       ep.Name,
			Content:     "来自 " + ep.Name + " 的回答",
			InputToken:  int(estimate),
			OutputToken: 128,
		}, nil
	})
	if err != nil {
		g.quota.Settle(req.TenantID, estimate, 0)
		return nil, err
	}

	actual := int64(resp.InputToken + resp.OutputToken)
	g.quota.Settle(req.TenantID, estimate, actual)
	return resp, nil
}

func main() {
	router := NewRouter()
	router.rules = []RouteRule{
		{TaskType: "chat", Tier: "pro", Models: []string{"model-premium", "model-standard", "model-lite"}},
	}
	router.UpdateEndpoint(ModelEndpoint{Name: "model-premium", Vendor: "vendor-a", Priority: 1, Healthy: true})
	router.UpdateEndpoint(ModelEndpoint{Name: "model-standard", Vendor: "vendor-b", Priority: 2, Healthy: true})
	router.UpdateEndpoint(ModelEndpoint{Name: "model-lite", Vendor: "vendor-c", Priority: 3, Healthy: true})

	gw := &Gateway{router: router, quota: NewMemoryQuota()}
	resp, err := gw.Chat(context.Background(), ChatRequest{
		TenantID: "tenant-001",
		Tier:     "pro",
		TaskType: "chat",
		Message:  "解释一下什么是 AI 网关",
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.Model, resp.Content)
}

生产化建议:从示例到高可用系统 ​

示例只展示核心骨架,上线前还需要补齐以下能力:

  • 配置热更新:路由规则变更不应重启进程,建议配合版本号和灰度发布。
  • 分布式配额:多实例部署时,内存计数会重复放行,应使用 Redis Lua 或独立限流服务完成原子扣减。
  • 熔断与半开探测:某模型连续超时后短暂摘除,随后用少量流量试探恢复。
  • 幂等与重试边界:非流式请求可用 request_id 做幂等;流式响应一旦已经向客户端输出,就不能透明重试。
  • 指标维度:至少记录 tenant、task_type、model、vendor、status、latency、input_token、output_token、fallback_from。

避坑总结:多模型治理中的高频问题 ​

1. 把所有错误都 failover,导致流量雪崩 ​

常见现象:主模型因为上下文超长稳定返回 400,网关仍逐个重试所有候选模型,最终把低级模型也打满。修正方法是只对 429、5xx、网络超时、连接重置等可恢复错误转移;400、401、内容审核拒绝等确定性错误应直接返回。

2. 只在响应后扣配额,突发流量瞬间超额 ​

常见现象:压测时大量请求同时进入,调用结束后才发现 token 已远超预算。修正方法是请求前基于 tokenizer 或保守上界预占,响应后按供应商 usage 结算差额;对超长输出还要设置 max_tokens。

3. 路由规则只看模型名,不看能力与约束 ​

常见现象:把需要 128K 上下文的请求路由到 32K 模型,或者把函数调用请求发给不支持 tools 的模型。修正方法是在端点元数据中维护上下文长度、模态、工具调用、价格和延迟等级,路由时做能力匹配。

4. 重试引发重复扣费和重复副作用 ​

常见现象:供应商实际已处理成功,但网关因读超时重试,造成重复生成甚至重复外部动作。修正方法是设置总超时和单模型超时,非幂等场景限制重试次数,并透传业务幂等键;涉及工具执行的请求默认不自动重试。

5. 配额维度过粗,无法定位成本责任 ​

常见现象:平台总量超支,但只能看到某个 API Key 的消耗,无法定位团队和功能。修正方法是从入口解析租户、应用、环境、任务类型,并把这些维度写入日志、指标和费用报表。

结语 ​

AI 网关的价值不是“多转发一次请求”,而是在模型供应快速变化时,为业务提供稳定、可控、可审计的访问边界。先用统一协议和候选链解决可用性,再用预占式配额和多维观测解决成本治理,最后通过热更新、熔断和灰度能力支撑规模化运营,这是多模型接入场景下更稳妥的落地路径。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。