摘要 :前面十篇解决的是"一个 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 节实现):
- 置信度闸门:低置信度不走猜测,转默认或澄清
- 路由可追溯:每个响应带上实际路由路径,便于事后分析
- 路由准确率监控:靠用户反馈(点踩/重问)回流,而不是靠系统指标
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 篇)解决,不要过早引入。
参考资料
- FastAPI 官方文档 ------ 异步请求处理与依赖注入:https://fastapi.tiangolo.com/
- Python 官方文档 ------
asyncio同步原语(Lock / Semaphore):https://docs.python.org/3/library/asyncio-sync.html - Nygard, M. T. --- Release It! (Circuit Breaker 模式的原始出处,含 HALF_OPEN 状态的必要性论述):https://pragprog.com/titles/mnee2/release-it-second-edition/
- Envoy 官方文档 ------ 熔断与异常点检测(Outlier Detection):https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/circuit_breaking
- OpenAI 官方文档 ------ Rate limits 与 TPM/RPM 配额模型:https://platform.openai.com/docs/guides/rate-limits
- LiteLLM 官方文档 ------ 统一模型 API 与多供应商路由、熔断实践:https://docs.litellm.ai/docs/
- Kong 官方文档 ------ 限流插件与令牌桶实现:https://docs.konghq.com/
- Google SRE Book ------ Handling Overload (过载保护与降级策略的系统性论述):https://sre.google/sre-book/handling-overload/
- Nginx / Martin Fowler ------ Token Bucket 与 Leaky Bucket 限流算法对比:https://martinfowler.com/articles/patterns-of-distributed-systems/rate-limiting.html