# ADR-038: OpenClaw 推理引擎併發治理架構 **狀態**: 已批准 **日期**: 2026-03-29 14:05 (台北時間) **決策者**: 統帥 + Antigravity (首席架構師) **觸發事件**: 沙盤推演發現 Thundering Herd 可導致 Ollama OOM 崩潰 --- ## 問題陳述 ### 場景:網路閃斷後的告警雪崩 ``` .188 網路閃斷 3 分鐘 → 恢復後: Alertmanager 積壓 N 個告警同時倒入 AWOOOI Webhook Sentry 積壓 N 個錯誤同時倒入 AWOOOI Webhook Signal Worker 拉起 N 個 asyncio.Task 同時呼叫 OpenClaw Ollama/GPU 收到 N 個並發 LLM 推理請求 → VRAM/RAM OOM → Ollama 進程崩潰 → Circuit Breaker 觸發,但系統大腦已死 ``` **核心問題**:Circuit Breaker 只防「失敗」,不防「超載」。 --- ## 決策:Semaphore + Circuit Breaker 雙層保護 ### 架構設計 ``` 告警事件 │ ▼ Signal Worker (asyncio.Task) │ ▼ ┌─────────────────────────────────────────┐ │ Layer 1: Circuit Breaker │ │ - 5 次連續失敗 → OPEN(60 秒冷卻) │ │ - OPEN 狀態:立即返回 None,不等待 │ └──────────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Layer 2: Concurrency Semaphore │ │ - 全域最多 3 個並發 LLM 推理 │ │ - 超過限制的請求:排隊等待(非拒絕) │ └──────────────────┬──────────────────────┘ │ ▼ OpenClaw / Ollama ``` ### 為何 max_concurrent = 3? | 考量 | 說明 | |------|------| | Ollama CPU 模式 | .188 純 CPU(無 GPU),每次推理佔 1-2 CPU Core | | .188 主機規格 | 共享 CPU,同時跑 PostgreSQL + Redis + SigNoz | | 安全邊界 | 3 = 不超過 .188 可用 CPU 的 60%(保留 40% 給其他服務) | | 排隊不拒絕 | 超出的請求排隊等待,確保不遺失重要告警 | --- ## 實作規範 ### 核心實作(`apps/api/src/core/circuit_breaker.py`) ```python """ OpenClaw 推理引擎保護機制 ========================= ADR-038: 雙層保護策略 - Layer 1: Circuit Breaker(防失敗傳播) - Layer 2: Concurrency Semaphore(防 Thundering Herd) 遵循 leWOOOgo 積木化鐵律: - 此模組屬於 core/ 基礎設施層 - 不依賴任何 Service 層 - 透過 Singleton 提供全域狀態 """ import asyncio import time from enum import Enum from dataclasses import dataclass, field import structlog logger = structlog.get_logger(__name__) class CircuitState(Enum): CLOSED = "closed" # 正常運作 OPEN = "open" # 斷路(快速失敗) HALF_OPEN = "half_open" # 試探性恢復 @dataclass class CircuitBreakerConfig: failure_threshold: int = 5 # 連續失敗次數觸發斷路 timeout_s: float = 60.0 # 斷路後冷卻時間(秒) max_concurrent: int = 3 # 最大並發 LLM 推理數 class OpenClawGuard: """ OpenClaw 雙層推理保護門衛 使用方式: guard = get_openclaw_guard() if guard.is_circuit_open(): return None # 快速失敗 async with guard.semaphore: # 排隊等待 try: result = await call_openclaw(...) guard.record_success() return result except Exception: guard.record_failure() raise """ def __init__(self, config: CircuitBreakerConfig | None = None): self.config = config or CircuitBreakerConfig() self.state = CircuitState.CLOSED self.failure_count = 0 self._opened_at: float | None = None # Semaphore 必須在 event loop 中建立 self._semaphore: asyncio.Semaphore | None = None @property def semaphore(self) -> asyncio.Semaphore: if self._semaphore is None: self._semaphore = asyncio.Semaphore(self.config.max_concurrent) return self._semaphore def is_circuit_open(self) -> bool: if self.state == CircuitState.OPEN: if time.time() - self._opened_at > self.config.timeout_s: self.state = CircuitState.HALF_OPEN logger.info("circuit_breaker_half_open") return False return True return False def record_success(self) -> None: self.failure_count = 0 if self.state != CircuitState.CLOSED: logger.info("circuit_breaker_closed") self.state = CircuitState.CLOSED def record_failure(self) -> None: self.failure_count += 1 if self.failure_count >= self.config.failure_threshold: self.state = CircuitState.OPEN self._opened_at = time.time() logger.warning( "circuit_breaker_opened", failure_count=self.failure_count, cooldown_s=self.config.timeout_s, ) def get_metrics(self) -> dict: return { "state": self.state.value, "failure_count": self.failure_count, "max_concurrent": self.config.max_concurrent, } # 全域 Singleton _guard: OpenClawGuard | None = None def get_openclaw_guard() -> OpenClawGuard: """取得全域 OpenClaw 保護門衛""" global _guard if _guard is None: _guard = OpenClawGuard() return _guard ``` ### 呼叫端整合(`sentry_webhook.py` + `signoz_webhook.py`) ```python from src.core.circuit_breaker import get_openclaw_guard async def call_openclaw_analyzer(error_context: dict) -> ErrorAnalysisResult | None: guard = get_openclaw_guard() # Layer 1: Circuit Breaker 快速失敗 if guard.is_circuit_open(): logger.warning("openclaw_circuit_open_skip", metrics=guard.get_metrics()) return None # Layer 2: Semaphore 排隊(最多 3 個並發,超出排隊等待) async with guard.semaphore: try: async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( f"{settings.OPENCLAW_BASE_URL}/analyze", json=error_context, ) response.raise_for_status() guard.record_success() return ErrorAnalysisResult(**response.json()) except Exception as e: guard.record_failure() logger.exception("openclaw_call_failed", error=str(e)) return None ``` --- ## 模組化合規驗證 | 項目 | 說明 | 合規狀態 | |------|------|---------| | 層次 | `core/` 基礎設施層 | ✅ 合規 | | 依賴 | 只依賴 stdlib + structlog | ✅ 合規 | | Singleton | `get_openclaw_guard()` 工廠函數 | ✅ 合規 | | 協議介面 | `OpenClawGuard` 直接使用(無需 Protocol,因為非跨模組) | ✅ 合規 | | 測試 | 可透過 `_guard = None` 重置 | ✅ 合規 | --- ## 驗收標準 | 項目 | 通過條件 | |------|---------| | Semaphore 排隊 | 第 4 個請求在前 3 個完成前不呼叫 OpenClaw | | Circuit Breaker 觸發 | 5 次失敗後 `is_circuit_open()` 返回 True | | 冷卻恢復 | 60 秒後切換到 HALF_OPEN,1 次成功恢復 CLOSED | | Graceful Degrade | Circuit OPEN 時呼叫返回 None,不拋例外 | --- ## 相關文件 - `docs/proposals/ARCHITECTURAL_RISK_WAR_GAME.md`:風險沙盤推演 - `apps/api/src/api/v1/sentry_webhook.py`:Sentry Webhook 整合 - `apps/api/src/api/v1/signoz_webhook.py`:SignOz Webhook 整合 - ADR-028:Failure Auto-Repair Loop - ADR-039:全域自動修復熔斷機制