- ADR-038: OpenClaw 併發治理架構 - ADR-039: 全域自動修復熔斷 - LOGBOOK: 今日進度記錄 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
7.8 KiB
7.8 KiB
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)
"""
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)
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:全域自動修復熔斷機制