feat(v3): Web 应用化基线(异步任务/SSE/llama-server 管理/Vue SPA 四页 + 设置页整页滚动修复)
This commit is contained in:
@@ -59,7 +59,7 @@ class ArchitectClient:
|
||||
def __init__(
|
||||
self,
|
||||
model: str,
|
||||
base_url: str = "https://api.deepseek.com/v1",
|
||||
base_url: str = "https://api.deepseek.com",
|
||||
api_key: Optional[str] = None,
|
||||
temperature: float = 0.2,
|
||||
timeout_s: float = 60.0,
|
||||
@@ -220,8 +220,8 @@ def build_architect(cfg: Dict[str, Any],
|
||||
_env = get_env or os.environ.get
|
||||
key = cfg.get("api_key") or _env(cfg.get("api_key_env", "DEEPSEEK_API_KEY"))
|
||||
return ArchitectClient(
|
||||
model=cfg.get("model", "deepseek-chat"),
|
||||
base_url=cfg.get("base_url", "https://api.deepseek.com/v1"),
|
||||
model=cfg.get("model", "deepseek-v4-flash"),
|
||||
base_url=cfg.get("base_url", "https://api.deepseek.com"),
|
||||
api_key=key,
|
||||
temperature=float(cfg.get("temperature", 0.2)),
|
||||
timeout_s=float(cfg.get("timeout_s", 60)),
|
||||
|
||||
@@ -10,7 +10,7 @@ confidence = 1 - exp(-s),保证 s=1 -> 0.63,s=2 -> 0.86,s=3 -> 0.95。
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .difficulty import estimate_difficulty
|
||||
from .models import Classification
|
||||
@@ -51,6 +51,14 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
|
||||
("知识产权", 1.1), ("版权", 0.9), ("专利", 0.9), ("违约", 0.9), ("赔偿", 0.8),
|
||||
("仲裁", 0.9), ("劳动法", 1.0), ("刑法", 1.0), ("民法典", 1.0),
|
||||
("法规", 0.8), ("条款", 0.7), ("律师", 0.8), ("起诉", 0.9), ("判决", 0.9),
|
||||
# 劳动法
|
||||
("加班", 0.9), ("加班费", 1.0), ("工资", 0.8), ("辞退", 0.9), ("裁员", 0.9),
|
||||
("试用期", 0.9), ("社保", 0.8), ("公积金", 0.8), ("年假", 0.9), ("离职", 0.8),
|
||||
("解除劳动合同", 1.1), ("经济补偿", 1.0), ("竞业", 1.0),
|
||||
# 房产/婚姻/消费者
|
||||
("租房", 0.9), ("买房", 0.9), ("购房", 0.9), ("押金", 0.8), ("房贷", 0.9),
|
||||
("离婚", 1.0), ("继承", 0.9), ("遗产", 0.9), ("抚养权", 0.9), ("遗嘱", 0.9),
|
||||
("退款", 0.9), ("退货", 0.8), ("消费者", 0.8), ("七天无理由", 1.0), ("维权", 0.8),
|
||||
("law", 1.0), ("legal", 1.1), ("contract", 1.0), ("compliance", 1.0),
|
||||
("litigation", 1.0), ("copyright", 0.9), ("patent", 0.9), ("trademark", 0.9),
|
||||
("liability", 0.9), ("regulatory", 0.8), ("jurisdiction", 0.9),
|
||||
@@ -61,11 +69,17 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
|
||||
("医生", 0.9), ("血压", 0.9), ("高血压", 1.0), ("糖尿病", 1.0), ("感冒", 0.9),
|
||||
("剂量", 0.9), ("副作用", 0.9), ("手术", 0.9), ("患者", 0.9),
|
||||
("吃药", 0.9), ("发烧", 1.0), ("疫苗", 0.9), ("感染", 0.9), ("体检", 0.7),
|
||||
# 急救/消化/心理/营养/儿科
|
||||
("烫伤", 1.0), ("烧伤", 1.0), ("止血", 0.9), ("扭伤", 0.9), ("中暑", 1.0),
|
||||
("急救", 0.9), ("腹泻", 0.9), ("拉肚子", 0.9), ("便秘", 0.9), ("胃", 0.7),
|
||||
("失眠", 0.9), ("焦虑", 0.9), ("抑郁", 0.9), ("压力", 0.6), ("睡眠", 0.7),
|
||||
("减肥", 0.8), ("营养", 0.7), ("卡路里", 0.9), ("儿童", 0.8), ("婴儿", 0.9),
|
||||
("宝宝", 0.8), ("抗生素", 0.9), ("止咳", 0.9),
|
||||
("medical", 1.0), ("patient", 0.9), ("symptom", 1.0), ("disease", 0.9),
|
||||
("diagnosis", 1.0), ("treatment", 0.8), ("prescription", 1.0),
|
||||
("dosage", 0.9), ("side effect", 0.9), ("hypertension", 1.0),
|
||||
("diabetes", 1.0), ("surgery", 0.8), ("clinic", 0.7), ("vaccine", 0.9),
|
||||
("infection", 0.9),
|
||||
("infection", 0.9), ("first aid", 0.9), ("insomnia", 0.9),
|
||||
],
|
||||
"general": [
|
||||
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
|
||||
@@ -74,6 +88,40 @@ DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
|
||||
("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3),
|
||||
("推荐", 0.3), ("评价", 0.3),
|
||||
],
|
||||
"finance": [
|
||||
("理财", 1.0), ("投资", 1.0), ("基金", 1.0), ("股票", 1.0), ("债券", 0.9),
|
||||
("存款", 0.9), ("储蓄", 0.8), ("利率", 0.8), ("利息", 0.8), ("贷款", 1.0),
|
||||
("房贷", 1.0), ("月供", 0.9), ("保险", 0.9), ("理赔", 0.9), ("保费", 0.8),
|
||||
("信用卡", 1.0), ("征信", 0.9), ("逾期", 0.9), ("分期", 0.8), ("记账", 0.7),
|
||||
("预算", 0.7), ("理财规划", 1.0), ("收益率", 0.9), ("定投", 0.9),
|
||||
("invest", 0.8), ("fund", 0.8), ("stock", 0.9), ("loan", 0.9),
|
||||
("mortgage", 0.9), ("insurance", 0.9), ("credit card", 0.9),
|
||||
("finance", 0.8), ("money", 0.6), ("lpr", 0.9), ("投资理财", 1.1),
|
||||
],
|
||||
"life": [
|
||||
("菜谱", 0.9), ("做饭", 0.8), ("烹饪", 0.9), ("美食", 0.8), ("做法", 0.7),
|
||||
("旅行", 0.9), ("旅游", 0.9), ("攻略", 0.8), ("机票", 0.8), ("酒店", 0.7),
|
||||
("签证", 0.9), ("景点", 0.8), ("自驾", 0.8),
|
||||
("装修", 0.9), ("收纳", 0.8), ("家居", 0.7), ("清洁", 0.7), ("打扫", 0.7),
|
||||
("宠物", 0.9), ("猫", 0.7), ("狗", 0.7), ("猫粮", 0.9), ("驱虫", 0.9),
|
||||
("健身", 0.9), ("锻炼", 0.8), ("跑步", 0.8), ("增肌", 0.9), ("减脂", 0.9),
|
||||
("瑜伽", 0.8), ("天气", 0.7), ("气温", 0.7),
|
||||
("recipe", 0.8), ("travel", 0.9), ("trip", 0.8), ("pet", 0.8),
|
||||
("workout", 0.9), ("gym", 0.8), ("weather", 0.7), ("cook", 0.8),
|
||||
],
|
||||
"education": [
|
||||
("学习方法", 1.0), ("怎么学", 0.7), ("高效学习", 1.0), ("记忆", 0.6), ("复习", 0.7),
|
||||
("预习", 0.7), ("笔记", 0.6), ("专注", 0.6), ("拖延", 0.7), ("学习效率", 0.9),
|
||||
("考试", 0.9), ("备考", 1.0), ("刷题", 0.9), ("模拟考", 0.9), ("中考", 0.9),
|
||||
("高考", 0.9), ("考研", 0.9), ("考前", 0.7),
|
||||
("英语", 0.8), ("单词", 0.7), ("口语", 0.8), ("听力", 0.7), ("雅思", 1.0),
|
||||
("托福", 1.0), ("四级", 0.9), ("六级", 0.9), ("背单词", 0.9),
|
||||
("选课", 0.9), ("课程", 0.6), ("专业选择", 0.9), ("报班", 0.8), ("网课", 0.7),
|
||||
("自学", 0.7), ("职业规划", 1.0), ("求职", 0.9), ("面试", 0.8), ("简历", 0.8),
|
||||
("实习", 0.7), ("跳槽", 0.8), ("转行", 0.9),
|
||||
("study", 0.8), ("exam", 0.9), ("language", 0.7), ("career", 0.8),
|
||||
("interview", 0.8), ("education", 0.7), ("learn", 0.6),
|
||||
],
|
||||
}
|
||||
|
||||
_STOPWORDS = {
|
||||
@@ -92,11 +140,21 @@ class BaseClassifier:
|
||||
|
||||
|
||||
class RuleClassifier(BaseClassifier):
|
||||
"""基于关键词规则的分类器(零依赖)。"""
|
||||
"""基于关键词规则的分类器(零依赖)。
|
||||
|
||||
def __init__(self, confidence_floor: float = 0.55):
|
||||
domains 参数(可选):限定只对部分领域打分 —— 两级路由中,
|
||||
每个大领域的组内路由模型用 RuleClassifier(domains=组内领域),
|
||||
只认识本组领域,体积与匹配开销约为统一分类器的 1/4。
|
||||
"""
|
||||
|
||||
def __init__(self, confidence_floor: float = 0.55,
|
||||
domains: Optional[List[str]] = None):
|
||||
self.confidence_floor = confidence_floor
|
||||
self.rules = DOMAIN_RULES
|
||||
if domains is None:
|
||||
self.rules = DOMAIN_RULES
|
||||
else:
|
||||
self.rules = {d: DOMAIN_RULES[d] for d in domains if d in DOMAIN_RULES}
|
||||
self.domains = list(self.rules.keys())
|
||||
|
||||
def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
|
||||
q = query.lower()
|
||||
@@ -159,7 +217,7 @@ class HuggingFaceClassifier(BaseClassifier):
|
||||
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str, num_labels: int = 5, confidence_floor: float = 0.55):
|
||||
def __init__(self, model_name: str, num_labels: int = 8, confidence_floor: float = 0.55):
|
||||
try:
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
except ImportError as e:
|
||||
@@ -170,7 +228,8 @@ class HuggingFaceClassifier(BaseClassifier):
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
model_name, num_labels=num_labels
|
||||
)
|
||||
self.labels = ["code", "math", "legal", "medical", "general"]
|
||||
self.labels = ["code", "math", "legal", "medical", "general",
|
||||
"finance", "life", "education"]
|
||||
self.confidence_floor = confidence_floor
|
||||
|
||||
def classify(self, query: str) -> Classification:
|
||||
|
||||
+118
-100
@@ -1,100 +1,118 @@
|
||||
"""配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。
|
||||
|
||||
设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。
|
||||
默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml"
|
||||
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"system": {"name": "multi-expert-router", "version": "0.1.0"},
|
||||
"router": {
|
||||
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走大模型
|
||||
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级大模型
|
||||
"default_temperature": 0.2,
|
||||
},
|
||||
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
|
||||
"domains": ["code", "math", "legal", "medical", "general"],
|
||||
"experts": {
|
||||
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
|
||||
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
},
|
||||
"fallback": {
|
||||
"type": "mock",
|
||||
"model": "deepseek-chat",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
},
|
||||
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"cache": {
|
||||
"enabled": True,
|
||||
"semantic_enabled": True,
|
||||
"similarity_threshold": 0.88,
|
||||
"promote_frequency": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_defaults() -> Dict[str, Any]:
|
||||
return _DEFAULTS
|
||||
|
||||
|
||||
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
|
||||
json_path = path.with_suffix(".json")
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将用户配置与内置默认配置做一层合并(用户优先)。"""
|
||||
merged = dict(_DEFAULTS)
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||||
merged[k] = {**merged[k], **v}
|
||||
else:
|
||||
merged[k] = v
|
||||
return merged
|
||||
|
||||
|
||||
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]:
|
||||
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。"""
|
||||
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if cfg_path.exists():
|
||||
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path)
|
||||
if data is not None:
|
||||
return _merge_defaults(data)
|
||||
return dict(_DEFAULTS)
|
||||
|
||||
|
||||
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""从环境变量读取 API Key(用于 api 类型后端)。"""
|
||||
env_name = cfg.get("api_key_env") or "API_KEY"
|
||||
return os.environ.get(env_name) or None␍
|
||||
"""配置加载:优先 YAML(若安装了 pyyaml),否则回退 JSON。
|
||||
|
||||
设计原则:router_system 核心零依赖,因此 pyyaml 是"可选"的。
|
||||
默认 config/config.yaml 存在;若 pyyaml 不可用,可提供同名 .json。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "config.yaml"
|
||||
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"system": {"name": "multi-expert-router", "version": "0.1.0"},
|
||||
"router": {
|
||||
"low_confidence_threshold": 0.60, # 分类置信度低于此值 -> 直接走最后处理者
|
||||
"judge_fallback_threshold": 0.70, # Judge 质量分低于此值 -> 升级最后处理者
|
||||
"default_temperature": 0.2,
|
||||
},
|
||||
"execution": {
|
||||
"mode": "rule", # rule(L0 零参数)| hybrid
|
||||
"planner": "rule", # rule | hf
|
||||
"expert_backend": "rule", # rule(规则执行器)| hf | api
|
||||
"model_level": "L0", # L0 | L1 | L2
|
||||
"max_plan_depth": 3,
|
||||
},
|
||||
"classifier": {"type": "rule", "model": "Qwen/Qwen3-0.6B", "confidence_floor": 0.55},
|
||||
"domains": ["code", "math", "legal", "medical", "finance", "life", "education", "general"],
|
||||
# 两级路由:大领域分组(用户接口指定 group → 组内路由模型 → 组内专业小模型)
|
||||
# 组内路由模型只识别本组领域,体积约为统一路由模型的 1/4
|
||||
"domain_groups": {
|
||||
"tech": ["code", "math"],
|
||||
"professional": ["legal", "medical", "finance"],
|
||||
"lifestyle": ["life", "education"],
|
||||
"general": ["general"],
|
||||
},
|
||||
"experts": {
|
||||
"code": {"type": "mock", "model": "Qwen/Qwen2.5-Coder-7B-Instruct"},
|
||||
"math": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"legal": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"medical": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"finance": {"type": "mock", "model": "Qwen/Qwen3-4B-Instruct"},
|
||||
"life": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"education": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"general": {"type": "mock", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
},
|
||||
"fallback": {
|
||||
"type": "mock",
|
||||
"model": "deepseek-v4-flash",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
},
|
||||
"judge": {"type": "rule", "model": "Qwen/Qwen3-1.7B-Instruct"},
|
||||
"cache": {
|
||||
"enabled": True,
|
||||
"semantic_enabled": True,
|
||||
"similarity_threshold": 0.88,
|
||||
"promote_frequency": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_defaults() -> Dict[str, Any]:
|
||||
return _DEFAULTS
|
||||
|
||||
|
||||
def _try_load_yaml(path: Path) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _try_load_json(path: Path) -> Optional[Dict[str, Any]]:
|
||||
json_path = path.with_suffix(".json")
|
||||
if not json_path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_defaults(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""将用户配置与内置默认配置做一层合并(用户优先)。"""
|
||||
merged = dict(_DEFAULTS)
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict) and isinstance(merged.get(k), dict):
|
||||
merged[k] = {**merged[k], **v}
|
||||
else:
|
||||
merged[k] = v
|
||||
return merged
|
||||
|
||||
|
||||
def load_config(path: Optional[Path | str] = None) -> Dict[str, Any]:
|
||||
"""加载配置,返回 dict。文件不存在或解析失败时返回内置默认配置。"""
|
||||
cfg_path = Path(path) if path else DEFAULT_CONFIG_PATH
|
||||
if cfg_path.exists():
|
||||
data = _try_load_yaml(cfg_path) or _try_load_json(cfg_path)
|
||||
if data is not None:
|
||||
return _merge_defaults(data)
|
||||
return dict(_DEFAULTS)
|
||||
|
||||
|
||||
def get_api_key(cfg: Dict[str, Any]) -> Optional[str]:
|
||||
"""从环境变量读取 API Key(用于 api 类型后端)。"""
|
||||
env_name = cfg.get("api_key_env") or "API_KEY"
|
||||
return os.environ.get(env_name) or None
|
||||
|
||||
+185
-99
@@ -1,99 +1,185 @@
|
||||
"""大模型回退层:Mock 与 OpenAI 兼容 API 两种后端。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import ExpertResponse
|
||||
|
||||
|
||||
class FallbackProvider:
|
||||
name: str = "fallback"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockFallback(FallbackProvider):
|
||||
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
|
||||
|
||||
def __init__(self, model: str = "mock-large"):
|
||||
self.model = model
|
||||
self.name = f"fallback-{model}"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
await asyncio.sleep(0.002)
|
||||
body = (
|
||||
f"(大模型回退)「{query}」\n\n"
|
||||
"这是一条来自大模型回退路径的完整回答。\n"
|
||||
"要点:\n"
|
||||
"1. 对复杂/跨域任务给出综合推理\n"
|
||||
"2. 补充领域专家未覆盖的上下文\n"
|
||||
"3. 给出可执行的后续建议\n"
|
||||
)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=2.0,
|
||||
tokens=120,
|
||||
cost_est=2.0 * 120 / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
class APIFallback(FallbackProvider):
|
||||
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.name = f"fallback-{model}"
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=90.0)
|
||||
return self._client
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
client = self._get_client()
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": query}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
body = data["choices"][0]["message"]["content"]
|
||||
usage = data.get("usage", {})
|
||||
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=0.0,
|
||||
tokens=tokens,
|
||||
cost_est=2.0 * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def build_fallback(cfg: Dict) -> FallbackProvider:
|
||||
"""cfg 为 fallback 段配置。"""
|
||||
ftype = cfg.get("type", "mock")
|
||||
model = cfg.get("model", "deepseek-chat")
|
||||
if ftype == "mock":
|
||||
return MockFallback(model=model)
|
||||
if ftype == "api":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
|
||||
)
|
||||
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com/v1"), api_key)
|
||||
raise ValueError(f"未知 fallback 类型: {ftype}(支持 mock | api)")␍
|
||||
"""大模型回退层(最后处理者):Mock / 降级模板 / 本地模型 / OpenAI 兼容 API。
|
||||
|
||||
- NoneFallback :降级模板(零参数,明确告知超出知识库范围)—— L0 最小可用
|
||||
- MockFallback :确定性 mock 大模型(零参数,测试升级路径用)
|
||||
- LocalFallback :本地小模型(≤8B,Q4 量化,OpenAI 兼容端点如 Ollama/vLLM)—— 按需加载
|
||||
- APIFallback :远程 OpenAI 兼容 API(可选,默认关闭)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .models import ExpertResponse
|
||||
|
||||
|
||||
class FallbackProvider:
|
||||
name: str = "fallback"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NoneFallback(FallbackProvider):
|
||||
"""降级模板:零参数兜底,明确告知查询超出知识库范围。"""
|
||||
|
||||
def __init__(self, model: str = "none"):
|
||||
self.model = model
|
||||
self.name = "fallback-none"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
body = (
|
||||
f"(降级响应)「{query}」\n\n"
|
||||
"当前查询超出本地知识库可处理范围(低置信度或质量校验未通过)。\n"
|
||||
"可选处理:\n"
|
||||
"1. 换个更明确的问法重试\n"
|
||||
"2. 启用 L2 本地小模型或配置最后处理者(fallback.type: local)\n"
|
||||
)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=0.0,
|
||||
tokens=80,
|
||||
cost_est=0.0,
|
||||
)
|
||||
|
||||
|
||||
class MockFallback(FallbackProvider):
|
||||
"""确定性 mock 大模型:标识为 fallback,便于测试升级路径。"""
|
||||
|
||||
def __init__(self, model: str = "mock-large"):
|
||||
self.model = model
|
||||
self.name = f"fallback-{model}"
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
await asyncio.sleep(0.002)
|
||||
body = (
|
||||
f"(大模型回退)「{query}」\n\n"
|
||||
"这是一条来自大模型回退路径的完整回答。\n"
|
||||
"要点:\n"
|
||||
"1. 对复杂/跨域任务给出综合推理\n"
|
||||
"2. 补充领域专家未覆盖的上下文\n"
|
||||
"3. 给出可执行的后续建议\n"
|
||||
)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=2.0,
|
||||
tokens=120,
|
||||
cost_est=2.0 * 120 / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
class LocalFallback(FallbackProvider):
|
||||
"""本地小模型最后处理者(≤8B,如 DeepSeek-R1-Distill-Qwen-7B Q4)。
|
||||
|
||||
通过 OpenAI 兼容端点调用(Ollama 默认 11434/v1,vLLM 默认 8001/v1),
|
||||
模型按需加载、用完即卸载(由本地推理服务管理),不常驻显存。
|
||||
"""
|
||||
|
||||
def __init__(self, model: str, base_url: str = "http://127.0.0.1:11434/v1",
|
||||
api_key: str = ""):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.name = f"fallback-local-{model}"
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=120.0)
|
||||
return self._client
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
client = self._get_client()
|
||||
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": query}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
body = data["choices"][0]["message"]["content"]
|
||||
usage = data.get("usage", {})
|
||||
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=0.0,
|
||||
tokens=tokens,
|
||||
cost_est=0.0, # 本地推理成本按电费计,模型层成本记为 0(相对 API)
|
||||
)
|
||||
|
||||
|
||||
class APIFallback(FallbackProvider):
|
||||
"""OpenAI 兼容大模型 API(如 DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.name = f"fallback-{model}"
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=90.0)
|
||||
return self._client
|
||||
|
||||
async def generate(self, query: str) -> ExpertResponse:
|
||||
client = self._get_client()
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [{"role": "user", "content": query}],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
body = data["choices"][0]["message"]["content"]
|
||||
usage = data.get("usage", {})
|
||||
tokens = usage.get("completion_tokens", int(len(body) / 2.2))
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=0.0,
|
||||
tokens=tokens,
|
||||
cost_est=2.0 * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def build_fallback(cfg: Dict) -> FallbackProvider:
|
||||
"""cfg 为 fallback 段配置。"""
|
||||
ftype = cfg.get("type", "mock")
|
||||
model = cfg.get("model", "deepseek-v4-flash")
|
||||
if ftype == "none":
|
||||
return NoneFallback(model=model)
|
||||
if ftype == "mock":
|
||||
return MockFallback(model=model)
|
||||
if ftype == "local":
|
||||
return LocalFallback(
|
||||
model,
|
||||
cfg.get("base_url", "http://127.0.0.1:11434/v1"),
|
||||
cfg.get("api_key", ""),
|
||||
)
|
||||
if ftype == "api":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
if not api_key:
|
||||
raise RuntimeError(
|
||||
f"APIFallback 缺少 API Key:请设置环境变量 {cfg.get('api_key_env')} 或配置 api_key"
|
||||
)
|
||||
return APIFallback(model, cfg.get("base_url", "https://api.deepseek.com"), api_key)
|
||||
raise ValueError(f"未知 fallback 类型: {ftype}(支持 none | mock | local | api)")
|
||||
|
||||
+193
-174
@@ -1,174 +1,193 @@
|
||||
"""质量控制器(Judge):评估专家输出,决定是否升级大模型。
|
||||
|
||||
- RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示),
|
||||
稳定可测,适合 MVP 与离线演示。
|
||||
- LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。
|
||||
|
||||
设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .experts import extract_content_terms
|
||||
|
||||
# 各领域期望的响应长度范围(字符数)
|
||||
_EXPECTED_LEN = {
|
||||
"code": (60, 2000),
|
||||
"math": (60, 2000),
|
||||
"legal": (80, 3000),
|
||||
"medical": (80, 3000),
|
||||
"general": (40, 2000),
|
||||
}
|
||||
|
||||
# 领域格式检查:响应应包含的标记
|
||||
_DOMAIN_FORMAT_HINTS = {
|
||||
"code": ["```", "def ", "function", "class "],
|
||||
"math": ["步骤", "推导", "=", "解", "step"],
|
||||
"legal": ["⚠", "法律", "意见", "合规", "contract", "law"],
|
||||
"medical": ["⚠", "就医", "医生", "症状", "诊断", "symptom"],
|
||||
"general": [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityEvaluation:
|
||||
overall_score: float
|
||||
scores: Dict[str, float] = field(default_factory=dict)
|
||||
needs_fallback: bool = False
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"overall_score": round(self.overall_score, 4),
|
||||
"scores": {k: round(v, 4) for k, v in self.scores.items()},
|
||||
"needs_fallback": self.needs_fallback,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
class BaseJudge:
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RuleJudge(BaseJudge):
|
||||
"""启发式质量评估(零依赖)。"""
|
||||
|
||||
def __init__(self, fallback_threshold: float = 0.70):
|
||||
self.fallback_threshold = fallback_threshold
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
scores: Dict[str, float] = {}
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1) 内容覆盖度:查询中的内容词有多少出现在响应里
|
||||
terms = extract_content_terms(query)
|
||||
if terms:
|
||||
hit = sum(1 for t in terms if t in response.lower())
|
||||
coverage = hit / len(terms)
|
||||
scores["coverage"] = coverage
|
||||
if coverage < 0.4:
|
||||
reasons.append(f"内容覆盖度低 ({coverage:.0%})")
|
||||
else:
|
||||
scores["coverage"] = 1.0
|
||||
|
||||
# 2) 长度充分性
|
||||
lo, hi = _EXPECTED_LEN.get(domain, (40, 2000))
|
||||
n = len(response)
|
||||
if n < lo:
|
||||
scores["length"] = max(0.0, n / lo)
|
||||
reasons.append(f"响应过短 ({n} 字符)")
|
||||
elif n > hi:
|
||||
scores["length"] = 0.8
|
||||
reasons.append(f"响应过长 ({n} 字符)")
|
||||
else:
|
||||
scores["length"] = 1.0
|
||||
|
||||
# 3) 领域格式检查
|
||||
hints = _DOMAIN_FORMAT_HINTS.get(domain, [])
|
||||
if hints:
|
||||
hit_hints = sum(1 for h in hints if h in response)
|
||||
scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints)
|
||||
if hit_hints == 0:
|
||||
reasons.append("缺少领域格式特征")
|
||||
else:
|
||||
scores["format"] = 1.0
|
||||
|
||||
# 4) 安全/免责提示(法律、医疗领域应有警示语)
|
||||
if domain in ("legal", "medical") and ("⚠" not in response and "提示" not in response):
|
||||
scores["safety"] = 0.6
|
||||
reasons.append("缺少免责提示")
|
||||
else:
|
||||
scores["safety"] = 1.0
|
||||
|
||||
weights = {"coverage": 0.4, "length": 0.2, "format": 0.2, "safety": 0.2}
|
||||
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
|
||||
needs = overall < self.fallback_threshold
|
||||
if needs:
|
||||
reasons.append("质量分低于阈值,建议升级大模型")
|
||||
return QualityEvaluation(
|
||||
overall_score=round(overall, 4),
|
||||
scores=scores,
|
||||
needs_fallback=needs,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
class LLMJudge(BaseJudge):
|
||||
"""可选:LLM-as-Judge(API 后端)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._client
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
client = self._get_client()
|
||||
prompt = (
|
||||
f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n"
|
||||
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n"
|
||||
f"只输出一个 0 到 1 之间的数字。"
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
score = float(resp.json()["choices"][0]["message"]["content"].strip())
|
||||
score = max(0.0, min(1.0, score))
|
||||
except Exception:
|
||||
score = 0.5
|
||||
return QualityEvaluation(
|
||||
overall_score=score,
|
||||
scores={"llm_judge": score},
|
||||
needs_fallback=score < self.fallback_threshold,
|
||||
)
|
||||
|
||||
|
||||
def build_judge(cfg: Dict, fallback_threshold: float = 0.70) -> BaseJudge:
|
||||
"""cfg 为 judge 段配置。"""
|
||||
jtype = cfg.get("type", "rule")
|
||||
if jtype == "rule":
|
||||
return RuleJudge(fallback_threshold=fallback_threshold)
|
||||
if jtype == "llm":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
return LLMJudge(
|
||||
cfg.get("model", "deepseek-chat"),
|
||||
cfg.get("base_url", "https://api.deepseek.com/v1"),
|
||||
api_key or "",
|
||||
fallback_threshold=fallback_threshold,
|
||||
)
|
||||
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm)")␍
|
||||
"""质量控制器(Judge):评估专家输出,决定是否升级大模型。
|
||||
|
||||
- RuleJudge:零依赖启发式(内容覆盖度 / 长度充分性 / 领域格式 / 安全提示),
|
||||
稳定可测,适合 MVP 与离线演示。
|
||||
- LLMJudge:可选,基于 transformers 小模型或 API 的 LLM-as-Judge。
|
||||
|
||||
设计对齐实现方案:overall_score < judge_fallback_threshold -> 升级大模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
|
||||
from .experts import extract_content_terms
|
||||
|
||||
# 各领域期望的响应长度范围(字符数)
|
||||
_EXPECTED_LEN = {
|
||||
"code": (60, 2000),
|
||||
"math": (60, 2000),
|
||||
"legal": (80, 3000),
|
||||
"medical": (80, 3000),
|
||||
"general": (40, 2000),
|
||||
}
|
||||
|
||||
# 领域格式检查:响应应包含的标记
|
||||
_DOMAIN_FORMAT_HINTS = {
|
||||
"code": ["```", "def ", "function", "class "],
|
||||
"math": ["步骤", "推导", "=", "解", "step"],
|
||||
"legal": ["⚠", "法律", "意见", "合规", "contract", "law"],
|
||||
"medical": ["⚠", "就医", "医生", "症状", "诊断", "symptom"],
|
||||
"general": [],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QualityEvaluation:
|
||||
overall_score: float
|
||||
scores: Dict[str, float] = field(default_factory=dict)
|
||||
needs_fallback: bool = False
|
||||
reasons: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"overall_score": round(self.overall_score, 4),
|
||||
"scores": {k: round(v, 4) for k, v in self.scores.items()},
|
||||
"needs_fallback": self.needs_fallback,
|
||||
"reasons": self.reasons,
|
||||
}
|
||||
|
||||
|
||||
class BaseJudge:
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class RuleJudge(BaseJudge):
|
||||
"""启发式质量评估(零依赖)。"""
|
||||
|
||||
def __init__(self, fallback_threshold: float = 0.70, kb=None):
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self.kb = kb # 可选知识库:facts 维度校验用
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
scores: Dict[str, float] = {}
|
||||
reasons: List[str] = []
|
||||
|
||||
# 1) 内容覆盖度:查询中的内容词有多少出现在响应里
|
||||
terms = extract_content_terms(query)
|
||||
if terms:
|
||||
hit = sum(1 for t in terms if t in response.lower())
|
||||
coverage = hit / len(terms)
|
||||
scores["coverage"] = coverage
|
||||
if coverage < 0.4:
|
||||
reasons.append(f"内容覆盖度低 ({coverage:.0%})")
|
||||
else:
|
||||
scores["coverage"] = 1.0
|
||||
|
||||
# 2) 长度充分性
|
||||
lo, hi = _EXPECTED_LEN.get(domain, (40, 2000))
|
||||
n = len(response)
|
||||
if n < lo:
|
||||
scores["length"] = max(0.0, n / lo)
|
||||
reasons.append(f"响应过短 ({n} 字符)")
|
||||
elif n > hi:
|
||||
scores["length"] = 0.8
|
||||
reasons.append(f"响应过长 ({n} 字符)")
|
||||
else:
|
||||
scores["length"] = 1.0
|
||||
|
||||
# 3) 领域格式检查
|
||||
hints = _DOMAIN_FORMAT_HINTS.get(domain, [])
|
||||
if hints:
|
||||
hit_hints = sum(1 for h in hints if h in response)
|
||||
scores["format"] = min(1.0, 0.4 + 0.2 * hit_hints)
|
||||
if hit_hints == 0:
|
||||
reasons.append("缺少领域格式特征")
|
||||
else:
|
||||
scores["format"] = 1.0
|
||||
|
||||
# 4) 安全/免责提示(法律、医疗领域应有警示语)
|
||||
if domain in ("legal", "medical") and ("⚠" not in response and "提示" not in response):
|
||||
scores["safety"] = 0.6
|
||||
reasons.append("缺少免责提示")
|
||||
else:
|
||||
scores["safety"] = 1.0
|
||||
|
||||
# 5) 领域知识引用(facts 维度):法律/医疗响应应覆盖查询命中的知识条目
|
||||
if domain in ("legal", "medical") and self.kb is not None:
|
||||
facts = self.kb.facts(domain)
|
||||
hit_facts = [f for f in facts if any(k in query for k in f.get("keywords", []))]
|
||||
if hit_facts:
|
||||
ok = sum(
|
||||
1 for f in hit_facts
|
||||
if any(k in response for k in f.get("keywords", []))
|
||||
)
|
||||
scores["facts"] = ok / len(hit_facts)
|
||||
if scores["facts"] < 0.6:
|
||||
reasons.append(f"领域知识引用不足 ({scores['facts']:.0%})")
|
||||
else:
|
||||
scores["facts"] = 1.0
|
||||
else:
|
||||
scores["facts"] = 1.0
|
||||
|
||||
weights = {"coverage": 0.35, "length": 0.15, "format": 0.15,
|
||||
"safety": 0.15, "facts": 0.2}
|
||||
overall = sum(scores.get(k, 0.0) * w for k, w in weights.items())
|
||||
needs = overall < self.fallback_threshold
|
||||
if needs:
|
||||
reasons.append("质量分低于阈值,建议升级大模型")
|
||||
return QualityEvaluation(
|
||||
overall_score=round(overall, 4),
|
||||
scores=scores,
|
||||
needs_fallback=needs,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
|
||||
class LLMJudge(BaseJudge):
|
||||
"""可选:LLM-as-Judge(API 后端)。"""
|
||||
|
||||
def __init__(self, model: str, base_url: str, api_key: str, fallback_threshold: float = 0.70):
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.fallback_threshold = fallback_threshold
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
import httpx
|
||||
self._client = httpx.AsyncClient(timeout=60.0)
|
||||
return self._client
|
||||
|
||||
async def evaluate(self, query: str, response: str, domain: str) -> QualityEvaluation:
|
||||
client = self._get_client()
|
||||
prompt = (
|
||||
f"你是质量评审员。评估以下回答对查询的满足程度,输出 0-1 分(相关性/正确性/完整性)。\n"
|
||||
f"查询: {query}\n领域: {domain}\n回答: {response[:2000]}\n"
|
||||
f"只输出一个 0 到 1 之间的数字。"
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||||
json={"model": self.model, "messages": [{"role": "user", "content": prompt}]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
score = float(resp.json()["choices"][0]["message"]["content"].strip())
|
||||
score = max(0.0, min(1.0, score))
|
||||
except Exception:
|
||||
score = 0.5
|
||||
return QualityEvaluation(
|
||||
overall_score=score,
|
||||
scores={"llm_judge": score},
|
||||
needs_fallback=score < self.fallback_threshold,
|
||||
)
|
||||
|
||||
|
||||
def build_judge(cfg: Dict, fallback_threshold: float = 0.70, kb=None) -> BaseJudge:
|
||||
"""cfg 为 judge 段配置。"""
|
||||
jtype = cfg.get("type", "rule")
|
||||
if jtype == "rule":
|
||||
return RuleJudge(fallback_threshold=fallback_threshold, kb=kb)
|
||||
if jtype == "llm":
|
||||
import os
|
||||
api_key = cfg.get("api_key") or os.environ.get(cfg.get("api_key_env", "API_KEY"))
|
||||
return LLMJudge(
|
||||
cfg.get("model", "deepseek-v4-flash"),
|
||||
cfg.get("base_url", "https://api.deepseek.com"),
|
||||
api_key or "",
|
||||
fallback_threshold=fallback_threshold,
|
||||
)
|
||||
raise ValueError(f"未知 judge 类型: {jtype}(支持 rule | llm)")
|
||||
|
||||
+76
-68
@@ -1,68 +1,76 @@
|
||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Classification:
|
||||
"""分类器输出:领域 + 置信度 + 难度"""
|
||||
domain: str
|
||||
confidence: float
|
||||
difficulty: str # easy | medium | hard
|
||||
difficulty_score: float = 0.5
|
||||
raw_scores: Dict[str, float] = field(default_factory=dict)
|
||||
matched_rules: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertResponse:
|
||||
"""专家模型输出"""
|
||||
text: str
|
||||
model_used: str
|
||||
latency_ms: float = 0.0
|
||||
tokens: int = 0
|
||||
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterResult:
|
||||
"""一次路由的完整结果"""
|
||||
query: str
|
||||
response: str
|
||||
domain: str
|
||||
difficulty: str
|
||||
confidence: float
|
||||
upgraded: bool # 是否升级到大模型
|
||||
quality_score: float
|
||||
model_used: str
|
||||
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
||||
latency_ms: float = 0.0
|
||||
cache_hit: bool = False
|
||||
cache_level: Optional[str] = None # exact | semantic
|
||||
cost_est: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"query": self.query,
|
||||
"response": self.response,
|
||||
"domain": self.domain,
|
||||
"difficulty": self.difficulty,
|
||||
"confidence": round(self.confidence, 4),
|
||||
"upgraded": self.upgraded,
|
||||
"quality_score": round(self.quality_score, 4),
|
||||
"model_used": self.model_used,
|
||||
"route": self.route,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"cache_hit": self.cache_hit,
|
||||
"cache_level": self.cache_level,
|
||||
"cost_est": round(self.cost_est, 6),
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def now_ms() -> float:
|
||||
return time.perf_counter() * 1000.0␍
|
||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Classification:
|
||||
"""分类器输出:领域 + 置信度 + 难度"""
|
||||
domain: str
|
||||
confidence: float
|
||||
difficulty: str # easy | medium | hard
|
||||
difficulty_score: float = 0.5
|
||||
raw_scores: Dict[str, float] = field(default_factory=dict)
|
||||
matched_rules: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertResponse:
|
||||
"""专家模型输出"""
|
||||
text: str
|
||||
model_used: str
|
||||
latency_ms: float = 0.0
|
||||
tokens: int = 0
|
||||
cost_est: float = 0.0 # 相对成本估计(美元,近似)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouterResult:
|
||||
"""一次路由的完整结果"""
|
||||
query: str
|
||||
response: str
|
||||
domain: str
|
||||
difficulty: str
|
||||
confidence: float
|
||||
upgraded: bool # 是否升级到大模型
|
||||
quality_score: float
|
||||
model_used: str
|
||||
route: List[str] = field(default_factory=list) # 路由决策轨迹
|
||||
latency_ms: float = 0.0
|
||||
cache_hit: bool = False
|
||||
cache_level: Optional[str] = None # exact | semantic
|
||||
cost_est: float = 0.0
|
||||
error: Optional[str] = None
|
||||
subdomain: Optional[str] = None # 二级子领域(如 investing/labor)
|
||||
subdomain2: Optional[str] = None # 三级子领域(如 fund/overtime)
|
||||
domain_group: Optional[str] = None # 大领域组(两级路由第一级:tech/professional/...)
|
||||
request_id: Optional[str] = None # 请求 ID(配合 /traces/{id} 查询完整推理链)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"query": self.query,
|
||||
"response": self.response,
|
||||
"domain": self.domain,
|
||||
"difficulty": self.difficulty,
|
||||
"confidence": round(self.confidence, 4),
|
||||
"upgraded": self.upgraded,
|
||||
"quality_score": round(self.quality_score, 4),
|
||||
"model_used": self.model_used,
|
||||
"route": self.route,
|
||||
"latency_ms": round(self.latency_ms, 2),
|
||||
"cache_hit": self.cache_hit,
|
||||
"cache_level": self.cache_level,
|
||||
"cost_est": round(self.cost_est, 6),
|
||||
"error": self.error,
|
||||
"subdomain": self.subdomain,
|
||||
"subdomain2": self.subdomain2,
|
||||
"domain_group": self.domain_group,
|
||||
"request_id": self.request_id,
|
||||
}
|
||||
|
||||
|
||||
def now_ms() -> float:
|
||||
return time.perf_counter() * 1000.0
|
||||
|
||||
@@ -68,8 +68,9 @@ class CollaborativePipeline:
|
||||
# ---------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------
|
||||
async def run(self, query: str) -> PipelineResult:
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
async def run(self, query: str, request_id: Optional[str] = None) -> PipelineResult:
|
||||
if request_id is None:
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
ws = Workspace.new(request_id, query, self.api_token_cap, self.rounds_cap)
|
||||
route: List[str] = ["v2"]
|
||||
t0 = time.perf_counter() * 1000.0
|
||||
@@ -100,11 +101,15 @@ class CollaborativePipeline:
|
||||
route.append("loop")
|
||||
plan = brief.get("plan") or []
|
||||
pending = [p.get("id") for p in plan]
|
||||
plan_ids = {p.get("id") for p in plan} # 用于 _deps_done 过滤
|
||||
while pending and not ws.exhausted():
|
||||
progressed = False
|
||||
for sid in list(pending):
|
||||
step = next((p for p in plan if p.get("id") == sid), {})
|
||||
if not self._deps_done(ws, step.get("deps") or []):
|
||||
deps = step.get("deps") or []
|
||||
# 只检查在 plan 中的依赖;不在 plan 的 ID 视为"不存在"→自动满足
|
||||
relevant = [d for d in deps if d in plan_ids]
|
||||
if not self._deps_done(ws, relevant):
|
||||
continue
|
||||
existing = self._read_artifact(request_id, self._artifact_name(sid, ws))
|
||||
outcome = await self.worker.run_step(ws, sid, existing_artifact=existing,
|
||||
@@ -115,6 +120,8 @@ class CollaborativePipeline:
|
||||
ws.rollup()
|
||||
route.append(f"step:{sid}:done")
|
||||
progressed = True
|
||||
if not pending: # 所有步骤完成,退出循环
|
||||
break
|
||||
else: # issue -> Architect 裁决
|
||||
route.append(f"step:{sid}:issue")
|
||||
try:
|
||||
@@ -227,7 +234,17 @@ class CollaborativePipeline:
|
||||
return artifact_name_for(sid, domain)
|
||||
|
||||
def _deps_done(self, ws: Workspace, deps: List[str]) -> bool:
|
||||
# progress 中 done 的条目;done 条目 rollup 后移到 archive(字符串格式如 "s1: ...")
|
||||
done = {p["step"] for p in ws.get("progress", []) if p.get("status") == "done"}
|
||||
for entry in ws.get("archive", []):
|
||||
if isinstance(entry, dict):
|
||||
sid = entry.get("step", "")
|
||||
elif isinstance(entry, str):
|
||||
sid = entry.split(":")[0].strip() if ":" in entry else ""
|
||||
else:
|
||||
sid = ""
|
||||
if sid in deps:
|
||||
done.add(sid)
|
||||
return all(d in done for d in deps)
|
||||
|
||||
def _last_decision_for(self, ws: Workspace, sid: str) -> str:
|
||||
|
||||
+415
-217
@@ -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)
|
||||
|
||||
@@ -39,7 +39,7 @@ LIMITS = {
|
||||
|
||||
# 允许的领域标签(4.2 brief.tags;仅用于安全标记与验证接地,不做路由 D3)
|
||||
ALLOWED_TAGS = {"code", "math", "legal", "medical", "finance",
|
||||
"life", "education", "general", "safety"}
|
||||
"life", "education", "general", "safety", "science"}
|
||||
|
||||
STATUS_FLOW = {
|
||||
"draft": {"in_progress"},
|
||||
|
||||
Reference in New Issue
Block a user