企业级 Agent 产品:多租户隔离与资源配额的架构设计

cover

一、共享集群的公平性危机:当大客户吃掉所有 GPU

企业级 Agent 产品通常采用多租户架构——多个客户共享同一套计算集群。当某个大客户发起大量并发推理请求时,可能占满所有 GPU 资源,导致其他客户的请求排队等待甚至超时。这种"资源抢占"问题不仅影响用户体验,还可能违反 SLA 合同中的延迟承诺。

更复杂的是,不同客户对资源的需求模式差异巨大:A 客户白天高峰期需要大量 GPU,B 客户则是夜间批量处理。静态分配资源会导致白天 A 浪费 B 的配额,夜间 B 浪费 A 的配额。需要一套动态的、可配置的资源隔离和配额管理系统。

flowchart TB
    subgraph 无隔离
        R1[请求队列] --> GPU1[GPU 池]
        T1[租户A<br/>100并发] --> R1
        T2[租户B<br/>5并发] --> R1
        Note1[租户A占满GPU<br/>租户B饥饿等待] -.-> GPU1
    end

    subgraph 多租户隔离
        T3[租户A] --> Q1[租户A队列<br/>配额: 80并发]
        T4[租户B] --> Q2[租户B队列<br/>配额: 20并发]
        Q1 --> S[调度器]
        Q2 --> S
        S --> GPU2[GPU 池]
        Note2[按配额分配<br/>保证最低资源] -.-> S
    end

二、多租户隔离的核心机制

2.1 三层隔离模型

多租户隔离分为三个层次:网络隔离(不同租户的流量互不可见)、计算隔离(GPU/CPU 资源按配额分配)、存储隔离(模型权重和数据分区存储)。对于 Agent 产品,计算隔离是核心——推理请求的 GPU 占用是最大的资源瓶颈。

2.2 令牌桶与优先级调度

令牌桶算法是资源配额的经典实现:每个租户有一个令牌桶,请求消耗令牌,令牌以固定速率补充。当令牌耗尽时,新请求被限流或排队。优先级调度则在资源竞争时决定谁先获得 GPU——高优先级租户的请求优先调度,低优先级租户使用剩余资源。

sequenceDiagram
    participant TA as 租户A
    participant TB as 租户B
    participant Scheduler as 调度器
    participant Quota as 配额管理器
    participant GPU as GPU池

    TA->>Scheduler: 提交50个推理请求
    Scheduler->>Quota: 查询租户A配额
    Quota-->>Scheduler: 配额80,已用30,剩余50
    Scheduler->>GPU: 调度50个请求

    TB->>Scheduler: 提交30个推理请求
    Scheduler->>Quota: 查询租户B配额
    Quota-->>Scheduler: 配额20,已用0,剩余20
    Scheduler->>GPU: 调度20个请求
    Scheduler->>TB: 10个请求排队等待
    Note over TB: 等待租户A释放资源<br/>或令牌补充

三、生产级代码实现

3.1 配额管理器

import time
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Dict, Optional
from enum import Enum

logger = logging.getLogger(__name__)


class Priority(Enum):
    LOW = 0
    NORMAL = 1
    HIGH = 2
    CRITICAL = 3


@dataclass
class TenantQuota:
    """租户配额定义"""
    tenant_id: str
    max_concurrent: int = 10         # 最大并发推理数
    tokens_per_minute: int = 100000  # 每分钟 Token 配额
    priority: Priority = Priority.NORMAL
    gpu_share: float = 1.0           # GPU 份额(0.0-1.0)


@dataclass
class TokenBucket:
    """令牌桶:控制每分钟 Token 消耗速率"""
    capacity: int          # 桶容量(最大突发量)
    tokens: float          # 当前令牌数
    refill_rate: float     # 每秒补充速率
    last_refill: float     # 上次补充时间

    def consume(self, amount: int) -> bool:
        """尝试消耗令牌,成功返回 True"""
        self._refill()
        if self.tokens >= amount:
            self.tokens -= amount
            return True
        return False

    def _refill(self) -> None:
        """补充令牌"""
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
        self.last_refill = now


class QuotaManager:
    """配额管理器:管理多租户的资源配额和令牌桶

    设计考量:
    - 每个租户独立的令牌桶,防止一个租户耗尽全局 Token 配额
    - 并发数硬限制:超过最大并发的请求直接排队
    - 优先级调度:高优先级租户在资源竞争时优先获得 GPU
    """

    def __init__(self):
        self._quotas: Dict[str, TenantQuota] = {}
        self._buckets: Dict[str, TokenBucket] = {}
        self._current_concurrent: Dict[str, int] = {}
        self._lock = asyncio.Lock()

    def register_tenant(self, quota: TenantQuota) -> None:
        """注册租户配额"""
        self._quotas[quota.tenant_id] = quota
        self._buckets[quota.tenant_id] = TokenBucket(
            capacity=quota.tokens_per_minute,
            tokens=quota.tokens_per_minute,
            refill_rate=quota.tokens_per_minute / 60.0,
            last_refill=time.time(),
        )
        self._current_concurrent[quota.tenant_id] = 0

    async def acquire(
        self,
        tenant_id: str,
        estimated_tokens: int = 1000,
    ) -> bool:
        """尝试获取资源配额

        Returns:
            True 表示配额充足,可以执行推理
            False 表示配额不足,需要排队或拒绝
        """
        async with self._lock:
            quota = self._quotas.get(tenant_id)
            if not quota:
                logger.error(f"未知租户: {tenant_id}")
                return False

            # 检查并发数限制
            if self._current_concurrent[tenant_id] >= quota.max_concurrent:
                logger.info(f"租户 {tenant_id} 并发数已达上限: {quota.max_concurrent}")
                return False

            # 检查令牌桶
            bucket = self._buckets[tenant_id]
            if not bucket.consume(estimated_tokens):
                logger.info(f"租户 {tenant_id} Token 配额不足")
                return False

            # 分配成功
            self._current_concurrent[tenant_id] += 1
            return True

    async def release(self, tenant_id: str, actual_tokens: int = 0) -> None:
        """释放资源配额

        Args:
            actual_tokens: 实际消耗的 Token 数,用于修正令牌桶
        """
        async with self._lock:
            if tenant_id in self._current_concurrent:
                self._current_concurrent[tenant_id] = max(
                    0, self._current_concurrent[tenant_id] - 1
                )
            # 如果实际消耗与预估差异超过 20%,记录日志用于优化预估
            if actual_tokens > 0:
                estimated = 1000  # 默认预估值
                if abs(actual_tokens - estimated) / max(estimated, 1) > 0.2:
                    logger.info(
                        f"租户 {tenant_id} Token 估算偏差: "
                        f"预估={estimated}, 实际={actual_tokens}"
                    )

    def get_priority(self, tenant_id: str) -> Priority:
        """获取租户优先级"""
        quota = self._quotas.get(tenant_id)
        return quota.priority if quota else Priority.NORMAL

    def get_status(self, tenant_id: str) -> Dict:
        """获取租户当前资源使用状态"""
        quota = self._quotas.get(tenant_id)
        bucket = self._buckets.get(tenant_id)
        return {
            "tenant_id": tenant_id,
            "concurrent": self._current_concurrent.get(tenant_id, 0),
            "max_concurrent": quota.max_concurrent if quota else 0,
            "tokens_remaining": int(bucket.tokens) if bucket else 0,
            "priority": quota.priority.value if quota else 0,
        }

3.2 优先级调度器

@dataclass
class InferenceRequest:
    """推理请求"""
    request_id: str
    tenant_id: str
    prompt: str
    estimated_tokens: int = 1000
    submitted_at: float = field(default_factory=time.time)


class PriorityScheduler:
    """优先级调度器:按租户优先级和提交时间排序请求

    设计考量:
    - 同优先级按 FIFO 排序,保证公平性
    - 高优先级请求可以插队,但不能抢占正在执行的请求
    - 支持超时丢弃:等待超过阈值的请求自动取消
    """

    MAX_WAIT_SECONDS = 30  # 最大等待时间

    def __init__(self, quota_manager: QuotaManager, max_gpu_slots: int = 8):
        self.quota_manager = quota_manager
        self.max_gpu_slots = max_gpu_slots
        self._active_slots = 0
        self._queue: list[InferenceRequest] = []
        self._lock = asyncio.Lock()
        self._condition = asyncio.Condition(self._lock)

    async def submit(self, request: InferenceRequest) -> bool:
        """提交推理请求"""
        async with self._condition:
            # 尝试直接获取配额
            if self._active_slots < self.max_gpu_slots:
                if await self.quota_manager.acquire(request.tenant_id, request.estimated_tokens):
                    self._active_slots += 1
                    return True

            # 配额不足或 GPU 满载,加入等待队列
            self._queue.append(request)
            # 按优先级降序、提交时间升序排序
            self._queue.sort(
                key=lambda r: (
                    -self.quota_manager.get_priority(r.tenant_id).value,
                    r.submitted_at,
                )
            )
            return False

    async def wait_for_slot(self, request: InferenceRequest) -> bool:
        """等待可用槽位,超时返回 False"""
        deadline = request.submitted_at + self.MAX_WAIT_SECONDS
        while time.time() < deadline:
            async with self._condition:
                if await self.quota_manager.acquire(request.tenant_id, request.estimated_tokens):
                    if request in self._queue:
                        self._queue.remove(request)
                    self._active_slots += 1
                    return True

                remaining = deadline - time.time()
                if remaining <= 0:
                    break
                await asyncio.wait_for(
                    self._condition.wait(),
                    timeout=min(remaining, 1.0),
                )

        # 超时,从队列移除
        if request in self._queue:
            self._queue.remove(request)
        return False

    async def complete(self, tenant_id: str, actual_tokens: int = 0) -> None:
        """请求完成,释放槽位"""
        async with self._condition:
            self._active_slots -= 1
            await self.quota_manager.release(tenant_id, actual_tokens)
            self._condition.notify_all()

四、边界分析与架构权衡

4.1 令牌桶 vs 漏桶

令牌桶允许突发流量——桶中有足够令牌时,请求可以瞬间消耗。漏桶则以固定速率放行请求,不允许突发。对于 AI 推理场景,令牌桶更合适,因为用户的请求模式天然是突发的(如打开应用后连续提问)。但突发可能导致 GPU 瞬间过载,需要配合并发数硬限制使用。

4.2 优先级与公平性的矛盾

高优先级租户可以插队,这意味着低优先级租户可能长时间等待。极端情况下,如果高优先级租户持续发送请求,低优先级租户可能永远得不到服务。解决方案是设置"最低保障"——每个租户无论优先级如何,都保证获得最低比例的 GPU 时间。

4.3 配额估算的准确性

令牌桶的预估值(estimated_tokens)与实际消耗可能差异很大。如果预估偏低,租户可能消耗超出配额;如果预估偏高,配额利用率不足。更精确的做法是基于历史数据动态调整预估值,但这增加了系统复杂度。

五、总结

多租户隔离的核心是在共享集群上实现公平的资源分配。令牌桶控制速率,并发数限制控制峰值,优先级调度处理竞争。三层机制协同工作,既保证了大客户的资源需求,也防止了小客户被饿死。

落地路线建议:第一步,为每个租户配置基础配额(并发数 + Token 速率),先做硬限制;第二步,引入令牌桶,支持突发流量;第三步,添加优先级调度,为 VIP 客户提供资源保障;第四步,基于历史数据动态调整配额,提升集群资源利用率。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐