为什么需要 AI 网关

当你只用一个 LLM 提供商时,直接调 API 就行。但现实是:

  • GPT-4 写代码好,Claude 写文章好,Llama 3 性价比高
  • OpenAI 偶尔宕机,需要自动故障转移
  • 不同业务线有不同的成本预算和延迟要求
  • 需要灰度切换模型而不改业务代码

AI 网关就是解决这些问题的统一入口。它不是简单的反向代理,而是包含路由决策、流量控制、缓存优化、成本管理的完整中间层。

网关核心职责

职责描述关键指标
智能路由根据任务类型/成本/延迟选择模型路由延迟 <5ms
限流保护租户级 + 全局级速率限制误杀率 <0.1%
语义缓存缓存相似查询的结果缓存命中率 15-30%
成本控制实时 Token 计费和预算预警计费误差 <0.01%
故障转移自动切换到备用模型/提供商切换时间 <2s
可观测性全链路追踪和监控日志延迟 <100ms
灰度发布逐步迁移流量到新模型回滚时间 <10s

架构总览

┌──────────────────────────────────────────────────┐
│                   Client SDK                     │
│         (统一接口,隐藏后端模型差异)                   │
└──────────────────┬───────────────────────────────┘
┌──────────────────▼───────────────────────────────┐
│                 AI Gateway                        │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
│  │  Router    │  │ RateLimit  │  │  Cache     │ │
│  │ (模型选择)  │  │ (限流)     │  │ (语义缓存) │ │
│  └─────┬──────┘  └─────┬──────┘  └─────┬──────┘ │
│  ┌─────▼──────────────▼───────────────▼──────┐  │
│  │            Request Pipeline                │  │
│  │  Auth → Sanitize → Enrich → Route → Proxy  │  │
│  └────────────────────────────────────────────┘  │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐ │
│  │  Metrics   │  │  Billing   │  │  Failover  │ │
│  │ (监控)     │  │ (计费)     │  │ (故障转移) │ │
│  └────────────┘  └────────────┘  └────────────┘ │
└──────────────────┬───────────────────────────────┘
    ┌──────────────┼──────────────┬───────────────┐
    ▼              ▼              ▼               ▼
┌────────┐  ┌────────┐    ┌──────────┐   ┌──────────┐
│ OpenAI │  │Claude  │    │  Llama   │   │ 自部署   │
│  API   │  │  API   │    │  (vLLM)  │   │ (TGI)   │
└────────┘  └────────┘    └──────────┘   └──────────┘

智能路由引擎

from dataclasses import dataclass
from typing import Optional
import asyncio

@dataclass
class ModelCandidate:
    provider: str        # "openai" | "anthropic" | "self-hosted"
    model: str           # "gpt-4-turbo" | "claude-3-opus" | "llama-3-70b"
    priority: int        # 优先级(数字越小越优先)
    cost_per_1k: float   # 每千 token 成本(美元)
    avg_latency_ms: int  # 平均延迟
    health_score: float  # 健康分(0-1)
    rate_limit_remaining: int  # 剩余配额

class IntelligentRouter:
    def __init__(self, redis, metrics_collector):
        self.redis = redis
        self.metrics = metrics_collector
        self.routing_rules = self._load_routing_rules()

    async def select_model(
        self,
        request: dict,
        constraints: dict = None
    ) -> ModelCandidate:
        """根据请求内容和约束选择最佳模型"""
        constraints = constraints or {}

        # 1. 获取所有候选模型
        candidates = await self._get_candidates(request, constraints)
        if not candidates:
            raise NoModelAvailableError("No healthy model found")

        # 2. 应用硬约束过滤
        filtered = self._apply_constraints(candidates, constraints)
        if not filtered:
            raise ConstraintViolationError("No model meets constraints")

        # 3. 按规则打分排序
        scored = []
        for c in filtered:
            score = await self._score_model(c, request, constraints)
            scored.append((c, score))
        scored.sort(key=lambda x: x[1], reverse=True)

        # 4. 返回最优模型
        best = scored[0][0]
        logger.info(f"Routed to {best.provider}/{best.model} (score={scored[0][1]:.3f})")
        return best

    async def _score_model(self, candidate: ModelCandidate, request, constraints) -> float:
        score = 1.0

        # 健康分(权重 0.3)
        score *= candidate.health_score * 0.3

        # 成本分(权重 0.25):越便宜分越高
        max_cost = max(c.cost_per_1k for c in [candidate])
        cost_score = 1 - (candidate.cost_per_1k / max(max_cost, 0.001))
        score += cost_score * 0.25

        # 延迟分(权重 0.2)
        latency_score = 1 - (candidate.avg_latency_ms / 5000)
        score += max(latency_score, 0) * 0.2

        # 剩余配额分(权重 0.15)
        quota_score = min(candidate.rate_limit_remaining / 10000, 1.0)
        score += quota_score * 0.15

        # 规则匹配加分(权重 0.1)
        rule_bonus = self._match_routing_rules(candidate, request)
        score += rule_bonus * 0.1

        return score

    def _match_routing_rules(self, candidate, request) -> float:
        bonus = 0.0
        task_type = request.get("metadata", {}).get("task_type", "general")

        # 代码任务优先路由到 GPT-4
        if task_type == "code" and "gpt-4" in candidate.model:
            bonus += 0.5
        # 长文写作优先路由到 Claude
        if task_type == "writing" and "claude" in candidate.model:
            bonus += 0.5
        # 简单问答优先路由到 Llama(成本低)
        if task_type == "simple_qa" and "llama" in candidate.model:
            bonus += 0.5

        return bonus

路由规则配置

# routing_rules.yaml
rules:
  - name: "code_generation"
    condition:
      task_type: "code"
      min_context_length: 0
    preferred_models:
      - { provider: "openai", model: "gpt-4-turbo", weight: 1.0 }
      - { provider: "anthropic", model: "claude-3-opus", weight: 0.8 }
    fallback:
      - { provider: "self-hosted", model: "llama-3-70b" }

  - name: "long_context"
    condition:
      min_context_length: 50000
    preferred_models:
      - { provider: "anthropic", model: "claude-3-sonnet", weight: 1.0 }  # 200K context
    fallback:
      - { provider: "openai", model: "gpt-4-turbo", weight: 0.9 }  # 128K context

  - name: "cost_optimized"
    condition:
      max_cost_per_request: 0.01
    preferred_models:
      - { provider: "self-hosted", model: "llama-3-8b", weight: 1.0 }
    fallback:
      - { provider: "openai", model: "gpt-3.5-turbo", weight: 0.7 }

  - name: "low_latency"
    condition:
      max_latency_ms: 500
    preferred_models:
      - { provider: "self-hosted", model: "llama-3-8b", weight: 1.0 }
      - { provider: "openai", model: "gpt-3.5-turbo", weight: 0.8 }

语义缓存

传统缓存用 hash key 精确匹配,LLM 场景下用户问 “Python 怎么读文件” 和 “如何在 Python 中读取文件” 是同一个问题,应该命中缓存。

import numpy as np
from dataclasses import dataclass

class SemanticCache:
    def __init__(self, embedder, vector_store, redis):
        self.embedder = embedder        # 嵌入模型(如 text-embedding-3-small)
        self.vector_store = vector_store  # 向量数据库(如 Qdrant)
        self.redis = redis              # 存储完整响应
        self.similarity_threshold = 0.92
        self.max_cache_entries = 100_000

    async def get(self, prompt: str, model: str, params: dict) -> Optional[dict]:
        """语义匹配查找缓存"""
        # 1. 生成 prompt 嵌入
        embedding = await self.embedder.embed(prompt)

        # 2. 向量搜索相似 prompt
        results = await self.vector_store.search(
            collection=f"cache_{model}",
            query_vector=embedding,
            limit=5,
            score_threshold=self.similarity_threshold,
            filter={
                "must": [
                    {"key": "temperature", "match": {"value": params.get("temperature", 0.7)}},
                    {"key": "model", "match": {"value": model}},
                ]
            }
        )

        if not results:
            return None

        # 3. 验证参数兼容性
        best_match = results[0]
        cached_key = best_match.payload["cache_key"]
        cached_response = await self.redis.get(f"semcache:{cached_key}")

        if cached_response:
            logger.info(f"Semantic cache HIT (similarity={best_match.score:.4f})")
            return json.loads(cached_response)
        return None

    async def set(self, prompt: str, response: dict, model: str, params: dict, ttl: int = 3600):
        """写入缓存"""
        embedding = await self.embedder.embed(prompt)
        cache_key = hashlib.sha256(
            f"{model}:{prompt}:{params}".encode()
        ).hexdigest()[:32]

        # 存入向量数据库
        await self.vector_store.upsert(
            collection=f"cache_{model}",
            points=[{
                "id": cache_key,
                "vector": embedding,
                "payload": {
                    "prompt_preview": prompt[:200],
                    "cache_key": cache_key,
                    "model": model,
                    "temperature": params.get("temperature", 0.7),
                    "created_at": datetime.utcnow().isoformat(),
                }
            }]
        )

        # 存入 Redis(带 TTL)
        await self.redis.setex(
            f"semcache:{cache_key}",
            ttl,
            json.dumps(response)
        )

Kong AI Gateway vs LiteLLM vs 自建

维度Kong AI GatewayLiteLLM Proxy自建网关
部署复杂度中(需 Kong 基础)低(pip install)
支持模型数50+100+自定义
语义缓存插件支持内置需自建
限流Kong 原生内置需自建
计费企业版内置需自建
可扩展性插件体系Python 中间件完全自定义
性能高(C/Lua)中(Python)取决于实现
社区活跃度N/A
生产就绪看团队

LiteLLM Proxy 配置示例

# litellm_config.yaml
model_list:
  - model_name: gpt-4
    litellm_params:
      model: openai/gpt-4-turbo
      api_key: os.environ/OPENAI_API_KEY
      rpm: 500
      tpm: 150000
    model_info:
      mode: "chat"

  - model_name: gpt-4  # 同名备选(故障转移)
    litellm_params:
      model: anthropic/claude-3-opus
      api_key: os.environ/ANTHROPIC_API_KEY
      rpm: 300

  - model_name: llama
    litellm_params:
      model: openai/llama-3-70b
      api_base: http://vllm-server:8000/v1
      api_key: "dummy"

router_settings:
  routing_strategy: "usage-based-routing"  # 基于使用量路由
  num_retries: 3
  timeout: 30
  fallbacks:
    - "gpt-4": ["llama"]

litellm_settings:
  cache: true
  cache_params:
    type: "redis"
    host: "redis"
    port: 6379
    ttl: 3600

  max_budget: 100.0  # 每日预算上限(美元)
  budget_duration: "1d"

灰度发布

class GrayscaleRelease:
    """模型灰度发布管理器"""

    def __init__(self, redis):
        self.redis = redis

    async def get_model_version(self, user_id: str, model_name: str) -> str:
        """根据灰度策略决定用户使用哪个模型版本"""

        # 1. 检查是否在白名单中
        whitelist = await self.redis.smembers(f"canary:{model_name}:whitelist")
        if user_id.encode() in whitelist:
            return f"{model_name}-canary"

        # 2. 检查灰度比例
        rollout_percent = await self.redis.get(f"canary:{model_name}:percent")
        rollout_percent = float(rollout_percent or 0)

        # 3. 基于 user_id 的确定性哈希(同一用户总是走同一版本)
        hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100
        if hash_val < rollout_percent:
            return f"{model_name}-canary"
        return model_name

    async def record_metric(self, model_version: str, metric: dict):
        """记录灰度版本指标用于对比"""
        await self.redis.lpush(
            f"canary:metrics:{model_version}",
            json.dumps(metric)
        )
        await self.redis.ltrim(f"canary:metrics:{model_version}", 0, 9999)

    async def auto_rollback(self, model_name: str) -> bool:
        """自动回滚:如果 canary 版本指标显著劣化"""
        baseline_metrics = await self._get_metrics(model_name)
        canary_metrics = await self._get_metrics(f"{model_name}-canary")

        if not canary_metrics:
            return False

        # 错误率对比
        if canary_metrics["error_rate"] > baseline_metrics["error_rate"] * 2:
            logger.warning(f"Auto rollback: canary error rate {canary_metrics['error_rate']}")
            await self.redis.set(f"canary:{model_name}:percent", "0")
            return True

        # 延迟对比
        if canary_metrics["p99_latency"] > baseline_metrics["p99_latency"] * 1.5:
            logger.warning(f"Auto rollback: canary latency {canary_metrics['p99_latency']}ms")
            await self.redis.set(f"canary:{model_name}:percent", "0")
            return True

        return False

故障转移

class FailoverManager:
    def __init__(self, health_checker, circuit_breaker):
        self.health_checker = health_checker
        self.circuit_breaker = circuit_breaker

    async def call_with_failover(self, candidates: list, request: dict):
        """依次尝试候选模型,直到成功"""
        errors = []

        for candidate in candidates:
            # 熔断器检查
            if not self.circuit_breaker.allow_request(candidate.provider):
                logger.warning(f"Circuit breaker open for {candidate.provider}")
                continue

            try:
                response = await self._call_model(candidate, request)

                # 记录成功
                self.circuit_breaker.record_success(candidate.provider)
                return response

            except RateLimitError as e:
                self.circuit_breaker.record_failure(candidate.provider)
                errors.append((candidate, e))
                # 限流错误:直接切换到下一个
                continue

            except TimeoutError as e:
                self.circuit_breaker.record_failure(candidate.provider)
                errors.append((candidate, e))
                continue

            except Exception as e:
                self.circuit_breaker.record_failure(candidate.provider)
                errors.append((candidate, e))
                # 未知错误:重试一次后切换
                try:
                    response = await self._call_model(candidate, request)
                    self.circuit_breaker.record_success(candidate.provider)
                    return response
                except Exception:
                    continue

        raise AllModelsFailedError(f"All models failed: {errors}")

实战建议

  1. 不要一开始就自建网关:先用 LiteLLM Proxy 跑起来,遇到瓶颈再考虑自建
  2. 语义缓存 ROI 最高:对于客服/问答场景,语义缓存可以省 30%+ 的 API 成本
  3. 熔断器是必备品:没有熔断的故障转移等于没有故障转移——一个慢提供商会拖垮整个链路
  4. 灰度发布要有自动回滚:人工介入太慢,监控指标劣化 2 倍就自动回滚
  5. 统一日志格式:所有提供商的日志归一化到同一格式,否则可观测性无从谈起
  6. 预算告警:日预算用到 80% 自动告警,100% 自动降级到便宜模型

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。