feat: 多专业小模型+路由模型系统 MVP(mock 全链路 + FastAPI 网关 + 论文调研)
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""多专业小模型 + 路由模型系统 (Multi-Expert Router System)
|
||||
|
||||
核心思想:用「轻量分类路由器 + 专业小模型池 + 质量控制器(Judge) + 大模型回退」
|
||||
在限定条件下替代单一通用大模型,大幅降低成本与延迟。
|
||||
|
||||
本包核心逻辑为零依赖纯标准库实现(mock 后端),可直接运行;
|
||||
可选接入真实模型(transformers / OpenAI 兼容 API)。
|
||||
"""
|
||||
|
||||
from .router import Router
|
||||
from .models import Classification, ExpertResponse, RouterResult
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["Router", "Classification", "ExpertResponse", "RouterResult", "__version__"]␍
|
||||
@@ -0,0 +1,141 @@
|
||||
"""两阶段路由缓存(对齐实现方案):
|
||||
- L1 精确缓存:完全相同的查询 -> 直接命中
|
||||
- L2 语义缓存:字符 n-gram 余弦相似度(零依赖)-> 相似查询命中
|
||||
- 命中 N 次(promote_frequency)后提升为精确缓存
|
||||
|
||||
说明:语义缓存中的"完全相同查询"(相似度=1.0)直接计为 exact 命中;
|
||||
高频语义命中会提升为 O(1) 的精确缓存条目。
|
||||
|
||||
只缓存"未升级"的结果(升级路径每次都走大模型,不缓存,避免陈旧)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
result: Dict[str, Any]
|
||||
hits: int = 1
|
||||
|
||||
|
||||
def _ngrams(text: str, n: int = 3) -> List[str]:
|
||||
"""字符 n-gram(去空白、小写),用于轻量语义相似度。"""
|
||||
cleaned = re.sub(r"\s+", "", text.lower())
|
||||
if len(cleaned) < n:
|
||||
return [cleaned]
|
||||
return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
|
||||
|
||||
|
||||
def _cosine(vec_a: Dict[str, float], vec_b: Dict[str, float]) -> float:
|
||||
if not vec_a or not vec_b:
|
||||
return 0.0
|
||||
common = set(vec_a) & set(vec_b)
|
||||
dot = sum(vec_a[k] * vec_b[k] for k in common)
|
||||
na = sum(v * v for v in vec_a.values()) ** 0.5
|
||||
nb = sum(v * v for v in vec_b.values()) ** 0.5
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
def _tf_vector(grams: List[str]) -> Dict[str, float]:
|
||||
vec: Dict[str, float] = {}
|
||||
for g in grams:
|
||||
vec[g] = vec.get(g, 0.0) + 1.0
|
||||
return vec
|
||||
|
||||
|
||||
class RouterCache:
|
||||
"""L1 精确缓存 + L2 语义缓存。"""
|
||||
|
||||
def __init__(self, semantic_enabled: bool = True, similarity_threshold: float = 0.88,
|
||||
promote_frequency: int = 5, max_exact: int = 10000, max_semantic: int = 5000):
|
||||
self.semantic_enabled = semantic_enabled
|
||||
self.similarity_threshold = similarity_threshold
|
||||
self.promote_frequency = promote_frequency
|
||||
self.max_exact = max_exact
|
||||
self.max_semantic = max_semantic
|
||||
self._exact: Dict[str, CacheEntry] = {}
|
||||
self._semantic: List[Tuple[str, CacheEntry]] = [] # (query, entry)
|
||||
self._sem_vecs: Dict[str, Dict[str, float]] = {}
|
||||
self.hits = {"exact": 0, "semantic": 0}
|
||||
self.misses = 0
|
||||
|
||||
# ---- 查询 ----
|
||||
def get(self, query: str) -> Optional[Tuple[Optional[str], Dict[str, Any]]]:
|
||||
"""返回 (level, result);未命中返回 None。level: 'exact' | 'semantic'"""
|
||||
entry = self._exact.get(query)
|
||||
if entry is not None:
|
||||
self.hits["exact"] += 1
|
||||
return ("exact", entry.result)
|
||||
|
||||
if self.semantic_enabled:
|
||||
q_vec = _tf_vector(_ngrams(query))
|
||||
best_sim = 0.0
|
||||
best_query: Optional[str] = None
|
||||
best_result: Optional[Dict[str, Any]] = None
|
||||
for q, e in self._semantic:
|
||||
sim = _cosine(q_vec, self._sem_vecs.get(q, {}))
|
||||
if sim > best_sim:
|
||||
best_sim = sim
|
||||
best_query = q
|
||||
best_result = e.result
|
||||
if best_query is not None and best_sim >= self.similarity_threshold:
|
||||
# 完全相同查询(相似度=1.0)计为 exact 命中
|
||||
is_exact = best_sim >= 0.999
|
||||
level = "exact" if is_exact else "semantic"
|
||||
self.hits[level] += 1
|
||||
self._semantic_hit(best_query)
|
||||
return (level, best_result)
|
||||
|
||||
self.misses += 1
|
||||
return None
|
||||
|
||||
def _semantic_hit(self, query: str):
|
||||
"""语义命中:累计命中次数,达到阈值提升为精确缓存。"""
|
||||
for i, (q, e) in enumerate(self._semantic):
|
||||
if q == query:
|
||||
e.hits += 1
|
||||
if e.hits >= self.promote_frequency:
|
||||
self._exact[query] = e
|
||||
self._semantic.pop(i)
|
||||
self._sem_vecs.pop(query, None)
|
||||
break
|
||||
|
||||
# ---- 写入 ----
|
||||
def put(self, query: str, result: Dict[str, Any]):
|
||||
if query in self._exact:
|
||||
return
|
||||
entry = CacheEntry(result=result)
|
||||
if self.semantic_enabled:
|
||||
if len(self._semantic) >= self.max_semantic:
|
||||
old_q, _ = self._semantic.pop(0)
|
||||
self._sem_vecs.pop(old_q, None)
|
||||
self._semantic.append((query, entry))
|
||||
self._sem_vecs[query] = _tf_vector(_ngrams(query))
|
||||
else:
|
||||
self._exact[query] = entry
|
||||
if len(self._exact) > self.max_exact:
|
||||
self._exact.pop(next(iter(self._exact)))
|
||||
|
||||
# ---- 统计 ----
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
total = self.hits["exact"] + self.hits["semantic"] + self.misses
|
||||
return {
|
||||
"exact_hits": self.hits["exact"],
|
||||
"semantic_hits": self.hits["semantic"],
|
||||
"misses": self.misses,
|
||||
"hit_rate": round((self.hits["exact"] + self.hits["semantic"]) / total, 4) if total else 0.0,
|
||||
"exact_size": len(self._exact),
|
||||
"semantic_size": len(self._semantic),
|
||||
}
|
||||
|
||||
def clear(self):
|
||||
self._exact.clear()
|
||||
self._semantic.clear()
|
||||
self._sem_vecs.clear()
|
||||
self.hits = {"exact": 0, "semantic": 0}
|
||||
self.misses = 0␍
|
||||
@@ -0,0 +1,203 @@
|
||||
"""意图分类器:识别查询领域(code/math/legal/medical/general)与难度。
|
||||
|
||||
- RuleClassifier:关键词/正则规则打分,纯标准库,零依赖,可离线运行。
|
||||
- HuggingFaceClassifier:可选,基于 transformers 的分类模型(需安装 ML 依赖)。
|
||||
|
||||
置信度设计:每个领域有一组 (关键词, 权重)。命中权重求和得原始分 s,
|
||||
confidence = 1 - exp(-s),保证 s=1 -> 0.63,s=2 -> 0.86,s=3 -> 0.95。
|
||||
无领域命中(或最高分领域为 general)时置信度低,触发 should_fallback。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .difficulty import estimate_difficulty
|
||||
from .models import Classification
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 领域关键词规则: (关键词, 权重)
|
||||
# ---------------------------------------------------------------
|
||||
DOMAIN_RULES: Dict[str, List[Tuple[str, float]]] = {
|
||||
"code": [
|
||||
# 中文
|
||||
("python", 1.2), ("java", 1.2), ("javascript", 1.2), ("typescript", 1.2),
|
||||
("代码", 1.2), ("编程", 1.2), ("函数", 0.9), ("接口", 0.8), ("报错", 0.9),
|
||||
("调试", 0.9), ("部署", 0.8), ("算法", 0.8), ("数组", 0.8), ("排序", 0.9),
|
||||
("正则", 0.8), ("数据库", 0.7), ("sql", 0.8), ("git", 0.7), ("api", 0.7),
|
||||
("变量", 0.7), ("循环", 0.7), ("递归", 0.8), ("重构", 0.8), ("编译", 0.9),
|
||||
("测试", 0.6), ("前端", 0.8), ("后端", 0.8), ("爬虫", 0.8), ("脚本", 0.7),
|
||||
# 英文
|
||||
("function", 0.9), ("class", 0.8), ("bug", 0.9), ("debug", 0.9),
|
||||
("compile", 0.9), ("error", 0.6), ("code", 0.7), ("script", 0.7),
|
||||
("algorithm", 0.8), ("sort", 0.7), ("array", 0.7), ("regex", 0.8),
|
||||
("import", 0.7), ("loop", 0.7), ("recursion", 0.8), ("refactor", 0.8),
|
||||
("deploy", 0.8), ("docker", 0.8), ("kubernetes", 0.8),
|
||||
("async", 0.7), ("flask", 0.7), ("django", 0.7), ("api", 0.7),
|
||||
("索引", 0.8), ("优化", 0.7), ("查询", 0.6),
|
||||
],
|
||||
"math": [
|
||||
("数学", 1.2), ("方程", 1.0), ("求解", 0.8), ("导数", 1.0), ("积分", 1.0),
|
||||
("矩阵", 0.9), ("概率", 0.9), ("统计", 0.8), ("证明", 0.8), ("定理", 0.9),
|
||||
("微积分", 1.1), ("代数", 0.9), ("几何", 0.9), ("不等式", 0.9),
|
||||
("equation", 1.0), ("derivative", 1.0), ("integral", 1.0), ("calculus", 1.1),
|
||||
("matrix", 0.9), ("probability", 0.9), ("statistics", 0.8), ("proof", 0.8),
|
||||
("theorem", 0.9), ("algebra", 0.9), ("geometry", 0.9), ("sqrt", 0.8),
|
||||
("gcd", 0.8), ("lim", 0.8), ("polynomial", 0.9), ("summation", 0.7),
|
||||
("math", 0.7), ("解", 0.6), ("计算", 0.8), ("等于", 0.6), ("求值", 0.7), ("函数", 0.6),
|
||||
],
|
||||
"legal": [
|
||||
("法律", 1.2), ("合同", 1.0), ("法条", 1.0), ("合规", 1.0), ("诉讼", 1.0),
|
||||
("知识产权", 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),
|
||||
("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),
|
||||
("clause", 0.8), ("agreement", 0.7), ("申请", 0.6),
|
||||
],
|
||||
"medical": [
|
||||
("医疗", 1.2), ("药物", 1.0), ("症状", 1.0), ("诊断", 1.0), ("治疗", 0.9),
|
||||
("医生", 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),
|
||||
("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),
|
||||
],
|
||||
"general": [
|
||||
("总结", 0.4), ("翻译", 0.4), ("介绍", 0.4), ("解释", 0.3),
|
||||
("summarize", 0.4), ("translate", 0.4), ("explain", 0.3),
|
||||
("introduce", 0.3), ("what is", 0.3), ("tell me", 0.3),
|
||||
("write an essay", 0.4), ("邮件", 0.4), ("email", 0.3),
|
||||
("推荐", 0.3), ("评价", 0.3),
|
||||
],
|
||||
}
|
||||
|
||||
_STOPWORDS = {
|
||||
"的", "了", "吗", "呢", "啊", "是", "在", "有", "和", "与", "或", "及", "一个", "如何",
|
||||
"the", "a", "an", "is", "are", "to", "of", "in", "on", "for", "with", "and",
|
||||
"or", "do", "does", "can", "could", "would", "should", "please", "me", "my",
|
||||
}
|
||||
|
||||
|
||||
class BaseClassifier:
|
||||
def classify(self, query: str) -> Classification:
|
||||
raise NotImplementedError
|
||||
|
||||
def should_fallback(self, classification: Classification, threshold: float) -> bool:
|
||||
return classification.confidence < threshold
|
||||
|
||||
|
||||
class RuleClassifier(BaseClassifier):
|
||||
"""基于关键词规则的分类器(零依赖)。"""
|
||||
|
||||
def __init__(self, confidence_floor: float = 0.55):
|
||||
self.confidence_floor = confidence_floor
|
||||
self.rules = DOMAIN_RULES
|
||||
|
||||
def _score(self, query: str) -> Tuple[Dict[str, float], Dict[str, List[str]]]:
|
||||
q = query.lower()
|
||||
scores: Dict[str, float] = {}
|
||||
matched: Dict[str, List[str]] = {}
|
||||
for domain, rules in self.rules.items():
|
||||
s = 0.0
|
||||
hits = []
|
||||
for kw, w in rules:
|
||||
if kw in q:
|
||||
s += w
|
||||
hits.append(kw)
|
||||
if s > 0:
|
||||
scores[domain] = s
|
||||
matched[domain] = hits
|
||||
return scores, matched
|
||||
|
||||
def classify(self, query: str) -> Classification:
|
||||
raw, matched = self._score(query)
|
||||
if not raw:
|
||||
# 完全无命中 -> general,低置信度
|
||||
diff, ds = estimate_difficulty(query)
|
||||
return Classification(
|
||||
domain="general",
|
||||
confidence=0.50,
|
||||
difficulty=diff,
|
||||
difficulty_score=ds,
|
||||
raw_scores={},
|
||||
matched_rules=[],
|
||||
)
|
||||
|
||||
best_domain = max(raw, key=raw.get)
|
||||
best_score = raw[best_domain]
|
||||
confidence = 1.0 - math.exp(-best_score)
|
||||
|
||||
# general 领域天然置信度压低
|
||||
if best_domain == "general":
|
||||
confidence = min(confidence, self.confidence_floor + 0.05)
|
||||
|
||||
# 与次高分的差距影响置信度(区分度)
|
||||
if len(raw) > 1:
|
||||
second = sorted(raw.values(), reverse=True)[1]
|
||||
if second > 0.7 * best_score:
|
||||
confidence *= 0.85
|
||||
|
||||
diff, ds = estimate_difficulty(query)
|
||||
return Classification(
|
||||
domain=best_domain,
|
||||
confidence=round(min(0.99, confidence), 4),
|
||||
difficulty=diff,
|
||||
difficulty_score=ds,
|
||||
raw_scores={k: round(v, 3) for k, v in raw.items()},
|
||||
matched_rules=matched.get(best_domain, []),
|
||||
)
|
||||
|
||||
|
||||
class HuggingFaceClassifier(BaseClassifier):
|
||||
"""可选:基于 transformers 的序列分类模型。
|
||||
|
||||
仅当安装 torch+transformers 且模型可加载时可用;否则抛错提示。
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str, num_labels: int = 5, confidence_floor: float = 0.55):
|
||||
try:
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"HuggingFaceClassifier 需要安装 ML 依赖:pip install -r requirements-ml.txt"
|
||||
) from e
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
|
||||
self.model = AutoModelForSequenceClassification.from_pretrained(
|
||||
model_name, num_labels=num_labels
|
||||
)
|
||||
self.labels = ["code", "math", "legal", "medical", "general"]
|
||||
self.confidence_floor = confidence_floor
|
||||
|
||||
def classify(self, query: str) -> Classification:
|
||||
import torch # type: ignore
|
||||
|
||||
inputs = self.tokenizer(query, return_tensors="pt", truncation=True, max_length=256)
|
||||
with torch.no_grad():
|
||||
logits = self.model(**inputs).logits
|
||||
probs = torch.softmax(logits, dim=-1)[0]
|
||||
idx = int(probs.argmax())
|
||||
diff, ds = estimate_difficulty(query)
|
||||
return Classification(
|
||||
domain=self.labels[idx],
|
||||
confidence=round(float(probs[idx]), 4),
|
||||
difficulty=diff,
|
||||
difficulty_score=ds,
|
||||
raw_scores={self.labels[i]: round(float(probs[i]), 3) for i in range(len(self.labels))},
|
||||
)
|
||||
|
||||
|
||||
def build_classifier(cfg: Dict) -> BaseClassifier:
|
||||
"""根据配置构建分类器。cfg 为 classifier 段配置。"""
|
||||
ctype = cfg.get("type", "rule")
|
||||
floor = cfg.get("confidence_floor", 0.55)
|
||||
if ctype == "rule":
|
||||
return RuleClassifier(confidence_floor=floor)
|
||||
if ctype == "hf":
|
||||
return HuggingFaceClassifier(cfg.get("model", "Qwen/Qwen3-0.6B"), confidence_floor=floor)
|
||||
raise ValueError(f"未知分类器类型: {ctype}(支持 rule | hf)")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""配置加载:优先 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␍
|
||||
@@ -0,0 +1,52 @@
|
||||
"""查询难度估计器(启发式,纯标准库)。
|
||||
|
||||
依据:RouterArena 用 Bloom 分类法把问题分为 easy/medium/hard。
|
||||
这里用查询长度、指令动词、数学/推理标记做轻量估计。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
# 触发 hard 的指令动词 / 推理标记
|
||||
_HARD_MARKERS = [
|
||||
"证明", "推导", "为什么", "如何", "对比", "比较", "分析", "评估", "设计", "优化",
|
||||
"复杂度", "时间复杂度", "空间复杂度", "原理", "机制", "优缺点", "区别", "推论", "定理",
|
||||
"proof", "prove", "derive", "explain why", "why", "how", "compare", "contrast",
|
||||
"analy", "evaluate", "design", "optimize", "refactor", "architect",
|
||||
"implement", "debug", "review", "plan", "synthesize",
|
||||
"int", "sum", "sqrt", "lim", "log", "derivative", "integral",
|
||||
]
|
||||
# 触发 medium 的标记
|
||||
_MEDIUM_MARKERS = [
|
||||
"用", "写", "计算", "求解", "生成", "翻译", "总结", "解释",
|
||||
"注意", "建议", "是否", "实现", "步骤",
|
||||
"write", "code", "function", "script", "calculate", "solve", "summarize",
|
||||
"translate", "fix", "explain", "describe", "list",
|
||||
]
|
||||
|
||||
|
||||
def estimate_difficulty(query: str) -> Tuple[str, float]:
|
||||
"""返回 (difficulty, score),score 属于 [0,1]。"""
|
||||
q = query.lower()
|
||||
hard_hits = sum(1 for m in _HARD_MARKERS if m in q)
|
||||
medium_hits = sum(1 for m in _MEDIUM_MARKERS if m in q)
|
||||
length = len(query)
|
||||
|
||||
score = 0.0
|
||||
score += min(0.30, length / 600.0) # 长度贡献
|
||||
score += min(0.55, hard_hits * 0.25) # 推理标记贡献
|
||||
score += min(0.30, medium_hits * 0.08) # 一般指令贡献
|
||||
|
||||
# 额外:代码 / 数学表达式(多步骤信号)
|
||||
if "```" in query or re.search(r"\b(def|class|function|import)\b", q):
|
||||
score += 0.15
|
||||
if re.search(r"[0-9]+\s*[+\-*/^=]\s*[0-9xya-z]", q):
|
||||
score += 0.15
|
||||
|
||||
score = max(0.0, min(1.0, score))
|
||||
if score >= 0.55:
|
||||
return "hard", score
|
||||
if score >= 0.25:
|
||||
return "medium", score
|
||||
return "easy", score␍
|
||||
@@ -0,0 +1,270 @@
|
||||
"""专家模型池:统一 Expert 接口,支持三种后端。
|
||||
|
||||
- MockExpert :确定性模板输出(零依赖,离线可跑,便于测试与演示)
|
||||
- HFExpert :HuggingFace transformers 真实小模型(可选,需 ML 依赖)
|
||||
- APIExpert :OpenAI 兼容 API(可选,需 API Key,如 DeepSeek)
|
||||
|
||||
成本估计:cost_est 按参数量粗估(美元/百万 token 的近似比例)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .models import ExpertResponse
|
||||
|
||||
# 按模型规模粗估的相对成本($ / 1M output tokens,近似)
|
||||
MODEL_COST_EST = {
|
||||
"mock": 0.0,
|
||||
"0.5b": 0.02,
|
||||
"1b": 0.05,
|
||||
"1.7b": 0.08,
|
||||
"3b": 0.12,
|
||||
"4b": 0.15,
|
||||
"7b": 0.25,
|
||||
"70b": 2.50,
|
||||
"api": 1.00,
|
||||
}
|
||||
|
||||
|
||||
def _cost_for(model_name: str, default: str = "1b") -> float:
|
||||
mn = model_name.lower()
|
||||
for key in ("0.5b", "1.7b", "3b", "4b", "7b", "70b"):
|
||||
if key in mn:
|
||||
return MODEL_COST_EST[key]
|
||||
if "api" in mn or mn in ("deepseek-chat", "gpt-4o-mini", "claude"):
|
||||
return MODEL_COST_EST["api"]
|
||||
return MODEL_COST_EST.get(default, 0.1)
|
||||
|
||||
|
||||
def extract_content_terms(query: str) -> List[str]:
|
||||
"""抽取查询中的"内容词"(中文词/英文单词),用于 Judge 覆盖度与 Mock 回显。"""
|
||||
q = query.lower()
|
||||
terms: List[str] = []
|
||||
# 英文单词(>=2 字符)
|
||||
for w in re.findall(r"[a-z][a-z0-9_]{1,}", q):
|
||||
if w not in _STOPWORDS_EN and w not in terms:
|
||||
terms.append(w)
|
||||
# 中文:按 2-4 字窗口切分,保留含中文字符的片段
|
||||
cn = re.findall(r"[\u4e00-\u9fff]{2,8}", q)
|
||||
for c in cn:
|
||||
terms.append(c)
|
||||
return terms
|
||||
|
||||
|
||||
_STOPWORDS_EN = {
|
||||
"the", "a", "an", "is", "are", "to", "of", "in", "on", "for", "with", "and",
|
||||
"or", "do", "does", "can", "could", "would", "should", "please", "me", "my",
|
||||
"this", "that", "it", "be", "was", "were", "have", "has", "had", "will",
|
||||
"not", "no", "yes", "i", "you", "he", "she", "we", "they",
|
||||
}
|
||||
|
||||
|
||||
class Expert:
|
||||
name: str = "expert"
|
||||
|
||||
async def generate(self, query: str, difficulty: str) -> ExpertResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MockExpert(Expert):
|
||||
"""确定性模板专家:零依赖,离线可跑。
|
||||
|
||||
输出会回显查询中的内容词以提高 Judge 覆盖度,并带领域结构,
|
||||
使端到端管线(分类 -> 专家 -> Judge -> 缓存)可被稳定测试与演示。
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, domain: str, model: str = "mock"):
|
||||
self.name = name
|
||||
self.domain = domain
|
||||
self.model = model
|
||||
|
||||
async def generate(self, query: str, difficulty: str) -> ExpertResponse:
|
||||
await asyncio.sleep(0.001) # 模拟极短推理延迟
|
||||
terms = extract_content_terms(query)
|
||||
body = self._template(query, terms, difficulty)
|
||||
# 预估 token 数:中文约 1.5 字符/token,英文约 4 字符/token
|
||||
tokens = max(8, int(len(body) / 2.2))
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=1.0,
|
||||
tokens=tokens,
|
||||
cost_est=_cost_for(self.model) * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
def _template(self, query: str, terms: List[str], difficulty: str) -> str:
|
||||
kw = "、".join(terms[:6]) if terms else "该主题"
|
||||
if self.domain == "code":
|
||||
return (
|
||||
f"(mock 代码专家)针对「{query}」的实现思路如下:\n\n"
|
||||
f"```python\n"
|
||||
f"def solve() -> None:\n"
|
||||
f" # 关键点:{kw}\n"
|
||||
f" # 1. 明确输入输出约束\n"
|
||||
f" # 2. 选择合适数据结构\n"
|
||||
f" # 3. 处理边界条件(空输入、极端值)\n"
|
||||
f" # 4. 补充单元测试\n"
|
||||
f" pass\n"
|
||||
f"```\n\n"
|
||||
f"复杂度:平均 O(n)。请按上述步骤补充具体实现。"
|
||||
)
|
||||
if self.domain == "math":
|
||||
return (
|
||||
f"(mock 数学专家)求解「{query}」的步骤:\n\n"
|
||||
f"1. 明确已知条件与目标:{kw}\n"
|
||||
f"2. 选择合适的方法(代数变形 / 积分 / 归纳等)\n"
|
||||
f"3. 逐步推导并验证中间结果\n"
|
||||
f"4. 检查边界与特殊情况\n\n"
|
||||
f"结论:在标准假设下,结果可化简为闭合形式。完整推导见正式解答。"
|
||||
)
|
||||
if self.domain == "legal":
|
||||
return (
|
||||
f"(mock 法律专家)关于「{query}」的初步法律分析:\n\n"
|
||||
f"相关要点:{kw}\n"
|
||||
f"1. 适用法规:请以现行有效法条为准(建议核对最新修订版)\n"
|
||||
f"2. 合同/合规风险点识别\n"
|
||||
f"3. 责任划分与救济途径\n\n"
|
||||
f"⚠️ 提示:以上为一般性分析,不构成正式法律意见,个案请咨询执业律师。"
|
||||
)
|
||||
if self.domain == "medical":
|
||||
return (
|
||||
f"(mock 医学专家)关于「{query}」的科普性说明:\n\n"
|
||||
f"相关关键词:{kw}\n"
|
||||
f"1. 常见表现与可能原因\n"
|
||||
f"2. 一般处理建议与注意事项\n"
|
||||
f"3. 何时需要就医(警示信号)\n\n"
|
||||
f"⚠️ 提示:内容仅供健康科普,不能替代医生诊断;如有不适请及时就医。"
|
||||
)
|
||||
return (
|
||||
f"(mock 通用专家)关于「{query}」的回答:\n\n"
|
||||
f"核心要点:{kw}\n"
|
||||
f"1. 背景与定义\n"
|
||||
f"2. 主要分类/维度\n"
|
||||
f"3. 实际应用与注意事项\n\n"
|
||||
f"如需更深入的分析,可以补充更多上下文。"
|
||||
)
|
||||
|
||||
|
||||
class HFExpert(Expert):
|
||||
"""可选:HuggingFace 真实小模型(需 requirements-ml.txt)。"""
|
||||
|
||||
def __init__(self, name: str, domain: str, model: str):
|
||||
self.name = name
|
||||
self.domain = domain
|
||||
self.model = model
|
||||
self._loaded = False
|
||||
self._model = None
|
||||
self._tokenizer = None
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._loaded:
|
||||
return
|
||||
try:
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
except ImportError as e:
|
||||
raise RuntimeError("HFExpert 需要安装 ML 依赖:pip install -r requirements-ml.txt") from e
|
||||
self._tokenizer = AutoTokenizer.from_pretrained(self.model)
|
||||
self._model = AutoModelForCausalLM.from_pretrained(
|
||||
self.model, device_map="auto", torch_dtype="auto"
|
||||
)
|
||||
self._loaded = True
|
||||
|
||||
async def generate(self, query: str, difficulty: str) -> ExpertResponse:
|
||||
self._ensure_loaded()
|
||||
return await asyncio.to_thread(self._generate_sync, query)
|
||||
|
||||
def _generate_sync(self, query: str) -> ExpertResponse:
|
||||
messages = [{"role": "user", "content": query}]
|
||||
text = self._tokenizer.apply_chat_template(messages, tokenize=False)
|
||||
inputs = self._tokenizer(text, return_tensors="pt").to(self._model.device)
|
||||
outputs = self._model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=512,
|
||||
temperature=0.2,
|
||||
do_sample=True,
|
||||
)
|
||||
body = self._tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
|
||||
return ExpertResponse(
|
||||
text=body,
|
||||
model_used=self.model,
|
||||
latency_ms=0.0,
|
||||
tokens=512,
|
||||
cost_est=_cost_for(self.model) * 512 / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
class APIExpert(Expert):
|
||||
"""可选:OpenAI 兼容 Chat Completions(DeepSeek / OpenAI / 本地 vLLM)。"""
|
||||
|
||||
def __init__(self, name: str, domain: str, model: str, base_url: str, api_key: str):
|
||||
self.name = name
|
||||
self.domain = domain
|
||||
self.model = model
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
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 generate(self, query: str, difficulty: 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.2,
|
||||
"max_tokens": 1024,
|
||||
},
|
||||
)
|
||||
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=_cost_for(self.model) * tokens / 1_000_000,
|
||||
)
|
||||
|
||||
|
||||
def build_expert(domain: str, cfg: Dict) -> Expert:
|
||||
"""根据配置构建领域专家。cfg 为 experts.<domain> 段配置。"""
|
||||
etype = cfg.get("type", "mock")
|
||||
model = cfg.get("model", "mock")
|
||||
name = f"expert-{domain}"
|
||||
if etype == "mock":
|
||||
return MockExpert(name, domain, model)
|
||||
if etype == "hf":
|
||||
return HFExpert(name, domain, model)
|
||||
if etype == "api":
|
||||
base_url = cfg.get("base_url", "https://api.deepseek.com/v1")
|
||||
api_key = cfg.get("api_key") or _env(cfg.get("api_key_env", ""))
|
||||
if not api_key:
|
||||
raise RuntimeError(f"APIExpert({domain}) 缺少 API Key(env: {cfg.get('api_key_env')})")
|
||||
return APIExpert(name, domain, model, base_url, api_key)
|
||||
raise ValueError(f"未知专家后端类型: {etype}(支持 mock | hf | api)")
|
||||
|
||||
|
||||
def _env(name: str) -> Optional[str]:
|
||||
import os
|
||||
return os.environ.get(name) if name else None
|
||||
|
||||
|
||||
def build_expert_pool(experts_cfg: Dict[str, Dict], domains: List[str]) -> Dict[str, Expert]:
|
||||
"""构建完整专家池。"""
|
||||
pool: Dict[str, Expert] = {}
|
||||
for domain in domains:
|
||||
cfg = experts_cfg.get(domain, {"type": "mock", "model": "mock"})
|
||||
pool[domain] = build_expert(domain, cfg)
|
||||
return pool␍
|
||||
@@ -0,0 +1,99 @@
|
||||
"""大模型回退层: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)")␍
|
||||
@@ -0,0 +1,174 @@
|
||||
"""质量控制器(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)")␍
|
||||
@@ -0,0 +1,68 @@
|
||||
"""核心数据模型(纯标准库,无外部依赖)"""
|
||||
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␍
|
||||
@@ -0,0 +1,217 @@
|
||||
"""主路由器:协调 缓存 -> 分类 -> 专家 -> 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)␍
|
||||
@@ -0,0 +1,63 @@
|
||||
"""运行指标收集(线程安全,零依赖)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import Counter, deque
|
||||
from typing import Any, Deque, Dict
|
||||
|
||||
|
||||
class Stats:
|
||||
def __init__(self, window: int = 1000):
|
||||
self._lock = threading.Lock()
|
||||
self.requests = 0
|
||||
self.domain_counter: Counter = Counter()
|
||||
self.difficulty_counter: Counter = Counter()
|
||||
self.upgraded = 0
|
||||
self.cache_hits = 0
|
||||
self.cache_levels: Counter = Counter()
|
||||
self.errors = 0
|
||||
self.latencies: Deque[float] = deque(maxlen=window)
|
||||
self.cost_total = 0.0
|
||||
self.model_usage: Counter = Counter()
|
||||
|
||||
def record(self, latency_ms: float, domain: str, difficulty: str,
|
||||
upgraded: bool, cache_hit: bool, cache_level: str | None,
|
||||
cost_est: float, model_used: str):
|
||||
with self._lock:
|
||||
self.requests += 1
|
||||
self.domain_counter[domain] += 1
|
||||
self.difficulty_counter[difficulty] += 1
|
||||
if upgraded:
|
||||
self.upgraded += 1
|
||||
if cache_hit:
|
||||
self.cache_hits += 1
|
||||
if cache_level:
|
||||
self.cache_levels[cache_level] += 1
|
||||
self.latencies.append(latency_ms)
|
||||
self.cost_total += cost_est
|
||||
self.model_usage[model_used] += 1
|
||||
|
||||
def record_error(self):
|
||||
with self._lock:
|
||||
self.errors += 1
|
||||
|
||||
def summary(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
n = self.requests
|
||||
lat = list(self.latencies)
|
||||
avg_lat = sum(lat) / len(lat) if lat else 0.0
|
||||
p99 = sorted(lat)[int(len(lat) * 0.99) - 1] if len(lat) >= 100 else (max(lat) if lat else 0.0)
|
||||
return {
|
||||
"total_requests": n,
|
||||
"domain_distribution": dict(self.domain_counter),
|
||||
"difficulty_distribution": dict(self.difficulty_counter),
|
||||
"fallback_rate": round(self.upgraded / n, 4) if n else 0.0,
|
||||
"upgraded_requests": self.upgraded,
|
||||
"cache_hit_rate": round(self.cache_hits / n, 4) if n else 0.0,
|
||||
"cache_levels": dict(self.cache_levels),
|
||||
"avg_latency_ms": round(avg_lat, 3),
|
||||
"p99_latency_ms": round(p99, 3),
|
||||
"total_cost_est_usd": round(self.cost_total, 6),
|
||||
"model_usage": dict(self.model_usage),
|
||||
"errors": self.errors,
|
||||
}␍
|
||||
Reference in New Issue
Block a user