Files
awoooi/apps/api/src/services/ai_router.py
OG T 000533d32e
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 4m35s
E2E Health Check / e2e-health (push) Successful in 16s
feat(ai): promote Nvidia nemotron as default arbitrator for high complexity/risk incidents
2026-03-30 00:26:53 +08:00

594 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI Router - Phase 13.3 #87
==========================
智能 AI 路由器,根據意圖和複雜度動態選擇 AI Provider
目標: 根據請求特性自動選擇最適模型
策略: Intent Classifier + Complexity Scorer → Routing Decision
延遲目標: < 50ms (規則引擎優先)
路由決策矩陣 (ADR-023):
┌─────────────────┬───────────────┬──────────────────────────────┐
│ 複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
版本: v3.0
建立: 2026-03-26 (台北時區)
建立者: Claude Code
最後修改: 2026-03-26 (台北時區)
修改者: Claude Code
變更紀錄:
| 版本 | 日期 | 執行者 | 變更內容 |
|------|------|--------|----------|
| v1.0 | 2026-03-26 | Claude Code | 初始實作 |
| v2.0 | 2026-03-26 | Claude Code | 支援 IntentResult + 新意圖類型 |
| v3.0 | 2026-03-26 | Claude Code | Phase 13.3 #87 完整路由決策矩陣 |
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
import structlog
from src.services.complexity_scorer import (
ComplexityScore,
get_complexity_scorer,
)
from src.services.intent_classifier import (
IntentResult,
IntentType,
RiskLevel,
get_intent_classifier,
normalize_intent,
)
from src.services.model_registry import get_model_registry
logger = structlog.get_logger(__name__)
# =============================================================================
# Provider 定義
# =============================================================================
class AIProvider(Enum):
"""AI 提供者"""
OLLAMA = "ollama"
GEMINI = "gemini"
CLAUDE = "claude"
# 2026-03-29 ogt: ADR-036 Nemotron Tool Calling (83.3% 精準度)
NVIDIA = "nvidia"
# Provider 對應延遲預算 (ms)
PROVIDER_LATENCY_BUDGET: dict[AIProvider, int] = {
AIProvider.OLLAMA: 60000, # 本地,允許較長處理時間
AIProvider.GEMINI: 30000, # 雲端,較低延遲
AIProvider.CLAUDE: 30000, # 雲端,較低延遲
# 2026-03-29 ogt: ADR-036 Nemotron Tool Calling (延遲 11-45s)
AIProvider.NVIDIA: 60000, # Tool Calling 專用,允許較長時間
}
@dataclass
class RoutingDecision:
"""
路由決策結果 (Phase 13.3 #87)
包含完整的路由資訊,供 OpenClaw 主流程使用
"""
# 核心決策
selected_provider: AIProvider # 選擇的 AI Provider
selected_model: str # 選擇的模型名稱
fallback_chain: list[tuple[AIProvider, str]] # 備援鏈 [(provider, model), ...]
routing_reason: str # 路由決策原因
latency_budget_ms: int # 延遲預算 (毫秒)
# 分類結果
intent: IntentType # 意圖分類 (正規化後)
intent_result: IntentResult # 完整 Intent 分類結果
complexity: ComplexityScore # 複雜度評分
risk_level: RiskLevel = field(default=RiskLevel.MEDIUM) # 風險等級
# 路由 metadata
routing_latency_ms: float = 0.0 # 路由決策耗時 (ms)
# 向後相容 (deprecated)
model: str = "" # -> selected_model
reason: str = "" # -> routing_reason
fallback_models: list[str] = field(default_factory=list) # -> fallback_chain
def __post_init__(self):
"""初始化後設定衍生欄位"""
self.risk_level = self.intent_result.risk_level
# 向後相容
self.model = self.selected_model
self.reason = self.routing_reason
self.fallback_models = [model for _, model in self.fallback_chain]
def to_dict(self) -> dict:
"""轉換為字典 (API 回應用)"""
return {
"selected_provider": self.selected_provider.value,
"selected_model": self.selected_model,
"fallback_chain": [
{"provider": p.value, "model": m} for p, m in self.fallback_chain
],
"routing_reason": self.routing_reason,
"latency_budget_ms": self.latency_budget_ms,
"intent": self.intent.value,
"risk_level": self.risk_level.value,
"complexity_score": self.complexity.score,
"routing_latency_ms": round(self.routing_latency_ms, 2),
}
class AIRouter:
"""
AI 路由器 (Phase 13.3 #87)
整合 IntentClassifier 和 ComplexityScorer
動態選擇最適合的 AI Provider 和模型。
路由決策矩陣:
┌─────────────────┬───────────────┬──────────────────────────────┐
│ 複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
路由策略 (按優先級):
1. CRITICAL 風險強制使用 Claude
2. DELETE 意圖強制使用 Claude
3. HIGH 風險或複雜度 4-5 → Gemini
4. 其他情況 → Ollama (成本優先)
"""
def __init__(self):
self._intent_classifier = get_intent_classifier()
self._complexity_scorer = get_complexity_scorer()
self._model_registry = get_model_registry()
# 從 ModelRegistry 取得模型配置
self._ollama_default = self._model_registry.get_model("ollama", "default")
self._ollama_summary = self._model_registry.get_model("ollama", "summary")
self._gemini_default = self._model_registry.get_model("gemini", "default")
self._claude_default = self._model_registry.get_model("claude", "default")
# 2026-03-29 ogt: ADR-036 Nemotron Tool Calling
self._nvidia_default = self._model_registry.get_model("nvidia", "default")
# Provider 對應模型映射
self._provider_models: dict[AIProvider, str] = {
AIProvider.OLLAMA: self._ollama_default,
AIProvider.GEMINI: self._gemini_default,
AIProvider.CLAUDE: self._claude_default,
AIProvider.NVIDIA: self._nvidia_default, # ADR-036
}
# 完整 Fallback 鏈 (Provider, Model)
# 2026-03-30 ogt: NVIDIA 成為首選仲裁,加入 Fallback 鏈首位
self._full_fallback_chain: list[tuple[AIProvider, str]] = [
(AIProvider.NVIDIA, self._nvidia_default),
(AIProvider.GEMINI, self._gemini_default),
(AIProvider.CLAUDE, self._claude_default),
(AIProvider.OLLAMA, self._ollama_default),
]
# Tool Calling 專用 Fallback 鏈 (ADR-036)
self._tool_calling_fallback_chain: list[tuple[AIProvider, str]] = [
(AIProvider.NVIDIA, self._nvidia_default),
(AIProvider.GEMINI, self._gemini_default),
(AIProvider.CLAUDE, self._claude_default),
]
# 意圖對應 Provider 強制覆寫 (None = 依複雜度決定)
self._intent_provider_overrides: dict[IntentType, AIProvider | None] = {
# 四大核心意圖
IntentType.RESTART: None, # 依複雜度
IntentType.SCALE: None, # 依複雜度
IntentType.CONFIG: None, # 依複雜度 (但 HIGH 會升級)
IntentType.DIAGNOSE: AIProvider.OLLAMA, # 診斷優先本地 (隱私)
# 輔助意圖
IntentType.DELETE: AIProvider.CLAUDE, # CRITICAL → 強制 Claude
IntentType.ROLLBACK: None, # 依複雜度
IntentType.UNKNOWN: None,
# 舊版兼容
IntentType.CODE_REVIEW: None,
IntentType.DEPLOYMENT: None,
IntentType.ALERT_TRIAGE: AIProvider.OLLAMA,
IntentType.QUERY: AIProvider.OLLAMA,
IntentType.MAINTENANCE: None,
}
# 向後相容
self._default_model = self._ollama_default
self._summary_model = self._ollama_summary
self._fallback_order = [
self._ollama_default,
self._ollama_summary,
"gemini",
"claude",
]
async def route(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""
路由請求到最適 AI Provider 和模型
延遲目標: < 50ms (規則引擎優先LLM 分類時可能稍長)
Args:
text: 用戶輸入或告警內容
context: 額外上下文 (服務、指標等)
Returns:
RoutingDecision: 完整路由決策
"""
start_time = time.perf_counter()
context = context or {}
# Step 1: 意圖分類 (返回 IntentResult, 規則引擎 < 10ms)
intent_result = await self._intent_classifier.classify(text)
intent = normalize_intent(intent_result.intent)
# Step 2: 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
# Step 3: Provider + Model 選擇 (< 1ms)
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# Step 4: 建立 Fallback 鏈
fallback_chain = self._build_fallback_chain(provider)
# Step 5: 計算延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
decision = RoutingDecision(
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
routing_latency_ms=routing_latency,
)
logger.info(
"ai_routing_decision",
provider=provider.value,
model=model,
intent=intent.value,
intent_confidence=intent_result.confidence,
risk_level=intent_result.risk_level.value,
complexity_score=complexity.score,
reason=reason,
latency_budget_ms=latency_budget,
routing_latency_ms=round(routing_latency, 2),
fallback_count=len(fallback_chain),
)
return decision
def _select_provider_and_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[AIProvider, str, str]:
"""
選擇 Provider 和模型 (Phase 13.3 #87 核心邏輯)
路由決策矩陣:
┌─────────────────┬───────────────┬──────────────────────────────┐
│ 複雜度 + 風險 │ Provider │ 備註 │
├─────────────────┼───────────────┼──────────────────────────────┤
│ 1-2 + LOW │ Ollama │ 快速本地處理 │
│ 3 + MEDIUM │ Ollama │ fallback → Gemini │
│ 4-5 + HIGH │ Gemini │ fallback → Claude │
│ DELETE/CRITICAL │ Claude │ 強制使用最強模型 │
└─────────────────┴───────────────┴──────────────────────────────┘
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(provider, model, reason)
"""
risk = intent_result.risk_level
score = complexity.score
# =======================================================================
# 規則 1: CRITICAL 風險強制 Claude (最高優先級)
# =======================================================================
if risk == RiskLevel.CRITICAL:
provider = AIProvider.CLAUDE
model = self._claude_default
reason = f"CRITICAL 風險 ({intent.value}) 強制使用 Claude"
return provider, model, reason
# =======================================================================
# 規則 2: DELETE 意圖強制 Claude (不可逆操作)
# =======================================================================
if intent == IntentType.DELETE:
provider = AIProvider.CLAUDE
model = self._claude_default
reason = "DELETE 意圖 (不可逆) 強制使用 Claude"
return provider, model, reason
# =======================================================================
# 規則 3: 檢查意圖強制覆寫
# =======================================================================
provider_override = self._intent_provider_overrides.get(intent)
if provider_override is not None:
provider = provider_override
model = self._provider_models[provider]
reason = f"意圖 {intent.value} 指定使用 {provider.value}"
return provider, model, reason
# =======================================================================
# 規則 4: 複雜度 4-5 或 HIGH 風險 → Nvidia Nemotron
# =======================================================================
if score >= 4 or risk == RiskLevel.HIGH:
provider = AIProvider.NVIDIA
model = self._nvidia_default
reason = f"複雜度={score}/5, 風險={risk.value} → Nvidia (fallback Gemini)"
return provider, model, reason
# =======================================================================
# 規則 5: 複雜度 3 + MEDIUM → Ollama (fallback Gemini)
# =======================================================================
if score == 3:
provider = AIProvider.OLLAMA
model = self._ollama_default
reason = f"複雜度={score}/5, 風險={risk.value} → Ollama (fallback Gemini)"
return provider, model, reason
# =======================================================================
# 規則 6: 複雜度 1-2 + LOW/MEDIUM → Ollama (快速本地處理)
# =======================================================================
provider = AIProvider.OLLAMA
# 低複雜度使用輕量模型 (更快回應)
model = self._ollama_summary if score <= 1 else self._ollama_default
reason = f"複雜度={score}/5, 風險={risk.value} → Ollama (成本優先)"
return provider, model, reason
def _select_model(
self,
intent: IntentType,
intent_result: IntentResult,
complexity: ComplexityScore,
) -> tuple[str, str]:
"""
選擇模型 (向後相容方法)
Deprecated: 請使用 _select_provider_and_model
Args:
intent: 正規化後的意圖
intent_result: 完整分類結果
complexity: 複雜度評分
Returns:
(model_name, reason)
"""
_, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
return model, reason
def _build_fallback_chain(
self, selected_provider: AIProvider
) -> list[tuple[AIProvider, str]]:
"""
建立 Fallback 鏈 (排除已選 Provider)
Fallback 順序: Ollama → Gemini → Claude
Args:
selected_provider: 已選擇的 Provider
Returns:
Fallback 鏈 [(provider, model), ...]
"""
fallback_chain: list[tuple[AIProvider, str]] = []
for provider, model in self._full_fallback_chain:
if provider != selected_provider:
fallback_chain.append((provider, model))
return fallback_chain
def _build_fallback_list(self, selected_model: str) -> list[str]:
"""建立 Fallback 列表 (向後相容)"""
fallbacks = [m for m in self._fallback_order if m != selected_model]
return fallbacks
def route_sync(
self,
text: str,
context: dict | None = None,
) -> RoutingDecision:
"""
同步版本路由 (僅關鍵字匹配,保證 < 50ms)
適用場景: 需要快速決策,不需要 LLM 分類的情況
Args:
text: 用戶輸入或告警內容
context: 額外上下文
Returns:
RoutingDecision: 路由決策
"""
start_time = time.perf_counter()
context = context or {}
# 同步分類 (僅規則引擎, < 10ms)
intent_result = self._intent_classifier.classify_sync(text)
intent = normalize_intent(intent_result.intent)
# 複雜度評分 (< 10ms)
complexity = self._complexity_scorer.score(context)
# Provider + Model 選擇
provider, model, reason = self._select_provider_and_model(
intent, intent_result, complexity
)
# 建立 Fallback 鏈
fallback_chain = self._build_fallback_chain(provider)
# 延遲預算
latency_budget = PROVIDER_LATENCY_BUDGET.get(provider, 30000)
# 計算路由決策耗時
routing_latency = (time.perf_counter() - start_time) * 1000
return RoutingDecision(
selected_provider=provider,
selected_model=model,
fallback_chain=fallback_chain,
routing_reason=reason,
latency_budget_ms=latency_budget,
intent=intent,
intent_result=intent_result,
complexity=complexity,
routing_latency_ms=routing_latency,
)
# =========================================================================
# Tool Calling 路由 (ADR-036)
# =========================================================================
def route_tool_calling(self) -> tuple[AIProvider, str, list[tuple[AIProvider, str]]]:
"""
Tool Calling 專用路由 (ADR-036)
Tool Calling 任務優先使用 Nemotron (83.3% 精準度)
Fallback 到 Gemini/Claude。
Returns:
(provider, model, fallback_chain)
"""
provider = AIProvider.NVIDIA
model = self._nvidia_default
fallback_chain = [
(p, m) for p, m in self._tool_calling_fallback_chain if p != provider
]
logger.info(
"tool_calling_routing",
provider=provider.value,
model=model,
fallback_count=len(fallback_chain),
)
return provider, model, fallback_chain
def get_tool_calling_fallback_chain(self) -> list[tuple[AIProvider, str]]:
"""取得 Tool Calling Fallback 鏈"""
return self._tool_calling_fallback_chain.copy()
# =========================================================================
# 便捷方法
# =========================================================================
def get_provider_for_intent(self, intent: IntentType) -> AIProvider:
"""取得意圖對應的 Provider (不考慮複雜度)"""
override = self._intent_provider_overrides.get(intent)
return override if override else AIProvider.OLLAMA
def get_model_for_provider(self, provider: AIProvider) -> str:
"""取得 Provider 對應的模型"""
return self._provider_models.get(provider, self._ollama_default)
def get_routing_matrix(self) -> list[dict]:
"""
取得路由決策矩陣 (用於 API 文檔或除錯)
Returns:
路由規則清單
"""
return [
{
"rule": 1,
"condition": "CRITICAL risk",
"provider": "claude",
"reason": "不可逆/高風險操作強制最強模型",
},
{
"rule": 2,
"condition": "DELETE intent",
"provider": "claude",
"reason": "刪除操作強制最強模型",
},
{
"rule": 3,
"condition": "Intent override",
"provider": "depends",
"reason": "特定意圖有預設 Provider",
},
{
"rule": 4,
"condition": "complexity >= 4 OR HIGH risk",
"provider": "nvidia",
"reason": "高複雜度需要 Nvidia Nemotron 強大推理能力",
},
{
"rule": 5,
"condition": "complexity == 3",
"provider": "ollama",
"reason": "中等複雜度本地處理",
},
{
"rule": 6,
"condition": "complexity 1-2",
"provider": "ollama",
"reason": "低複雜度快速處理",
},
]
# 單例
_router: AIRouter | None = None
def get_ai_router() -> AIRouter:
"""取得 AIRouter 單例"""
global _router
if _router is None:
_router = AIRouter()
return _router
def reset_ai_router() -> None:
"""重置單例 (用於測試)"""
global _router
_router = None