mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-18 02:30:08 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
LLM 运维与调度管理模块。
|
||||
"""
|
||||
@@ -0,0 +1,173 @@
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.config import ProviderConfig, get_llm_config
|
||||
from zhenxun.services.ai.core.exceptions import ConfigurationException, LLMException
|
||||
from zhenxun.services.ai.core.models import ModelDetail, ModelModality
|
||||
from zhenxun.services.ai.core.options import GenerationConfig
|
||||
from zhenxun.services.ai.llm.builder import validate_override_params
|
||||
from zhenxun.services.ai.llm.engine.service import LLMModel
|
||||
from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities
|
||||
from zhenxun.services.ai.llm.system.network import health_manager, http_client_manager
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import dump_json_safely, model_copy, model_dump
|
||||
|
||||
_model_cache: dict[str, tuple[LLMModel, float]] = {}
|
||||
_cache_ttl = 3600
|
||||
_max_cache_size = 10
|
||||
|
||||
|
||||
def _make_cache_key(
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
override_config: dict | GenerationConfig | None,
|
||||
) -> str:
|
||||
"""生成缓存键"""
|
||||
config_str = (
|
||||
dump_json_safely(override_config, sort_keys=True) if override_config else "None"
|
||||
)
|
||||
key_data = f"{provider_name}/{model_name}:{config_str}"
|
||||
return hashlib.md5(key_data.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_cached_model(cache_key: str) -> LLMModel | None:
|
||||
"""从缓存获取模型"""
|
||||
if cache_key in _model_cache:
|
||||
model, created_time = _model_cache[cache_key]
|
||||
current_time = time.time()
|
||||
|
||||
if current_time - created_time > _cache_ttl:
|
||||
del _model_cache[cache_key]
|
||||
logger.debug(f"模型缓存已过期: {cache_key}")
|
||||
return None
|
||||
|
||||
if model._is_closed:
|
||||
logger.debug(
|
||||
f"缓存的模型 {cache_key} ({model.provider_name}/{model.model_name}) "
|
||||
f"处于_is_closed=True状态,重置为False以供复用。"
|
||||
)
|
||||
model._is_closed = False
|
||||
|
||||
logger.debug(
|
||||
f"使用缓存的模型: {cache_key} -> {model.provider_name}/{model.model_name}"
|
||||
)
|
||||
return model
|
||||
return None
|
||||
|
||||
|
||||
def _cache_model(cache_key: str, model: LLMModel):
|
||||
"""缓存模型实例"""
|
||||
current_time = time.time()
|
||||
|
||||
if len(_model_cache) >= _max_cache_size:
|
||||
oldest_key = min(_model_cache.keys(), key=lambda k: _model_cache[k][1])
|
||||
del _model_cache[oldest_key]
|
||||
|
||||
_model_cache[cache_key] = (model, current_time)
|
||||
|
||||
|
||||
def clear_model_cache():
|
||||
"""仅清空内存中的模型实例缓存。"""
|
||||
global _model_cache
|
||||
_model_cache.clear()
|
||||
logger.debug("已清空模型实例缓存")
|
||||
|
||||
|
||||
async def get_or_create_model(
|
||||
provider_config_found: ProviderConfig,
|
||||
model_detail_found: ModelDetail,
|
||||
override_config: dict[str, Any] | GenerationConfig | None = None,
|
||||
) -> Any:
|
||||
"""组装或获取底层 LLMModel 状态机实例 (不包含任何字符串解析)。"""
|
||||
prov_name_str = provider_config_found.name
|
||||
mod_name_str = model_detail_found.model_name
|
||||
|
||||
cache_key = _make_cache_key(prov_name_str, mod_name_str, override_config)
|
||||
cached_model = _get_cached_model(cache_key)
|
||||
|
||||
def _get_clean_log_config(cfg: GenerationConfig) -> dict:
|
||||
"""辅助函数:剔除超长 Schema 以防止日志刷屏"""
|
||||
log_dict = model_dump(cfg, exclude_none=True)
|
||||
if "output" in log_dict and isinstance(log_dict["output"], dict):
|
||||
if "response_schema" in log_dict["output"]:
|
||||
log_dict["output"]["response_schema"] = (
|
||||
"<JSON Schema Hidden for brevity>"
|
||||
)
|
||||
return log_dict
|
||||
|
||||
if cached_model:
|
||||
if override_config:
|
||||
validated_override = validate_override_params(override_config)
|
||||
if cached_model._generation_config != validated_override:
|
||||
cached_model._generation_config = validated_override
|
||||
cached_model.identity.generation_config = validated_override
|
||||
logger.debug(
|
||||
f"对缓存模型 {prov_name_str}/{mod_name_str} 应用新的覆盖配置: "
|
||||
f"{_get_clean_log_config(validated_override)}"
|
||||
)
|
||||
return cached_model
|
||||
|
||||
capabilities = get_model_capabilities(model_detail_found.model_name)
|
||||
capabilities = model_copy(capabilities, deep=True)
|
||||
|
||||
if model_detail_found.task_type == "image_generation":
|
||||
capabilities.output_modalities.add(ModelModality.IMAGE)
|
||||
capabilities.supports_tool_calling = False
|
||||
|
||||
llm_config = get_llm_config()
|
||||
client_settings = llm_config.client_settings
|
||||
default_timeout = (
|
||||
provider_config_found.timeout
|
||||
if provider_config_found.timeout is not None
|
||||
else client_settings.timeout
|
||||
)
|
||||
|
||||
config_for_http_client = ProviderConfig(
|
||||
name=provider_config_found.name,
|
||||
api_key=provider_config_found.api_key,
|
||||
models=provider_config_found.models,
|
||||
timeout=default_timeout,
|
||||
api_base=provider_config_found.api_base,
|
||||
api_type=provider_config_found.api_type,
|
||||
openai_compat=provider_config_found.openai_compat,
|
||||
temperature=provider_config_found.temperature,
|
||||
generation_max_tokens=provider_config_found.generation_max_tokens,
|
||||
)
|
||||
|
||||
shared_http_client = await http_client_manager.get_client(config_for_http_client)
|
||||
|
||||
try:
|
||||
model_instance = LLMModel(
|
||||
provider_config=config_for_http_client,
|
||||
model_detail=model_detail_found,
|
||||
health_manager=health_manager,
|
||||
http_client=shared_http_client,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
if override_config:
|
||||
validated_override_params = validate_override_params(override_config)
|
||||
model_instance._generation_config = validated_override_params
|
||||
model_instance.identity.generation_config = validated_override_params
|
||||
logger.debug(
|
||||
f"为新模型 {prov_name_str}/{mod_name_str} 应用配置覆盖: "
|
||||
f"{_get_clean_log_config(validated_override_params)}"
|
||||
)
|
||||
|
||||
_cache_model(cache_key, model_instance)
|
||||
logger.debug(
|
||||
f"创建并缓存了新模型: {cache_key} -> {prov_name_str}/{mod_name_str}"
|
||||
)
|
||||
return model_instance
|
||||
except LLMException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"实例化 LLMModel ({prov_name_str}/{mod_name_str}) 时发生内部错误: {e!s}",
|
||||
e=e,
|
||||
)
|
||||
raise ConfigurationException(
|
||||
f"初始化模型 '{prov_name_str}/{mod_name_str}' 失败: {e!s}",
|
||||
cause=e,
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
import fnmatch
|
||||
|
||||
from zhenxun.services.ai.core.models import (
|
||||
ModelCapabilities,
|
||||
ModelModality,
|
||||
ReasoningMode,
|
||||
)
|
||||
from zhenxun.utils.pydantic_compat import model_copy
|
||||
|
||||
CTX_1M = 1_000_000
|
||||
CTX_400K = 400_000
|
||||
CTX_256K = 256_000
|
||||
CTX_200K = 204_800
|
||||
CTX_128K = 128_000
|
||||
CTX_8K = 8_192
|
||||
|
||||
CAP_MULTIMODAL_EMBEDDING = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
ModelModality.FILE,
|
||||
},
|
||||
is_embedding_model=True,
|
||||
supports_tool_calling=False,
|
||||
)
|
||||
|
||||
STANDARD_TEXT_TOOL_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"computer_use",
|
||||
"file_search",
|
||||
},
|
||||
)
|
||||
CAP_GEMINI_2_5 = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
reasoning_mode=ReasoningMode.BUDGET,
|
||||
reasoning_visibility="visible",
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"google_map",
|
||||
"url_context",
|
||||
},
|
||||
)
|
||||
CAP_GEMINI_3_BASE = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
reasoning_mode=ReasoningMode.LEVEL,
|
||||
reasoning_visibility="visible",
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"google_map",
|
||||
"url_context",
|
||||
},
|
||||
features={
|
||||
"mixed_tools",
|
||||
"server_side_tool_invocations",
|
||||
},
|
||||
)
|
||||
|
||||
CAP_GEMINI_3_PRO = model_copy(
|
||||
CAP_GEMINI_3_BASE,
|
||||
update={
|
||||
"reasoning_effort_map": {
|
||||
"max": "high",
|
||||
"xhigh": "high",
|
||||
"minimal": "low",
|
||||
"none": "low",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
CAP_GEMINI_3_FLASH = model_copy(
|
||||
CAP_GEMINI_3_BASE,
|
||||
update={
|
||||
"reasoning_effort_map": {"max": "high", "xhigh": "high", "none": "minimal"}
|
||||
},
|
||||
)
|
||||
CAP_OPENAI_REASONING = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
reasoning_mode=ReasoningMode.EFFORT,
|
||||
reasoning_visibility="hidden",
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"computer_use",
|
||||
"file_search",
|
||||
},
|
||||
reasoning_effort_map={"max": "xhigh", "minimal": "none"},
|
||||
)
|
||||
CAP_OPENAI_MULTIMODAL = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
"computer_use",
|
||||
"file_search",
|
||||
},
|
||||
reasoning_effort_map={"max": "xhigh", "minimal": "none"},
|
||||
)
|
||||
CAP_DEEPSEEK_V4 = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
reasoning_mode=ReasoningMode.EFFORT,
|
||||
reasoning_visibility="visible",
|
||||
reasoning_effort_map={"minimal": "low"},
|
||||
)
|
||||
CAP_MINIMAX_REASONING = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
reasoning_mode=ReasoningMode.EFFORT,
|
||||
reasoning_visibility="visible",
|
||||
)
|
||||
|
||||
CAP_GLM_MULTIMODAL = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.VIDEO,
|
||||
ModelModality.FILE,
|
||||
},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
CAP_MINIMAX_MULTIMODAL = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE, ModelModality.VIDEO},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
CAP_MIMO_TEXT = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.TEXT},
|
||||
supports_tool_calling=True,
|
||||
supported_native_tools={"web_search"},
|
||||
reasoning_effort_map={"max": "high", "xhigh": "high", "minimal": "low"},
|
||||
)
|
||||
|
||||
CAP_MIMO_MULTIMODAL = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={ModelModality.TEXT, ModelModality.AUDIO},
|
||||
supports_tool_calling=True,
|
||||
supported_native_tools={"web_search"},
|
||||
reasoning_effort_map={"max": "high", "xhigh": "high", "minimal": "low"},
|
||||
)
|
||||
|
||||
|
||||
CAP_OPENAI_TTS = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.AUDIO},
|
||||
supports_tool_calling=False,
|
||||
default_voice_id="alloy",
|
||||
)
|
||||
|
||||
CAP_GEMINI_TTS = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.AUDIO},
|
||||
supports_tool_calling=False,
|
||||
default_voice_id="Aoede",
|
||||
)
|
||||
|
||||
CAP_MINIMAX_TTS = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.AUDIO},
|
||||
supports_tool_calling=False,
|
||||
default_voice_id="female-shaonv",
|
||||
)
|
||||
|
||||
CAP_MIMO_TTS = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
output_modalities={ModelModality.AUDIO},
|
||||
supports_tool_calling=False,
|
||||
default_voice_id="mimo_default",
|
||||
)
|
||||
|
||||
CAP_TEXT_EMBEDDING = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT},
|
||||
is_embedding_model=True,
|
||||
supports_tool_calling=False,
|
||||
)
|
||||
|
||||
CAP_RERANK_ONLY = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
is_rerank_model=True,
|
||||
)
|
||||
|
||||
CAP_OPENAI_IMAGE = ModelCapabilities(
|
||||
input_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
output_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
supports_tool_calling=False,
|
||||
)
|
||||
|
||||
CAP_GEMINI_IMAGE = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={ModelModality.TEXT, ModelModality.IMAGE},
|
||||
supports_tool_calling=True,
|
||||
supported_native_tools={
|
||||
"web_search",
|
||||
},
|
||||
)
|
||||
|
||||
DEFAULT_PERMISSIVE_CAPABILITIES = ModelCapabilities(
|
||||
input_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
ModelModality.VIDEO,
|
||||
},
|
||||
output_modalities={
|
||||
ModelModality.TEXT,
|
||||
ModelModality.IMAGE,
|
||||
ModelModality.AUDIO,
|
||||
},
|
||||
supports_tool_calling=True,
|
||||
)
|
||||
|
||||
MODEL_ALIAS_MAPPING: dict[str, str] = {
|
||||
"*DeepSeek-V4-Pro*": "deepseek-v4-pro",
|
||||
"*DeepSeek-V4-Flash*": "deepseek-v4-flash",
|
||||
}
|
||||
|
||||
|
||||
_ROUTING_TABLE: list[tuple[list[str], ModelCapabilities, int]] = [
|
||||
(["mimo-*tts*"], CAP_MIMO_TTS, CTX_8K),
|
||||
(["gemini-*tts*"], CAP_GEMINI_TTS, CTX_8K),
|
||||
(["*minimax-*tts*", "*MiniMax-*tts*"], CAP_MINIMAX_TTS, CTX_8K),
|
||||
(["*tts*"], CAP_OPENAI_TTS, CTX_8K),
|
||||
(["*gpt*image*"], CAP_OPENAI_IMAGE, CTX_128K),
|
||||
(["*gemini*image*", "*nano-banana*"], CAP_GEMINI_IMAGE, CTX_128K),
|
||||
(["glm-4.6v*"], CAP_GLM_MULTIMODAL, CTX_128K),
|
||||
(["glm-4.7-flash*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_128K),
|
||||
(["deepseek-v4-pro*", "deepseek-v4-flash*"], CAP_DEEPSEEK_V4, CTX_1M),
|
||||
(["glm-4-long*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_1M),
|
||||
(["*MiniMax-M3*"], CAP_MINIMAX_MULTIMODAL, CTX_1M),
|
||||
(["mimo-v2.5-pro*", "mimo-v2-pro*", "mimo-v2-flash*"], CAP_MIMO_TEXT, CTX_1M),
|
||||
(["mimo-v2.5", "mimo-v2-omni*"], CAP_MIMO_MULTIMODAL, CTX_1M),
|
||||
(["gpt-5.5*", "gpt-5.4*"], CAP_OPENAI_MULTIMODAL, CTX_1M),
|
||||
(["gemini-3*pro*"], CAP_GEMINI_3_PRO, CTX_1M),
|
||||
(["gemini-3*"], CAP_GEMINI_3_FLASH, CTX_1M),
|
||||
(
|
||||
["gemini-2.5-pro*", "gemini-2.5-flash*"],
|
||||
CAP_GEMINI_2_5,
|
||||
CTX_1M,
|
||||
),
|
||||
(
|
||||
["gpt-5*", "gpt-5-mini*", "gpt-5-nano*", "*codex*"],
|
||||
CAP_OPENAI_MULTIMODAL,
|
||||
CTX_400K,
|
||||
),
|
||||
(
|
||||
["kimi-k2.7*", "kimi-k2.6*", "kimi-k2.5*"],
|
||||
DEFAULT_PERMISSIVE_CAPABILITIES,
|
||||
CTX_256K,
|
||||
),
|
||||
(["glm-5v*"], CAP_GLM_MULTIMODAL, CTX_200K),
|
||||
(["glm-5*", "glm-4.7*", "glm-4.6*"], STANDARD_TEXT_TOOL_CAPABILITIES, CTX_200K),
|
||||
(["*MiniMax-M2*", "*minimax-m2*"], CAP_MINIMAX_REASONING, CTX_200K),
|
||||
(["gpt-4*", "gpt-3.5*", "gpt-*"], CAP_OPENAI_MULTIMODAL, CTX_128K),
|
||||
(["o1-*", "o3-*"], CAP_OPENAI_REASONING, CTX_128K),
|
||||
(["glm-4v*"], CAP_GLM_MULTIMODAL, CTX_128K),
|
||||
(
|
||||
["glm-4.5*", "glm-4-flashx-*", "glm-4*"],
|
||||
STANDARD_TEXT_TOOL_CAPABILITIES,
|
||||
CTX_128K,
|
||||
),
|
||||
(
|
||||
["gemini-embedding-2*", "jina-embeddings-v5-omni*"],
|
||||
CAP_MULTIMODAL_EMBEDDING,
|
||||
CTX_8K,
|
||||
),
|
||||
(
|
||||
["*embedding*", "*Embedding*", "jina-embeddings-*", "bge-m3*", "*bge-large*"],
|
||||
CAP_TEXT_EMBEDDING,
|
||||
CTX_8K,
|
||||
),
|
||||
(["*reranker*", "*rerank*", "jina-colbert-*"], CAP_RERANK_ONLY, CTX_8K),
|
||||
]
|
||||
|
||||
|
||||
def _build_registry() -> dict[str, ModelCapabilities]:
|
||||
"""构建模型能力注册表 (基于声明式路由表)"""
|
||||
registry: dict[str, ModelCapabilities] = {}
|
||||
|
||||
for patterns, cap_template, ctx_limit in _ROUTING_TABLE:
|
||||
cap_instance = model_copy(cap_template, update={"max_input_tokens": ctx_limit})
|
||||
for pattern in patterns:
|
||||
registry[pattern] = cap_instance
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
MODEL_CAPABILITIES_REGISTRY = _build_registry()
|
||||
|
||||
|
||||
def get_model_capabilities(model_name: str) -> ModelCapabilities:
|
||||
"""
|
||||
从注册表获取模型能力,支持别名映射和通配符匹配。
|
||||
"""
|
||||
canonical_name = model_name
|
||||
for alias_pattern, c_name in MODEL_ALIAS_MAPPING.items():
|
||||
if fnmatch.fnmatch(model_name, alias_pattern):
|
||||
canonical_name = c_name
|
||||
break
|
||||
|
||||
parts = canonical_name.split("/")
|
||||
names_to_check = ["/".join(parts[i:]) for i in range(len(parts))]
|
||||
|
||||
for name in names_to_check:
|
||||
if name in MODEL_CAPABILITIES_REGISTRY:
|
||||
return MODEL_CAPABILITIES_REGISTRY[name]
|
||||
|
||||
for pattern, capabilities in MODEL_CAPABILITIES_REGISTRY.items():
|
||||
if "*" in pattern and fnmatch.fnmatch(name, pattern):
|
||||
return capabilities
|
||||
|
||||
return DEFAULT_PERMISSIVE_CAPABILITIES
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
LLM 服务内部状态与配置数据模型。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HttpClientConfig(BaseModel):
|
||||
"""HTTP 客户端底层连接池配置"""
|
||||
|
||||
timeout: int = 180
|
||||
"""请求超时时间(秒)"""
|
||||
max_connections: int = 100
|
||||
"""连接池最大允许的总连接数"""
|
||||
max_keepalive_connections: int = 20
|
||||
"""连接池允许保持复用的最大 Keep-Alive 连接数"""
|
||||
|
||||
|
||||
class RouteHealthState(str, Enum):
|
||||
"""模型路由级别健康状态枚举"""
|
||||
|
||||
CLOSED = "CLOSED"
|
||||
"""闭合状态:健康正常,放行所有请求"""
|
||||
OPEN = "OPEN"
|
||||
"""打开状态:熔断器已触发,拒绝所有请求"""
|
||||
HALF_OPEN = "HALF_OPEN"
|
||||
"""半开状态:冷却期结束,允许少量探活请求试探服务是否恢复"""
|
||||
|
||||
|
||||
class KeyHealthStatus(BaseModel):
|
||||
"""API 密钥凭证级别的详细状态信息"""
|
||||
|
||||
status: str = "HEALTHY"
|
||||
"""当前密钥的健康状态 (HEALTHY, WARNING, COOLDOWN, DISABLED 等)"""
|
||||
successes: int = 0
|
||||
"""该密钥请求成功的总次数"""
|
||||
failures: int = 0
|
||||
"""该密钥请求失败的总次数"""
|
||||
cooldown_until: float = 0.0
|
||||
"""冷却解锁的 Unix 时间戳,0.0 表示无冷却"""
|
||||
last_error: str | None = None
|
||||
"""最近一次请求失败记录的错误原因"""
|
||||
|
||||
|
||||
class RouteHealthStatus(BaseModel):
|
||||
"""模型路由级别的遥测与熔断状态信息"""
|
||||
|
||||
state: RouteHealthState = RouteHealthState.CLOSED
|
||||
"""当前路由节点的熔断器状态"""
|
||||
cooldown_until: float = 0.0
|
||||
"""熔断器探活冷却解锁的 Unix 时间戳"""
|
||||
success_rate: float = 100.0
|
||||
"""该路由的请求成功率百分比 (0.0 - 100.0)"""
|
||||
successes: int = 0
|
||||
"""该路由请求成功的总次数"""
|
||||
failures: int = 0
|
||||
"""该路由请求失败的总次数"""
|
||||
latency_ema: float = 0.0
|
||||
"""平滑指数移动平均 (EMA) 的响应延迟预估值(毫秒)"""
|
||||
last_error: str | None = None
|
||||
"""该路由最近一次发生服务端故障的错误原因"""
|
||||
|
||||
|
||||
class ProviderHealthStatus(BaseModel):
|
||||
"""单一提供商(服务商)的综合健康状态"""
|
||||
|
||||
api_keys: dict[str, KeyHealthStatus] = Field(default_factory=dict)
|
||||
"""该提供商下绑定的所有 API 密钥状态映射表"""
|
||||
|
||||
|
||||
class GlobalHealthState(BaseModel):
|
||||
"""全局 AI 服务遥测与健康状态基座持久化模型"""
|
||||
|
||||
providers: dict[str, ProviderHealthStatus] = Field(default_factory=dict)
|
||||
"""所有提供商维度的状态映射表"""
|
||||
routes: dict[str, RouteHealthStatus] = Field(default_factory=dict)
|
||||
"""所有模型路由端点维度的状态映射表"""
|
||||
|
||||
|
||||
class CircuitBreakerPolicy(BaseModel):
|
||||
"""熔断器与密钥冷却策略配置"""
|
||||
|
||||
rate_limit_cooldown: int = Field(default=60)
|
||||
"""被限流时的短时冷却时间(秒)"""
|
||||
auth_error_cooldown: int = Field(default=31536000)
|
||||
"""凭证错误/鉴权失败时的长时冷却(默认1年即为禁用)"""
|
||||
server_error_cooldown: int = Field(default=120)
|
||||
"""服务端发生崩溃(500)等异常时的模型节点熔断时间(秒)"""
|
||||
quota_error_cooldown: int = Field(default=3600)
|
||||
"""API 额度耗尽时的冷却时间(秒)"""
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""请求重试与轮询策略配置"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 1.0,
|
||||
exponential_backoff: bool = True,
|
||||
key_rotation: bool = True,
|
||||
):
|
||||
self.max_retries = max_retries
|
||||
"""发生可恢复异常时的最大重试次数"""
|
||||
self.retry_delay = retry_delay
|
||||
"""基础重试等待延迟(秒)"""
|
||||
self.exponential_backoff = exponential_backoff
|
||||
"""是否启用指数退避策略(延迟时间随重试次数翻倍)"""
|
||||
self.key_rotation = key_rotation
|
||||
"""发生凭证错误或限流时,是否自动轮换到下一个可用的 API Key"""
|
||||
@@ -0,0 +1,594 @@
|
||||
"""
|
||||
LLM 核心基础设施模块
|
||||
|
||||
包含执行 LLM 请求所需的底层组件,如 HTTP 客户端、API Key 存储和智能重试逻辑。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
import nonebot
|
||||
|
||||
from zhenxun.configs.config import BotConfig
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
from zhenxun.services.ai.config import ProviderConfig
|
||||
from zhenxun.services.ai.core.exceptions import (
|
||||
AuthenticationException,
|
||||
ConfigurationException,
|
||||
LocationNotSupportedException,
|
||||
QuotaExceededException,
|
||||
RateLimitException,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump, parse_as
|
||||
from zhenxun.utils.user_agent import get_user_agent
|
||||
|
||||
from .models import (
|
||||
CircuitBreakerPolicy,
|
||||
GlobalHealthState,
|
||||
HttpClientConfig,
|
||||
KeyHealthStatus,
|
||||
ProviderHealthStatus,
|
||||
RouteHealthState,
|
||||
RouteHealthStatus,
|
||||
)
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
|
||||
class LLMHttpClient:
|
||||
"""[内部 API] LLM 服务专用异步 HTTP 客户端封装。"""
|
||||
|
||||
def __init__(self, config: HttpClientConfig | None = None):
|
||||
"""初始化 LLM 服务专用 HTTP 客户端"""
|
||||
self.config = config or HttpClientConfig()
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._active_requests = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_client_initialized(self) -> httpx.AsyncClient:
|
||||
"""确保底层的 AsyncClient 已完成初始化"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
async with self._lock:
|
||||
if self._client is None or self._client.is_closed:
|
||||
logger.debug(
|
||||
f"LLMHttpClient: 正在初始化新的 httpx.AsyncClient "
|
||||
f"配置: {self.config}"
|
||||
)
|
||||
headers = get_user_agent()
|
||||
limits = httpx.Limits(
|
||||
max_connections=self.config.max_connections,
|
||||
max_keepalive_connections=self.config.max_keepalive_connections,
|
||||
)
|
||||
timeout = httpx.Timeout(self.config.timeout)
|
||||
|
||||
client_kwargs = {}
|
||||
if BotConfig.system_proxy:
|
||||
try:
|
||||
version_parts = httpx.__version__.split(".")
|
||||
major = int(
|
||||
"".join(c for c in version_parts[0] if c.isdigit())
|
||||
)
|
||||
minor = (
|
||||
int("".join(c for c in version_parts[1] if c.isdigit()))
|
||||
if len(version_parts) > 1
|
||||
else 0
|
||||
)
|
||||
if (major, minor) >= (0, 28):
|
||||
client_kwargs["proxy"] = BotConfig.system_proxy
|
||||
else:
|
||||
client_kwargs["proxies"] = BotConfig.system_proxy
|
||||
except (ValueError, IndexError):
|
||||
client_kwargs["proxies"] = BotConfig.system_proxy
|
||||
logger.warning(
|
||||
f"无法解析 httpx version '{httpx.__version__}',"
|
||||
"LLM模块将默认使用旧版 'proxies' 参数语法。"
|
||||
)
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
headers=headers,
|
||||
limits=limits,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
**client_kwargs,
|
||||
)
|
||||
if self._client is None:
|
||||
raise ConfigurationException(
|
||||
"HTTP 客户端初始化失败。",
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
||||
"""发送异步 HTTP 请求"""
|
||||
client = await self._ensure_client_initialized()
|
||||
async with self._lock:
|
||||
self._active_requests += 1
|
||||
try:
|
||||
return await client.request(method, url, **kwargs)
|
||||
finally:
|
||||
async with self._lock:
|
||||
self._active_requests -= 1
|
||||
|
||||
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
|
||||
"""发送异步 POST 请求"""
|
||||
return await self.request("POST", url, **kwargs)
|
||||
|
||||
async def close(self):
|
||||
"""安全关闭 HTTP 客户端并释放连接池"""
|
||||
async with self._lock:
|
||||
if self._client and not self._client.is_closed:
|
||||
logger.debug(
|
||||
f"LLMHttpClient: 正在关闭,配置: {self.config}. "
|
||||
f"活跃请求数: {self._active_requests}"
|
||||
)
|
||||
if self._active_requests > 0:
|
||||
logger.warning(
|
||||
f"LLMHttpClient: 关闭时仍有 {self._active_requests} "
|
||||
f"个请求处于活跃状态。"
|
||||
)
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug(f"配置为 {self.config} 的 LLMHttpClient 已完全关闭。")
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""检查底层客户端是否已关闭"""
|
||||
return self._client is None or self._client.is_closed
|
||||
|
||||
|
||||
class LLMHttpClientManager:
|
||||
"""[内部 API] 负责管理与复用 LLMHttpClient 连接池。"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化客户端管理器"""
|
||||
self._clients: dict[tuple[str, str, int], LLMHttpClient] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _get_client_key(self, provider_config: ProviderConfig) -> tuple[str, str, int]:
|
||||
"""获取客户端唯一缓存标识"""
|
||||
api_base = provider_config.api_base or ""
|
||||
return (provider_config.api_type, api_base, provider_config.timeout)
|
||||
|
||||
async def get_client(self, provider_config: ProviderConfig) -> LLMHttpClient:
|
||||
"""获取或创建指定配置的 HTTP 客户端实例"""
|
||||
key = self._get_client_key(provider_config)
|
||||
async with self._lock:
|
||||
client = self._clients.get(key)
|
||||
if client and not client.is_closed:
|
||||
logger.debug(
|
||||
f"LLMHttpClientManager: 复用现有的 LLMHttpClient 密钥: {key}"
|
||||
)
|
||||
return client
|
||||
|
||||
if client and client.is_closed:
|
||||
logger.debug(
|
||||
f"LLMHttpClientManager: 发现密钥 {key} 对应的客户端已关闭。"
|
||||
f"正在创建新的客户端。"
|
||||
)
|
||||
|
||||
logger.debug(f"LLMHttpClientManager: 为密钥 {key} 创建新的 LLMHttpClient")
|
||||
http_client_config = HttpClientConfig(timeout=provider_config.timeout)
|
||||
new_client = LLMHttpClient(config=http_client_config)
|
||||
self._clients[key] = new_client
|
||||
return new_client
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有托管的客户端连接池"""
|
||||
async with self._lock:
|
||||
logger.info(
|
||||
f"LLMHttpClientManager: 正在关闭。关闭 {len(self._clients)} 个客户端。"
|
||||
)
|
||||
close_tasks = [
|
||||
client.close()
|
||||
for client in self._clients.values()
|
||||
if client and not client.is_closed
|
||||
]
|
||||
if close_tasks:
|
||||
await asyncio.gather(*close_tasks, return_exceptions=True)
|
||||
self._clients.clear()
|
||||
logger.info("LLMHttpClientManager: 关闭完成。")
|
||||
|
||||
|
||||
http_client_manager = LLMHttpClientManager()
|
||||
|
||||
|
||||
async def create_llm_http_client(
|
||||
timeout: int = 180,
|
||||
) -> LLMHttpClient:
|
||||
"""创建并返回一个新的 HTTP 客户端"""
|
||||
config = HttpClientConfig(timeout=timeout)
|
||||
return LLMHttpClient(config)
|
||||
|
||||
|
||||
class HealthStatePersister:
|
||||
"""后台异步持久化管理器"""
|
||||
|
||||
def __init__(self, state: GlobalHealthState, file_path: Path):
|
||||
"""初始化持久化管理器"""
|
||||
self.state = state
|
||||
self.file_path = file_path
|
||||
self._is_dirty = False
|
||||
self._lock = asyncio.Lock()
|
||||
self._watchdog_task: asyncio.Task | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
def start(self):
|
||||
"""启动后台异步保存定时任务"""
|
||||
if self._watchdog_task is None or self._watchdog_task.done():
|
||||
self._stop_event.clear()
|
||||
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
|
||||
|
||||
def mark_dirty(self):
|
||||
"""标记内存状态已脏,需要存盘"""
|
||||
self._is_dirty = True
|
||||
|
||||
async def _watchdog_loop(self):
|
||||
"""后台循环检测并持久化脏数据"""
|
||||
while not self._stop_event.is_set():
|
||||
await asyncio.sleep(5)
|
||||
if self._is_dirty:
|
||||
await self.force_save()
|
||||
|
||||
async def force_save(self):
|
||||
"""强制将内存中的状态同步写入磁盘文件"""
|
||||
if not self._is_dirty:
|
||||
return
|
||||
async with self._lock:
|
||||
self._is_dirty = False
|
||||
|
||||
data_to_save = model_dump(self.state)
|
||||
try:
|
||||
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = self.file_path.with_suffix(".json.tmp")
|
||||
async with aiofiles.open(temp_path, "w", encoding="utf-8") as f:
|
||||
await f.write(
|
||||
json.dumps(data_to_save, ensure_ascii=False, indent=2)
|
||||
)
|
||||
if self.file_path.exists():
|
||||
self.file_path.unlink()
|
||||
os.rename(temp_path, self.file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"保存密钥状态到文件失败: {e}", e=e)
|
||||
|
||||
async def stop(self):
|
||||
"""停止后台任务并保存所有脏状态"""
|
||||
self._stop_event.set()
|
||||
if self._watchdog_task:
|
||||
self._watchdog_task.cancel()
|
||||
try:
|
||||
await self._watchdog_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self.force_save()
|
||||
|
||||
|
||||
class KeyRotationManager:
|
||||
"""专门负责 API Key 的负载均衡与冷却状态维护"""
|
||||
|
||||
def __init__(self, state: GlobalHealthState):
|
||||
"""初始化 API Key 轮询管理器"""
|
||||
self.state = state
|
||||
self._provider_key_index: dict[str, int] = {}
|
||||
|
||||
def get_next_available_key(
|
||||
self,
|
||||
provider_name: str,
|
||||
api_keys: list[str],
|
||||
exclude_keys: set[str] | None = None,
|
||||
strict_mode: bool = False,
|
||||
) -> str | None:
|
||||
"""轮询策略获取下一个健康可用的 API Key"""
|
||||
if not api_keys:
|
||||
return None
|
||||
|
||||
exclude_keys = exclude_keys or set()
|
||||
|
||||
provider_state = self.state.providers.setdefault(
|
||||
provider_name, ProviderHealthStatus()
|
||||
)
|
||||
for key in api_keys:
|
||||
if key not in provider_state.api_keys:
|
||||
provider_state.api_keys[key] = KeyHealthStatus()
|
||||
|
||||
now = time.time()
|
||||
available_keys = [
|
||||
key
|
||||
for key in api_keys
|
||||
if key not in exclude_keys
|
||||
and provider_state.api_keys[key].cooldown_until <= now
|
||||
]
|
||||
|
||||
if not available_keys:
|
||||
if strict_mode:
|
||||
return None
|
||||
return api_keys[0]
|
||||
|
||||
current_index = self._provider_key_index.get(provider_name, 0)
|
||||
selected_key = available_keys[current_index % len(available_keys)]
|
||||
self._provider_key_index[provider_name] = current_index + 1
|
||||
|
||||
stats = provider_state.api_keys[selected_key]
|
||||
total_usage = stats.successes + stats.failures
|
||||
logger.debug(f"轮询选择API密钥 (使用次数: {total_usage})")
|
||||
return selected_key
|
||||
|
||||
def record_key_success(self, provider_name: str, api_key: str):
|
||||
"""记录指定 API Key 调用成功状态"""
|
||||
provider_state = self.state.providers.setdefault(
|
||||
provider_name, ProviderHealthStatus()
|
||||
)
|
||||
stats = provider_state.api_keys.setdefault(api_key, KeyHealthStatus())
|
||||
stats.cooldown_until = 0.0
|
||||
stats.successes += 1
|
||||
stats.status = "HEALTHY"
|
||||
stats.last_error = None
|
||||
|
||||
def record_key_failure(
|
||||
self,
|
||||
provider_name: str,
|
||||
api_key: str,
|
||||
exception: Exception,
|
||||
policy: CircuitBreakerPolicy,
|
||||
):
|
||||
"""记录并处理指定 API Key 的调用失败冷却"""
|
||||
now = time.time()
|
||||
cooldown_duration = 0
|
||||
|
||||
error_message = str(exception)
|
||||
|
||||
if isinstance(exception, LocationNotSupportedException):
|
||||
provider_state = self.state.providers.setdefault(
|
||||
provider_name, ProviderHealthStatus()
|
||||
)
|
||||
stats = provider_state.api_keys.setdefault(api_key, KeyHealthStatus())
|
||||
stats.failures += 1
|
||||
stats.last_error = error_message[:256]
|
||||
return
|
||||
|
||||
if isinstance(exception, QuotaExceededException):
|
||||
cooldown_duration = policy.quota_error_cooldown
|
||||
elif isinstance(exception, AuthenticationException):
|
||||
cooldown_duration = policy.auth_error_cooldown
|
||||
elif isinstance(exception, RateLimitException):
|
||||
cooldown_duration = policy.rate_limit_cooldown
|
||||
|
||||
provider_state = self.state.providers.setdefault(
|
||||
provider_name, ProviderHealthStatus()
|
||||
)
|
||||
stats = provider_state.api_keys.setdefault(api_key, KeyHealthStatus())
|
||||
if cooldown_duration > 0:
|
||||
stats.cooldown_until = now + cooldown_duration
|
||||
stats.status = (
|
||||
"COOLDOWN"
|
||||
if cooldown_duration < policy.auth_error_cooldown
|
||||
else "DISABLED"
|
||||
)
|
||||
stats.failures += 1
|
||||
stats.last_error = error_message[:256]
|
||||
|
||||
def reset_key_status(self, provider_name: str, api_key: str):
|
||||
"""重置 API Key 状态为健康"""
|
||||
provider_state = self.state.providers.setdefault(
|
||||
provider_name, ProviderHealthStatus()
|
||||
)
|
||||
stats = provider_state.api_keys.setdefault(api_key, KeyHealthStatus())
|
||||
stats.cooldown_until = 0.0
|
||||
stats.last_error = None
|
||||
stats.status = "HEALTHY"
|
||||
|
||||
|
||||
class CircuitBreakerManager:
|
||||
"""专门负责模型路由级别的熔断与探活 (无锁化设计)"""
|
||||
|
||||
def __init__(self, state: GlobalHealthState):
|
||||
"""初始化路由级熔断管理器"""
|
||||
self.state = state
|
||||
|
||||
def is_route_healthy(self, route_name: str, strict_mode: bool = True) -> bool:
|
||||
"""检查路由节点的健康与熔断状态"""
|
||||
stats = self.state.routes.get(route_name)
|
||||
if not stats:
|
||||
return True
|
||||
|
||||
if stats.state == RouteHealthState.CLOSED:
|
||||
return True
|
||||
|
||||
if stats.state == RouteHealthState.OPEN:
|
||||
if time.time() >= stats.cooldown_until:
|
||||
stats.state = RouteHealthState.HALF_OPEN
|
||||
logger.info(
|
||||
f"🔄 [Route-Level] 节点 '{route_name}' "
|
||||
"冷却期结束,进入 HALF_OPEN 半开试探状态。"
|
||||
)
|
||||
return True
|
||||
if not strict_mode:
|
||||
logger.debug(
|
||||
f"👉 [Route-Level] 节点 '{route_name}' 处于熔断状态(OPEN),"
|
||||
"但因非严格模式(单模型直调),强制放行执行探活。"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
if stats.state == RouteHealthState.HALF_OPEN:
|
||||
if not strict_mode:
|
||||
return True
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def record_route_success(self, route_name: str, latency: float):
|
||||
"""记录路由请求成功并尝试闭合熔断器"""
|
||||
stats = self.state.routes.setdefault(route_name, RouteHealthStatus())
|
||||
stats.successes += 1
|
||||
total = stats.successes + stats.failures
|
||||
stats.success_rate = (stats.successes / total) * 100
|
||||
|
||||
if stats.latency_ema == 0.0:
|
||||
stats.latency_ema = latency
|
||||
else:
|
||||
stats.latency_ema = 0.2 * latency + 0.8 * stats.latency_ema
|
||||
|
||||
if stats.state != RouteHealthState.CLOSED:
|
||||
logger.info(
|
||||
f"✅ [Route-Level] 节点 '{route_name}' "
|
||||
"试探成功!已完全恢复健康状态 (CLOSED)。"
|
||||
)
|
||||
stats.state = RouteHealthState.CLOSED
|
||||
stats.cooldown_until = 0.0
|
||||
|
||||
stats.last_error = None
|
||||
|
||||
def record_route_failure(
|
||||
self, route_name: str, exception: Exception, policy: CircuitBreakerPolicy
|
||||
):
|
||||
"""记录路由失败并开启熔断状态"""
|
||||
now = time.time()
|
||||
cooldown_duration = policy.server_error_cooldown
|
||||
|
||||
stats = self.state.routes.setdefault(route_name, RouteHealthStatus())
|
||||
stats.failures += 1
|
||||
total = stats.successes + stats.failures
|
||||
stats.success_rate = (stats.successes / total) * 100
|
||||
|
||||
stats.state = RouteHealthState.OPEN
|
||||
stats.cooldown_until = now + cooldown_duration
|
||||
stats.last_error = str(exception)[:256]
|
||||
|
||||
def get_best_fallback_route(self, route_names: list[str]) -> str:
|
||||
"""选择处于熔断冷却最少或最健康的备选路由"""
|
||||
|
||||
def get_cooldown(name: str) -> float:
|
||||
stats = self.state.routes.get(name)
|
||||
return stats.cooldown_until if stats else 0.0
|
||||
|
||||
return sorted(route_names, key=get_cooldown)[0]
|
||||
|
||||
|
||||
class HealthManager:
|
||||
"""全局 AI 健康与遥测门面"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化健康监测与遥测门面"""
|
||||
self.state = GlobalHealthState()
|
||||
self._file_path = DATA_PATH / "ai" / "api_key.json"
|
||||
self._persister: HealthStatePersister | None = None
|
||||
self._key_manager = KeyRotationManager(self.state)
|
||||
self._circuit_manager = CircuitBreakerManager(self.state)
|
||||
self.policy = CircuitBreakerPolicy()
|
||||
|
||||
async def initialize(self):
|
||||
"""从本地文件异步加载遥测状态"""
|
||||
if not self._file_path.exists():
|
||||
logger.debug("未找到遥测状态文件,将使用内存状态启动。")
|
||||
else:
|
||||
try:
|
||||
import aiofiles
|
||||
|
||||
logger.debug(f"正在从 {self._file_path} 加载密钥状态...")
|
||||
async with aiofiles.open(self._file_path, encoding="utf-8") as f:
|
||||
content = await f.read()
|
||||
if content:
|
||||
self.state = parse_as(GlobalHealthState, json.loads(content))
|
||||
self._key_manager.state = self.state
|
||||
self._circuit_manager.state = self.state
|
||||
total_keys = sum(
|
||||
len(provider.api_keys) for provider in self.state.providers.values()
|
||||
)
|
||||
logger.debug(f"成功加载 {total_keys} 个密钥的状态。")
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"遥测状态文件 {self._file_path} 格式错误,无法解析。")
|
||||
except Exception as e:
|
||||
logger.error(f"加载遥测状态文件时发生错误: {e}", e=e)
|
||||
|
||||
self._persister = HealthStatePersister(self.state, self._file_path)
|
||||
self._persister.start()
|
||||
|
||||
async def shutdown(self):
|
||||
"""在应用关闭时安全地持久化健康状态"""
|
||||
if self._persister:
|
||||
await self._persister.stop()
|
||||
logger.debug("HealthManager 已在关闭前保存遥测状态。")
|
||||
|
||||
async def get_next_available_key(
|
||||
self,
|
||||
provider_name: str,
|
||||
api_keys: list[str],
|
||||
exclude_keys: set[str] | None = None,
|
||||
strict_mode: bool = False,
|
||||
) -> str | None:
|
||||
"""路由获取下一个可用 API Key"""
|
||||
return self._key_manager.get_next_available_key(
|
||||
provider_name, api_keys, exclude_keys, strict_mode
|
||||
)
|
||||
|
||||
def is_route_healthy(self, route_name: str, strict_mode: bool = True) -> bool:
|
||||
"""路由判断指定模型路由是否健康"""
|
||||
return self._circuit_manager.is_route_healthy(route_name, strict_mode)
|
||||
|
||||
async def record_route_success(self, route_name: str, latency: float):
|
||||
"""路由记录模型请求成功"""
|
||||
self._circuit_manager.record_route_success(route_name, latency)
|
||||
if self._persister:
|
||||
self._persister.mark_dirty()
|
||||
|
||||
async def record_route_failure(self, route_name: str, exception: Exception):
|
||||
"""路由记录模型请求失败并记录熔断"""
|
||||
self._circuit_manager.record_route_failure(route_name, exception, self.policy)
|
||||
if self._persister:
|
||||
self._persister.mark_dirty()
|
||||
logger.warning(
|
||||
f"🚨 [Route-Level] 节点 '{route_name}' 发生服务端故障,"
|
||||
f"已触发熔断 (OPEN)。错误: {exception}"
|
||||
)
|
||||
|
||||
def get_best_fallback_route(self, route_names: list[str]) -> str:
|
||||
"""路由获取最佳备选健康节点"""
|
||||
return self._circuit_manager.get_best_fallback_route(route_names)
|
||||
|
||||
async def record_key_success(self, provider_name: str, api_key: str):
|
||||
"""路由记录 API Key 成功状态"""
|
||||
self._key_manager.record_key_success(provider_name, api_key)
|
||||
if self._persister:
|
||||
self._persister.mark_dirty()
|
||||
|
||||
async def record_key_failure(
|
||||
self,
|
||||
provider_name: str,
|
||||
api_key: str,
|
||||
exception: Exception,
|
||||
):
|
||||
"""路由记录 API Key 失败冷却状态"""
|
||||
self._key_manager.record_key_failure(
|
||||
provider_name, api_key, exception, self.policy
|
||||
)
|
||||
if self._persister:
|
||||
self._persister.mark_dirty()
|
||||
key_id = self._get_key_id(api_key)
|
||||
logger.debug(f"API Key {key_id} 发生失败: {exception}")
|
||||
|
||||
async def reset_key_status(self, provider_name: str, api_key: str):
|
||||
"""路由重置 API Key 的健康状态"""
|
||||
self._key_manager.reset_key_status(provider_name, api_key)
|
||||
if self._persister:
|
||||
self._persister.mark_dirty()
|
||||
logger.info(f"重置API密钥状态: {self._get_key_id(api_key)}")
|
||||
|
||||
def _get_key_id(self, api_key: str) -> str:
|
||||
"""脱敏获取用于日志的 API Key 摘要 ID"""
|
||||
if len(api_key) <= 8:
|
||||
return api_key
|
||||
return f"{api_key[:4]}...{api_key[-4:]}"
|
||||
|
||||
|
||||
health_manager = HealthManager()
|
||||
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _shutdown_health_manager():
|
||||
await health_manager.shutdown()
|
||||
Reference in New Issue
Block a user