feat(v1): T-R1 采纳 ai-model-router 可解释路由评分——五维加权+硬过滤带拒绝理由+置信度分差推导
- 新增 router_system/explain.py:CandidateProfile(capability/cost/latency/ reliability/difficulty 画像)+ explain_routing(硬过滤先行、逐条人话拒绝理由、 五维归一评分、同分按领域字典序、confidence=0.8+分差推导封顶 0.99) - Router._explain 纯解释层装配(解释层任何异常不影响主链路,D-G4 同款纪律); 路由胜负与既有决策完全等价,/chat 响应新增 route_explanation 字段(缓存命中为 None) - experts:Expert.nominal_latency_ms 标称延迟元数据(mock 1/hf 300/api 800) - stats:upgraded_by_domain 计数 + domain_reliability()(无数据给 0.9 中性先验) - .gitignore 登记 extra/(参考项目目录不入库) - 新增 tests/test_explain.py 8 项;全量 33 passed(基线 25)
This commit is contained in:
@@ -28,3 +28,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# 安全扫描器工作目录(不入库)
|
# 安全扫描器工作目录(不入库)
|
||||||
.mimosa/
|
.mimosa/
|
||||||
|
|
||||||
|
# 参考项目目录(不入库,仅作设计吸收来源)
|
||||||
|
extra/
|
||||||
|
|||||||
+95
-94
@@ -1,94 +1,95 @@
|
|||||||
"""FastAPI 网关:对外提供 /chat /health /metrics 接口。
|
"""FastAPI 网关:对外提供 /chat /health /metrics 接口。
|
||||||
|
|
||||||
启动:
|
启动:
|
||||||
uvicorn gateway.api:app --host 0.0.0.0 --port 8000
|
uvicorn gateway.api:app --host 0.0.0.0 --port 8000
|
||||||
或:
|
或:
|
||||||
python -m gateway.api
|
python -m gateway.api
|
||||||
|
|
||||||
依赖:fastapi, uvicorn, pydantic(见 requirements.txt)
|
依赖:fastapi, uvicorn, pydantic(见 requirements.txt)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from router_system.config import load_config
|
from router_system.config import load_config
|
||||||
from router_system.router import Router, build_router
|
from router_system.router import Router, build_router
|
||||||
|
|
||||||
# ---- 全局单例 ----
|
# ---- 全局单例 ----
|
||||||
_router: Optional[Router] = None
|
_router: Optional[Router] = None
|
||||||
|
|
||||||
|
|
||||||
def get_router() -> Router:
|
def get_router() -> Router:
|
||||||
global _router
|
global _router
|
||||||
if _router is None:
|
if _router is None:
|
||||||
_router = build_router()
|
_router = build_router()
|
||||||
return _router
|
return _router
|
||||||
|
|
||||||
|
|
||||||
# ---- 请求/响应模型 ----
|
# ---- 请求/响应模型 ----
|
||||||
class QueryRequest(BaseModel):
|
class QueryRequest(BaseModel):
|
||||||
query: str = Field(..., min_length=1, max_length=8000, description="用户查询")
|
query: str = Field(..., min_length=1, max_length=8000, description="用户查询")
|
||||||
|
|
||||||
|
|
||||||
class QueryResponse(BaseModel):
|
class QueryResponse(BaseModel):
|
||||||
response: str
|
response: str
|
||||||
domain: str
|
domain: str
|
||||||
difficulty: str
|
difficulty: str
|
||||||
confidence: float
|
confidence: float
|
||||||
upgraded: bool
|
upgraded: bool
|
||||||
quality_score: float
|
quality_score: float
|
||||||
model_used: str
|
model_used: str
|
||||||
route: List[str]
|
route: List[str]
|
||||||
latency_ms: float
|
latency_ms: float
|
||||||
cache_hit: bool
|
cache_hit: bool
|
||||||
cache_level: Optional[str]
|
cache_level: Optional[str]
|
||||||
cost_est: float
|
cost_est: float
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
route_explanation: Optional[Dict[str, Any]] = None # T-R1 可解释评分(缓存命中为 None)
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
status: str
|
class HealthResponse(BaseModel):
|
||||||
domains: List[str]
|
status: str
|
||||||
classifier: str
|
domains: List[str]
|
||||||
judge: str
|
classifier: str
|
||||||
fallback: str
|
judge: str
|
||||||
|
fallback: str
|
||||||
|
|
||||||
# ---- FastAPI 应用 ----
|
|
||||||
try:
|
# ---- FastAPI 应用 ----
|
||||||
from fastapi import FastAPI
|
try:
|
||||||
|
from fastapi import FastAPI
|
||||||
app = FastAPI(
|
|
||||||
title="Multi-Expert Router API",
|
app = FastAPI(
|
||||||
description="多专业小模型 + 路由模型系统(MVP)",
|
title="Multi-Expert Router API",
|
||||||
version="0.1.0",
|
description="多专业小模型 + 路由模型系统(MVP)",
|
||||||
)
|
version="0.1.0",
|
||||||
|
)
|
||||||
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
|
||||||
async def health():
|
@app.get("/health", response_model=HealthResponse, tags=["system"])
|
||||||
return get_router().health()
|
async def health():
|
||||||
|
return get_router().health()
|
||||||
@app.post("/chat", response_model=QueryResponse, tags=["chat"])
|
|
||||||
async def chat(req: QueryRequest):
|
@app.post("/chat", response_model=QueryResponse, tags=["chat"])
|
||||||
result = await get_router().route(req.query)
|
async def chat(req: QueryRequest):
|
||||||
return QueryResponse(**result.to_dict())
|
result = await get_router().route(req.query)
|
||||||
|
return QueryResponse(**result.to_dict())
|
||||||
@app.get("/metrics", tags=["system"])
|
|
||||||
async def metrics():
|
@app.get("/metrics", tags=["system"])
|
||||||
r = get_router()
|
async def metrics():
|
||||||
return {
|
r = get_router()
|
||||||
"router": r.stats.summary(),
|
return {
|
||||||
"cache": r.cache.stats(),
|
"router": r.stats.summary(),
|
||||||
}
|
"cache": r.cache.stats(),
|
||||||
|
}
|
||||||
except ImportError:
|
|
||||||
# fastapi 未安装时,提供 CLI 入口提示
|
except ImportError:
|
||||||
app = None
|
# fastapi 未安装时,提供 CLI 入口提示
|
||||||
print("[gateway] 未安装 fastapi,请执行: pip install -r requirements.txt")
|
app = None
|
||||||
|
print("[gateway] 未安装 fastapi,请执行: pip install -r requirements.txt")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
if __name__ == "__main__":
|
||||||
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)␍
|
import uvicorn
|
||||||
|
uvicorn.run("gateway.api:app", host="0.0.0.0", port=8000, reload=False)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ _STOPWORDS_EN = {
|
|||||||
|
|
||||||
class Expert:
|
class Expert:
|
||||||
name: str = "expert"
|
name: str = "expert"
|
||||||
|
nominal_latency_ms: float = 100.0 # 标称延迟元数据(可解释评分 speed 维输入)
|
||||||
|
|
||||||
async def generate(self, query: str, difficulty: str) -> ExpertResponse:
|
async def generate(self, query: str, difficulty: str) -> ExpertResponse:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -87,6 +88,8 @@ class MockExpert(Expert):
|
|||||||
使端到端管线(分类 -> 专家 -> Judge -> 缓存)可被稳定测试与演示。
|
使端到端管线(分类 -> 专家 -> Judge -> 缓存)可被稳定测试与演示。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
nominal_latency_ms = 1.0
|
||||||
|
|
||||||
def __init__(self, name: str, domain: str, model: str = "mock"):
|
def __init__(self, name: str, domain: str, model: str = "mock"):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
@@ -162,6 +165,8 @@ class MockExpert(Expert):
|
|||||||
class HFExpert(Expert):
|
class HFExpert(Expert):
|
||||||
"""可选:HuggingFace 真实小模型(需 requirements-ml.txt)。"""
|
"""可选:HuggingFace 真实小模型(需 requirements-ml.txt)。"""
|
||||||
|
|
||||||
|
nominal_latency_ms = 300.0
|
||||||
|
|
||||||
def __init__(self, name: str, domain: str, model: str):
|
def __init__(self, name: str, domain: str, model: str):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
@@ -210,6 +215,8 @@ class HFExpert(Expert):
|
|||||||
class APIExpert(Expert):
|
class APIExpert(Expert):
|
||||||
"""可选:OpenAI 兼容 Chat Completions(DeepSeek / OpenAI / 本地 vLLM)。"""
|
"""可选:OpenAI 兼容 Chat Completions(DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||||
|
|
||||||
|
nominal_latency_ms = 800.0
|
||||||
|
|
||||||
def __init__(self, name: str, domain: str, model: str, base_url: str, api_key: str):
|
def __init__(self, name: str, domain: str, model: str, base_url: str, api_key: str):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""可解释路由评分层(T-R1,采纳 ai-model-router 五维评分 + 硬过滤带拒绝理由设计)。
|
||||||
|
|
||||||
|
对分类产生的候选领域专家做透明评分与解释。路由胜负保持既有逻辑不变
|
||||||
|
(本模块是纯解释层,不参与决策):
|
||||||
|
|
||||||
|
- 硬过滤先行:无规则命中/低置信度/缺专家的候选先淘汰,逐条记录人话拒绝理由
|
||||||
|
- 五维加权评分(各维归一到 0-1,权重可配):
|
||||||
|
capability 分类器该领域原始分归一(语义匹配强度)
|
||||||
|
cost_efficiency 1/(1+cost*100) 平滑映射,零成本本地模型恒 1.0
|
||||||
|
speed 1/(1+标称延迟/200)(200ms 参考延迟)
|
||||||
|
reliability Judge 通过率(无数据时 0.9 中性先验)
|
||||||
|
quality 难度 × 领域纵深启发式
|
||||||
|
- 置信度可推导:confidence = 0.8 + (最高分 - 次高分),分差越大越自信(封顶 0.99)
|
||||||
|
- 同分决胜按领域名字典序(与仓库既有确定性约定一致)
|
||||||
|
|
||||||
|
输出 RoutingExplanation.to_dict() 直接进 /chat 响应的 route_explanation 字段。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# 领域纵深启发式(该领域知识可承载的任务深度,0-1)
|
||||||
|
_DOMAIN_DEPTH = {
|
||||||
|
"code": 0.90, "math": 0.90, "legal": 0.85, "medical": 0.85, "general": 0.60,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_WEIGHTS: Dict[str, float] = {
|
||||||
|
"capability": 0.35,
|
||||||
|
"cost_efficiency": 0.20,
|
||||||
|
"speed": 0.15,
|
||||||
|
"reliability": 0.20,
|
||||||
|
"quality": 0.10,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CandidateProfile:
|
||||||
|
"""一个候选领域专家的评分输入(由 Router 从分类/专家池/统计装配)。"""
|
||||||
|
domain: str
|
||||||
|
model: str # 专家模型名
|
||||||
|
capability: float # 分类器该领域原始分归一 0-1
|
||||||
|
cost_per_mtok: float # $ / 1M output tokens 估计
|
||||||
|
nominal_latency_ms: float # 标称延迟(专家后端元数据)
|
||||||
|
reliability: float # Judge 通过率(无数据给中性先验)
|
||||||
|
difficulty_score: float # 查询难度分 0-1(质量维输入)
|
||||||
|
hard_fail_reason: Optional[str] = None # 非 None 即硬过滤淘汰(人话理由)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutingExplanation:
|
||||||
|
"""一次路由的完整解释:胜者 + 排名 + 拒绝名单 + 权重快照。"""
|
||||||
|
winner: str # 胜出领域;无存活候选时为 "fallback"
|
||||||
|
confidence: float # 0.8 + 分差推导(封顶 0.99)
|
||||||
|
ranked: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
rejected: List[Dict[str, Any]] = field(default_factory=list)
|
||||||
|
weights: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"winner": self.winner,
|
||||||
|
"confidence": round(self.confidence, 4),
|
||||||
|
"ranked": self.ranked,
|
||||||
|
"rejected": self.rejected,
|
||||||
|
"weights": dict(self.weights),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def score_candidate(c: CandidateProfile, weights: Dict[str, float]) -> Dict[str, Any]:
|
||||||
|
"""单候选五维评分(全部绝对映射 0-1,跨候选可比、可单测)。"""
|
||||||
|
dims = {
|
||||||
|
"capability": max(0.0, min(1.0, c.capability)),
|
||||||
|
"cost_efficiency": 1.0 / (1.0 + max(0.0, c.cost_per_mtok) * 100.0),
|
||||||
|
"speed": 1.0 / (1.0 + max(0.0, c.nominal_latency_ms) / 200.0),
|
||||||
|
"reliability": max(0.0, min(1.0, c.reliability)),
|
||||||
|
"quality": max(0.0, min(1.0, c.difficulty_score)) * _DOMAIN_DEPTH.get(c.domain, 0.60),
|
||||||
|
}
|
||||||
|
total = sum(dims[k] * weights.get(k, 0.0) for k in dims)
|
||||||
|
return {"domain": c.domain, "model": c.model,
|
||||||
|
"total": round(total, 4), "dims": {k: round(v, 4) for k, v in dims.items()}}
|
||||||
|
|
||||||
|
|
||||||
|
def explain_routing(candidates: List[CandidateProfile],
|
||||||
|
weights: Optional[Dict[str, float]] = None) -> RoutingExplanation:
|
||||||
|
"""硬过滤 -> 五维加权 -> 排名与置信度推导(确定性:同分按领域名字典序)。"""
|
||||||
|
w = dict(DEFAULT_WEIGHTS if weights is None else weights)
|
||||||
|
rejected: List[Dict[str, Any]] = []
|
||||||
|
alive: List[CandidateProfile] = []
|
||||||
|
for c in candidates:
|
||||||
|
if c.hard_fail_reason is not None:
|
||||||
|
rejected.append({"domain": c.domain, "reason": c.hard_fail_reason})
|
||||||
|
else:
|
||||||
|
alive.append(c)
|
||||||
|
|
||||||
|
scored = [score_candidate(c, w) for c in alive]
|
||||||
|
scored.sort(key=lambda s: (-s["total"], s["domain"]))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
# 全部被硬过滤(含低置信度直连回退路径):决策即走大模型回退
|
||||||
|
return RoutingExplanation(winner="fallback", confidence=0.80,
|
||||||
|
ranked=[], rejected=rejected, weights=w)
|
||||||
|
|
||||||
|
gap = scored[0]["total"] - (scored[1]["total"] if len(scored) > 1 else 0.0)
|
||||||
|
confidence = min(0.99, 0.80 + max(0.0, gap))
|
||||||
|
return RoutingExplanation(winner=scored[0]["domain"], confidence=confidence,
|
||||||
|
ranked=scored, rejected=rejected, weights=w)
|
||||||
+70
-68
@@ -1,68 +1,70 @@
|
|||||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Classification:
|
class Classification:
|
||||||
"""分类器输出:领域 + 置信度 + 难度"""
|
"""分类器输出:领域 + 置信度 + 难度"""
|
||||||
domain: str
|
domain: str
|
||||||
confidence: float
|
confidence: float
|
||||||
difficulty: str # easy | medium | hard
|
difficulty: str # easy | medium | hard
|
||||||
difficulty_score: float = 0.5
|
difficulty_score: float = 0.5
|
||||||
raw_scores: Dict[str, float] = field(default_factory=dict)
|
raw_scores: Dict[str, float] = field(default_factory=dict)
|
||||||
matched_rules: List[str] = field(default_factory=list)
|
matched_rules: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExpertResponse:
|
class ExpertResponse:
|
||||||
"""专家模型输出"""
|
"""专家模型输出"""
|
||||||
text: str
|
text: str
|
||||||
model_used: str
|
model_used: str
|
||||||
latency_ms: float = 0.0
|
latency_ms: float = 0.0
|
||||||
tokens: int = 0
|
tokens: int = 0
|
||||||
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RouterResult:
|
class RouterResult:
|
||||||
"""一次路由的完整结果"""
|
"""一次路由的完整结果"""
|
||||||
query: str
|
query: str
|
||||||
response: str
|
response: str
|
||||||
domain: str
|
domain: str
|
||||||
difficulty: str
|
difficulty: str
|
||||||
confidence: float
|
confidence: float
|
||||||
upgraded: bool # 是否升级到大模型
|
upgraded: bool # 是否升级到大模型
|
||||||
quality_score: float
|
quality_score: float
|
||||||
model_used: str
|
model_used: str
|
||||||
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
||||||
latency_ms: float = 0.0
|
latency_ms: float = 0.0
|
||||||
cache_hit: bool = False
|
cache_hit: bool = False
|
||||||
cache_level: Optional[str] = None # exact | semantic
|
cache_level: Optional[str] = None # exact | semantic
|
||||||
cost_est: float = 0.0
|
cost_est: float = 0.0
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
route_explanation: Optional[Dict[str, Any]] = None # 可解释评分(T-R1,缓存命中为 None)
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"query": self.query,
|
return {
|
||||||
"response": self.response,
|
"query": self.query,
|
||||||
"domain": self.domain,
|
"response": self.response,
|
||||||
"difficulty": self.difficulty,
|
"domain": self.domain,
|
||||||
"confidence": round(self.confidence, 4),
|
"difficulty": self.difficulty,
|
||||||
"upgraded": self.upgraded,
|
"confidence": round(self.confidence, 4),
|
||||||
"quality_score": round(self.quality_score, 4),
|
"upgraded": self.upgraded,
|
||||||
"model_used": self.model_used,
|
"quality_score": round(self.quality_score, 4),
|
||||||
"route": self.route,
|
"model_used": self.model_used,
|
||||||
"latency_ms": round(self.latency_ms, 2),
|
"route": self.route,
|
||||||
"cache_hit": self.cache_hit,
|
"latency_ms": round(self.latency_ms, 2),
|
||||||
"cache_level": self.cache_level,
|
"cache_hit": self.cache_hit,
|
||||||
"cost_est": round(self.cost_est, 6),
|
"cache_level": self.cache_level,
|
||||||
"error": self.error,
|
"cost_est": round(self.cost_est, 6),
|
||||||
}
|
"error": self.error,
|
||||||
|
"route_explanation": self.route_explanation,
|
||||||
|
}
|
||||||
def now_ms() -> float:
|
|
||||||
return time.perf_counter() * 1000.0␍
|
|
||||||
|
def now_ms() -> float:
|
||||||
|
return time.perf_counter() * 1000.0
|
||||||
|
|||||||
+43
-7
@@ -45,6 +45,8 @@ class Router:
|
|||||||
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
|
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
|
||||||
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
|
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
|
||||||
self.cache_enabled = cfg.get("cache", {}).get("enabled", True)
|
self.cache_enabled = cfg.get("cache", {}).get("enabled", True)
|
||||||
|
# T-R1:可解释路由评分层(纯解释,不影响决策;默认开启)
|
||||||
|
self.explain_enabled = bool(rcfg.get("explain_enabled", True))
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
async def route(self, query: str) -> RouterResult:
|
async def route(self, query: str) -> RouterResult:
|
||||||
@@ -80,10 +82,16 @@ class Router:
|
|||||||
classification = self.classifier.classify(query)
|
classification = self.classifier.classify(query)
|
||||||
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
|
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
|
||||||
|
|
||||||
|
# T-R1:候选装配与可解释评分(纯解释层,不改变后续任何决策分支)
|
||||||
|
explanation = self._explain(classification) if self.explain_enabled else None
|
||||||
|
if explanation is not None:
|
||||||
|
route.append(f"explain:{explanation.winner}@{explanation.confidence:.2f}")
|
||||||
|
|
||||||
# 低置信度 -> 直接走大模型
|
# 低置信度 -> 直接走大模型
|
||||||
if self.classifier.should_fallback(classification, self.low_confidence_threshold):
|
if self.classifier.should_fallback(classification, self.low_confidence_threshold):
|
||||||
route.append("direct_fallback")
|
route.append("direct_fallback")
|
||||||
return await self._fallback_result(query, classification, route, start)
|
return await self._fallback_result(query, classification, route, start,
|
||||||
|
explanation=explanation)
|
||||||
|
|
||||||
# ---- Step 3: 选择专家 ----
|
# ---- Step 3: 选择专家 ----
|
||||||
domain = classification.domain
|
domain = classification.domain
|
||||||
@@ -101,7 +109,7 @@ class Router:
|
|||||||
self.stats.record_error()
|
self.stats.record_error()
|
||||||
route.append(f"expert_error:{type(e).__name__}")
|
route.append(f"expert_error:{type(e).__name__}")
|
||||||
return await self._fallback_result(query, classification, route, start,
|
return await self._fallback_result(query, classification, route, start,
|
||||||
error=str(e))
|
error=str(e), explanation=explanation)
|
||||||
|
|
||||||
# ---- Step 5: Judge 评估 ----
|
# ---- Step 5: Judge 评估 ----
|
||||||
try:
|
try:
|
||||||
@@ -123,19 +131,46 @@ class Router:
|
|||||||
latency = now_ms() - start
|
latency = now_ms() - start
|
||||||
result = self._finalize(query, classification, final_resp, quality_score=quality_score,
|
result = self._finalize(query, classification, final_resp, quality_score=quality_score,
|
||||||
upgraded=upgraded, route=route, latency_ms=latency,
|
upgraded=upgraded, route=route, latency_ms=latency,
|
||||||
model_used=final_resp.model_used, cost_est=final_resp.cost_est)
|
model_used=final_resp.model_used, cost_est=final_resp.cost_est,
|
||||||
|
explanation=explanation)
|
||||||
self._record(result, latency)
|
self._record(result, latency)
|
||||||
|
|
||||||
# 未升级的结果写缓存
|
# 未升级的结果写缓存(解释不随缓存复用——缓存命中路径 explanation=None)
|
||||||
if self.cache_enabled and not upgraded and result.response:
|
if self.cache_enabled and not upgraded and result.response:
|
||||||
self.cache.put(query, result.to_dict())
|
self.cache.put(query, result.to_dict())
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
def _explain(self, classification: Classification):
|
||||||
|
"""装配候选画像并生成可解释评分(T-R1;任何异常不影响主链路)。"""
|
||||||
|
try:
|
||||||
|
from .explain import CandidateProfile, explain_routing
|
||||||
|
from .experts import _cost_for
|
||||||
|
|
||||||
|
raw = classification.raw_scores or {}
|
||||||
|
raw_max = max(raw.values()) if raw else 0.0
|
||||||
|
candidates = []
|
||||||
|
for dom, expert in self.experts.items():
|
||||||
|
cap = (raw.get(dom, 0.0) / raw_max) if raw_max > 0 else 0.0
|
||||||
|
reason = "无规则命中(capability=0)" if cap <= 0.0 else None
|
||||||
|
candidates.append(CandidateProfile(
|
||||||
|
domain=dom, model=getattr(expert, "model", expert.name),
|
||||||
|
capability=cap,
|
||||||
|
cost_per_mtok=_cost_for(getattr(expert, "model", "mock")),
|
||||||
|
nominal_latency_ms=getattr(expert, "nominal_latency_ms", 100.0),
|
||||||
|
reliability=self.stats.domain_reliability(dom),
|
||||||
|
difficulty_score=classification.difficulty_score,
|
||||||
|
hard_fail_reason=reason))
|
||||||
|
return explain_routing(candidates)
|
||||||
|
except Exception: # noqa: BLE001 解释层故障不影响路由(D-G4 同款纪律)
|
||||||
|
return None
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
async def _fallback_result(self, query: str, classification: Classification,
|
async def _fallback_result(self, query: str, classification: Classification,
|
||||||
route: list, start: float,
|
route: list, start: float,
|
||||||
error: Optional[str] = None) -> RouterResult:
|
error: Optional[str] = None,
|
||||||
|
explanation=None) -> RouterResult:
|
||||||
"""兜底路径的公共收尾:调用大模型回退 -> finalize -> 记录指标。
|
"""兜底路径的公共收尾:调用大模型回退 -> finalize -> 记录指标。
|
||||||
|
|
||||||
低置信度直连、专家异常两条路径共用,避免收尾逻辑三处重复。
|
低置信度直连、专家异常两条路径共用,避免收尾逻辑三处重复。
|
||||||
@@ -145,7 +180,7 @@ class Router:
|
|||||||
result = self._finalize(query, classification, fb, quality_score=0.0,
|
result = self._finalize(query, classification, fb, quality_score=0.0,
|
||||||
upgraded=True, route=route, latency_ms=latency,
|
upgraded=True, route=route, latency_ms=latency,
|
||||||
model_used=fb.model_used, cost_est=fb.cost_est,
|
model_used=fb.model_used, cost_est=fb.cost_est,
|
||||||
error=error)
|
error=error, explanation=explanation)
|
||||||
self._record(result, latency)
|
self._record(result, latency)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -164,7 +199,7 @@ class Router:
|
|||||||
def _finalize(query: str, classification: Classification, resp: ExpertResponse,
|
def _finalize(query: str, classification: Classification, resp: ExpertResponse,
|
||||||
quality_score: float, upgraded: bool, route: list,
|
quality_score: float, upgraded: bool, route: list,
|
||||||
latency_ms: float, model_used: str, cost_est: float,
|
latency_ms: float, model_used: str, cost_est: float,
|
||||||
error: Optional[str] = None) -> RouterResult:
|
error: Optional[str] = None, explanation=None) -> RouterResult:
|
||||||
return RouterResult(
|
return RouterResult(
|
||||||
query=query,
|
query=query,
|
||||||
response=resp.text,
|
response=resp.text,
|
||||||
@@ -179,6 +214,7 @@ class Router:
|
|||||||
cache_hit=False,
|
cache_hit=False,
|
||||||
cost_est=cost_est,
|
cost_est=cost_est,
|
||||||
error=error,
|
error=error,
|
||||||
|
route_explanation=explanation.to_dict() if explanation is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _record(self, result: RouterResult, latency_ms: float):
|
def _record(self, result: RouterResult, latency_ms: float):
|
||||||
|
|||||||
+74
-63
@@ -1,63 +1,74 @@
|
|||||||
"""运行指标收集(线程安全,零依赖)。"""
|
"""运行指标收集(线程安全,零依赖)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from collections import Counter, deque
|
from collections import Counter, deque
|
||||||
from typing import Any, Deque, Dict
|
from typing import Any, Deque, Dict
|
||||||
|
|
||||||
|
|
||||||
class Stats:
|
class Stats:
|
||||||
def __init__(self, window: int = 1000):
|
def __init__(self, window: int = 1000):
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self.requests = 0
|
self.requests = 0
|
||||||
self.domain_counter: Counter = Counter()
|
self.domain_counter: Counter = Counter()
|
||||||
self.difficulty_counter: Counter = Counter()
|
self.difficulty_counter: Counter = Counter()
|
||||||
self.upgraded = 0
|
self.upgraded = 0
|
||||||
self.cache_hits = 0
|
self.upgraded_by_domain: Counter = Counter() # 可解释评分 reliability 维输入
|
||||||
self.cache_levels: Counter = Counter()
|
self.cache_hits = 0
|
||||||
self.errors = 0
|
self.cache_levels: Counter = Counter()
|
||||||
self.latencies: Deque[float] = deque(maxlen=window)
|
self.errors = 0
|
||||||
self.cost_total = 0.0
|
self.latencies: Deque[float] = deque(maxlen=window)
|
||||||
self.model_usage: Counter = Counter()
|
self.cost_total = 0.0
|
||||||
|
self.model_usage: Counter = Counter()
|
||||||
def record(self, latency_ms: float, domain: str, difficulty: str,
|
|
||||||
upgraded: bool, cache_hit: bool, cache_level: str | None,
|
def record(self, latency_ms: float, domain: str, difficulty: str,
|
||||||
cost_est: float, model_used: str):
|
upgraded: bool, cache_hit: bool, cache_level: str | None,
|
||||||
with self._lock:
|
cost_est: float, model_used: str):
|
||||||
self.requests += 1
|
with self._lock:
|
||||||
self.domain_counter[domain] += 1
|
self.requests += 1
|
||||||
self.difficulty_counter[difficulty] += 1
|
self.domain_counter[domain] += 1
|
||||||
if upgraded:
|
self.difficulty_counter[difficulty] += 1
|
||||||
self.upgraded += 1
|
if upgraded:
|
||||||
if cache_hit:
|
self.upgraded += 1
|
||||||
self.cache_hits += 1
|
self.upgraded_by_domain[domain] += 1
|
||||||
if cache_level:
|
if cache_hit:
|
||||||
self.cache_levels[cache_level] += 1
|
self.cache_hits += 1
|
||||||
self.latencies.append(latency_ms)
|
if cache_level:
|
||||||
self.cost_total += cost_est
|
self.cache_levels[cache_level] += 1
|
||||||
self.model_usage[model_used] += 1
|
self.latencies.append(latency_ms)
|
||||||
|
self.cost_total += cost_est
|
||||||
def record_error(self):
|
self.model_usage[model_used] += 1
|
||||||
with self._lock:
|
|
||||||
self.errors += 1
|
def record_error(self):
|
||||||
|
with self._lock:
|
||||||
def summary(self) -> Dict[str, Any]:
|
self.errors += 1
|
||||||
with self._lock:
|
|
||||||
n = self.requests
|
def domain_reliability(self, domain: str, prior: float = 0.9) -> float:
|
||||||
lat = list(self.latencies)
|
"""领域可靠性 = 1 - 升级率(无数据时返回中性先验,可解释评分用)。"""
|
||||||
avg_lat = sum(lat) / len(lat) if lat else 0.0
|
with self._lock:
|
||||||
p99 = sorted(lat)[int(len(lat) * 0.99) - 1] if len(lat) >= 100 else (max(lat) if lat else 0.0)
|
n = self.domain_counter.get(domain, 0)
|
||||||
return {
|
if n <= 0:
|
||||||
"total_requests": n,
|
return prior
|
||||||
"domain_distribution": dict(self.domain_counter),
|
up = self.upgraded_by_domain.get(domain, 0)
|
||||||
"difficulty_distribution": dict(self.difficulty_counter),
|
return max(0.0, min(1.0, 1.0 - up / n))
|
||||||
"fallback_rate": round(self.upgraded / n, 4) if n else 0.0,
|
|
||||||
"upgraded_requests": self.upgraded,
|
def summary(self) -> Dict[str, Any]:
|
||||||
"cache_hit_rate": round(self.cache_hits / n, 4) if n else 0.0,
|
with self._lock:
|
||||||
"cache_levels": dict(self.cache_levels),
|
n = self.requests
|
||||||
"avg_latency_ms": round(avg_lat, 3),
|
lat = list(self.latencies)
|
||||||
"p99_latency_ms": round(p99, 3),
|
avg_lat = sum(lat) / len(lat) if lat else 0.0
|
||||||
"total_cost_est_usd": round(self.cost_total, 6),
|
p99 = sorted(lat)[int(len(lat) * 0.99) - 1] if len(lat) >= 100 else (max(lat) if lat else 0.0)
|
||||||
"model_usage": dict(self.model_usage),
|
return {
|
||||||
"errors": self.errors,
|
"total_requests": n,
|
||||||
}␍
|
"domain_distribution": dict(self.domain_counter),
|
||||||
|
"difficulty_distribution": dict(self.difficulty_counter),
|
||||||
|
"fallback_rate": round(self.upgraded / n, 4) if n else 0.0,
|
||||||
|
"upgraded_requests": self.upgraded,
|
||||||
|
"cache_hit_rate": round(self.cache_hits / n, 4) if n else 0.0,
|
||||||
|
"cache_levels": dict(self.cache_levels),
|
||||||
|
"avg_latency_ms": round(avg_lat, 3),
|
||||||
|
"p99_latency_ms": round(p99, 3),
|
||||||
|
"total_cost_est_usd": round(self.cost_total, 6),
|
||||||
|
"model_usage": dict(self.model_usage),
|
||||||
|
"errors": self.errors,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""可解释路由评分层单元测试(T-R1,采纳 ai-model-router 设计)。"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from router_system.explain import CandidateProfile, explain_routing
|
||||||
|
from router_system.router import build_router
|
||||||
|
|
||||||
|
|
||||||
|
def _cand(domain, capability, cost=1.0, latency=100.0, reliability=0.9, fail=None):
|
||||||
|
return CandidateProfile(domain=domain, model=f"m-{domain}", capability=capability,
|
||||||
|
cost_per_mtok=cost, nominal_latency_ms=latency,
|
||||||
|
reliability=reliability, difficulty_score=0.5,
|
||||||
|
hard_fail_reason=fail)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hard_filter_rejects_with_reason():
|
||||||
|
"""硬过滤淘汰带人话理由,被拒者不进排名。"""
|
||||||
|
exp = explain_routing([
|
||||||
|
_cand("code", 1.0),
|
||||||
|
_cand("math", 0.0, fail="无规则命中(capability=0)"),
|
||||||
|
])
|
||||||
|
assert exp.winner == "code"
|
||||||
|
assert any(r["domain"] == "math" and "无规则命中" in r["reason"] for r in exp.rejected)
|
||||||
|
assert all(s["domain"] != "math" for s in exp.ranked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_rejected_falls_back():
|
||||||
|
"""全部候选被硬过滤:决策者是大模型回退,置信度取基准 0.8。"""
|
||||||
|
exp = explain_routing([_cand("code", 0.0, fail="x"), _cand("math", 0.0, fail="y")])
|
||||||
|
assert exp.winner == "fallback"
|
||||||
|
assert exp.confidence == 0.80
|
||||||
|
assert len(exp.rejected) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_cost_local_beats_costly():
|
||||||
|
"""成本效率维平滑映射:零成本恒 1.0,贵模型显著吃亏。"""
|
||||||
|
cheap = explain_routing([_cand("code", 1.0, cost=0.0, latency=1.0)]).ranked[0]
|
||||||
|
pricey = explain_routing([_cand("math", 1.0, cost=2.5, latency=800.0)]).ranked[0]
|
||||||
|
assert cheap["dims"]["cost_efficiency"] == 1.0
|
||||||
|
assert pricey["dims"]["cost_efficiency"] < 0.5
|
||||||
|
assert cheap["total"] > pricey["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_confidence_gap_derivation():
|
||||||
|
"""置信度 = 0.8 + 分差:独苗封顶 0.99,双候选同分回落 0.8。"""
|
||||||
|
single = explain_routing([_cand("code", 1.0)])
|
||||||
|
tied = explain_routing([_cand("code", 1.0), _cand("math", 1.0)])
|
||||||
|
assert single.confidence == 0.99
|
||||||
|
assert tied.confidence == 0.80
|
||||||
|
|
||||||
|
|
||||||
|
def test_tie_break_is_deterministic():
|
||||||
|
"""同分决胜按领域名字典序(与仓库既有确定性约定一致)。"""
|
||||||
|
exp = explain_routing([_cand("zeta", 1.0), _cand("alpha", 1.0)])
|
||||||
|
assert exp.winner == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weights_snapshot_and_rank_order():
|
||||||
|
"""排名按 total 降序,权重快照随解释输出。"""
|
||||||
|
exp = explain_routing([
|
||||||
|
_cand("code", 1.0, cost=0.0, latency=1.0),
|
||||||
|
_cand("math", 0.5, cost=2.0, latency=900.0),
|
||||||
|
])
|
||||||
|
totals = [s["total"] for s in exp.ranked]
|
||||||
|
assert totals == sorted(totals, reverse=True)
|
||||||
|
assert exp.weights["capability"] == 0.35
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_attaches_explanation():
|
||||||
|
"""主链路返回结构化 route_explanation;缓存命中路径为 None。"""
|
||||||
|
router = build_router()
|
||||||
|
r1 = await router.route("用 Python 写一个快速排序函数")
|
||||||
|
assert r1.route_explanation is not None
|
||||||
|
assert r1.route_explanation["winner"] == "code"
|
||||||
|
assert any("explain:" in step for step in r1.route)
|
||||||
|
assert any("无规则命中" in r["reason"]
|
||||||
|
for r in r1.route_explanation["rejected"])
|
||||||
|
r2 = await router.route("用 Python 写一个快速排序函数")
|
||||||
|
assert r2.cache_hit is True
|
||||||
|
assert r2.route_explanation is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_low_confidence_explains_fallback():
|
||||||
|
"""低置信度直连回退:解释层给出 fallback 胜者与拒绝名单。"""
|
||||||
|
router = build_router()
|
||||||
|
r = await router.route("今天天气怎么样")
|
||||||
|
assert r.upgraded is True
|
||||||
|
assert r.route_explanation is not None
|
||||||
|
assert r.route_explanation["winner"] == "fallback"
|
||||||
Reference in New Issue
Block a user