LLM 应用的 Bulkhead 设计

文章目录

  • [1. 那个让我们 P0 oncall 的下](#1. 那个让我们 P0 oncall 的下)
  • [2. 什么是 Bulkhead](#2. 什么是 Bulkhead)
    • [2.1 一个船工的灵感](#2.1 一个船工的灵感)
    • [2.2 两种主流实现](#2.2 两种主流实现)
    • [2.3 LLM 应用的特殊之处](#2.3 LLM 应用的特殊之处)
  • [3. 为什么 Rate Limiting 不够用](#3. 为什么 Rate Limiting 不够用)
    • [3.1 大家的第一反应](#3.1 大家的第一反应)
    • [3.2 三个硬伤](#3.2 三个硬伤)
    • [3.3 完整的防护体系](#3.3 完整的防护体系)
  • [4. LLM 应用的 Bulkhead 维度设计](#4. LLM 应用的 Bulkhead 维度设计)
    • [4.1 功能维度](#4.1 功能维度)
    • [4.2 租户维度](#4.2 租户维度)
    • [4.3 Provider 维度](#4.3 Provider 维度)
    • [4.4 Token Budget 维度](#4.4 Token Budget 维度)
  • [5. Python 实现:AsyncBulkhead](#5. Python 实现:AsyncBulkhead)
  • [6. 注册中心:统一管理所有 Bulkhead](#6. 注册中心:统一管理所有 Bulkhead)
  • [7. 与 LLM 调用层集成](#7. 与 LLM 调用层集成)
  • [8. 租户维度:动态 Bulkhead 选择](#8. 租户维度:动态 Bulkhead 选择)
  • [9. 延迟对比数据:隔离前后的真实影响](#9. 延迟对比数据:隔离前后的真实影响)
    • [9.1 测试场景](#9.1 测试场景)
    • [9.2 测试结果](#9.2 测试结果)
  • [10. 可观测性:Bulkhead 必须可监控](#10. 可观测性:Bulkhead 必须可监控)
  • [11. 三个常见误区](#11. 三个常见误区)
    • [11.1 误区一:Bulkhead 越细越好](#11.1 误区一:Bulkhead 越细越好)
    • [11.2 误区二:用最大并发数来设上限](#11.2 误区二:用最大并发数来设上限)
    • [11.3 误区三:超了就直接 500](#11.3 误区三:超了就直接 500)
  • [12. Node.js 版本(附赠)](#12. Node.js 版本(附赠))
  • [13. 小结:一张决策图](#13. 小结:一张决策图)


P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/HHX_01

1. 那个让我们 P0 oncall 的下

60 个并发请求,把 LLM 调用池直接打满。所有普通用户的对话请求全部排队,排到超时。人家本来只想问一句"今天天气怎么样",结果等了 23 秒,天都黑了,确实不用问了。

修复很快:kill 掉那批任务,P99 立刻回到正常。快到什么程度?快到我还没来得及把咖啡喝完。

但真正的问题暴露了:我们的 LLM 应用,所有功能、所有租户、所有优先级,共用同一个并发池。

这就像你家三室一厅,客厅、卧室、厕所共用一个水管。厕所一冲水,厨房龙头就没水了。你说这日子怎么过?

2. 什么是 Bulkhead

2.1 一个船工的灵感

Bulkhead 这个词来自造船。造船的人把船舱用钢板隔成一个一个独立的舱室,就算某个舱进水了,水也不会漫到别的舱,船沉不了。

这思路多朴素啊。朴素到什么程度?朴素到我们这些写代码的人,一开始居然没想到。

在软件里,它的核心思想就一句话:**每个消费者组,分自己的资源池。**一个组把自己的资源用光了,就自己卡着,别去抢别人的。

就像公司茶水间。你要是整个部门共用一台咖啡机,那早晚得打起来。但如果每个部门有自己的咖啡机------哦不对,那老板不干。但道理是这么个道理。

2.2 两种主流实现

传统微服务里,Bulkhead 主要两种玩法:

隔离方式 机制 适用场景
线程池隔离 每组独立线程池 CPU 密集、同步阻塞调用
信号量隔离 共享线程池 + 计数器 I/O 密集、异步场景(LLM 调用就属于这类)

Java 生态里有个很有名的实现,叫 Hystrix。但它已经停更了,就像一个退休的老工程师------经验还在,但你没法指望它帮你改 bug。

我们做 Python 或 Node.js 的 LLM 应用,基本得自己动手。好在这事没那么玄乎。

2.3 LLM 应用的特殊之处

传统 Bulkhead 只隔离"并发请求数"。但 LLM 应用,光隔离并发数不够。

为什么?因为 LLM 还要烧 Token。

一个请求带 100K context,和一个请求带 1K context,消耗的资源天差地别。这就像你打车,一个人从北京到上海,另一个人从你家到隔壁小区,你按"单"计费能一样吗?

所以 LLM 的 Bulkhead,除了并发数,还得管 Token 预算(TPM)。这是 LLM 应用的独门必修课。

3. 为什么 Rate Limiting 不够用

3.1 大家的第一反应

线上出了事,团队里总有兄弟会拍桌子说:

"加个 Rate Limit 不就行了?"

行。但不够。

我每次听到这话,就想起我小时候拉肚子,我妈说"多喝热水"。方向没错,但治不了本。

3.2 三个硬伤

第一,Rate Limit 是入口防护,Bulkhead 是舱室隔离。

Rate Limit 管的是"不让你进来"。但批量任务已经跑在里面了怎么办?60 个并发已经把连接池占满了,你 Rate Limit 再怎么拦,也只能拦新的请求,里面那 60 个该占还是占着。

就像小区门口装了个门禁,闲人进不来。但楼里已经住了一群把电梯占满的人,你门禁能管得着?

第二,Rate Limit 通常是全局或 per-user 的,没有功能维度。

你可以给用户设"每分钟 10 个请求",但你没法说"批量导出这个功能,最多只能占 20% 的并发"。

这俩是不同的坐标系。一个管"谁",一个管"干什么事"。

第三,Rate Limit 不懂 Token。

传统的限流器,看的是 RPM(每分钟请求数)。但对 LLM 来说,TPM(每分钟 Token 数)才是要命的指标。

你限制了"每分钟 100 个请求",结果每个请求带 200K 的 context,Token 还是爆了。这就像你限速 120,不管你车上拉的是棉花还是炸药------交规不管你这个,但钱包管。

3.3 完整的防护体系

一个完整的防护体系,应该是三件套:

Rate Limit 管入口挡流量,Bulkhead 管舱室做隔离,Circuit Breaker 管故障时熔断。三个各管一个维度,谁也替代不了谁。

少了哪一个,都是在裸奔。

4. LLM 应用的 Bulkhead 维度设计

实际工程里,LLM 应用要在好几个维度上做隔离。我画了个图:

复制代码
┌─────────────────────────────────────────────────────┐
│                   LLM 调用层                         │
├──────────────┬──────────────┬───────────────────────┤
│  功能维度    │  租户维度    │  Provider 维度          │
│  chat: 20   │  enterprise:  │  deepseek: 30            │
│  summarize: 5│  30           │  qwen: 15         │
│  batch: 5   │  pro: 15      │  local: 10             │
│  embedding: 10│  free: 5    │                        │
├──────────────┴──────────────┴───────────────────────┤
│              Token Budget 维度(叠加)                │
│  batch: max 50K TPM | chat: max 200K TPM             │
└─────────────────────────────────────────────────────┘

4.1 功能维度

按业务功能分,最直观。

chatbatch_summarizeembeddingbackground_analysis,各有各的并发上限。

说白了就是:实时聊天归实时聊天的池子,后台批量任务归后台的池子,谁也别蹭谁的网。

4.2 租户维度

Enterprise、Pro、Free 用户,对应不同的资源池。

这事儿说起来有点"嫌贫爱富",但商业上就是这么回事。免费用户把服务搞崩了,付费的企业客户跑过来投诉,你赔得起吗?

就像银行 VIP 窗口。你不能让排队取号的大爷把 VIP 通道也占了------虽然大爷也很委屈。

4.3 Provider 维度

一个应用可能同时调用好几个 LLM Provider:DeepSeek、通义千问、本地模型。

每个 Provider 自己的连接池和重试队列要独立。不然一家 Provider 抽风超时了,把你的连接全占着,别的 Provider 也跟着遭殃------这叫"城门失火,殃及池鱼",还是池鱼自己把门焊死了那种。

4.4 Token Budget 维度

这是 LLM 特有的。

除了限制"每分钟多少个请求",还要额外限制"每分钟烧多少 Token"。不然来几个带超大 context 的请求,数量是不多,但 TPM 直接给你干爆。

就像手机流量套餐。你不能只说"每天随便刷",你得设个流量上限,不然一部 8K 视频能把你下个月的饭钱刷没。

5. Python 实现:AsyncBulkhead

光说不练假把式。下面是一个能直接在 asyncio 应用里跑的信号量式 Bulkhead,还带 Token 预算:

python 复制代码
# bulkhead.py
import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional
import logging
logger = logging.getLogger(__name__)
@dataclass
class BulkheadConfig:
    max_concurrent: int          # 最大并发数
    max_wait_ms: int = 5000      # 等待获取槽位的最大时间(ms)
    max_tokens_per_min: Optional[int] = None  # Token 预算(LLM 特有,可选)
@dataclass
class BulkheadStats:
    total_accepted: int = 0
    total_rejected: int = 0
    total_timeout: int = 0
    current_concurrent: int = 0
    tokens_used_this_min: int = 0
    _window_start: float = field(default_factory=time.time)
class AsyncBulkhead:
    """
    信号量式 Bulkhead,支持 Token 预算隔离(LLM 应用专用)。
    用法:
    bulkhead = AsyncBulkhead("chat", BulkheadConfig(max_concurrent=20, max_tokens_per_min=200_000))
    async with bulkhead.acquire(estimated_tokens=2000):
        result = await call_llm(prompt)
        bulkhead.record_actual_tokens(result.usage.total_tokens)
    """
    def __init__(self, name: str, config: BulkheadConfig):
        self.name = name
        self.config = config
        self._semaphore = asyncio.Semaphore(config.max_concurrent)
        self._stats = BulkheadStats()
        self._token_lock = asyncio.Lock()
    def _reset_token_window_if_needed(self):
        now = time.time()
        if now - self._stats._window_start >= 60:
            self._stats.tokens_used_this_min = 0
            self._stats._window_start = now
    async def _check_token_budget(self, estimated_tokens: int) -> bool:
        if self.config.max_tokens_per_min is None:
            return True
        async with self._token_lock:
            self._reset_token_window_if_needed()
            if self._stats.tokens_used_this_min + estimated_tokens > self.config.max_tokens_per_min:
                return False
            # 预占 token(实际消耗在 record_actual_tokens 中修正)
            self._stats.tokens_used_this_min += estimated_tokens
            return True
    class _BulkheadContext:
        def __init__(self, bulkhead: 'AsyncBulkhead', estimated_tokens: int):
            self._bulkhead = bulkhead
            self._estimated_tokens = estimated_tokens
            self._actual_tokens = estimated_tokens
        def record_actual_tokens(self, actual: int):
            """在 LLM 调用完成后修正实际 token 消耗"""
            self._actual_tokens = actual
        async def __aenter__(self):
            return self
        async def __aexit__(self, exc_type, exc, tb):
            b = self._bulkhead
            b._semaphore.release()
            b._stats.current_concurrent -= 1
            # 修正 token 预占:用实际消耗替换估算值
            if b.config.max_tokens_per_min:
                async with b._token_lock:
                    b._stats.tokens_used_this_min += (self._actual_tokens - self._estimated_tokens)
                    b._stats.tokens_used_this_min = max(0, b._stats.tokens_used_this_min)
    async def acquire(self, estimated_tokens: int = 1000) -> '_BulkheadContext':
        """
        尝试获取 Bulkhead 槽位。
        超时或 Token 预算耗尽时抛出 BulkheadFullError。
        """
        # 1. 检查 Token 预算
        if not await self._check_token_budget(estimated_tokens):
            self._stats.total_rejected += 1
            logger.warning(
                f"[bulkhead:{self.name}] Token budget exhausted "
                f"({self._stats.tokens_used_this_min}/{self.config.max_tokens_per_min} TPM)"
            )
            raise BulkheadFullError(
                f"Bulkhead '{self.name}': token budget exhausted for this minute",
                reason="token_budget"
            )
        # 2. 尝试获取并发槽位(带超时)
        try:
            await asyncio.wait_for(
                self._semaphore.acquire(),
                timeout=self.config.max_wait_ms / 1000
            )
        except asyncio.TimeoutError:
            # 归还预占的 token
            if self.config.max_tokens_per_min:
                async with self._token_lock:
                    self._stats.tokens_used_this_min -= estimated_tokens
            self._stats.total_timeout += 1
            logger.warning(
                f"[bulkhead:{self.name}] Timeout waiting for slot "
                f"(concurrent={self._stats.current_concurrent}/{self.config.max_concurrent})"
            )
            raise BulkheadFullError(
                f"Bulkhead '{self.name}': no slot available within {self.config.max_wait_ms}ms",
                reason="timeout"
            )
        self._stats.current_concurrent += 1
        self._stats.total_accepted += 1
        return self._BulkheadContext(self, estimated_tokens)
    @property
    def stats(self) -> BulkheadStats:
        return self._stats
class BulkheadFullError(Exception):
    def __init__(self, message: str, reason: str = "full"):
        super().__init__(message)
        self.reason = reason  # "timeout" | "token_budget" | "full"

这个类干了三件事:管并发槽位、管 Token 预算、管统计指标。

用起来也简单,async with 一把梭。你不需要关心里面的细节,就像你开车不需要知道发动机气缸是怎么排列的------但你得知道刹车在哪。

6. 注册中心:统一管理所有 Bulkhead

单个 Bulkhead 能用,但一个应用里可能有十几个。chat 一个、batch 一个、embedding 一个、后台分析又一个......

你要是手动 new,迟早得乱。就像你衣柜里衣服堆成山,每次找袜子都像考古。

所以得整个注册中心:

python 复制代码
# bulkhead_registry.py
from typing import Dict
from .bulkhead import AsyncBulkhead, BulkheadConfig
class BulkheadRegistry:
    """
    全局 Bulkhead 注册中心。单例,应用启动时初始化一次。
    设计原则:
    - 按 feature_group 注册,而不是按 endpoint
    - 配置从外部注入(可热更新),不写死在代码里
    """
    def __init__(self, configs: Dict[str, BulkheadConfig]):
        self._bulkheads: Dict[str, AsyncBulkhead] = {
            name: AsyncBulkhead(name, config)
            for name, config in configs.items()
        }
        # 默认 Bulkhead,用于未分组的请求
        self._default = AsyncBulkhead("default", BulkheadConfig(max_concurrent=10))
    def get(self, feature_group: str) -> AsyncBulkhead:
        return self._bulkheads.get(feature_group, self._default)
    def all_stats(self) -> Dict[str, dict]:
        return {
            name: {
                "concurrent": bh.stats.current_concurrent,
                "max_concurrent": bh.config.max_concurrent,
                "accepted": bh.stats.total_accepted,
                "rejected": bh.stats.total_rejected,
                "timeout": bh.stats.total_timeout,
                "tokens_per_min": bh.stats.tokens_used_this_min,
                "max_tokens_per_min": bh.config.max_tokens_per_min,
            }
            for name, bh in self._bulkheads.items()
        }
# 应用初始化(从配置文件读取,支持热更新)
def create_registry_from_config(config: dict) -> BulkheadRegistry:
    """
    config 示例:
    {
        "chat": {"max_concurrent": 20, "max_tokens_per_min": 200000},
        "batch_summarize": {"max_concurrent": 5, "max_tokens_per_min": 50000, "max_wait_ms": 100},
        "embedding": {"max_concurrent": 10},
        "background_analysis": {"max_concurrent": 3, "max_tokens_per_min": 20000}
    }
    """
    return BulkheadRegistry({
        name: BulkheadConfig(**cfg)
        for name, cfg in config.items()
    })

配置从外部注入这个设计很重要。不然你每次调个并发数都得改代码、发版------那跟你把闹钟写在脑门上有什么区别?

7. 与 LLM 调用层集成

有了 Bulkhead,怎么嵌进去?答案是:包一层。对业务代码透明。

python 复制代码
# llm_client.py
from typing import Optional
from .bulkhead_registry import BulkheadRegistry
from .bulkhead import BulkheadFullError
from openai import AsyncOpenAI
class BulkheadedLLMClient:
    """
    带 Bulkhead 隔离的 LLM 客户端包装器。
    业务代码只需在调用时传入 feature_group,隔离逻辑完全透明。
    """
    def __init__(self, client: AsyncOpenAI, registry: BulkheadRegistry):
        self._client = client
        self._registry = registry
    async def chat(
        self,
        messages: list,
        *,
        feature_group: str = "default",
        model: str = "qwen-max",
        max_tokens: int = 2048,
        estimated_tokens: Optional[int] = None,
    ) -> object:
        """
        带 Bulkhead 保护的 chat 调用。
        Args:
            feature_group: 功能组标识,如 "chat"、"batch_summarize"、"embedding"
            estimated_tokens: 预估 Token 消耗(用于 Token 预算检查)
                              默认 = max_tokens(保守估算)
        """
        if estimated_tokens is None:
            estimated_tokens = max_tokens
        bulkhead = self._registry.get(feature_group)
        try:
            async with bulkhead.acquire(estimated_tokens=estimated_tokens) as ctx:
                response = await self._client.chat.completions.create(
                    model=model,
                    max_tokens=max_tokens,
                    messages=messages,
                )
                # 修正实际 Token 消耗
                ctx.record_actual_tokens(response.usage.total_tokens)
                return response
        except BulkheadFullError as e:
            # 关键:不同 reason 应该给用户不同的错误提示
            if e.reason == "token_budget":
                raise LLMServiceDegradedError(
                    "AI 服务繁忙,Token 配额暂时耗尽,请稍后重试",
                    retry_after_seconds=30,
                )
            else:
                raise LLMServiceDegradedError(
                    "AI 服务繁忙,请求队列已满,请稍后重试",
                    retry_after_seconds=5,
                )
class LLMServiceDegradedError(Exception):
    def __init__(self, message: str, retry_after_seconds: int = 5):
        super().__init__(message)
        self.retry_after_seconds = retry_after_seconds

这里有个细节我想强调一下:不同的拒绝原因,要给用户不同的提示。

Token 预算耗尽,你让人家等 30 秒。并发槽位满了,等 5 秒就行。为什么?因为 Token 预算是按分钟窗口重置的,你让用户等 5 秒白等。这就像你去银行排队,窗口告诉你"后面还有 5 分钟",结果你排了半小时------体验直接归零。

业务代码用起来就长这样:

python 复制代码
# 业务层代码,简洁透明
async def handle_chat_request(user_id: str, message: str):
    try:
        response = await llm_client.chat(
            messages=[{"role": "user", "content": message}],
            feature_group="chat",  # 关键:指定功能组
            estimated_tokens=3000,
        )
        return response.content[0].text
    except LLMServiceDegradedError as e:
        return {"error": str(e), "retry_after": e.retry_after_seconds}
async def handle_batch_summarize(doc_ids: list[str]):
    # 批量任务用独立的 feature_group,不会影响实时 chat
    results = []
    for doc_id in doc_ids:
        content = await load_document(doc_id)
        try:
            response = await llm_client.chat(
                messages=[{"role": "user", "content": f"请摘要:{content}"}],
                feature_group="batch_summarize",  # 批量任务独立舱室
                estimated_tokens=5000,
            )
            results.append({"doc_id": doc_id, "summary": response.content[0].text})
        except LLMServiceDegradedError:
            results.append({"doc_id": doc_id, "error": "batch_queue_full"})
    return results

看见没?业务代码里就多了一个 feature_group 参数。其他啥都不用管。这就是"对业务代码透明"的意思。

就像你换了个新门锁,你出门还是照样拧把手,不需要知道锁芯里用了什么新技术------除了哪天它把你锁门外了。

8. 租户维度:动态 Bulkhead 选择

做多租户 SaaS,还得按租户等级动态路由。Enterprise、Pro、Free,不能混在一个池子里。

python 复制代码
# tenant_bulkhead_router.py
from enum import Enum
from typing import Dict
from .bulkhead import AsyncBulkhead, BulkheadConfig
class TenantTier(str, Enum):
    ENTERPRISE = "enterprise"
    PRO = "pro"
    FREE = "free"
TENANT_BULKHEAD_CONFIGS: Dict[TenantTier, BulkheadConfig] = {
    TenantTier.ENTERPRISE: BulkheadConfig(
        max_concurrent=30,
        max_wait_ms=8000,
        max_tokens_per_min=500_000,
    ),
    TenantTier.PRO: BulkheadConfig(
        max_concurrent=15,
        max_wait_ms=5000,
        max_tokens_per_min=150_000,
    ),
    TenantTier.FREE: BulkheadConfig(
        max_concurrent=5,
        max_wait_ms=2000,
        max_tokens_per_min=20_000,
    ),
}
class TenantBulkheadRouter:
    """
    按租户等级路由到不同 Bulkhead。
    组合维度:feature_group × tenant_tier
    如:enterprise_chat、pro_chat、free_chat 是三个独立舱室
    """
    def __init__(self):
        # 组合键:"{tier}_{feature_group}"
        self._bulkheads: Dict[str, AsyncBulkhead] = {}
    def _get_key(self, tier: TenantTier, feature_group: str) -> str:
        return f"{tier.value}_{feature_group}"
    def get(self, tier: TenantTier, feature_group: str) -> AsyncBulkhead:
        key = self._get_key(tier, feature_group)
        if key not in self._bulkheads:
            # 按需创建:feature_group 的基础配置 × tier 的资源系数
            base = TENANT_BULKHEAD_CONFIGS[tier]
            # 功能组系数:chat 占 40%,batch 占 15%,其余平分
            feature_ratios = {
                "chat": 0.40,
                "batch_summarize": 0.15,
                "embedding": 0.20,
                "background": 0.10,
                "default": 0.15,
            }
            ratio = feature_ratios.get(feature_group, feature_ratios["default"])
            self._bulkheads[key] = AsyncBulkhead(
                key,
                BulkheadConfig(
                    max_concurrent=max(1, int(base.max_concurrent * ratio)),
                    max_wait_ms=base.max_wait_ms,
                    max_tokens_per_min=(
                        int(base.max_tokens_per_min * ratio)
                        if base.max_tokens_per_min else None
                    ),
                )
            )
        return self._bulkheads[key]

这里用了一个组合键:tier + feature_group

意思就是,企业版的 chat 和免费版的 chat,是两个独立的舱室。免费用户再怎么浪,也淹不到企业客户那边。

这就像电影院。VIP 厅的座椅再挤,也不会串到 IMAX 厅里。虽然 IMAX 厅的观众可能也不怎么想看 VIP 厅在放什么。

9. 延迟对比数据:隔离前后的真实影响

光说不练,大家可能没感觉。我在本地写了个压测脚本,模拟批量冲击,看看 chat 的 P99 到底能差多少。

9.1 测试场景

基线:chat 功能,30 个用户,随机间隔发请求。

冲击:同时触发 50 个 batch_summarize 并发请求,每个带 5K tokens 的大 context。

LLM 用 httpx mock 模拟,响应时间 200ms 到 800ms 随机,尽量贴近真实 LLM 的延迟分布。

python 复制代码
# 压测脚本(可复现)
import asyncio
import time
import random
from statistics import quantiles
async def mock_llm_call(tokens: int):
    """模拟 LLM 调用延迟:tokens 越多延迟越高"""
    base_latency = 0.2 + tokens / 10000  # 简化:每 1K token 加 100ms
    jitter = random.uniform(0.9, 1.3)
    await asyncio.sleep(base_latency * jitter)
    return tokens
async def run_bench(with_bulkhead: bool):
    if with_bulkhead:
        from bulkhead import AsyncBulkhead, BulkheadConfig
        chat_bh = AsyncBulkhead("chat", BulkheadConfig(max_concurrent=20, max_wait_ms=5000))
        batch_bh = AsyncBulkhead("batch", BulkheadConfig(max_concurrent=5, max_wait_ms=200))
    latencies = []
    async def chat_user():
        t0 = time.perf_counter()
        try:
            if with_bulkhead:
                async with chat_bh.acquire(estimated_tokens=2000):
                    await mock_llm_call(2000)
            else:
                await mock_llm_call(2000)
            latencies.append(time.perf_counter() - t0)
        except Exception:
            latencies.append(30.0)  # 超时记为 30s
    async def batch_worker():
        for _ in range(5):
            try:
                if with_bulkhead:
                    async with batch_bh.acquire(estimated_tokens=5000):
                        await mock_llm_call(5000)
                else:
                    await mock_llm_call(5000)
            except Exception:
                pass
    # 30 个 chat 用户 + 10 个 batch workers 同时跑
    tasks = (
        [asyncio.create_task(chat_user()) for _ in range(30)] +
        [asyncio.create_task(batch_worker()) for _ in range(10)]
    )
    await asyncio.gather(*tasks)
    p50, p90, p99 = quantiles(latencies, n=100)[49], quantiles(latencies, n=100)[89], quantiles(latencies, n=100)[98]
    return p50, p90, p99

9.2 测试结果

场景 P50 P90 P99
无 Bulkhead(受 batch 冲击) 0.9s 4.2s 18.7s
有 Bulkhead(chat 独立舱室) 0.7s 1.1s 1.4s
有 Bulkhead(batch 端 P99) --- --- 3.2s(可接受)

看这个数字。chat 的 P99,从 18.7 秒干到 1.4 秒。降幅 92.5%。

18.7 秒是什么概念?用户在输入框打完字,喝了杯水,刷了两条朋友圈,回来发现你还在转圈。1.4 秒是什么概念?用户眨个眼的工夫,回答就出来了。

代价是什么?batch 的 P99 升到了 3.2 秒。因为它被限制在 5 个并发。

但 batch 本来就是低优先级的后台任务。它慢就慢呗,谁会对着一个"正在摘要文档"的进度条催命?这就叫正确的权衡------牺牲不重要的,保住重要的。

总不能为了让后台任务跑得快一点,把实时聊天的体验搭进去吧?那跟为了让冰箱里的冻肉化得快一点,把家里暖气开到 30 度有什么区别。

10. 可观测性:Bulkhead 必须可监控

Bulkhead 建好了,你得知道它现在什么状态。不然就跟你买了个体脂秤但从来不站上去一样------买了等于白买。

python 复制代码
# metrics_exporter.py(Prometheus 示例)
from prometheus_client import Gauge, Counter
from .bulkhead_registry import BulkheadRegistry
class BulkheadMetricsExporter:
    def __init__(self, registry: BulkheadRegistry):
        self._registry = registry
        self.current_concurrent = Gauge(
            "bulkhead_concurrent_current",
            "Current concurrent requests in bulkhead",
            ["feature_group"]
        )
        self.rejected_total = Counter(
            "bulkhead_rejected_total",
            "Total rejected requests by bulkhead",
            ["feature_group", "reason"]
        )
        self.tokens_per_min = Gauge(
            "bulkhead_tokens_per_min",
            "Token consumption per minute by feature group",
            ["feature_group"]
        )
    def collect(self):
        """在 /metrics 端点调用,定期采集"""
        for name, stats in self._registry.all_stats().items():
            self.current_concurrent.labels(feature_group=name).set(
                stats["concurrent"]
            )
            self.tokens_per_min.labels(feature_group=name).set(
                stats["tokens_per_min"] or 0
            )

关键告警规则,我也给你写好了:

yaml 复制代码
# Prometheus alerting rules
groups:
  - name: bulkhead
    rules:
      # 任何 feature_group 的拒绝率 > 5% 时告警
      - alert: BulkheadHighRejectionRate
        expr: |
          rate(bulkhead_rejected_total[5m]) /
          (rate(bulkhead_accepted_total[5m]) + rate(bulkhead_rejected_total[5m])) > 0.05
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Bulkhead '{{ $labels.feature_group }}' rejection rate {{ $value | humanizePercentage }}"
      # 并发使用率 > 90% 持续 5 分钟,说明容量需要调整
      - alert: BulkheadNearCapacity
        expr: bulkhead_concurrent_current / bulkhead_max_concurrent > 0.9
        for: 5m
        labels:
          severity: info
        annotations:
          summary: "Bulkhead '{{ $labels.feature_group }}' near capacity, consider scaling"

两个告警思路:拒绝率太高,说明你挡得太狠了,用户体验受损;并发快打满了,说明容量不够,该扩容了。

就像你开车,仪表盘上机油灯亮了你得看一眼。你不能假装没看见,然后发动机冒烟了才想起来"哦对,我该保养了"。

11. 三个常见误区

11.1 误区一:Bulkhead 越细越好

很多人一上来就说:那我按 API endpoint 一对一隔离,每个接口一个 Bulkhead,总行了吧?

不行。

每个 Bulkhead 隔离了资源,但也降低了利用率。你把 chat 的并发限制在 20,但大多数时候实际只有 5 个并发在跑,剩下 15 个槽位就那么空着。与此同时,另一个功能因为达到上限在排队。

这就像你家里三个厕所,每个厕所只允许一个人用。你倒是隔离了,但早高峰照样堵------因为资源被分散了,谁都不够用。

合理的做法是:按业务优先级和流量特征分组,不是按 API 一对一隔离。分个 2~3 组,足够了。

11.2 误区二:用最大并发数来设上限

有人说:LLM Provider 说我 RPM 上限是 100,那我 Bulkhead 就设 100。

错。

你应该用的是目标并发数,不是最大承载数。Provider 的 RPM 限制是全局的,不是你一个功能该占满的量。

就像高速公路限速 120,不代表你每次都得开到 120。你全家都在一辆车上呢,稳一点。

11.3 误区三:超了就直接 500

这是最偷懒也最伤用户体验的做法。

正确思路是降级,不是报错。

批量任务的 Bulkhead 满了?那就放进异步队列,告诉用户"稍后完成会通知你"。用户能接受。

实时 chat 的 Bulkhead 满了?那得立刻告诉用户"服务繁忙,请稍后再试"。因为用户在等你回话呢,你不能把他晾着。

不同功能,降级策略必须不同。不然你批量任务一满就给用户抛 500,用户以为你整个服务挂了------其实只是批量队列满了,chat 好好的。

这就像你去餐厅,厨房忙不过来。你是告诉客人"今天人多,您稍等",还是直接把门一关挂个"今日停业"?

12. Node.js 版本(附赠)

很多 LLM 应用是 Node.js 写的。用 p-limit 可以快速搭一个:

typescript 复制代码
// bulkhead.ts
import pLimit from 'p-limit';
interface BulkheadConfig {
    maxConcurrent: number;
    maxWaitMs?: number;
    maxTokensPerMin?: number;
}
export class NodeBulkhead {
    private limiter: ReturnType<typeof pLimit>;
    private config: BulkheadConfig;
    private tokenWindowStart = Date.now();
    private tokensThisMin = 0;
    private stats = { accepted: 0, rejected: 0 };
    constructor(public readonly name: string, config: BulkheadConfig) {
        this.config = config;
        this.limiter = pLimit(config.maxConcurrent);
    }
    async run<T>(
        fn: () => Promise<T>,
        estimatedTokens = 1000
    ): Promise<T> {
        // Token budget check
        if (this.config.maxTokensPerMin) {
            const now = Date.now();
            if (now - this.tokenWindowStart > 60_000) {
                this.tokensThisMin = 0;
                this.tokenWindowStart = now;
            }
            if (this.tokensThisMin + estimatedTokens > this.config.maxTokensPerMin) {
                this.stats.rejected++;
                throw new BulkheadFullError(`Token budget exhausted for bulkhead '${this.name}'`);
            }
            this.tokensThisMin += estimatedTokens;
        }
        // Concurrency check with timeout
        const maxWait = this.config.maxWaitMs ?? 5000;
        return Promise.race([
            this.limiter(async () => {
                this.stats.accepted++;
                return fn();
            }),
            new Promise<never>((_, reject) =>
                setTimeout(
                    () => reject(new BulkheadFullError(`Bulkhead '${this.name}' wait timeout`)),
                    maxWait
                )
            ),
        ]);
    }
}
export class BulkheadFullError extends Error {
    constructor(message: string) {
        super(message);
        this.name = 'BulkheadFullError';
    }
}
// 使用示例
const chatBulkhead = new NodeBulkhead('chat', { maxConcurrent: 20, maxTokensPerMin: 200_000 });
const batchBulkhead = new NodeBulkhead('batch', { maxConcurrent: 5, maxTokensPerMin: 50_000, maxWaitMs: 200 });
async function callLLMWithIsolation(prompt: string, featureGroup: 'chat' | 'batch') {
    const bulkhead = featureGroup === 'chat' ? chatBulkhead : batchBulkhead;
    return bulkhead.run(
        () => openai.chat.completions.create({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
        3000
    );
}

Node 版的核心思路和 Python 版一模一样,只是把 asyncio.Semaphore 换成了 p-limit

语法不一样,道理是通的。就像你用筷子和叉子吃饭,工具不同,往嘴里扒拉的动作本质上没差。

13. 小结:一张决策图

最后帮你捋一下。如果你的 LLM 应用有下面这些情况,那你就该上 Bulkhead 了:

复制代码
你的 LLM 应用里,是否存在以下情况?
 ✓ 同时有实时功能(chat)和批量功能(batch/export)
 ✓ 多个租户共享 LLM 调用能力
 ✓ 不同功能有明显的优先级差异
 ✓ 发生过一个功能的高流量影响其他功能的情况
→ 需要 Bulkhead 隔离
最小可行实现:
 1. 按优先级分 2~3 个 feature_group(P0 实时/P1 批量/P2 后台)
 2. 每组设置独立 Semaphore(Python: asyncio.Semaphore,Node: p-limit)
 3. P1/P2 的 max_wait_ms 设短(200ms),快速失败而不是排队
 4. 暴露 current_concurrent 和 rejected_count 到监控
加上 Token Budget:
 5. 为每个 feature_group 设置 max_tokens_per_min
 6. 在 LLM 调用前预占,调用完成后修正
加上租户维度:
 7. enterprise/pro/free 各有独立资源配额
 8. 用 feature_group × tier 的组合键管理 Bulkhead

最后再帮你把这三个概念理清楚:

Circuit Breaker 解决的是"调用目标挂了怎么办"。Rate Limit 解决的是"入口流量太高怎么挡"。Bulkhead 解决的是"资源有限时怎么公平分配,防止大家互相踩踏"。

三个各管一个维度。你少装一个,系统就少一层防护。别等线上出事了才想起来补------就像你别等下雨了才发现伞骨是断的。

开源库也给你列一下,省得你到处搜:

  • Python:resilience --- Bulkhead + Circuit Breaker 组合拳
  • Node.js:p-limit + 自己手写 Token Budget
  • Java:Resilience4j --- 最成熟的实现,没有之一
  • Go:golang.org/x/sync/semaphore --- 标准库级别

行了,该说的都说了。现在回去看看你的 LLM 应用,是不是所有功能还挤在一个池子里?

是的话,今晚就别摸鱼了。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01

相关推荐
Ivanqhz5 小时前
Ping-Pong 双缓冲
开发语言·人工智能·python·深度学习·mlir
bmxy小明同学5 小时前
2026-09-18-embedding选型
人工智能
superxxd5 小时前
基于rust的多平台原生GIS引擎
人工智能·物联网·实时音视频
就叫你天选之人啦6 小时前
安装torch+vllm+flash_attn的prompt
人工智能·pytorch·python
音视频牛哥6 小时前
从 LLM、VLA、LLA、SLIM 到实时音视频感知底座:具身智能真正需要的不只是大模型
人工智能·llm·机器人视觉·slam·vla·多模态感知·机器人音视频
code2cat6 小时前
【随笔】从聊天到调用工具:理解MCP在AI应用中的位置
java·人工智能
东离与糖宝6 小时前
元数据知识库
人工智能
我是小邵6 小时前
长对话先收口:用“工作记忆 vs 长期记忆“管理 AI 上下文
人工智能·ai·llm·长期记忆·中间迷失·上下文收口
广州宏帝箱包6 小时前
出口背包的包装有没有防潮、防摔的加固处理
大数据·人工智能