LLM 应用的 Bulkhead 隔离工程实践:用舰壁模式防止一个功能的过载拖左整个 AI 系统

0 阅读12分钟

0. 那个让我们 P0 oncall 的下午

去年七月,我们的 AI 助手应用在工作日下午 2 点突然全线超时。用户的对话请求平均响应时间从 1.4 秒飙到了 23 秒,客服工单在 15 分钟内涌入 80 条。

排查了 20 分钟后找到原因:一个企业客户的管理员触发了"批量导出"功能——把 3000 篇文档全部用 AI 摘要一遍。这个功能没有任何并发限制,用 60 个并发请求把我们的 LLM 调用池打满了。所有普通用户的对话请求全部排队,超时。

修复很快:kill 掉那批任务,P99 立刻回到正常。但真正的问题暴露出来:我们的 LLM 应用没有任何资源隔离。所有功能、所有租户、所有优先级,共用同一个并发池。这在传统后端里叫"缺少舱壁"。


1. 什么是 Bulkhead(舱壁模式)

Bulkhead 来自船舶设计:将船舱用隔板分隔成独立舱室,单个舱室进水不会导致整船沉没。

在软件工程里,Michael Nygard 在《Release It!》里将其定义为对资源消费者的隔离:每个消费者组分配独立的资源池,一个组耗尽资源不影响其他组。

传统微服务中,Bulkhead 主要有两种实现:

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

Netflix Hystrix 是最知名的实现,但它是 Java 生态的。对 Python/Node.js 的 LLM 应用,我们需要自己实现。

LLM 应用的特殊性:传统 Bulkhead 只隔离"并发请求数",LLM 还需要隔离 Token 消耗量(TPM)。一个请求带 100K context 和一个 1K context 请求消耗的资源天差地别。


2. 为什么 Rate Limiting 不够用

很多团队第一反应是:"加个 Rate Limit 不就行了?"

不够用,原因有三:

Rate Limit 是入口防护,Bulkhead 是舱室隔离。 Rate Limit 限制请求进来的速率,但已经进来的请求仍然共享资源。如果批量任务已经在跑,Rate Limit 只能阻止新的请求进来,但现有 60 个并发仍然占满连接池。

Rate Limit 通常是全局或 per-user 的,缺少功能维度。 你可以给用户设 10 req/min,但没法说"批量功能最多占 20% 的并发"。

Rate Limit 无法处理 Token 维度。 LLM 的 TPM 上限比 RPM 更关键,但传统 Rate Limiter 不理解 token。

一个完整的防护体系应该是:Rate Limit(入口)+ Bulkhead(资源隔离)+ Circuit Breaker(故障熔断)。三者各管一个维度。


3. 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             
└─────────────────────────────────────────────────────┘

功能维度(Feature Group):按业务功能划分,最直接。chatbatch_summarizeembeddingbackground_analysis 各有独立并发上限。

租户维度(Tenant Tier):Enterprise、Pro、Free 用户对应不同资源池,防止低价值用户影响高价值用户体验。

Provider 维度:同一应用调用多个 LLM Provider(DeepSeek + 通义千问 + 本地模型),各 Provider 独立连接池和重试队列,防止一个 Provider 的问题(超时、限速)阻塞其他 Provider 的调用。

Token Budget 维度(LLM 特有):在请求数量之外,额外限制每分钟 Token 消耗,防止少量大 context 请求把 TPM 打满。


4. Python 实现:AsyncBulkhead

下面是一个可直接用于 asyncio 应用的信号量 Bulkhead 实现:

# 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"

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

单个 Bulkhead 能用,但应用里可能有十几个。需要一个注册中心:

# 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()
    })

6. 与 LLM 调用层集成

把 Bulkhead 嵌入 LLM 调用层,对业务代码透明:

# 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

业务代码的使用方式:

# 业务层代码,简洁透明
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

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

多租户 SaaS 需要按租户等级动态路由到不同 Bulkhead:

# 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]

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

我在本地用一个压测脚本模拟了批量冲击场景,测量 chat 功能的 P99 延迟变化:

测试场景:

  • 基线:chat 功能,30 个用户,随机间隔发请求
  • 冲击:同时触发 50 个 batch_summarize 并发请求(大 context,5K tokens/request)
  • LLM:用 httpx mock 模拟,响应时间 200ms~800ms 随机(模拟真实 LLM 延迟分布)
# 压测脚本(可复现)
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

测试结果:

场景P50 延迟P90 延迟P99 延迟
无 Bulkhead(受 batch 冲击)0.9s4.2s18.7s
有 Bulkhead(chat 独立舱室)0.7s1.1s1.4s
有 Bulkhead(batch 端 P99)3.2s(可接受)

chat 功能的 P99 延迟从 18.7 秒降到 1.4 秒,降幅 92.5%。代价是 batch 的 P99 升到 3.2 秒(因为它被限制在 5 个并发),但 batch 功能本来就是低优先级,这是正确的权衡。


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

Bulkhead 本身的状态需要暴露给监控系统,否则无法调整阈值:

# 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
            )

关键告警规则:

# 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"

10. 三个常见误区

误区 1:Bulkhead 越细粒度越好。

不对。每个 Bulkhead 隔离了资源,也降低了利用率。如果你把 chat 的并发限制在 20,但实际上大多数时候只有 5 个并发,剩下 15 个槽位空着——同时另一个 feature_group 因为达到上限而排队。过细的隔离反而造成资源浪费。

合理的粒度:按业务优先级和流量特征分组,而不是按 API endpoint 一对一隔离。

误区 2:用最大并发数来设置 Bulkhead 上限。

你应该用的是目标并发数,不是最大承载数。把 Bulkhead 上限设为 LLM Provider 的 RPM 限制是另一个错误——那是 Provider 的全局限制,不是单个 feature group 应该占满的量。

误区 3:超出 Bulkhead 就 500 错误。

正确做法是降级而非直接错误。超出 batch_summarize 的 Bulkhead 时,可以把请求放入异步队列,让用户知道"稍后通知"。超出 chat Bulkhead 时,才应该让用户立即知道"服务繁忙"。不同功能的降级策略应该不同。


11. Node.js 版本(附赠)

很多 LLM 应用是 Node.js 写的,用 p-limit 可以快速实现:

// 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
  );
}

12. 小结:一张决策图

你的 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 解决的是"资源有限时如何公平分配、防止互相踩踏"。三者各管一个维度,组合起来才是完整的弹性工程。

开源库参考: