Files
abot/utils/ai/llm_registry.py
liuwei 1446bf5f39 feat: 将LLM配置主存储迁移到MySQL
变更项: 1) 新增 t_llm_config 数据访问层与建表逻辑。 2) Robot 启动时自动初始化并在空库时从 YAML 导入。 3) 后台 system LLM API 改为读写 MySQL。 4) LLMRegistry 改为优先 MySQL 读取并回退 YAML。 5) DashboardServer 挂载 llm_config_db 提供后台访问。
2026-04-20 14:51:43 +08:00

207 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. 优先读取 MySQLt_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