变更项: 1) 新增 t_llm_config 数据访问层与建表逻辑。 2) Robot 启动时自动初始化并在空库时从 YAML 导入。 3) 后台 system LLM API 改为读写 MySQL。 4) LLMRegistry 改为优先 MySQL 读取并回退 YAML。 5) DashboardServer 挂载 llm_config_db 提供后台访问。
207 lines
7.5 KiB
Python
207 lines
7.5 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional
|
||
|
||
import yaml
|
||
|
||
from db.connection import DBConnectionManager
|
||
|
||
|
||
class LLMRegistry:
|
||
"""集中式 LLM 配置注册器。
|
||
|
||
读取优先级:
|
||
1. 优先读取 MySQL(t_llm_config);
|
||
2. MySQL 不可用或无数据时,回退读取 config.yaml 的 llm 节点。
|
||
"""
|
||
|
||
_cache: Dict[str, Any] = {
|
||
# cache_until: 缓存过期时间戳,避免每次调用都打数据库;
|
||
# data: 最近一次成功读取并归一化后的 llm 配置对象。
|
||
"cache_until": 0.0,
|
||
"data": {},
|
||
}
|
||
|
||
@classmethod
|
||
def get_root_config_path(cls) -> Path:
|
||
return Path(__file__).resolve().parents[2] / "config.yaml"
|
||
|
||
@classmethod
|
||
def _load_llm_from_yaml(cls) -> Dict[str, Any]:
|
||
"""从 YAML 读取 llm 配置(兜底来源)。"""
|
||
path = cls.get_root_config_path()
|
||
if not path.exists():
|
||
return {}
|
||
|
||
with open(path, "r", encoding="utf-8") as fp:
|
||
root = yaml.safe_load(fp) or {}
|
||
llm_config = root.get("llm", {}) or {}
|
||
if not isinstance(llm_config, dict):
|
||
return {}
|
||
return llm_config
|
||
|
||
@staticmethod
|
||
def _loads_json(value: Any) -> Dict[str, Any]:
|
||
"""把数据库 JSON 字段统一解析为 dict。"""
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
obj = json.loads(value)
|
||
return obj if isinstance(obj, dict) else {}
|
||
except json.JSONDecodeError:
|
||
return {}
|
||
return {}
|
||
|
||
@classmethod
|
||
def _load_llm_from_mysql(cls) -> Dict[str, Any]:
|
||
"""从 MySQL 读取 llm 配置。
|
||
|
||
注意:
|
||
1. 该函数必须“无副作用失败”,即任何异常都返回空 dict,交由上层做 YAML 回退;
|
||
2. 不依赖 Robot 实例,直接走 DBConnectionManager 单例,便于在插件调用链路中复用。
|
||
"""
|
||
try:
|
||
db_manager = DBConnectionManager.get_instance()
|
||
if not db_manager or not db_manager.mysql_pool:
|
||
return {}
|
||
|
||
conn = db_manager.get_mysql_connection()
|
||
try:
|
||
with conn.cursor(dictionary=True) as cursor:
|
||
cursor.execute(
|
||
"""
|
||
SELECT default_backend, backends_json, scenes_json
|
||
FROM t_llm_config
|
||
WHERE id = 1
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
row = cursor.fetchone() or {}
|
||
finally:
|
||
conn.close()
|
||
|
||
if not row:
|
||
return {}
|
||
|
||
return {
|
||
"default_backend": str(row.get("default_backend") or "").strip(),
|
||
"backends": cls._loads_json(row.get("backends_json")),
|
||
"scenes": cls._loads_json(row.get("scenes_json")),
|
||
}
|
||
except Exception:
|
||
return {}
|
||
|
||
@classmethod
|
||
def _normalize_llm_config(cls, llm_config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""统一规整 llm 配置结构,避免下游出现类型分支。"""
|
||
data = llm_config if isinstance(llm_config, dict) else {}
|
||
default_backend = str(data.get("default_backend") or "").strip()
|
||
backends = data.get("backends", {}) or {}
|
||
scenes = data.get("scenes", {}) or {}
|
||
if not isinstance(backends, dict):
|
||
backends = {}
|
||
if not isinstance(scenes, dict):
|
||
scenes = {}
|
||
return {
|
||
"default_backend": default_backend,
|
||
"backends": backends,
|
||
"scenes": scenes,
|
||
}
|
||
|
||
@classmethod
|
||
def get_llm_config(cls) -> Dict[str, Any]:
|
||
"""获取运行时 LLM 配置(优先 MySQL,失败回退 YAML)。"""
|
||
now = time.time()
|
||
if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("data"):
|
||
return cls._cache["data"]
|
||
|
||
llm_config = cls._load_llm_from_mysql()
|
||
if not llm_config:
|
||
llm_config = cls._load_llm_from_yaml()
|
||
|
||
normalized = cls._normalize_llm_config(llm_config)
|
||
# 轻量缓存 3 秒:兼顾“后台编辑后较快生效”和“降低高频调用的 DB 压力”。
|
||
cls._cache = {
|
||
"cache_until": now + 3.0,
|
||
"data": normalized,
|
||
}
|
||
return normalized
|
||
|
||
@classmethod
|
||
def get_default_backend(cls) -> str:
|
||
"""读取全局默认后端名称。"""
|
||
llm_config = cls.get_llm_config()
|
||
return str(llm_config.get("default_backend", "") or "").strip()
|
||
|
||
@classmethod
|
||
def get_backend(cls, backend_name: str) -> Dict[str, Any]:
|
||
if not backend_name:
|
||
return {}
|
||
llm_config = cls.get_llm_config()
|
||
backends = llm_config.get("backends", {}) or {}
|
||
backend = backends.get(backend_name, {}) or {}
|
||
return dict(backend) if isinstance(backend, dict) else {}
|
||
|
||
@classmethod
|
||
def get_scenes(cls) -> Dict[str, str]:
|
||
"""读取 llm.scenes 场景路由配置,返回 scene->backend 的映射。"""
|
||
llm_config = cls.get_llm_config()
|
||
raw_scenes = llm_config.get("scenes", {}) or {}
|
||
if not isinstance(raw_scenes, dict):
|
||
return {}
|
||
|
||
normalized: Dict[str, str] = {}
|
||
for raw_scene, raw_backend in raw_scenes.items():
|
||
scene_name = str(raw_scene or "").strip()
|
||
backend_name = str(raw_backend or "").strip()
|
||
if not scene_name or not backend_name:
|
||
continue
|
||
normalized[scene_name] = backend_name
|
||
return normalized
|
||
|
||
@classmethod
|
||
def get_scene_backend_name(cls, scene_name: str) -> str:
|
||
"""根据场景名解析后端名;若场景不存在则自动回退 default_backend。"""
|
||
name = str(scene_name or "").strip()
|
||
if not name:
|
||
return cls.get_default_backend()
|
||
scenes = cls.get_scenes()
|
||
backend_name = str(scenes.get(name, "") or "").strip()
|
||
if backend_name:
|
||
return backend_name
|
||
return cls.get_default_backend()
|
||
|
||
@classmethod
|
||
def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]:
|
||
"""按场景解析最终后端配置,并附带 scene 字段用于链路追踪。"""
|
||
backend_name = cls.get_scene_backend_name(scene_name)
|
||
backend = cls.get_backend(backend_name)
|
||
if backend_name:
|
||
backend["backend"] = backend_name
|
||
if scene_name:
|
||
backend["scene"] = str(scene_name).strip()
|
||
return backend
|
||
|
||
@classmethod
|
||
def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
local = dict(local_config or {})
|
||
# 严格模式说明:
|
||
# 1. 统一只认 scene 作为路由入口,避免 backend/backend_ref 等多入口并存;
|
||
# 2. 若未声明 scene,则视为“调用方直接给出完整连接参数”,原样返回 local。
|
||
scene_name = local.get("scene") or ""
|
||
scene_name = str(scene_name).strip()
|
||
if scene_name:
|
||
merged = cls.resolve_by_scene(scene_name)
|
||
merged.update(local)
|
||
# 约定:只要声明了 scene,就以 scene 路由结果为准。
|
||
# 这样后台切换 scene 绑定时,无需改插件配置即可全局生效。
|
||
merged["backend"] = cls.get_scene_backend_name(scene_name)
|
||
merged["scene"] = scene_name
|
||
return merged
|
||
|
||
return local
|