Files
projectAIpopular/router_system/router.py
T
tzt a9f60159e7 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)
2026-09-19 09:23:04 +08:00

258 lines
11 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.
"""主路由器:协调 缓存 -> 分类 -> 专家 -> 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)