✨ 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:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
@@ -0,0 +1,3 @@
"""
LLM 运维与调度管理模块。
"""
+173
View File
@@ -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
+112
View File
@@ -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"""
+594
View File
@@ -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()