feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)

This commit is contained in:
tzt
2026-09-01 08:31:47 +08:00
parent 8d36eeec59
commit 3bbdcb7cc7
60 changed files with 7236 additions and 1160 deletions
+415 -217
View File
@@ -1,217 +1,415 @@
"""主路由器:协调 缓存 -> 分类 -> 专家 -> 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)
# ---------------------------------------------------------------
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}")
# 低置信度 -> 直接走大模型
if self.classifier.should_fallback(classification, self.low_confidence_threshold):
route.append("direct_fallback")
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)
self._record(result, latency)
return result
# ---- 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__}")
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=str(e))
self._record(result, latency)
return result
# ---- 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)
self._record(result, latency)
# 未升级的结果写缓存
if self.cache_enabled and not upgraded and result.response:
self.cache.put(query, result.to_dict())
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) -> 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,
)
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)
"""主路由器:两级路由(大领域组 → 组内路由模型 → 专业执行器)专家系统编排
两级体系(对齐用户架构决策):
第一级:用户通过接口指定大领域组(domain_group: tech/professional/lifestyle/general),
或系统自动检测(8 领域分类 → 映射到组
第二级:组内路由模型(RuleClassifier(domains=组内领域) + 组内知识/模板)识别具体
领域、子领域、拆解子任务 → 组内专业小模型/规则执行器
组内路由模型只认识本组领域:体积与匹配开销约为统一路由模型的 1/4,
且未来 L2 模型层可每组一个更小的路由模型,按需加载不常驻。
链路:缓存 → 组路由(分类/子领域/拆解) → 黑板+前向链 → DAG 执行 → 合并
→ Judge 校验 → (不达标)最后处理者升级 → 缓存/指标
L0 模式(默认):规则分类 + 规则拆解 + 规则执行器 + 规则 Judge —— 零模型参数、零 API。
L2 模式(可选):execution.expert_backend = hf/api 时,子任务改由专家池小模型执行
(≤8B,按需加载),其余流程不变。
"""
from __future__ import annotations
import uuid
from typing import Any, Dict, List, Optional
from .cache import RouterCache
from .classifier import BaseClassifier, RuleClassifier, build_classifier
from .config import load_config
from .executors import NodeExecutor, build_node_executor
from .experts import Expert, build_expert_pool
from .fallback import FallbackProvider, build_fallback
from .inference import InferenceEngine
from .judge import BaseJudge, build_judge
from .knowledge import KnowledgeBase
from .memory import TaskGraph, TaskNode, WorkingMemory
from .models import Classification, ExpertResponse, RouterResult, now_ms
from .planner import Planner
from .stats import Stats
from .trace import TraceStore
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,
kb: Optional[KnowledgeBase] = None,
planner: Optional[Planner] = 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)
# ---- 专家系统内核 ----
self.kb = kb or KnowledgeBase()
self.planner = planner or Planner(self.kb)
self.inference = InferenceEngine(self.kb)
ecfg = cfg.get("execution", {})
self.expert_backend = ecfg.get("expert_backend", "rule") # rule | hf | api
# 子任务执行后端(T1 抽象:NodeExecutor 工厂,新增后端无需改 Router)
self.node_executor: NodeExecutor = build_node_executor(
self.expert_backend, kb=self.kb, experts=experts)
# 推理链轨迹存储(T3:可解释性产品化)
self.trace_store = TraceStore()
# ---- 两级路由:大领域分组 + 组内路由模型(更小更专) ----
self.domain_groups: Dict[str, List[str]] = cfg.get("domain_groups", {}) or {}
if not self.domain_groups:
# 兜底:未配置时按单组(全部领域)处理,行为退化为一级路由
self.domain_groups = {"all": list(self.experts.keys())}
self._group_of_domain: Dict[str, str] = {}
for g, domains in self.domain_groups.items():
for d in domains:
self._group_of_domain[d] = g
# 组内路由模型:每组一个轻量分类器(只认识组内领域)
self._group_classifiers: Dict[str, RuleClassifier] = {
g: RuleClassifier(domains=domains)
for g, domains in self.domain_groups.items()
}
# ---------------------------------------------------------------
async def route(self, query: str, domain_group: Optional[str] = None) -> RouterResult:
"""两级路由入口。
domain_group 指定时:跳过 8 领域统一分类器,直接用组内路由模型
RuleClassifier(domains=组内领域))识别组内领域 —— 更小更专。
未指定时:统一分类器识别领域 → 自动映射到大领域组(向后兼容)。
"""
start = now_ms()
route: list = []
request_id = uuid.uuid4().hex[:12]
# ---- 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,
subdomain=cached.get("subdomain"),
subdomain2=cached.get("subdomain2"),
domain_group=cached.get("domain_group"),
request_id=request_id,
)
self._store_trace(
request_id=request_id, query=query, group=cached.get("domain_group"),
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=result.subdomain,
subdomain2=result.subdomain2, route=route, quality=result.quality_score,
upgraded=False, model=result.model_used, latency=latency,
cache_hit=True, cache_level=level,
)
self.stats.record(latency, result.domain, result.difficulty, False, True, level, 0.0, result.model_used)
return result
route.append("cache:miss")
# ---- Step 2: 组路由(两级第一级)→ 组内分类(两级第二级) ----
classifier = self.classifier
group = domain_group
if group is not None:
# 用户指定大领域:校验 + 使用组内路由模型
if group not in self.domain_groups:
raise ValueError(
f"未知大领域组: {group}(可用: {sorted(self.domain_groups)}"
)
classifier = self._group_classifiers[group]
route.append(f"group:{group}@explicit")
classification = classifier.classify(query)
if group is None:
# 自动检测:8 领域分类 → 映射大领域组
group = self._group_of_domain.get(classification.domain, "general")
route.append(f"group:{group}@auto")
route.append(f"classify:{classification.domain}@{classification.confidence:.2f}/{classification.difficulty}")
subdomain, subdomain2 = self._detect_subdomain(query, classification.domain)
if subdomain:
route.append(f"subdomain:{subdomain}")
if subdomain2:
route.append(f"subdomain2:{subdomain2}")
# ---- Step 3: 低置信度 -> 直接走最后处理者 ----
if classifier.should_fallback(classification, self.low_confidence_threshold):
route.append("direct_fallback")
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,
subdomain=subdomain, subdomain2=subdomain2,
domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=0.0,
upgraded=True, model=fb.model_used, latency=latency,
)
self._record(result, latency)
return result
# ---- Step 4: Planner 任务拆解(DAG ----
graph = self.planner.plan(query, classification)
route.extend(self.planner.explain_plan(graph))
# ---- Step 5: 黑板初始化 + 前向链(规则轨迹) ----
memory = WorkingMemory()
self.inference.initialize(
query, classification.domain, classification.difficulty,
classification.confidence, memory,
)
fired = self.inference.run(query, classification.domain, memory)
if fired:
route.append(f"rules:{','.join(fired[:5])}")
# ---- Step 6: DAG 顺序执行(拓扑序) ----
order = graph.topo_order()
last_model = f"rule:{classification.domain}"
for node in order:
last_model = await self._execute_node(graph, node, classification, memory, route) or last_model
# ---- Step 7: 黑板合并(节点输出 + 推理机规则产出) ----
response = memory.merge([n.id for n in order])
# 追加推理机规则产出的部分解(带 output 的知识规则,如 git/docker/常识条目)
node_ids = {n.id for n in order}
extra_sections = [sid for sid in memory.sections if sid not in node_ids]
extras = [memory.section(s) for s in extra_sections if memory.section(s)]
if extras:
extra_text = "\n\n".join(extras)
response = (response + "\n\n" + extra_text) if response.strip() else extra_text
if not response.strip():
response = "(规则执行器)未能生成有效回答:任务均未产出内容。"
route.append("merge:empty")
# ---- Step 8: Judge 校验 ----
try:
evaluation = await self.judge.evaluate(query, response, classification.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
if evaluation is not None and evaluation.needs_fallback:
route.append("upgrade")
fb = await self._call_fallback(query)
response = fb.text
last_model = fb.model_used
upgraded = True
latency = now_ms() - start
result = self._finalize(query, classification, ExpertResponse(
text=response, model_used=last_model, latency_ms=latency,
tokens=max(8, int(len(response) / 2.2)), cost_est=0.0,
), quality_score=quality_score, upgraded=upgraded, route=route,
latency_ms=latency, model_used=last_model, cost_est=0.0,
subdomain=subdomain, subdomain2=subdomain2, domain_group=group)
result.request_id = request_id
self._store_trace(
request_id=request_id, query=query, group=group,
domain=result.domain, difficulty=result.difficulty,
confidence=result.confidence, subdomain=subdomain,
subdomain2=subdomain2, route=route, quality=quality_score,
upgraded=upgraded, model=last_model, latency=latency,
)
self._record(result, latency)
# 未升级的结果写缓存
if self.cache_enabled and not upgraded and result.response:
self.cache.put(query, result.to_dict())
return result
# ---------------------------------------------------------------
def _store_trace(self, request_id: str, query: str, group: Optional[str],
domain: str, difficulty: str, confidence: float,
subdomain: Optional[str], subdomain2: Optional[str],
route: list, quality: float, upgraded: bool,
model: str, latency: float,
cache_hit: bool = False, cache_level: Optional[str] = None) -> None:
"""记录完整推理链到轨迹存储(T3:可解释性产品化)。"""
self.trace_store.put(request_id, {
"request_id": request_id,
"query": query,
"domain_group": group,
"domain": domain,
"difficulty": difficulty,
"confidence": round(confidence, 4),
"subdomain": subdomain,
"subdomain2": subdomain2,
"route": list(route),
"quality_score": round(quality, 4),
"upgraded": upgraded,
"model_used": model,
"latency_ms": round(latency, 2),
"cache_hit": cache_hit,
"cache_level": cache_level,
})
# ---------------------------------------------------------------
def _detect_subdomain(self, query: str, domain: str) -> tuple:
"""子领域识别:返回 (二级 subdomain, 三级 subdomain2)。
二级取领域内最高优先级带 subdomain 的命中规则;
三级取最高优先级带 subdomain2 的命中规则(可与二级来自不同规则)。
"""
hits = self.kb.match(query, domain=domain)
sub = None
sub2 = None
for h in hits:
if sub is None and h.subdomain:
sub = h.subdomain
if sub2 is None and h.subdomain2:
sub2 = h.subdomain2
if sub is not None and sub2 is not None:
break
return sub, sub2
# ---------------------------------------------------------------
async def _execute_node(self, graph: TaskGraph, node: TaskNode,
classification: Classification, memory: WorkingMemory,
route: list) -> Optional[str]:
"""执行一个子任务节点;返回使用的 model_used(失败返回 None)。"""
# 依赖检查:依赖失败/跳过 → 本节点跳过
for dep_id in node.deps:
dep = graph.get(dep_id)
if dep is not None and dep.status in ("failed", "skipped"):
node.status = "skipped"
route.append(f"{node.id}:{node.kind}:skip")
return None
node.status = "running"
try:
# NodeExecutor 后端执行(rule 零参数 / model 专家池 ≤8B)
resp = await self.node_executor.execute(
node, classification.domain, classification.difficulty, memory)
node.output = resp.text
node.status = "done"
memory.write_section(node.id, resp.text)
route.append(f"{node.id}:{node.kind}")
return resp.model_used
except Exception as e:
node.status = "failed"
node.error = str(e)
self.stats.record_error()
route.append(f"{node.id}:{node.kind}:error:{type(e).__name__}")
return None
# ---------------------------------------------------------------
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,
subdomain: Optional[str] = None,
subdomain2: Optional[str] = None,
domain_group: Optional[str] = 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,
subdomain=subdomain,
subdomain2=subdomain2,
domain_group=domain_group,
)
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()),
"domain_groups": self.domain_groups,
"classifier": type(self.classifier).__name__,
"judge": type(self.judge).__name__,
"fallback": type(self.fallback).__name__,
"planner": type(self.planner).__name__,
"execution_mode": self.expert_backend,
"rules": self.kb.rules_count(),
}
def build_router(config_path: Optional[str] = None) -> Router:
"""从配置构建完整 Router(默认 L0 专家系统模式:零参数可跑)。"""
config = load_config(config_path)
kb = KnowledgeBase()
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),
kb=kb)
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),
)
ecfg = config.get("execution", {})
planner = Planner(kb, max_depth=ecfg.get("max_plan_depth", 3))
stats = Stats()
return Router(classifier, experts, judge, fallback, cache, stats, config,
kb=kb, planner=planner)