- 新增 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)
258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""主路由器:协调 缓存 -> 分类 -> 专家 -> Judge -> 大模型回退 的完整链路。
|
||
|
||
流程(对齐实现方案):
|
||
1. 检查缓存(L1 精确 / L2 语义)
|
||
2. 低置信度查询直接走大模型(should_fallback)
|
||
3. 分类器输出领域 + 难度
|
||
4. 选择专家模型生成
|
||
5. Judge 评估质量
|
||
6. 质量不达标 -> 升级大模型
|
||
7. 记录指标、写缓存、返回结果
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Optional
|
||
|
||
from .cache import RouterCache
|
||
from .classifier import BaseClassifier, build_classifier
|
||
from .config import load_config
|
||
from .experts import Expert, build_expert_pool
|
||
from .fallback import FallbackProvider, build_fallback
|
||
from .judge import BaseJudge, build_judge
|
||
from .models import Classification, ExpertResponse, RouterResult, now_ms
|
||
from .stats import Stats
|
||
|
||
|
||
class Router:
|
||
def __init__(
|
||
self,
|
||
classifier: BaseClassifier,
|
||
experts: Dict[str, Expert],
|
||
judge: BaseJudge,
|
||
fallback: FallbackProvider,
|
||
cache: Optional[RouterCache] = None,
|
||
stats: Optional[Stats] = None,
|
||
config: Optional[Dict[str, Any]] = None,
|
||
):
|
||
self.classifier = classifier
|
||
self.experts = experts
|
||
self.judge = judge
|
||
self.fallback = fallback
|
||
self.cache = cache or RouterCache()
|
||
self.stats = stats or Stats()
|
||
cfg = config or {}
|
||
rcfg = cfg.get("router", {})
|
||
self.low_confidence_threshold = rcfg.get("low_confidence_threshold", 0.60)
|
||
self.judge_fallback_threshold = rcfg.get("judge_fallback_threshold", 0.70)
|
||
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:
|
||
start = now_ms()
|
||
route: list = []
|
||
|
||
# ---- Step 1: 缓存 ----
|
||
if self.cache_enabled:
|
||
hit = self.cache.get(query)
|
||
if hit is not None:
|
||
level, cached = hit
|
||
latency = now_ms() - start
|
||
result = RouterResult(
|
||
query=query,
|
||
response=cached.get("response", ""),
|
||
domain=cached.get("domain", "general"),
|
||
difficulty=cached.get("difficulty", "medium"),
|
||
confidence=cached.get("confidence", 0.0),
|
||
upgraded=False,
|
||
quality_score=cached.get("quality_score", 0.0),
|
||
model_used=cached.get("model_used", ""),
|
||
route=["cache:" + level],
|
||
latency_ms=latency,
|
||
cache_hit=True,
|
||
cache_level=level,
|
||
cost_est=0.0,
|
||
)
|
||
self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used)
|
||
return result
|
||
route.append("cache:miss")
|
||
|
||
# ---- Step 2: 分类 ----
|
||
classification = self.classifier.classify(query)
|
||
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):
|
||
route.append("direct_fallback")
|
||
return await self._fallback_result(query, classification, route, start,
|
||
explanation=explanation)
|
||
|
||
# ---- Step 3: 选择专家 ----
|
||
domain = classification.domain
|
||
expert = self.experts.get(domain)
|
||
if expert is None:
|
||
expert = self.experts.get("general")
|
||
route.append("expert:fallback-to-general")
|
||
else:
|
||
route.append(f"expert:{expert.name}")
|
||
|
||
# ---- Step 4: 生成 ----
|
||
try:
|
||
expert_resp = await expert.generate(query, classification.difficulty)
|
||
except Exception as e:
|
||
self.stats.record_error()
|
||
route.append(f"expert_error:{type(e).__name__}")
|
||
return await self._fallback_result(query, classification, route, start,
|
||
error=str(e), explanation=explanation)
|
||
|
||
# ---- Step 5: Judge 评估 ----
|
||
try:
|
||
evaluation = await self.judge.evaluate(query, expert_resp.text, domain)
|
||
except Exception:
|
||
evaluation = None
|
||
route.append("judge_error")
|
||
|
||
quality_score = evaluation.overall_score if evaluation else 0.0
|
||
route.append(f"judge:{quality_score:.2f}")
|
||
|
||
upgraded = False
|
||
final_resp = expert_resp
|
||
if evaluation is not None and evaluation.needs_fallback:
|
||
route.append("upgrade")
|
||
final_resp = await self._call_fallback(query)
|
||
upgraded = True
|
||
|
||
latency = now_ms() - start
|
||
result = self._finalize(query, classification, final_resp, quality_score=quality_score,
|
||
upgraded=upgraded, route=route, latency_ms=latency,
|
||
model_used=final_resp.model_used, cost_est=final_resp.cost_est,
|
||
explanation=explanation)
|
||
self._record(result, latency)
|
||
|
||
# 未升级的结果写缓存(解释不随缓存复用——缓存命中路径 explanation=None)
|
||
if self.cache_enabled and not upgraded and result.response:
|
||
self.cache.put(query, result.to_dict())
|
||
|
||
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,
|
||
route: list, start: float,
|
||
error: Optional[str] = None,
|
||
explanation=None) -> RouterResult:
|
||
"""兜底路径的公共收尾:调用大模型回退 -> finalize -> 记录指标。
|
||
|
||
低置信度直连、专家异常两条路径共用,避免收尾逻辑三处重复。
|
||
"""
|
||
fb = await self._call_fallback(query)
|
||
latency = now_ms() - start
|
||
result = self._finalize(query, classification, fb, quality_score=0.0,
|
||
upgraded=True, route=route, latency_ms=latency,
|
||
model_used=fb.model_used, cost_est=fb.cost_est,
|
||
error=error, explanation=explanation)
|
||
self._record(result, latency)
|
||
return result
|
||
|
||
async def _call_fallback(self, query: str) -> ExpertResponse:
|
||
try:
|
||
return await self.fallback.generate(query)
|
||
except Exception as e:
|
||
# 回退也失败:返回错误占位响应
|
||
return ExpertResponse(
|
||
text=f"[系统错误] 专家与大模型回退均失败:{type(e).__name__}: {e}",
|
||
model_used=f"error:{self.fallback.name}",
|
||
cost_est=0.0,
|
||
)
|
||
|
||
@staticmethod
|
||
def _finalize(query: str, classification: Classification, resp: ExpertResponse,
|
||
quality_score: float, upgraded: bool, route: list,
|
||
latency_ms: float, model_used: str, cost_est: float,
|
||
error: Optional[str] = None, explanation=None) -> RouterResult:
|
||
return RouterResult(
|
||
query=query,
|
||
response=resp.text,
|
||
domain=classification.domain,
|
||
difficulty=classification.difficulty,
|
||
confidence=classification.confidence,
|
||
upgraded=upgraded,
|
||
quality_score=quality_score,
|
||
model_used=model_used,
|
||
route=route,
|
||
latency_ms=latency_ms,
|
||
cache_hit=False,
|
||
cost_est=cost_est,
|
||
error=error,
|
||
route_explanation=explanation.to_dict() if explanation is not None else None,
|
||
)
|
||
|
||
def _record(self, result: RouterResult, latency_ms: float):
|
||
self.stats.record(
|
||
latency_ms,
|
||
result.domain,
|
||
result.difficulty,
|
||
result.upgraded,
|
||
result.cache_hit,
|
||
result.cache_level,
|
||
result.cost_est,
|
||
result.model_used,
|
||
)
|
||
|
||
# ---------------------------------------------------------------
|
||
def health(self) -> Dict[str, Any]:
|
||
return {
|
||
"status": "ok",
|
||
"domains": list(self.experts.keys()),
|
||
"classifier": type(self.classifier).__name__,
|
||
"judge": type(self.judge).__name__,
|
||
"fallback": type(self.fallback).__name__,
|
||
}
|
||
|
||
|
||
def build_router(config_path: Optional[str] = None) -> Router:
|
||
"""从配置构建完整 Router(默认 mock 全链路,零依赖可跑)。"""
|
||
config = load_config(config_path)
|
||
classifier = build_classifier(config.get("classifier", {}))
|
||
experts = build_expert_pool(config.get("experts", {}), config.get("domains", []))
|
||
judge = build_judge(config.get("judge", {}), config.get("router", {}).get("judge_fallback_threshold", 0.70))
|
||
fallback = build_fallback(config.get("fallback", {}))
|
||
cache_cfg = config.get("cache", {})
|
||
cache = RouterCache(
|
||
semantic_enabled=cache_cfg.get("semantic_enabled", True),
|
||
similarity_threshold=cache_cfg.get("similarity_threshold", 0.88),
|
||
promote_frequency=cache_cfg.get("promote_frequency", 5),
|
||
)
|
||
stats = Stats()
|
||
return Router(classifier, experts, judge, fallback, cache, stats, config)
|