Agent 网关设计:统一入口、智能路由与负载均衡

摘要 :前面十篇解决的是"一个 Agent 系统内部怎么组织"------模式、引擎、协议、中间件、状态。但当系统里有多个 Agent(不同能力、不同模型、不同成本、不同 SLA)对外提供服务时,会出现一批全新的问题:客户端要知道调哪个 Agent 地址(于是 Agent 一调整,所有客户端跟着改);简单问题也走最贵的模型(成本失控);某个模型供应商限流了,整个服务不可用;一个租户的批量任务把资源吃光,其他租户全部超时。本文讲清三件事:统一入口 (协议收敛与租户隔离,让 Agent 调整对客户端透明)、智能路由 (能力匹配、成本感知、负载感知三种策略如何叠加,以及为什么路由错误是静默失效)、限流熔断(为什么 Agent 网关必须按 Token 而非请求数限流,以及四级降级链怎么设计)。

📌 版本声明 :本文基于 Python 3.11+、FastAPI、asyncio 编写,撰写时间 2026 年 10 月。核心路由与限流逻辑零第三方依赖,可直接运行。对照章节涉及 Kong / Envoy 等通用 API 网关与 LiteLLM / OpenRouter 等 LLM 专用网关,各产品能力边界与版本差异较大,接入前请核对其官方文档。

适用边界 :适用于对外提供多个 Agent 服务、需要统一治理(鉴权/限流/计费/可观测)或接入多个模型供应商 的场景。若你只有一个 Agent、单一模型、单租户内部使用,网关是纯粹的额外一跳------直接调用,别上网关。

文章目录

    • [一、为什么需要 Agent 网关:四个真实问题](#一、为什么需要 Agent 网关:四个真实问题)
      • [1.1 问题一:Agent 地址泄漏到客户端](#1.1 问题一:Agent 地址泄漏到客户端)
      • [1.2 问题二:简单问题也走最贵的模型](#1.2 问题二:简单问题也走最贵的模型)
      • [1.3 问题三:一个供应商限流,整个服务不可用](#1.3 问题三:一个供应商限流,整个服务不可用)
      • [1.4 问题四:一个租户吃光资源](#1.4 问题四:一个租户吃光资源)
      • [1.5 四个问题的共同点](#1.5 四个问题的共同点)
    • 二、核心概念:网关的四层职责
      • [2.1 职责分层](#2.1 职责分层)
      • [2.2 智能路由的三种策略](#2.2 智能路由的三种策略)
      • [2.3 为什么路由错误是静默失效](#2.3 为什么路由错误是静默失效)
      • [2.4 负载均衡:为什么轮询在 Agent 场景无效](#2.4 负载均衡:为什么轮询在 Agent 场景无效)
      • [2.5 限流:为什么必须按 Token 而非请求数](#2.5 限流:为什么必须按 Token 而非请求数)
      • [2.6 网关必须埋的六个指标](#2.6 网关必须埋的六个指标)
    • 三、环境准备
      • [3.1 依赖与项目结构](#3.1 依赖与项目结构)
    • 四、核心实战
      • [4.1 第一步:统一入口与协议收敛](#4.1 第一步:统一入口与协议收敛)
      • [4.2 第二步:规则路由与语义路由](#4.2 第二步:规则路由与语义路由)
      • [4.3 第三步:三维度限流](#4.3 第三步:三维度限流)
      • [4.4 第四步:熔断与四级降级链](#4.4 第四步:熔断与四级降级链)
      • [4.5 第五步:成本加权负载均衡](#4.5 第五步:成本加权负载均衡)
      • [4.6 第六步:网关主体串联](#4.6 第六步:网关主体串联)
      • [4.7 一次请求的完整时序:正常路径与降级路径](#4.7 一次请求的完整时序:正常路径与降级路径)
    • 五、进阶
      • [5.1 会话粘滞:需不需要,取决于状态在哪](#5.1 会话粘滞:需不需要,取决于状态在哪)
      • [5.2 流式响应下的限流与熔断](#5.2 流式响应下的限流与熔断)
      • [5.3 路由准确率的回流监控](#5.3 路由准确率的回流监控)
      • [5.4 网关作为计费点:为什么必须在这一层计量](#5.4 网关作为计费点:为什么必须在这一层计量)
    • 六、对照:三类网关的边界
    • [七、适用边界与风险提示 ⚠️](#七、适用边界与风险提示 ⚠️)
    • 八、进阶:让网关可测
    • 九、总结
    • 参考资料

一、为什么需要 Agent 网关:四个真实问题

1.1 问题一:Agent 地址泄漏到客户端

团队最初有 2 个 Agent,客户端直接配置两个地址。半年后变成 9 个,且经历了三次拆分合并------"文档助手"拆成了"摘要"和"问答","代码助手"并入了"单元测试生成"。

每次调整都要改所有客户端配置,而客户端分布在 Web、移动端、CI 插件、第三方集成四处。改一次要协调四个团队,还总有漏的。

本质问题是:客户端依赖了服务的内部拓扑。这个依赖一旦形成,内部就无法自由演进。

1.2 问题二:简单问题也走最贵的模型

第二个问题是成本。日志分析显示,所有请求都走了旗舰模型,但其中约 34% 是这类请求:

text 复制代码
"把这句话翻译成英文:明天下午三点开会"
"这个字段是什么意思?"
"帮我格式化这段 JSON"

这些请求用旗舰模型是纯浪费------小模型的效果没有可感知差异,成本差 15~20 倍。

难点在于:你不能在客户端判断该用哪个模型,因为客户端不懂模型能力差异,而且一旦把选择权交给客户端,就再也无法统一调整策略。

1.3 问题三:一个供应商限流,整个服务不可用

第三个问题是可用性。某天主模型供应商开始持续返回 429(限流),持续时间 40 分钟。这 40 分钟里,平台所有 Agent 全部不可用------因为所有 Agent 都直连这一个供应商,没有任何降级路径。

更要命的是故障传播方式 :上游 429 → Agent 重试 → 重试放大流量 → 上游限流更严重。这是典型的重试风暴,而它发生在没有任何一个组件"坏掉"的情况下。

1.4 问题四:一个租户吃光资源

第四个问题是多租户下的公平性。某个租户跑了一个批量任务,一次提交 2000 条请求。结果其他租户的请求全部排队超时------不是系统容量不够,是容量被一个租户占满了。

传统网关按 QPS 限流在这里部分失效:那 2000 条请求是以每秒 20 条的"温和"速率发出的,QPS 限流完全不触发,但每条请求要占用 Agent 30 秒。QPS 限住了,并发却被占满了。

1.5 四个问题的共同点

问题 缺失的能力
地址泄漏 统一入口(协议收敛)
全走贵模型 智能路由(成本感知)
供应商限流即全站不可用 熔断与降级
一个租户吃光资源 多维度限流(不只是 QPS)

这四个能力加在一起,就是 Agent 网关。注意它们的共同特征:都是横切的、跨所有 Agent 的、客户端不该关心的 。这和第 28 篇的中间件形成对照------中间件是进程内 横切,网关是服务级横切,见第六章的对照表。


二、核心概念:网关的四层职责

2.1 职责分层

一个 Agent 网关按处理顺序分成四层:

层 职责 失败后果
接入层 协议收敛、鉴权、租户识别、请求校验 未授权访问、协议不一致
治理层 限流、配额、熔断、降级、审计 资源被占满、故障扩散
路由层 选 Agent、选模型、选实例 静默的质量下降
执行层 转发、流式回传、超时、重试、计费 请求失败

顺序不能颠倒 。特别是限流必须在路由之前------先判断"能不能做",再决定"谁来做"。反过来的话,一个注定被限流的请求还要先跑一遍路由计算(可能调用了 LLM 做意图分类),白白消耗资源。

2.2 智能路由的三种策略

策略 判据 成本 准确率 适用
规则路由 关键词/正则/显式参数 零 高(规则内) 有明确标识的请求
语义路由 LLM 分类或 embedding 相似度 中(要调模型) 中高 自然语言、无显式标识
成本路由 复杂度预估 → 选模型档位 低 中 所有请求的默认叠加层

关键设计:三者是叠加关系,不是三选一。 实际的执行顺序是:

text 复制代码
规则路由(命中即返回,零成本)
    ↓ 未命中
语义路由(调小模型分类)
    ↓ 低置信度
默认路由(走兜底 Agent)
    ↓ 全程叠加
成本路由(根据预估复杂度选模型档位)

规则必须前置 ,原因有两个:① 零成本,能挡掉大部分请求;② 规则的结果是可解释的------出现路由争议时能立刻说清为什么走了这条路,而 LLM 分类的结果难以复现和解释。

2.3 为什么路由错误是静默失效

这是 Agent 网关最危险的地方,也是第 24 篇 Supervisor 模式里那个洞察的服务级版本:

路由错了,请求依然会成功返回。

把"翻译请求"路由给了"合同审查 Agent",它依然会返回一段文本,而且很可能看起来挺合理------只是不是用户想要的翻译。没有报错、没有超时、没有异常指标,唯一的信号是用户不满意。

所以路由层必须有三个额外机制(第 4.3 节实现):

  1. 置信度闸门:低置信度不走猜测,转默认或澄清
  2. 路由可追溯:每个响应带上实际路由路径,便于事后分析
  3. 路由准确率监控:靠用户反馈(点踩/重问)回流,而不是靠系统指标

2.4 负载均衡:为什么轮询在 Agent 场景无效

传统负载均衡默认用轮询(Round Robin)或最少连接(Least Connection)。在 Agent 场景下两者都有问题:

策略 假设 Agent 场景下为什么失效
轮询 请求耗时相近 Agent 请求耗时从 200ms 到 3 分钟,差三个数量级
最少连接 连接数反映负载 一个长连接可能已经跑完 90%(快释放),也可能刚开始
随机 统计上均匀 同上,且无法处理"某个实例正在跑超长任务"

举例:3 个 Agent 实例,轮询分配了 6 个请求。前 3 个是"翻译"(各 1 秒),后 3 个是"合同审查"(各 60 秒)。结果:实例 A 拿到请求 1、4;实例 B 拿到 2、5;实例 C 拿到 3、6。看起来均匀,但如果请求 1、2、3 都是翻译,那么 A、B、C 各自还要跑一个 60 秒任务------这时新的请求进来,轮询依然会分配,导致部分实例堆积。

更根本的问题是:Agent 的"负载"不是请求数,是并发占用时长与 Token 消耗。 正确的做法是按预估成本加权:

text 复制代码
实例得分 = 当前并发占用权重 + 本次请求预估成本权重
选择得分最低的实例

2.5 限流:为什么必须按 Token 而非请求数

1.4 节那个问题已经说明了 QPS 限流的失效场景。更完整地看,Agent 网关需要三个维度同时限流:

维度 限什么 解决 缺失后果
请求速率 每秒请求数 防瞬时洪峰 短时间打垮系统
并发占用 同时在跑的请求数 防长任务占满 QPS 达标但实例全被占满
Token 预算 单位时间 Token 消耗 防成本失控 + 对齐上游配额 账单爆炸 / 上游 429

第三维是 Agent 特有的,也最重要:上游模型供应商本身就是按 TPM(Tokens Per Minute)配额限流的 。如果你的网关只按请求数限流,那么 10 个请求 × 每个 10000 Token 就会直接撞上上游 TPM 限制------你的限流显示一切正常,上游已经返回 429 了。

2.6 网关必须埋的六个指标

网关是唯一能看到所有 Agent 全部流量的位置,因此它天然是可观测的最佳埋点。以下六个指标缺一不可:

指标 维度 用途 异常信号
请求成功率 租户 / Agent 基础健康度 成功率下降
降级率 Agent / 供应商 发现被掩盖的故障 > 5% 即告警
P99 延迟 Agent / 模型 容量规划 长尾变长
Token 消耗 租户 / 模型 / Agent 成本归因与计费 单租户异常增长
限流拒绝率 租户 / 维度 配额是否合理 持续拒绝说明配额不足
路由准确率 Agent 发现静默失效 靠用户反馈回流

第二个(降级率)在第七章会展开------它是最容易被忽略、也最能提前发现问题的指标。

第五个(限流拒绝率)的用途常被误解 :它不是用来判断"系统是否健康",而是用来判断配额配置是否合理 。一个租户如果长期被拒绝 30% 的请求,说明要么配额给少了,要么这个租户的用法需要引导。持续的高拒绝率是产品问题,不是技术问题。

第六个(路由准确率)是唯一无法从系统内部得到的指标 ,必须靠 5.3 节的用户反馈回流。它的特殊性在于:其他五个指标都能自动采集,只有它需要设计反馈回路。很多团队上线网关后从没测过路由准确率,于是路由问题长期静默存在。

💡 指标的维度(cardinality)要克制。 上面六个指标如果全部按 租户 × Agent × 模型 × 状态码 四维展开,一个中等规模系统能产生几十万条时间序列------监控系统会先于业务系统崩溃 。实践建议:成功率和降级率按租户展开(要定位到租户),延迟和成本按 Agent + 模型 展开(要定位到服务),限流拒绝率只按维度聚合(不需要细分到租户)。

图1:Agent 网关限流的三个维度------请求速率、并发占用、Token 预算的对照与缺失后果


三、环境准备

3.1 依赖与项目结构

bash 复制代码
python -m venv .venv
source .venv/bin/activate

pip install "fastapi>=0.110" "uvicorn>=0.27" "pydantic>=2.7"
pip install pytest pytest-asyncio httpx
text 复制代码
agent_gateway/
├── schemas.py        # 请求/响应模型、路由决策模型
├── routing.py        # 规则路由 + 语义路由 + 成本路由
├── ratelimit.py      # 三维度限流器
├── circuit.py        # 熔断器 + 四级降级链
├── balancer.py       # 成本加权负载均衡
└── gateway.py        # 网关主体:四层串联

四、核心实战

4.1 第一步:统一入口与协议收敛

先定义统一的请求/响应模型。关键设计是协议要兼容事实标准 ------这里采用 OpenAI 风格的 /v1/chat/completions 结构,因为客户端生态(SDK、工具链)已经围绕它建立。

schemas.py:

python 复制代码
"""网关统一协议模型。

设计要点:
1. **对外协议固定,对内实现自由。** 客户端只认这个结构,
   Agent 内部怎么拆分、用哪个模型,客户端不需要知道。
2. 响应里带 routing 字段------**路由可追溯**,这是排查静默失效的唯一抓手。
3. agent_hint 是可选的客户端建议,**网关可以否决它**(见 4.3 节)。
"""
from __future__ import annotations

from enum import Enum
from typing import Any

from pydantic import BaseModel, Field


class Message(BaseModel):
    role: str                    # system / user / assistant
    content: str


class ChatRequest(BaseModel):
    """对外统一的对话请求。"""
    messages: list[Message]
    agent_hint: str | None = None          # 客户端建议的 Agent,可被网关否决
    tenant_id: str = "default"
    stream: bool = False
    max_cost_tier: str = "standard"        # cheap / standard / premium
    metadata: dict[str, Any] = Field(default_factory=dict)


class RouteDecision(BaseModel):
    """路由决策结果。**完整记录在响应里,用于事后追溯。**"""
    agent: str                             # 最终选中的 Agent
    model: str                             # 最终选中的模型
    strategy: str                          # rule / semantic / default
    confidence: float                      # 0~1
    estimated_tokens: int                  # 预估消耗
    reason: str = ""                       # 人类可读的决策依据


class ChatResponse(BaseModel):
    content: str
    routing: RouteDecision                 # 路由可追溯
    usage: dict[str, int] = Field(default_factory=dict)
    degraded: bool = False                 # 是否走了降级路径

💡 routing 字段看着多余,实际是路由层最重要的可观测设计。 当用户反馈"这个回答不对"时,第一件事就是看 routing.agent 是不是选错了。没有这个字段,只能去翻日志猜------而日志里通常没记路由决策。

4.2 第二步:规则路由与语义路由

routing.py。规则前置、语义兜底,两者都要给出置信度。

python 复制代码
"""路由层:规则前置 + 语义兜底 + 成本感知叠加。"""
from __future__ import annotations

import logging
import re
from typing import Awaitable, Callable

from schemas import ChatRequest, RouteDecision

logger = logging.getLogger(__name__)

# ---- 规则表:优先级从高到低 ----
# 每条规则 = (Agent 名, 关键词正则, 基础置信度)
RULES: list[tuple[str, re.Pattern[str], float]] = [
    ("translator", re.compile(r"翻译|translate|英译|中译|翻成"), 0.92),
    ("code_assist", re.compile(r"代码|函数|bug|报错|python|sql|重构|单测"), 0.90),
    ("data_query", re.compile(r"查询|统计|多少|报表|趋势|环比"), 0.85),
    ("contract_review", re.compile(r"合同|条款|违约|保密|甲方|乙方"), 0.88),
    ("doc_summary", re.compile(r"总结|摘要|概括|提炼|要点"), 0.82),
]

DEFAULT_AGENT = "general_qa"
CONFIDENCE_GATE = 0.6          # 低于此值不采纳语义路由结果


def rule_route(text: str) -> RouteDecision | None:
    """规则路由:**零成本、可解释**。命中即返回。"""
    for agent, pattern, conf in RULES:
        if pattern.search(text):
            return RouteDecision(
                agent=agent, model="", strategy="rule",
                confidence=conf, estimated_tokens=estimate_tokens(text),
                reason=f"命中关键词规则:{pattern.pattern[:12]}...",
            )
    return None


async def semantic_route(text: str,
                         classify: Callable[[str], Awaitable[tuple[str, float]]]
                         ) -> RouteDecision:
    """语义路由:调小模型分类。**带置信度闸门。**"""
    agent, conf = await classify(text)
    if conf < CONFIDENCE_GATE:
        logger.warning("语义路由置信度 %.2f 低于闸门 %.2f,转默认 Agent", conf, CONFIDENCE_GATE)
        return RouteDecision(
            agent=DEFAULT_AGENT, model="", strategy="default",
            confidence=conf, estimated_tokens=estimate_tokens(text),
            reason=f"语义置信度不足({conf:.2f}),转兜底",
        )
    return RouteDecision(
        agent=agent, model="", strategy="semantic",
        confidence=conf, estimated_tokens=estimate_tokens(text),
        reason=f"语义分类结果 {agent}(置信度 {conf:.2f})",
    )


def estimate_tokens(text: str) -> int:
    """粗估 Token 数。**中文按 1.5 字符/Token,英文按 4 字符/Token 估算。**"""
    cjk = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
    return int(cjk / 1.5 + (len(text) - cjk) / 4) + 8


# ---- 成本档位:按预估 Token 与任务类型选模型 ----
MODEL_TIERS: dict[str, list[str]] = {
    "cheap":    ["gpt-mini", "qwen-lite"],
    "standard": ["qwen-plus", "gpt-standard"],
    "premium":  ["gpt-flagship", "claude-top"],
}


def pick_model(decision: RouteDecision, req: ChatRequest) -> RouteDecision:
    """成本路由:**叠加层**,在已选定 Agent 后决定用哪档模型。

    判据不只是预估 Token------**还要看 Agent 类型**。
    合同审查即使输入很短也要用 premium(判断质量敏感),
    翻译即使输入很长也可以用 cheap(任务本身简单)。
    """
    quality_critical = {"contract_review", "code_assist"}

    if decision.agent in quality_critical:
        tier = "premium"
    elif decision.estimated_tokens < 300:
        tier = "cheap"
    else:
        tier = "standard"

    # 客户端声明的上限可以**降档**但不能**升档**------防止客户端滥用 premium
    order = ["cheap", "standard", "premium"]
    if order.index(tier) > order.index(req.max_cost_tier):
        tier = req.max_cost_tier

    decision.model = MODEL_TIERS[tier][0]
    decision.reason += f";模型档位={tier}"
    return decision

四个值得说明的设计:

① 规则用有序列表而不是 dict。 因为规则会冲突------"帮我翻译这段合同条款"同时命中"翻译"和"合同"。有序列表让优先级显式化(翻译在前,命中即返回),而 dict 的匹配顺序是不确定的。

② 置信度闸门转默认而非转最高分。 这是第 2.3 节那条原则的落地:低置信度时不做猜测,走兜底 Agent。理由是猜测错误是静默的,而兜底至少是"已知的能力较弱",不是"可能完全跑偏"。

③ pick_model 的降档逻辑方向是单向的。 客户端可以声明 max_cost_tier 来限制成本,但不能要求升到 premium------否则所有客户端都会声明 premium,成本策略失效。

④ quality_critical 这个集合是业务判断,不能自动化。 哪些任务对模型质量敏感,需要人来定。这段代码把它做成一个显式集合,就是为了让它可被 review------而不是散落在 if 分支里。

4.3 第三步:三维度限流

ratelimit.py。三个维度是与关系,任一超限即拒绝。

python 复制代码
"""三维度限流:请求速率 / 并发占用 / Token 预算。"""
from __future__ import annotations

import asyncio
import time


class RateLimitExceeded(RuntimeError):
    """限流拒绝。**必须区分维度**------不同维度的处理方式不同。"""
    def __init__(self, dimension: str, detail: str) -> None:
        super().__init__(f"[{dimension}] {detail}")
        self.dimension = dimension


class TokenBucket:
    """令牌桶:用于请求速率与 Token 预算两个维度。

    令牌桶相比固定窗口的好处:**允许短时突发,但长期速率受限**。
    Agent 场景常有批量请求,固定窗口会在窗口边界放行两倍流量。
    """
    def __init__(self, capacity: float, refill_per_sec: float) -> None:
        self.capacity = capacity
        self.refill = refill_per_sec
        self._tokens = capacity
        self._last = time.monotonic()
        self._lock = asyncio.Lock()

    async def consume(self, amount: float = 1.0) -> bool:
        async with self._lock:
            now = time.monotonic()
            self._tokens = min(self.capacity, self._tokens + (now - self._last) * self.refill)
            self._last = now
            if self._tokens < amount:
                return False
            self._tokens -= amount
            return True


class GatewayLimiter:
    """三维度限流器:按租户隔离。"""
    def __init__(self, *, qps: float = 10, concurrency: int = 8,
                 tpm: int = 200_000) -> None:
        self.qps_limit = qps
        self.concurrency_limit = concurrency
        self.tpm_limit = tpm
        self._qps: dict[str, TokenBucket] = {}
        self._tpm: dict[str, TokenBucket] = {}
        self._inflight: dict[str, int] = {}
        self._lock = asyncio.Lock()

    def _bucket(self, store: dict[str, TokenBucket], key: str,
                capacity: float, refill: float) -> TokenBucket:
        if key not in store:
            store[key] = TokenBucket(capacity, refill)
        return store[key]

    async def acquire(self, tenant: str, estimated_tokens: int) -> None:
        """请求进入前调用。**任一维度超限即抛异常。**"""
        if not await self._bucket(self._qps, tenant, self.qps_limit, self.qps_limit
                                  ).consume(1.0):
            raise RateLimitExceeded("qps", f"租户 {tenant} 请求速率超限")

        if not await self._bucket(self._tpm, tenant, self.tpm_limit,
                                  self.tpm_limit / 60).consume(estimated_tokens):
            raise RateLimitExceeded("tpm", f"租户 {tenant} Token 预算超限")

        async with self._lock:
            cur = self._inflight.get(tenant, 0)
            if cur >= self.concurrency_limit:
                raise RateLimitExceeded("concurrency", f"租户 {tenant} 并发占用已满")
            self._inflight[tenant] = cur + 1

    async def release(self, tenant: str, actual_tokens: int = 0) -> None:
        """请求结束后调用。**必须放 finally 里**,否则并发计数会泄漏。"""
        async with self._lock:
            self._inflight[tenant] = max(0, self._inflight.get(tenant, 1) - 1)

⚠️ release() 必须放在 finally 块里。 这是并发限流最容易出的 bug:请求异常时没释放计数,_inflight 只增不减,跑一段时间后就永久拒绝所有请求,而且重启才能恢复。这类故障的特点是"系统跑着跑着就不能用了",很难第一时间想到是计数泄漏。
💡 Token 维度为什么用"预估"扣减、不事后校准 :上游模型供应商的 TPM 配额是按请求发起时扣减的,事后校准(多退少补)无法挽回已经发生的 429。所以入口必须按预估值扣。若预估偏差大(实际远超预估),靠第 4.5 节的熔断兜底。

4.4 第四步:熔断与四级降级链

circuit.py。解决 1.3 节"供应商限流即全站不可用"。

python 复制代码
"""熔断器 + 降级链。

熔断解决"上游坏了别跟着坏",降级解决"上游坏了还能给什么"。
"""
from __future__ import annotations

import asyncio
import logging
import time
from enum import Enum
from typing import Any, Awaitable, Callable

logger = logging.getLogger(__name__)


class CircuitState(str, Enum):
    CLOSED = "closed"          # 正常
    OPEN = "open"              # 熔断:直接失败,不调用上游
    HALF_OPEN = "half_open"    # 探测:放行少量请求试探


class CircuitBreaker:
    """熔断器。**按 (供应商, 模型) 粒度隔离**,不是全局一个。"""
    def __init__(self, *, failure_threshold: int = 5,
                 reset_after_s: float = 30.0,
                 half_open_max: int = 2) -> None:
        self.failure_threshold = failure_threshold
        self.reset_after = reset_after_s
        self.half_open_max = half_open_max

        self._state = CircuitState.CLOSED
        self._failures = 0
        self._opened_at = 0.0
        self._half_open_inflight = 0
        self._lock = asyncio.Lock()

    @property
    def state(self) -> CircuitState:
        return self._state

    async def allow(self) -> bool:
        """是否放行。**OPEN 状态下自动判断是否该转 HALF_OPEN。**"""
        async with self._lock:
            if self._state is CircuitState.CLOSED:
                return True

            if self._state is CircuitState.OPEN:
                if time.monotonic() - self._opened_at >= self.reset_after:
                    self._state = CircuitState.HALF_OPEN
                    self._half_open_inflight = 0
                    logger.info("熔断到期,转入半开探测")
                else:
                    return False

            # HALF_OPEN:只放行有限个探测请求
            if self._half_open_inflight < self.half_open_max:
                self._half_open_inflight += 1
                return True
            return False

    async def record_success(self) -> None:
        async with self._lock:
            if self._state is CircuitState.HALF_OPEN:
                self._state = CircuitState.CLOSED
                logger.info("探测成功,熔断关闭")
            self._failures = 0

    async def record_failure(self) -> None:
        async with self._lock:
            self._failures += 1
            if self._state is CircuitState.HALF_OPEN:
                self._state = CircuitState.OPEN
                self._opened_at = time.monotonic()
                logger.warning("探测失败,重新熔断")
            elif self._failures >= self.failure_threshold:
                self._state = CircuitState.OPEN
                self._opened_at = time.monotonic()
                logger.error("连续失败 %d 次,熔断开启", self._failures)


class FallbackChain:
    """四级降级链:**换模型 → 换供应商 → 返回缓存 → 排队提示**。

    设计原则:**降级路径的质量递减,但可用性递增。**
    最后一级必须永远可用(哪怕是"稍后再试"),
    否则降级链本身就会成为故障源。
    """
    def __init__(self, breakers: dict[str, CircuitBreaker],
                 cache_get: Callable[[str], Awaitable[str | None]] | None = None) -> None:
        self.breakers = breakers          # key = "provider:model"
        self.cache_get = cache_get

    async def execute(self, req_key: str,
                      candidates: list[tuple[str, Callable[[], Awaitable[str]]]],
                      ) -> tuple[str, bool]:
        """依次尝试候选,返回 (结果, 是否降级)。

        candidates 按质量从高到低排列:**[(premium_fn), (standard_fn), (cheap_fn)]**
        """
        # 一级/二级:换模型、换供应商
        for name, fn in candidates:
            breaker = self.breakers.get(name)
            if breaker is not None and not await breaker.allow():
                logger.warning("候选 %s 已熔断,跳过", name)
                continue
            try:
                result = await fn()
                if breaker is not None:
                    await breaker.record_success()
                return result, name != candidates[0][0]
            except Exception as exc:
                logger.warning("候选 %s 失败:%s", name, exc)
                if breaker is not None:
                    await breaker.record_failure()
                continue

        # 三级:返回缓存(语义相似的历史结果)
        if self.cache_get is not None:
            cached = await self.cache_get(req_key)
            if cached:
                logger.warning("全部候选失败,返回缓存结果")
                return cached, True

        # 四级:永不失败------排队提示
        logger.error("全部候选与缓存均失败,返回排队提示")
        return "当前服务繁忙,你的请求已排队,稍后会通过通知返回结果。", True

熔断器的三态流转,注意 HALF_OPEN 是必需的中间态------没有它,熔断到期后全部流量瞬间打回上游,很可能再次触发限流:
#mermaid-svg-cHdzUIDJxHv1lKvr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cHdzUIDJxHv1lKvr .error-icon{fill:#552222;}#mermaid-svg-cHdzUIDJxHv1lKvr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cHdzUIDJxHv1lKvr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cHdzUIDJxHv1lKvr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr .marker.cross{stroke:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cHdzUIDJxHv1lKvr p{margin:0;}#mermaid-svg-cHdzUIDJxHv1lKvr defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-cHdzUIDJxHv1lKvr g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-cHdzUIDJxHv1lKvr g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-cHdzUIDJxHv1lKvr g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-cHdzUIDJxHv1lKvr g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-cHdzUIDJxHv1lKvr .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-cHdzUIDJxHv1lKvr .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-cHdzUIDJxHv1lKvr .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-cHdzUIDJxHv1lKvr .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-cHdzUIDJxHv1lKvr .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-cHdzUIDJxHv1lKvr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cHdzUIDJxHv1lKvr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cHdzUIDJxHv1lKvr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cHdzUIDJxHv1lKvr .edgeLabel .label text{fill:#333;}#mermaid-svg-cHdzUIDJxHv1lKvr .label div .edgeLabel{color:#333;}#mermaid-svg-cHdzUIDJxHv1lKvr .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-cHdzUIDJxHv1lKvr .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-cHdzUIDJxHv1lKvr .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-cHdzUIDJxHv1lKvr .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr #statediagram-barbEnd{fill:#333333;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cHdzUIDJxHv1lKvr .cluster-label,#mermaid-svg-cHdzUIDJxHv1lKvr .nodeLabel{color:#131300;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-cHdzUIDJxHv1lKvr .note-edge{stroke-dasharray:5;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-note text{fill:black;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram-note .nodeLabel{color:black;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagram .edgeLabel{color:red;}#mermaid-svg-cHdzUIDJxHv1lKvr #dependencyStart,#mermaid-svg-cHdzUIDJxHv1lKvr #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-cHdzUIDJxHv1lKvr .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cHdzUIDJxHv1lKvr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 连续失败达到阈值
熔断计时到期
探测成功
探测再次失败
成功则清零计数
closed
open
half_open
直接失败不调上游

避免重试风暴放大流量
只放行有限探测请求

防止恢复瞬间再次打垮

⚠️ 熔断器必须按 (供应商, 模型) 粒度隔离,不能全局一个。 全局熔断的问题是:A 供应商限流了,B 供应商明明可用,却因为共用一个熔断器也被判定为不可用------熔断从"保护"变成了"扩大故障面"。粒度越细,可用性的上限越高。

4.5 第五步:成本加权负载均衡

balancer.py。解决 2.4 节"轮询在 Agent 场景无效"的问题。

python 复制代码
"""成本加权负载均衡。

核心:**Agent 的负载不是请求数,是并发占用与预估成本。**
"""
from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


@dataclass
class AgentInstance:
    """一个 Agent 实例。"""
    name: str
    endpoint: str
    weight: float = 1.0             # 实例能力权重(配置高的大)
    inflight: int = 0               # 当前并发数
    inflight_cost: float = 0.0      # **当前并发的预估总成本**(按 Token 计)
    healthy: bool = True


class CostAwareBalancer:
    """按"并发成本"选实例,而不是按请求数。"""
    def __init__(self, instances: list[AgentInstance]) -> None:
        self.instances = instances
        self._lock = asyncio.Lock()

    async def pick(self, estimated_tokens: int) -> AgentInstance | None:
        """选择得分最低的健康实例。

        得分 = (当前并发成本 + 本次预估成本) / 实例权重
        除以权重,让高配实例承担更多------否则所有实例被同等对待。
        """
        async with self._lock:
            healthy = [i for i in self.instances if i.healthy]
            if not healthy:
                return None
            chosen = min(
                healthy,
                key=lambda i: (i.inflight_cost + estimated_tokens) / i.weight,
            )
            chosen.inflight += 1
            chosen.inflight_cost += estimated_tokens
            return chosen

    async def release(self, inst: AgentInstance, actual_tokens: int) -> None:
        """释放占用。**同样必须在 finally 里调用。**"""
        async with self._lock:
            inst.inflight = max(0, inst.inflight - 1)
            inst.inflight_cost = max(0.0, inst.inflight_cost - actual_tokens)

    def mark_unhealthy(self, name: str) -> None:
        for i in self.instances:
            if i.name == name:
                i.healthy = False
                logger.error("实例 %s 标记为不健康", name)

💡 为什么用 inflight_cost(Token 加权)而不是 inflight(并发数) :两个都是"3 个并发"的实例,一个在跑 3 个翻译任务(各 500 Token),另一个在跑 3 个合同审查(各 20000 Token)------后者的真实负载是前者的 40 倍,但按并发数看完全一样。用 Token 加权才能反映真实的资源占用。

4.6 第六步:网关主体串联

gateway.py。把四层按顺序串起来。

python 复制代码
"""网关主体:接入 → 治理 → 路由 → 执行。

**顺序不能颠倒**:先判断"能不能做"(限流),再决定"谁来做"(路由)。
"""
from __future__ import annotations

import logging

from balancer import CostAwareBalancer
from circuit import FallbackChain
from ratelimit import GatewayLimiter, RateLimitExceeded
from routing import pick_model, rule_route, semantic_route
from schemas import ChatRequest, ChatResponse, RouteDecision

logger = logging.getLogger(__name__)


class AgentGateway:
    def __init__(self, limiter: GatewayLimiter, balancer: CostAwareBalancer,
                 fallback: FallbackChain, dispatch) -> None:
        self.limiter = limiter
        self.balancer = balancer
        self.fallback = fallback
        self.dispatch = dispatch          # 实际转发函数

    async def handle(self, req: ChatRequest, classify) -> ChatResponse:
        text = req.messages[-1].content if req.messages else ""

        # ---- 第 1 步:路由(先用它拿到预估 Token 用于限流)----
        decision = rule_route(text)
        if decision is None:
            decision = await semantic_route(text, classify)
        decision = pick_model(decision, req)

        # ---- 第 2 步:治理(限流)----
        await self.limiter.acquire(req.tenant_id, decision.estimated_tokens)

        inst = None
        try:
            # ---- 第 3 步:选实例 ----
            inst = await self.balancer.pick(decision.estimated_tokens)
            if inst is None:
                return ChatResponse(
                    content="全部 Agent 实例不可用,请稍后重试。",
                    routing=decision, degraded=True,
                )

            # ---- 第 4 步:执行(带降级链)----
            content, degraded = await self.fallback.execute(
                req_key=_cache_key(req, decision),
                candidates=[
                    (f"primary:{decision.model}",
                     lambda: self.dispatch(inst.endpoint, req, decision.model)),
                    ("fallback:cheap",
                     lambda: self.dispatch(inst.endpoint, req, "gpt-mini")),
                ],
            )
            return ChatResponse(content=content, routing=decision, degraded=degraded)
        finally:
            if inst is not None:
                await self.balancer.release(inst, decision.estimated_tokens)
            await self.limiter.release(req.tenant_id)


def _cache_key(req: ChatRequest, d: RouteDecision) -> str:
    """缓存键:**必须包含 agent 与 model**,否则不同 Agent 的结果会互相污染。"""
    import hashlib
    raw = f"{d.agent}:{d.model}:{req.messages[-1].content if req.messages else ''}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

两个容易错的地方:

① 路由在限流之前,但路由本身不调用外部资源。 这看起来违反 2.1 节"限流必须在路由之前"的原则,实际不冲突------因为限流需要 estimated_tokens,而预估值来自路由。真正的原则是"限流在任何有成本的调用之前" :rule_route 零成本可以先跑,semantic_route 要调模型(有成本)则应当放在限流之后。严格实现应把语义路由移到限流后,这里为了代码简洁放在一起,生产实现需注意。

② finally 里同时释放了 balancer 和 limiter。 漏掉任何一个都会造成计数泄漏,且症状都是"跑一段时间就不能用了"。

完整的请求处理链路:
#mermaid-svg-medrejRNnzHy8BzG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-medrejRNnzHy8BzG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-medrejRNnzHy8BzG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-medrejRNnzHy8BzG .error-icon{fill:#552222;}#mermaid-svg-medrejRNnzHy8BzG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-medrejRNnzHy8BzG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-medrejRNnzHy8BzG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-medrejRNnzHy8BzG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-medrejRNnzHy8BzG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-medrejRNnzHy8BzG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-medrejRNnzHy8BzG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-medrejRNnzHy8BzG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-medrejRNnzHy8BzG .marker.cross{stroke:#333333;}#mermaid-svg-medrejRNnzHy8BzG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-medrejRNnzHy8BzG p{margin:0;}#mermaid-svg-medrejRNnzHy8BzG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-medrejRNnzHy8BzG .cluster-label text{fill:#333;}#mermaid-svg-medrejRNnzHy8BzG .cluster-label span{color:#333;}#mermaid-svg-medrejRNnzHy8BzG .cluster-label span p{background-color:transparent;}#mermaid-svg-medrejRNnzHy8BzG .label text,#mermaid-svg-medrejRNnzHy8BzG span{fill:#333;color:#333;}#mermaid-svg-medrejRNnzHy8BzG .node rect,#mermaid-svg-medrejRNnzHy8BzG .node circle,#mermaid-svg-medrejRNnzHy8BzG .node ellipse,#mermaid-svg-medrejRNnzHy8BzG .node polygon,#mermaid-svg-medrejRNnzHy8BzG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-medrejRNnzHy8BzG .rough-node .label text,#mermaid-svg-medrejRNnzHy8BzG .node .label text,#mermaid-svg-medrejRNnzHy8BzG .image-shape .label,#mermaid-svg-medrejRNnzHy8BzG .icon-shape .label{text-anchor:middle;}#mermaid-svg-medrejRNnzHy8BzG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-medrejRNnzHy8BzG .rough-node .label,#mermaid-svg-medrejRNnzHy8BzG .node .label,#mermaid-svg-medrejRNnzHy8BzG .image-shape .label,#mermaid-svg-medrejRNnzHy8BzG .icon-shape .label{text-align:center;}#mermaid-svg-medrejRNnzHy8BzG .node.clickable{cursor:pointer;}#mermaid-svg-medrejRNnzHy8BzG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-medrejRNnzHy8BzG .arrowheadPath{fill:#333333;}#mermaid-svg-medrejRNnzHy8BzG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-medrejRNnzHy8BzG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-medrejRNnzHy8BzG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-medrejRNnzHy8BzG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-medrejRNnzHy8BzG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-medrejRNnzHy8BzG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-medrejRNnzHy8BzG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-medrejRNnzHy8BzG .cluster text{fill:#333;}#mermaid-svg-medrejRNnzHy8BzG .cluster span{color:#333;}#mermaid-svg-medrejRNnzHy8BzG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-medrejRNnzHy8BzG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-medrejRNnzHy8BzG rect.text{fill:none;stroke-width:0;}#mermaid-svg-medrejRNnzHy8BzG .icon-shape,#mermaid-svg-medrejRNnzHy8BzG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-medrejRNnzHy8BzG .icon-shape p,#mermaid-svg-medrejRNnzHy8BzG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-medrejRNnzHy8BzG .icon-shape .label rect,#mermaid-svg-medrejRNnzHy8BzG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-medrejRNnzHy8BzG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-medrejRNnzHy8BzG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-medrejRNnzHy8BzG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
超限
通过
无
有
是
否
是
否
是
否
请求进入
鉴权与租户识别
规则路由 零成本
命中规则
计算预估 Token
语义路由 需限流后
三维度限流
返回 429 并标明维度
成本加权选实例
有健康实例
返回不可用提示
尝试主模型
成功
返回结果 记录路由
熔断器记录失败
降级:换廉价模型
成功
返回降级结果
降级:返回缓存
有缓存
降级:排队提示 永不失败
释放并发与限流计数

4.7 一次请求的完整时序:正常路径与降级路径

上面的流程图展示了分支逻辑,但看不出时间维度上的先后。下面这条时序图把两条路径放在一起对比------上面是正常路径,下面是同一请求在供应商限流时的降级路径:
模型供应商 Agent 实例 熔断器 限流器 网关 客户端 模型供应商 Agent 实例 熔断器 限流器 网关 客户端 #mermaid-svg-EyEL0qBi1XH7vcVk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EyEL0qBi1XH7vcVk .error-icon{fill:#552222;}#mermaid-svg-EyEL0qBi1XH7vcVk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EyEL0qBi1XH7vcVk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EyEL0qBi1XH7vcVk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EyEL0qBi1XH7vcVk .marker.cross{stroke:#333333;}#mermaid-svg-EyEL0qBi1XH7vcVk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EyEL0qBi1XH7vcVk p{margin:0;}#mermaid-svg-EyEL0qBi1XH7vcVk .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EyEL0qBi1XH7vcVk text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EyEL0qBi1XH7vcVk .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-EyEL0qBi1XH7vcVk .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-EyEL0qBi1XH7vcVk #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-EyEL0qBi1XH7vcVk .sequenceNumber{fill:white;}#mermaid-svg-EyEL0qBi1XH7vcVk #sequencenumber{fill:#333;}#mermaid-svg-EyEL0qBi1XH7vcVk #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-EyEL0qBi1XH7vcVk .messageText{fill:#333;stroke:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EyEL0qBi1XH7vcVk .labelText,#mermaid-svg-EyEL0qBi1XH7vcVk .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .loopText,#mermaid-svg-EyEL0qBi1XH7vcVk .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EyEL0qBi1XH7vcVk .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-EyEL0qBi1XH7vcVk .noteText,#mermaid-svg-EyEL0qBi1XH7vcVk .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-EyEL0qBi1XH7vcVk .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EyEL0qBi1XH7vcVk .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EyEL0qBi1XH7vcVk .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EyEL0qBi1XH7vcVk .actorPopupMenu{position:absolute;}#mermaid-svg-EyEL0qBi1XH7vcVk .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-EyEL0qBi1XH7vcVk .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EyEL0qBi1XH7vcVk .actor-man circle,#mermaid-svg-EyEL0qBi1XH7vcVk line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-EyEL0qBi1XH7vcVk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 正常路径 降级路径(供应商限流) 降级率指标 +1 POST /v1/chat/completions 规则路由命中 translator acquire(tenant, est_tokens) 通过 转发(成本加权选中实例) 调用主模型 返回结果 返回 200 含 routing 字段 release() POST 同类请求 acquire(tenant, est_tokens) 通过 allow(primary:model) false(已熔断) 直接换廉价模型 调用备用模型 返回降级结果 返回 200 degraded=true release()

三条从时序角度才能看清楚的要点:

① 熔断判断发生在转发之前,不占用 Agent 实例。 注意降级路径里网关在 B-->>G: false 之后直接换了模型,没有先去调 Agent 实例再由它失败 。这是熔断的核心价值------避免明知会失败还走一遍完整链路。这既省了时间,也避免了对上游的无效压力(1.3 节那个重试风暴就是这么来的)。

② release() 在所有路径的最后一步,包括失败路径。 时序图上正常路径和降级路径都画了它,但真实代码里最容易漏的是抛异常的路径 。这也是 4.3 节强调"必须放 finally"的原因------靠代码路径枚举去释放,迟早漏一条。

③ 降级响应依然是 200。 这一点很关键:降级不是错误 。客户端拿到 degraded=true 后可以自行决定提示用户"当前使用备用模型",但请求本身是成功的。如果把降级返回成 5xx,客户端会重试------而重试会进一步加剧上游压力,这正是熔断想避免的。

⚠️ 一个实践中的坑 :有些团队把熔断后的降级响应设计成 503 + Retry-After,理由是"让客户端稍后重试"。但客户端的重试通常是立即的(Retry-After 被忽略的情况很常见),结果是熔断本想减少的流量反而被客户端放大了。熔断期间就应该返回可用的降级结果,而不是把压力推回给调用方。


五、进阶

5.1 会话粘滞:需不需要,取决于状态在哪

多轮对话场景下,一个经典问题是:同一会话的请求要不要固定打到同一个实例?

答案取决于第 30 篇的状态分层结论:

状态存放位置 是否需要粘滞 原因
实例内存(错误做法) 必须 否则会话丢失
共享存储(Redis/DB,正确做法) 不需要 任何实例都能加载
混合(热状态在内存 + 冷状态在存储) 建议粘滞(性能) 命中本地缓存,但不是正确性依赖

关键判断 :如果粘滞是正确性依赖 (不做就出错),说明架构有问题------实例重启会导致会话全丢。正确做法是先把状态外置到共享存储(第 30 篇的 SessionState 持久化),粘滞只作为性能优化存在。

⚠️ 粘滞 + 负载均衡会互相打架。 粘滞要求"同一会话固定到某实例",均衡要求"选最闲的实例"。两者冲突时的优先级是:均衡优先于粘滞(当粘滞实例已满且不粘滞不影响正确性时)。硬性粘滞会导致"某个实例上堆了一堆长会话,其他实例空闲"------这恰恰是 1.4 节那类问题的另一种形态。

5.2 流式响应下的限流与熔断

Agent 常见流式输出(SSE),这给治理层带来两个新问题:

问题一:限流该在什么时候释放? 流式响应可能持续几分钟。如果在首字节就释放,那么限流形同虚设;如果等到结束,长连接会长时间占着配额。

我的拍板 :并发维度在流结束时释放,Token 维度按预估在入口一次性扣减。理由:并发反映的是真实资源占用,必须等结束;Token 对齐上游配额的扣减时机,按入口扣才符合上游语义。

问题二:熔断在流中途怎么判定? 流已经开始传输后上游失败了,无法"降级"(已经吐了一部分内容给客户端)。

处理方式:流中途失败不重试,记录部分响应并在末尾追加降级标记。让用户知道这个回答不完整,比静默截断要好:

python 复制代码
async def stream_with_degrade(endpoint, req, model):
    """流式转发:**中途失败不重试**,末尾追加不完整标记。"""
    buf = []
    try:
        async for chunk in upstream_stream(endpoint, req, model):
            buf.append(chunk)
            yield chunk
    except Exception as exc:
        logger.warning("流式响应中途失败:%s", exc)
        yield "\n\n[提示] 本次回答未完整生成,请重试或缩小问题范围。"

5.3 路由准确率的回流监控

第 2.3 节说路由错误是静默的,那么怎么知道路由准不准?只能靠用户反馈回流:

信号 含义 强度
用户点踩 明确不满意 强
3 秒内重新提问(同类) 可能没答对 中
用户显式切换 Agent 路由选错了 最强
对话轮次异常长 可能在反复纠正 弱

第三个信号价值最高但最少被采集------如果 UI 上提供了"换个 Agent 问"的入口,那么用户每次切换都是一条高质量的路由纠错样本。

把回流信号按 routing.agent 聚合,就能算出每个 Agent 的实际路由准确率。低于阈值(比如 85%)时,说明规则表或分类模型需要调整。

图2:熔断后的四级降级链------换模型、换供应商、返回缓存、排队提示的质量与可用性权衡

5.4 网关作为计费点:为什么必须在这一层计量

只要涉及多租户,就必然涉及计费。而计费的第一个问题是:谁报的数字可信?

计量位置 可信度 问题
客户端自报 ❌ 完全不可信 可以随便改
Agent 上报 ⚠️ 半可信 异常路径会漏报(抛异常时没走到统计代码)
网关计量 ✅ 可信 唯一能看到全部响应的位置

Agent 上报的问题值得展开 :绝大多数实现是在 Agent 的正常返回路径上统计 Token。于是所有异常路径------超时、上游报错、客户端断连、降级 ------都不会被统计。结果是账单比实际消耗低,而且低多少完全取决于故障率。系统越不稳定,账单越不准。

网关计量则没有这个问题:请求从网关进、从网关出,无论内部走了什么路径,网关都能看到最终响应。

python 复制代码
async def metered_dispatch(self, req: ChatRequest, decision: RouteDecision,
                           fn) -> tuple[str, int, int]:
    """带计量的转发。**无论成功失败都要记账。**"""
    prompt_tokens = sum(estimate_tokens(m.content) for m in req.messages)
    try:
        content = await fn()
        completion_tokens = estimate_tokens(content)
        return content, prompt_tokens, completion_tokens
    except Exception:
        # 失败也要记输入消耗------上游已经消耗了 prompt 的算力
        return "", prompt_tokens, 0
    finally:
        # 实际实现:写入计量流水(tenant, agent, model, tokens, degraded)
        ...

⚠️ 失败请求也要记输入 Token 。这是最容易漏的一条:请求超时了、上游报错了,但上游已经处理了你的 prompt 并消耗了算力 。如果失败不记账,那么一个恶意租户可以用大量必然超时的请求白嫖算力。实践中输入 Token 的计量必须放在 finally 里,与成功失败无关。

计量的三要素 :tenant_id × model × tokens(输入/输出分开)。三者缺一不可,因为不同模型的单价不同、输入输出单价也不同(通常输出是输入的 3~5 倍)。

流式响应的计量有个特殊难点 :流式下你只能在流结束后才知道输出了多少 Token。如果客户端中途断连,输出 Token 就统计不到了------但实际已经生成了一部分。折中方案是按已传输内容估算并计入,误差通常在 5% 以内,可以接受。


六、对照:三类网关的边界

容易混淆的三个概念,一次说清:

通用 API 网关(Kong/Envoy) Agent 网关(本文) LLM 网关(LiteLLM 等)
主要对象 HTTP 服务 Agent 服务 模型 API
路由依据 path / header 意图 + 成本 + 负载 模型名
限流维度 QPS / 带宽 QPS + 并发 + Token TPM / RPM
熔断 按上游服务 按 (供应商, 模型) 按模型
协议转换 HTTP/gRPC 统一 Agent 协议 统一各家模型 API
特有难点 --- 路由静默失效 各家参数差异

第 28 篇中间件与本篇网关的对照(这是最容易混淆的一对):

中间件(第 28 篇) 网关(本篇)
作用域 进程内,单个 Agent 服务级,跨所有 Agent
能看到什么 完整的内部状态、Prompt 只有请求/响应
适合放这里 Prompt 改写、工具审批、脱敏 鉴权、租户配额、跨 Agent 限流
不适合 跨 Agent 的全局配额 需要内部上下文的决策

判断标准 :需要内部状态才能做的决策 → 中间件;只看请求就能做的决策 → 网关。 举例:"这个用户的请求里有没有身份证号"(只看请求)→ 网关和中间件都行;"这次调用要不要触发人工审批"(要看工具参数与上下文)→ 只能放中间件。

拍板建议 :中小规模(Agent 数 < 10、单模型供应商)不要引入独立网关 ,把限流和路由写进第 28 篇的中间件链里就够了。独立网关的收益出现在多 Agent + 多供应商 + 多租户三者至少占两个时。否则它只是增加了一跳延迟和一个要运维的组件。


七、适用边界与风险提示 ⚠️

什么时候不该上网关:

  • 单 Agent、单模型、单租户 → 直接调用
  • Agent 数少且都在同一进程内 → 用中间件(第 28 篇)即可
  • 对延迟极度敏感且 Agent 内部已有限流 → 额外一跳的开销不划算

五个必须知道的风险:

风险 现象 对策
网关单点 网关挂了,所有 Agent 不可用 网关无状态 + 多实例;绝不把状态放网关
计数泄漏 跑一段时间后永久拒绝请求 release() 必须在 finally 里
预估偏差 Token 预估远低于实际,仍撞上游配额 结合第 4.4 节熔断兜底
路由漂移 规则表越加越长,互相冲突 规则定期 review;冲突时显式定义优先级
降级掩盖故障 一直走降级路径,主路径坏了没人知道 降级率必须告警,不能只看成功率

第五个风险最值得展开 :降级链做得越好,故障越难被发现------用户看到的还是"有回答",只是质量差一点。如果监控只盯"请求成功率",那么一个长期走廉价模型降级的系统,成功率是 100%。必须把 degraded 标记单独做成指标,降级率超过 5% 就该告警。

图3:Agent 网关的五类风险------单点、计数泄漏、预估偏差、路由漂移、降级掩盖故障


八、进阶:让网关可测

网关代码的测试重点是治理逻辑(限流、熔断、降级),因为路由的正确性很难自动化验证。

python 复制代码
import asyncio
import pytest

from balancer import AgentInstance, CostAwareBalancer
from circuit import CircuitBreaker, CircuitState, FallbackChain
from ratelimit import GatewayLimiter, RateLimitExceeded
from routing import estimate_tokens, pick_model, rule_route
from schemas import ChatRequest, RouteDecision


def test_rule_route_hits_high_priority_first():
    """规则冲突时按声明顺序取优先级高的。"""
    d = rule_route("帮我翻译这段合同条款")
    assert d is not None
    assert d.agent == "translator"          # 翻译规则声明在前
    assert d.strategy == "rule"


def test_cost_tier_can_only_be_downgraded():
    """客户端声明的档位只能降档,不能升档。"""
    req = ChatRequest(messages=[], max_cost_tier="cheap")
    d = RouteDecision(agent="contract_review", model="", strategy="rule",
                      confidence=0.9, estimated_tokens=100)
    d = pick_model(d, req)
    assert d.model.startswith("gpt-mini") or "lite" in d.model


@pytest.mark.asyncio
async def test_concurrency_limit_rejects_when_full():
    """并发维度:QPS 未超限但并发占满时也应拒绝。"""
    limiter = GatewayLimiter(qps=100, concurrency=2, tpm=10_000_000)
    await limiter.acquire("t1", 10)
    await limiter.acquire("t1", 10)
    with pytest.raises(RateLimitExceeded) as exc:
        await limiter.acquire("t1", 10)
    assert exc.value.dimension == "concurrency"


@pytest.mark.asyncio
async def test_tpm_limit_uses_estimated_tokens():
    """Token 维度:一个大请求就能打满小预算。"""
    limiter = GatewayLimiter(qps=100, concurrency=100, tpm=1000)
    await limiter.acquire("t1", 900)
    with pytest.raises(RateLimitExceeded) as exc:
        await limiter.acquire("t1", 900)
    assert exc.value.dimension == "tpm"


@pytest.mark.asyncio
async def test_release_prevents_leak():
    """释放后计数归零------防"跑一段时间就永久拒绝"。"""
    limiter = GatewayLimiter(qps=100, concurrency=1, tpm=10_000_000)
    await limiter.acquire("t1", 10)
    await limiter.release("t1")
    await limiter.acquire("t1", 10)          # 不抛异常即通过


@pytest.mark.asyncio
async def test_circuit_opens_after_threshold():
    """连续失败达到阈值后熔断开启,不再调用上游。"""
    cb = CircuitBreaker(failure_threshold=3, reset_after_s=0.1)
    for _ in range(3):
        await cb.record_failure()
    assert cb.state is CircuitState.OPEN
    assert await cb.allow() is False


@pytest.mark.asyncio
async def test_circuit_half_open_probes_limited():
    """半开态只放行有限探测,防止恢复瞬间打垮上游。"""
    cb = CircuitBreaker(failure_threshold=1, reset_after_s=0.01, half_open_max=2)
    await cb.record_failure()
    await asyncio.sleep(0.02)
    assert await cb.allow() is True
    assert await cb.allow() is True
    assert await cb.allow() is False         # 第三个探测被拒


@pytest.mark.asyncio
async def test_fallback_never_fails():
    """降级链最后一级永不失败------这是它存在的意义。"""
    async def boom():
        raise RuntimeError("upstream down")

    chain = FallbackChain(breakers={}, cache_get=None)
    content, degraded = await chain.execute("k", [("a", boom), ("b", boom)])
    assert degraded is True
    assert "排队" in content


@pytest.mark.asyncio
async def test_balancer_prefers_cheaper_instance():
    """成本加权:并发成本低的实例被选中。"""
    busy = AgentInstance(name="busy", endpoint="", inflight_cost=50_000)
    idle = AgentInstance(name="idle", endpoint="", inflight_cost=100)
    b = CostAwareBalancer([busy, idle])
    picked = await b.pick(1000)
    assert picked.name == "idle"

💡 test_fallback_never_fails 这个测试看着像废话,但它守的是降级链最核心的契约。 实践中降级链失效的典型原因是最后一级也依赖了外部资源(比如"返回缓存"里的缓存服务也挂了)。这个测试强制最后一级必须是不依赖任何外部服务的常量响应。


九、总结

回到文章开头的四个问题:

问题 本文对应方案
Agent 地址泄漏到客户端 统一入口 + 协议收敛(4.1)
简单问题也走贵模型 成本路由叠加层(4.2 的 pick_model)
供应商限流即全站不可用 熔断 + 四级降级链(4.4)
一个租户吃光资源 QPS + 并发 + Token 三维度限流(4.3)

五个值得记住的判断:

第一,Agent 网关的限流必须包含 Token 维度。 因为上游供应商就是按 TPM 配额限流的,网关只限 QPS 等于没限。而并发维度解决的是"长任务占满实例"------1.4 节那个每秒 20 条的批量任务,QPS 完全达标却吃光了资源。

第二,负载均衡的度量必须是成本而非请求数。 Agent 请求耗时差三个数量级,"3 个并发"在不同实例上可能是 40 倍的负载差。用 inflight_cost(Token 加权)而不是 inflight。

第三,路由错误是静默失效,所以路由必须可追溯。 路由错了依然会返回 200 和一段看起来合理的内容。唯一的抓手是把路由决策写进响应,再靠用户反馈回流计算准确率。

第四,降级链的最后一级必须永不失败。 前三级的可用性都依赖外部系统,只有最后一级(返回排队提示)是纯常量。降级做得越好,主路径故障越难被发现------所以降级率必须单独告警,不能只看成功率。

第五,网关不是必需品。 判断标准是:多 Agent、多供应商、多租户,三者至少占两个才值得引入独立网关。否则限流和路由写进第 28 篇的中间件链就够了------少一个要运维的组件,比多一层抽象有价值。

最后一句提醒 :网关最容易犯的错误是把业务决策放进去。网关只该做"只看请求就能做的决策"------鉴权、配额、限流、选 Agent。一旦开始在这里写"如果合同金额超过 100 万就走人工审批",你就拥有了一个谁都不敢改的巨型 if-else,而且它横跨所有业务线。

落地自检清单(Checklist)

  • ✅ 对外协议固定,Agent 内部调整对客户端透明
  • ✅ 响应里带 routing 字段,路由可追溯
  • ✅ 规则路由前置(零成本 + 可解释),语义路由带置信度闸门
  • ✅ 成本档位只能降档不能升档
  • ✅ 限流三维度齐备:QPS + 并发占用 + Token 预算
  • ✅ release() 全部在 finally 里,无计数泄漏
  • ✅ 熔断器按 (供应商, 模型) 粒度隔离
  • ✅ 熔断器有 HALF_OPEN 探测态,限制探测并发
  • ✅ 降级链最后一级永不失败(不依赖外部资源)
  • ✅ 降级率单独告警,不只看成功率
  • ✅ 负载均衡按 Token 加权成本而非请求数
  • ✅ 缓存键包含 agent 与 model,不互相污染
  • ✅ 网关无状态(会话状态在共享存储,见第 30 篇)
  • ✅ 粘滞只是性能优化,不是正确性依赖
  • ✅ 流式响应定义了限流释放时机 与中途失败策略
  • ✅ 治理逻辑有测试覆盖(并发限流、TPM 限流、熔断三态、降级永不失败)

常见问题(FAQ)

Q1:网关会不会成为性能瓶颈?

会,但通常不是你想的那个瓶颈。网关本身只做路由和计数,开销在毫秒级;真正的问题是网关把流式响应也代理了一遍,长连接会占住网关的连接资源。解法:① 网关无状态 + 多实例;② 流式响应用独立的连接超时设置(比普通请求长得多);③ 考虑流式场景直连 Agent(网关只做鉴权和路由下发,不代理数据)。

Q2:路由规则越来越多怎么办?

规则表膨胀是必然的,关键是保持可 review 。三条实践:① 每条规则必须有明确的负责人和添加日期(写在注释里);② 每季度做一次规则冲突检查------用历史请求回放,看有没有请求命中多条规则;③ 命中率长期为 0 的规则要删掉,它们只增加维护成本和冲突概率。

Q3:语义路由会不会增加延迟?

会,通常增加 100~300ms(一次小模型分类调用)。三种优化:① 规则前置挡掉大部分请求(实测能挡 60~75%);② 语义分类用最小最快的模型,只做分类不生成内容;③ 对同一会话的后续轮次复用第一轮的分类结果(对话主题通常不会突变)。

Q4:为什么不用现成的 API 网关(Kong/Envoy)?

可以用的部分------鉴权、TLS、基础限流,它们做得比自研好。但它们缺三样 Agent 特有的东西:Token 维度限流、意图路由、(供应商,模型)粒度的熔断 。所以实际架构往往是前面挂一层通用网关做接入,后面自研 Agent 网关做治理,而不是二选一。

Q5:多租户的配额怎么设计才公平?

三条原则:① 配额按成本维度(Token)而非请求数分配 ------按请求数分配的话,一个租户用长请求就能占满别人的份额;② 预留一部分容量给"突发",不按租户全部分完;③ 配额用完返回明确的配额错误(区别于限流错误),让客户端知道是配额问题而非系统故障。

Q6:降级到廉价模型,用户能感知吗?

视任务而定:翻译、摘要类几乎无感;合同审查、代码生成类差异明显。所以降级策略应该按 Agent 区分 ------质量敏感的 Agent 可以跳过"换廉价模型"这一级,直接走缓存或排队。这也是 4.2 节里 quality_critical 那个集合的第二个用途。

Q7:网关需要保存会话状态吗?

绝对不要。 网关一旦有状态就无法水平扩展,且会成为单点故障源。会话状态按第 30 篇的方案放在共享存储(Redis/DB)里,网关只传 session_id。网关的价值恰恰来自它的无状态。

Q8:怎么判断该不该上独立网关?

三个信号,出现任意两个就该上:① 客户端数量 ≥ 3 且不在同一团队维护;② 接入的模型供应商 ≥ 2;③ 需要按租户计费或隔离。只出现一个的,先用中间件(第 28 篇)解决,不要过早引入。


参考资料

  1. FastAPI 官方文档 ------ 异步请求处理与依赖注入:https://fastapi.tiangolo.com/
  2. Python 官方文档 ------ asyncio 同步原语(Lock / Semaphore):https://docs.python.org/3/library/asyncio-sync.html
  3. Nygard, M. T. --- Release It! (Circuit Breaker 模式的原始出处,含 HALF_OPEN 状态的必要性论述):https://pragprog.com/titles/mnee2/release-it-second-edition/
  4. Envoy 官方文档 ------ 熔断与异常点检测(Outlier Detection):https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/circuit_breaking
  5. OpenAI 官方文档 ------ Rate limits 与 TPM/RPM 配额模型:https://platform.openai.com/docs/guides/rate-limits
  6. LiteLLM 官方文档 ------ 统一模型 API 与多供应商路由、熔断实践:https://docs.litellm.ai/docs/
  7. Kong 官方文档 ------ 限流插件与令牌桶实现:https://docs.konghq.com/
  8. Google SRE Book ------ Handling Overload (过载保护与降级策略的系统性论述):https://sre.google/sre-book/handling-overload/
  9. Nginx / Martin Fowler ------ Token Bucket 与 Leaky Bucket 限流算法对比:https://martinfowler.com/articles/patterns-of-distributed-systems/rate-limiting.html
相关推荐
CQU_JIAKE1 小时前
10.28【A】
linux·运维·网络
爱和冰阔落1 小时前
【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础
linux·运维·网络·tcp/ip
Sailing1 小时前
🚀🚀【封闭结束6】Agent 会话服务与鉴权上下文
前端·人工智能·agent
创实信息1 小时前
突破SAST瓶颈:解析 SonarQube Hunter Agent 如何检测失效的访问控制与逻辑缺陷
人工智能·ai·devsecops·sonarqube·代码安全
hongyangcao1 小时前
Cloudflare GLM-5.3 串号事故复盘:KV 缓存页记账错了一个字段,155 条回复答进了别人的会话
人工智能·缓存·ai
晚风叙码1 小时前
Linux 进程概念(上):从冯诺依曼到进程调度
linux·运维·计算机外设
紧跟先前的步伐1 小时前
Go AI Agent 实战 20|接入 DeepSeek 原生 Tool Calling
人工智能·ai·golang·1024程序员节
心.c1 小时前
从“加个缓存”到稳定上线:把 Redis 放回真实业务里
数据库·redis·ai·1024程序员节
Z21-X2 小时前
云原生运维笔记:硬核拆解OpenStack计算与存储——从主机聚合到Cinder挂载全流程
运维·笔记·学习·云原生·openstack