✨ 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 执行引擎模块。
"""
@@ -0,0 +1,641 @@
import asyncio
import hashlib
import json
import re
import time
from typing import Any, ClassVar, cast
from aiocache import SimpleMemoryCache
import httpx
from zhenxun.services.ai.core.exceptions import (
ConfigurationException,
LLMException,
NetworkTimeoutException,
UpstreamServerException,
)
from zhenxun.services.ai.core.messages import (
AudioPart,
AudioResponse,
ChatRequest,
ChatResponse,
EmbeddingRequest,
EmbeddingResponse,
FilePart,
ImagePart,
ImageRequest,
ImageResponse,
RerankRequest,
RerankResponse,
SpeechRequest,
TextPart,
VideoPart,
)
from zhenxun.services.ai.core.models import (
LLMContext,
ModelCapabilities,
ModelIdentity,
ModelModality,
)
from zhenxun.services.ai.core.options import (
GenerationConfig,
)
from zhenxun.services.ai.core.protocols.middleware import LLMMiddleware, NextCall
from zhenxun.services.ai.llm.adapters.base import (
BaseAdapter,
RequestData,
process_image_data,
)
from zhenxun.services.ai.llm.system.models import RetryConfig
from zhenxun.services.ai.llm.system.network import (
HealthManager,
LLMHttpClient,
)
from zhenxun.services.log import logger
from zhenxun.utils.http_utils import AsyncHttpx
from zhenxun.utils.log_sanitizer import sanitize_for_logging
from zhenxun.utils.pydantic_compat import (
dump_json_safely,
model_copy,
model_dump,
parse_as,
)
_LLM_API_CACHE = SimpleMemoryCache(namespace="zhenxun_llm_api_cache")
class MiddlewarePipeline:
"""中间件管线组装器"""
def __init__(self):
self.middlewares: list[LLMMiddleware] = []
def add_middleware(self, middleware: LLMMiddleware) -> None:
"""按顺序追加中间件,先加入的将处在调用链的最外层"""
self.middlewares.append(middleware)
def build(self, terminal_handler: NextCall[Any, Any]) -> NextCall[Any, Any]:
handler = terminal_handler
for middleware in reversed(self.middlewares):
def _wrap(
mw: LLMMiddleware[Any, Any], next_c: NextCall[Any, Any]
) -> NextCall[Any, Any]:
async def _handler(context: LLMContext[Any, Any]) -> Any:
return await mw(context, next_c)
return _handler
handler = _wrap(middleware, handler)
return handler
class LLMCacheMiddleware:
"""
大模型极速缓存中间件:
只在开发者显式配置了 __cache_ttl__ 时生效。
拦截高成本的 API 网络请求,直接返回本地缓存。
"""
_RESPONSE_TYPE_MAP: ClassVar[dict[type, type]] = {
ChatRequest: ChatResponse,
EmbeddingRequest: EmbeddingResponse,
ImageRequest: ImageResponse,
SpeechRequest: AudioResponse,
RerankRequest: RerankResponse,
}
def __init__(self, model_name: str):
self.model_name = model_name
def _generate_cache_key(self, context: LLMContext[Any, Any]) -> str:
"""构造绝对纯净的请求哈希键,剔除时间戳等干扰项"""
payload = {
"model": self.model_name,
"type": type(context.request).__name__,
"request": context.request.get_cache_hash_payload(),
}
json_str = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.md5(json_str.encode("utf-8")).hexdigest()
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
ttl = None
if hasattr(context.request, "config") and context.request.config:
ttl = getattr(context.request.config, "custom_kwargs", {}).get(
"__cache_ttl__"
)
if ttl is None:
return await next_call(context)
cache_key = self._generate_cache_key(context)
cached_data = await _LLM_API_CACHE.get(cache_key)
if cached_data is not None:
logger.debug(
f"⚡ [LLMCache] 命中本地极速缓存 - "
f"model: {self.model_name}, type: {type(context.request).__name__}"
)
response_type = self._RESPONSE_TYPE_MAP.get(type(context.request))
if response_type:
cached_resp = parse_as(response_type, cached_data)
else:
cached_resp = cached_data
if isinstance(cached_resp, ChatResponse):
cached_resp.usage_info = {
"is_cache_hit": True,
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"promptTokenCount": 0,
"candidatesTokenCount": 0,
"totalTokenCount": 0,
}
return cached_resp
response = await next_call(context)
await _LLM_API_CACHE.set(cache_key, model_dump(response), ttl=ttl)
return response
class FailoverAndRetryMiddleware:
"""
故障转移与重试中间件:
结合了密钥轮询 (Key Selection) 与异常退避重试 (Retry) 逻辑。
"""
def __init__(
self,
retry_config: RetryConfig,
health_manager: HealthManager,
provider_name: str,
api_keys: list[str],
):
self.retry_config = retry_config
self.health_manager = health_manager
self.provider_name = provider_name
self.api_keys = api_keys
self._failed_keys: set[str] = set()
def _raise_with_masked_key(self, e: LLMException, api_key: str) -> None:
"""辅助函数:掩码 API Key 并原样抛出异常,防止密钥泄露"""
masked = f"{api_key[:8]}..." if api_key else "unknown"
if isinstance(e.details, dict):
e.details["api_key"] = masked
raise e.with_traceback(None) from None
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
last_exception: Exception | None = None
is_routed = context.request.extra.get("_is_routed_call", False)
max_retries = 0 if is_routed else self.retry_config.max_retries
total_attempts = max_retries + 1
for attempt in range(total_attempts):
selected_key = await self.health_manager.get_next_available_key(
self.provider_name,
self.api_keys,
exclude_keys=self._failed_keys,
strict_mode=is_routed,
)
if not selected_key:
raise ConfigurationException(
f"提供商 {self.provider_name} 无可用 API Key"
)
context.runtime_state["api_key"] = selected_key
context.runtime_state["provider_name"] = self.provider_name
try:
context.runtime_state["attempt"] = attempt + 1
return await next_call(context)
except LLMException as e:
last_exception = e
await self.health_manager.record_key_failure(
self.provider_name, selected_key, e
)
if e.should_rotate_key:
self._failed_keys.add(selected_key)
if not e.is_retryable:
self._raise_with_masked_key(e, selected_key)
if attempt == total_attempts - 1:
self._raise_with_masked_key(e, selected_key)
wait_time = self.retry_config.retry_delay
if self.retry_config.exponential_backoff:
wait_time *= 2**attempt
logger.warning(
f"请求失败,{wait_time:.2f}秒后重试"
f" (第{attempt + 1}/{max_retries}次重试): {e}"
)
await asyncio.sleep(wait_time)
except Exception as e:
logger.error(f"非预期异常,停止重试: {e}", e=e)
raise e.with_traceback(None) from None
if last_exception:
raise last_exception.with_traceback(None) from None
raise LLMException("重试循环异常结束").with_traceback(None) from None
class LoggingMiddleware:
"""
日志中间件:
职责归位后,统一负责 HTTP Payload 的生成、安全脱敏以及完整生命周期的日志记录。
"""
def __init__(
self,
provider_name: str,
model_name: str,
adapter: BaseAdapter,
identity: ModelIdentity,
log_context: str = "Generation",
):
self.provider_name = provider_name
self.model_name = model_name
self.adapter = adapter
self.identity = identity
self.log_context = log_context
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
attempt = context.runtime_state.get("attempt", 1)
api_key = context.runtime_state.get("api_key", "unknown")
masked_key = f"{api_key[:8]}..."
logger.info(
f"🌐 发起LLM请求 (尝试 {attempt}) - {self.provider_name}/{self.model_name} "
f"[{self.log_context}] Key: {masked_key}"
)
request_data = await self.adapter.prepare_payload(
identity=self.identity,
api_key=api_key,
request=context.request,
)
context.runtime_state["request_data"] = request_data
logger.debug(f"📡 请求URL: {request_data.url}")
logger.debug(f"📋 请求头: {dict(request_data.headers)}")
if self.identity.api_type == "smart":
from zhenxun.services.ai.llm.adapters.factory import SmartAdapter
smart_adapter = cast(SmartAdapter, self.adapter)
delegate_adapter = smart_adapter._get_delegate_adapter(self.identity)
sanitizer_req_context = f"{delegate_adapter.api_type}_request"
else:
sanitizer_req_context = self.adapter.log_sanitization_context
sanitized_body = sanitize_for_logging(
request_data.body, context=sanitizer_req_context
)
if request_data.files and isinstance(sanitized_body, dict):
file_info: list[str] = []
file_count = 0
if isinstance(request_data.files, list):
file_count = len(request_data.files)
for key, value in request_data.files:
filename = (
value[0]
if isinstance(value, tuple) and len(value) > 0
else "..."
)
file_info.append(f"{key}='{filename}'")
elif isinstance(request_data.files, dict):
file_count = len(request_data.files)
file_info = list(request_data.files.keys())
sanitized_body["[MULTIPART_FILES]"] = f"Count: {file_count} | {file_info}"
request_body_str = dump_json_safely(
sanitized_body, ensure_ascii=False, indent=2
)
logger.debug(f"📦 请求体: {request_body_str}")
try:
start_time = time.monotonic()
response = await next_call(context)
duration = (time.monotonic() - start_time) * 1000
logger.debug(f"🎯 LLM响应成功 [{self.log_context}] 耗时: {duration:.2f}ms")
return response
except Exception as e:
raise e.with_traceback(None) from None
class HttpExecutionMiddleware:
"""
终端 HTTP 执行中间件:
只负责将上游构建好的 Payload 发送出去,并拦截纯粹的 HTTP 网络故障。
"""
def __init__(
self,
http_client: LLMHttpClient,
identity: ModelIdentity,
health_manager: HealthManager,
adapter: BaseAdapter,
):
self.http_client = http_client
self.identity = identity
self.health_manager = health_manager
self.adapter = adapter
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
api_key = context.runtime_state["api_key"]
provider_name = self.identity.provider_name
route_id = f"{self.identity.provider_name}/{self.identity.model_name}"
request_data: RequestData = context.runtime_state["request_data"]
if context.cancellation_token:
context.cancellation_token.raise_if_cancelled()
start_time = time.monotonic()
try:
method = getattr(request_data, "method", "POST").upper()
req_kwargs = {
"headers": request_data.headers,
"timeout": context.request.timeout,
}
if method in ("POST", "PUT", "PATCH"):
if request_data.files:
req_kwargs["data"] = request_data.body
req_kwargs["files"] = request_data.files
else:
req_kwargs["content"] = json.dumps(
request_data.body, ensure_ascii=False
)
elif method == "GET" and request_data.body:
req_kwargs["params"] = request_data.body
post_task = asyncio.create_task(
self.http_client.request(method, request_data.url, **req_kwargs)
)
if context.cancellation_token:
context.cancellation_token.link_future(post_task)
raw_engine_output = await post_task
logger.debug(f"📥 HTTP响应状态码: {raw_engine_output.status_code}")
if exception := self.adapter.handle_http_error(raw_engine_output):
error_text = raw_engine_output.content.decode("utf-8", errors="ignore")
logger.debug(f"💥 完整错误响应: {error_text}")
raise exception.with_traceback(None) from None
latency = (time.monotonic() - start_time) * 1000
await self.health_manager.record_key_success(provider_name, api_key)
await self.health_manager.record_route_success(route_id, latency)
return await self.adapter.parse_payload(
identity=self.identity,
request=context.request,
raw_response=raw_engine_output,
)
except asyncio.CancelledError:
logger.warning(f"网络请求已被取消: {request_data.url}")
raise
except httpx.TimeoutException as e:
await self.health_manager.record_route_failure(route_id, e)
raise NetworkTimeoutException(f"HTTP请求超时: {e}", cause=e)
except httpx.NetworkError as e:
await self.health_manager.record_route_failure(route_id, e)
raise UpstreamServerException(f"网络连接中断: {e}", cause=e)
except LLMException as e:
if e.should_failover:
await self.health_manager.record_route_failure(route_id, e)
raise e.with_traceback(None) from None
except Exception as e:
logger.error(f"解析响应失败或发生未知错误: {e}")
masked_key = (
f"{api_key[:8]}...{api_key[-4:] if len(api_key) > 12 else '***'}"
if api_key
else "N/A"
)
raise UpstreamServerException(
f"网络请求异常: {type(e).__name__} - {e}",
details={"api_key": masked_key},
cause=e,
).with_traceback(None) from None
class ModalityFilterMiddleware:
"""模态过滤中间件:负责自动剔除当前模型不支持的多模态输入"""
def __init__(self, model_name: str, capabilities: ModelCapabilities):
self.model_name = model_name
self.capabilities = capabilities
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
request = context.request
if isinstance(request, ChatRequest):
filtered_messages = []
_warned_image, _warned_audio, _warned_video = False, False, False
for msg in request.messages:
new_content = []
for part in msg.content:
if (
isinstance(part, ImagePart)
and ModelModality.IMAGE
not in self.capabilities.input_modalities
):
if not _warned_image:
logger.warning(
f"模型 {self.model_name} 不支持图像输入,"
"已自动将图片替换为占位符"
)
_warned_image = True
new_content.append(TextPart(text="<图片>"))
elif (
isinstance(part, AudioPart)
and ModelModality.AUDIO
not in self.capabilities.input_modalities
):
if not _warned_audio:
logger.warning(
f"模型 {self.model_name} 不支持音频输入,"
"已自动将音频替换为占位符"
)
_warned_audio = True
new_content.append(TextPart(text="<音频>"))
elif (
isinstance(part, VideoPart)
and ModelModality.VIDEO
not in self.capabilities.input_modalities
):
if not _warned_video:
logger.warning(
f"模型 {self.model_name} 不支持视频输入,"
"已自动将视频替换为占位符"
)
_warned_video = True
new_content.append(TextPart(text="<视频>"))
elif (
isinstance(part, FilePart)
and ModelModality.FILE not in self.capabilities.input_modalities
):
new_content.append(TextPart(text="<文件>"))
else:
new_content.append(part)
filtered_messages.append(
model_copy(msg, update={"content": new_content})
)
context.request = model_copy(
request, update={"messages": filtered_messages}
)
return await next_call(context)
class ConfigMergeMiddleware:
"""配置合并中间件:合并覆盖配置,统一填充默认参数"""
def __init__(self, generation_config: GenerationConfig | None):
self.generation_config = generation_config
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
request = context.request
updates = {}
if hasattr(request, "tools") and getattr(request, "tools", None) is not None:
tools = getattr(request, "tools")
updates["tools"] = (
list(tools.values())
if isinstance(tools, dict)
else (tools if isinstance(tools, list) else [tools])
)
if hasattr(request, "config"):
req_config = getattr(request, "config", None)
if isinstance(req_config, GenerationConfig) and self.generation_config:
updates["config"] = self.generation_config.merge_with(req_config)
elif (
req_config is None
and self.generation_config
and hasattr(request, "messages")
):
updates["config"] = self.generation_config
if updates:
context.request = model_copy(request, update=updates)
return await next_call(context)
class ResponseRescueMiddleware:
"""响应挽救中间件:对于没有按要求返回图片链接的模型,尝试进行正则兜底下载"""
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
response = await next_call(context)
request = context.request
if isinstance(request, ChatRequest) and isinstance(response, ChatResponse):
gen_config = request.config
policy = gen_config.validation_policy if gen_config else None
should_rescue_image = policy and policy.get("require_image")
if (
should_rescue_image
and not response.images
and response.text
and gen_config
):
markdown_matches = re.findall(
r"(!?\[.*?\]\((https?://[^\)]+)\))", response.text
)
if markdown_matches:
logger.info(
f"检测到 {len(markdown_matches)} 个链接,尝试自动下载清洗。"
)
current_text = response.text
other_parts = [
p for p in response.content_parts if not isinstance(p, TextPart)
]
downloaded_urls = set()
for full_tag, url in markdown_matches:
try:
if url not in downloaded_urls:
content = await AsyncHttpx.get_content(url)
processed = process_image_data(content)
if isinstance(processed, bytes):
img_part = ImagePart(raw=processed)
else:
img_part = ImagePart(path=processed)
other_parts.append(img_part)
downloaded_urls.add(url)
current_text = current_text.replace(full_tag, "")
except Exception as exc:
logger.warning(f"自动下载图片失败: {url}, 错误: {exc}")
response.content_parts = [
TextPart(text=current_text.strip()),
*other_parts,
]
return response
class OutputValidationMiddleware:
"""输出验证中间件:负责策略校验与自定义格式验证)"""
async def __call__(
self, context: LLMContext[Any, Any], next_call: NextCall[Any, Any]
) -> Any:
response = await next_call(context)
request = context.request
if isinstance(request, ChatRequest) and isinstance(response, ChatResponse):
gen_config = request.config
if not gen_config:
return response
if gen_config.response_validator:
try:
gen_config.response_validator(response)
except Exception as exc:
raise LLMException(
f"响应内容未通过自定义验证器: {exc}",
details={"validator_error": str(exc)},
).with_traceback(None) from None
policy = gen_config.validation_policy
if policy and policy.get("require_image") and not response.images:
prompt_had_image = any(
isinstance(p, ImagePart)
for msg in request.messages
for p in msg.content
)
if not prompt_had_image:
logger.debug("提示词中未包含图片,跳过要求图片返回的重试特判。")
else:
raise LLMException(
"响应验证失败:要求返回图片但未找到图片数据。",
details={"policy": policy, "text_response": response.text},
)
return response
+162
View File
@@ -0,0 +1,162 @@
from abc import ABC, abstractmethod
from typing import Any
from zhenxun.services.ai.core.exceptions import (
ConfigurationException,
LLMException,
UpstreamServerException,
)
from zhenxun.services.ai.core.options import GenerationConfig
from zhenxun.services.ai.llm.manager import (
_get_group_name,
_resolve_model_group,
get_default_model,
get_model_instance,
list_available_models,
)
from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities
from zhenxun.services.ai.llm.system.network import health_manager
from zhenxun.services.log import logger
class BaseModelRouter(ABC):
@abstractmethod
async def route(
self,
request: Any,
model_names: list[str],
task: str,
override_config: GenerationConfig | dict | None,
cancellation_token: Any | None,
) -> Any:
pass
class FallbackRouter(BaseModelRouter):
"""主备故障转移路由器"""
async def route(
self,
request: Any,
model_names: list[str],
task: str,
override_config: GenerationConfig | dict | None,
cancellation_token: Any | None,
) -> Any:
errors = []
all_nodes_bypassed = True
is_routed_call = len(model_names) > 1
request.extra["_is_routed_call"] = is_routed_call
start_idx = request.extra.get("_working_route_index", 0)
indices_to_try = list(range(start_idx, len(model_names))) + list(
range(0, start_idx)
)
for idx in indices_to_try:
m_name = model_names[idx]
if not health_manager.is_route_healthy(m_name, strict_mode=is_routed_call):
logger.debug(f"👉 [Orchestrator] 节点 '{m_name}' 熔断中,已跳过")
errors.append(f"{m_name}(熔断中)")
continue
caps = get_model_capabilities(m_name)
if not caps.supports_task(task):
errors.append(f"{m_name}(Unsupported Task: {task})")
continue
all_nodes_bypassed = False
try:
if len(model_names) > 1:
if idx != start_idx:
logger.debug(f"🔄 [Orchestrator] 切换至备用节点: '{m_name}'...")
async with await get_model_instance(
m_name, override_config, task=task
) as instance:
response = await instance.invoke(request, cancellation_token)
request.extra["_working_route_index"] = idx
if run_ctx := request.extra.get("run_context"):
run_ctx.state["_working_route_index"] = idx
return response
except LLMException as e:
if not e.should_failover:
logger.warning(
f"🚫 [Orchestrator] 节点 '{m_name}' "
f"返回不可恢复错误 ({e.__class__.__name__}),停止故障转移。"
)
raise e
logger.warning(
f"⚠️ [Orchestrator] 节点 '{m_name}' "
f"错误 ({e.__class__.__name__}),触发故障转移..."
)
errors.append(f"{m_name}({e.__class__.__name__})")
except Exception as e:
logger.warning(
f"⚠️ [Orchestrator] 节点 '{m_name}' 发生未知异常,触发故障转移: {e}"
)
errors.append(f"{m_name}(Error)")
if all_nodes_bypassed and len(model_names) > 1:
fallback_model = health_manager.get_best_fallback_route(model_names)
logger.warning(
f"⚠️ [Orchestrator] 路由组所有节点均已宕机!"
f"强制放行 '{fallback_model}' 探活..."
)
try:
async with await get_model_instance(
fallback_model, override_config, task=task
) as instance:
return await instance.invoke(request, cancellation_token)
except Exception as e:
errors.append(f"{fallback_model}(保底探活彻底失败:{e})")
err_msg = f"所有路由尝试均已失败: {', '.join(errors)}"
raise UpstreamServerException(err_msg)
class BaseOrchestrator:
"""顶层大模型请求编排器,负责组解析与路由策略委派。"""
def __init__(self, router: BaseModelRouter | None = None):
self.router = router or FallbackRouter()
async def invoke(
self,
request: Any,
model_name: str | None = None,
task: str = "chat",
override_config: GenerationConfig | dict | None = None,
cancellation_token: Any | None = None,
) -> Any:
resolved_model_name = model_name
if resolved_model_name is None:
resolved_model_name = get_default_model(task)
if resolved_model_name is None:
available_models = list_available_models()
if not available_models:
raise ConfigurationException("未配置任何AI模型")
resolved_model_name = available_models[0]["full_name"]
logger.warning(f"未指定模型,使用第一个可用模型: {resolved_model_name}")
group_name = _get_group_name(resolved_model_name)
if group_name is not None:
model_names = _resolve_model_group(group_name)
if not model_names:
raise ConfigurationException(
f"模型路由组 '{group_name}' 解析失败或为空,请检查配置。"
)
else:
model_names = [resolved_model_name]
return await self.router.route(
request=request,
model_names=model_names,
task=task,
override_config=override_config,
cancellation_token=cancellation_token,
)
LLMOrchestrator = BaseOrchestrator()
@@ -0,0 +1,356 @@
from abc import ABC, abstractmethod
import copy
from typing import Any
class BaseSchemaTransformer(ABC):
"""JSON Schema 节点处理器基类"""
@abstractmethod
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
"""处理单个 Schema 节点并返回修改后的节点"""
pass
class SchemaPipeline:
"""JSON Schema 转换管道执行器"""
def __init__(self, transformers: list[BaseSchemaTransformer]):
self.transformers = transformers
def run(self, schema: dict[str, Any]) -> dict[str, Any]:
"""执行管道,递归遍历并处理整个 Schema AST"""
if not isinstance(schema, dict):
return schema
schema_copy = copy.deepcopy(schema)
return self._walk(schema_copy, is_root=True)
def _walk(self, node: Any, is_root: bool = False) -> Any:
"""核心递归遍历算法,定向深入标准的 Schema 容器键"""
if isinstance(node, list):
return [self._walk(item, is_root=False) for item in node]
if not isinstance(node, dict):
return node
current_node = node
for transformer in self.transformers:
current_node = transformer.process_node(current_node, is_root=is_root)
if not isinstance(current_node, dict):
return current_node
for dict_key in ["properties", "patternProperties", "$defs", "definitions"]:
if dict_key in current_node and isinstance(current_node[dict_key], dict):
current_node[dict_key] = {
k: self._walk(v, is_root=False)
for k, v in current_node[dict_key].items()
}
for list_key in ["anyOf", "allOf", "oneOf", "prefixItems"]:
if list_key in current_node and isinstance(current_node[list_key], list):
current_node[list_key] = [
self._walk(v, is_root=False) for v in current_node[list_key]
]
for single_key in ["items", "additionalProperties", "contains"]:
if single_key in current_node and isinstance(
current_node[single_key], dict
):
current_node[single_key] = self._walk(
current_node[single_key], is_root=False
)
return current_node
class RootRefInlineTransformer(BaseSchemaTransformer):
"""将根节点的 $ref 展开,保留 $defs 供内部递归使用"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if is_root and "$ref" in node:
ref_path = node.pop("$ref")
ref_name = ref_path.split("/")[-1]
defs = node.get("$defs") or node.get("definitions") or {}
if ref_name in defs:
def_content = defs[ref_name].copy()
for k, v in def_content.items():
if k not in node:
node[k] = v
return node
class RemoveUnsupportedKeysTransformer(BaseSchemaTransformer):
"""移除不支持的键"""
def __init__(self, keys_to_remove: list[str]):
self.keys_to_remove = keys_to_remove
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
for key in self.keys_to_remove:
node.pop(key, None)
return node
class GeminiEnumTransformer(BaseSchemaTransformer):
"""将 const 转换为 enum (Gemini 专用)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if "const" in node:
node["enum"] = [node.pop("const")]
return node
class GeminiNullableUnionTransformer(BaseSchemaTransformer):
"""处理 anyOf 和 type 列表中的 null,转换为 nullable: True (Gemini 专用)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if "type" in node and isinstance(node["type"], list):
types_list = node["type"]
if "null" in types_list:
node["nullable"] = True
types_list = [t for t in types_list if t != "null"]
node["type"] = types_list[0] if len(types_list) == 1 else types_list
if "anyOf" in node and isinstance(node["anyOf"], list):
any_of = node["anyOf"]
has_null = any(
isinstance(x, dict) and x.get("type") == "null" for x in any_of
)
if has_null:
node["nullable"] = True
new_any_of = [
x
for x in any_of
if not (isinstance(x, dict) and x.get("type") == "null")
]
if len(new_any_of) == 1:
node.update(new_any_of[0])
node.pop("anyOf", None)
else:
node["anyOf"] = new_any_of
return node
class GeminiFormatTransformer(BaseSchemaTransformer):
"""清理不支持的 format 格式 (Gemini 专用)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if node.get("format") and node["format"] not in ["enum", "date-time"]:
node.pop("format", None)
return node
class StrictObjectTransformer(BaseSchemaTransformer):
"""
强制对象必须关闭 additionalProperties,
并将所有 properties 设为 required (OpenAI/DeepSeek 专用)
"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if node.get("type") == "object" or "properties" in node:
node["type"] = "object"
node["additionalProperties"] = False
if "properties" not in node:
node["properties"] = {}
node["required"] = list(node["properties"].keys())
return node
class OpenAIUnionFlattenTransformer(BaseSchemaTransformer):
"""
将 anyOf/allOf/oneOf 拍平,选取第一个非 null 的类型作为降级方案
(OpenAI 严格模式不支持复杂 Union)
"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
for union_key in ["anyOf", "allOf", "oneOf"]:
if union_key in node:
union_list = node.pop(union_key)
if isinstance(union_list, list) and len(union_list) > 0:
fallback = {}
for item in union_list:
if isinstance(item, dict) and item.get("type") != "null":
fallback = item
break
if not fallback and isinstance(union_list[0], dict):
fallback = union_list[0]
for k, v in fallback.items():
if k not in node:
node[k] = v
return node
class TypeEnforcerTransformer(BaseSchemaTransformer):
"""强制赋予空节点类型 (默认 string)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if "type" not in node and "properties" not in node and "$ref" not in node:
node["type"] = "string"
return node
class DeepSeekFallbackTransformer(BaseSchemaTransformer):
"""非根空对象转为字符串 (DeepSeek 专用避坑)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if node.get("type") == "object" and not is_root and not node.get("properties"):
node["type"] = "string"
for k in ["properties", "required", "additionalProperties"]:
node.pop(k, None)
node["description"] = (
f"{node.get('description', '')} (Please provide a JSON string)".strip()
)
return node
class GeminiCyclicRefTransformer(BaseSchemaTransformer):
"""
识别并处理循环引用:
Gemini 要求循环引用的 $ref 决不能出现在父级的 required 列表中
"""
def __init__(self, full_schema: dict):
self.cyclic_refs = self._detect_cycles(full_schema)
def _detect_cycles(self, schema: dict) -> set[str]:
defs = schema.get("$defs") or schema.get("definitions") or {}
cyclic = set()
def check_cycle(def_name: str, visited: set, path: list):
if def_name in path:
for node in path[path.index(def_name) :]:
cyclic.add(f"#/$defs/{node}")
cyclic.add(f"#/definitions/{node}")
return
if def_name in visited:
return
visited.add(def_name)
node = defs.get(def_name, {})
def find_refs(n: Any) -> list[str]:
refs = []
if isinstance(n, dict):
if "$ref" in n:
refs.append(n["$ref"])
for v in n.values():
refs.extend(find_refs(v))
elif isinstance(n, list):
for v in n:
refs.extend(find_refs(v))
return refs
for ref in find_refs(node):
if ref.startswith("#/$defs/") or ref.startswith("#/definitions/"):
next_def = ref.split("/")[-1]
check_cycle(next_def, visited, [*path, def_name])
visited_set = set()
for name in defs:
check_cycle(name, visited_set, [])
return cyclic
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if "properties" in node and isinstance(node.get("required"), list):
new_required = []
for req_key in node["required"]:
prop = node.get("properties", {}).get(req_key, {})
is_cyclic = False
if prop.get("$ref") in self.cyclic_refs:
is_cyclic = True
elif prop.get("type") == "array" and isinstance(
prop.get("items"), dict
):
if prop["items"].get("$ref") in self.cyclic_refs:
is_cyclic = True
if not is_cyclic:
new_required.append(req_key)
if not new_required:
node.pop("required")
else:
node["required"] = new_required
return node
class RefComplianceTransformer(BaseSchemaTransformer):
"""如果包含 $ref,同级不允许出现任何非 $ 开头的键 (如 description, title)"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if "$ref" in node:
keys_to_remove = [k for k in node.keys() if not k.startswith("$")]
for k in keys_to_remove:
node.pop(k)
return node
class GeminiDeepRefInlineTransformer(BaseSchemaTransformer):
"""
深度展开所有 $ref,并清理 $defs (Gemini Function Calling 专用)。
大模型 API 网关层面严格拒绝 $defs 与 $ref,所以进行全量物理替换。
如果遇到循环引用,将安全退化为 string 类型防止栈溢出死循环。
"""
def process_node(
self, node: dict[str, Any], is_root: bool = False
) -> dict[str, Any]:
if not is_root:
return node
defs = {}
if "$defs" in node:
defs.update(node["$defs"])
if "definitions" in node:
defs.update(node["definitions"])
def _resolve_refs(current: Any, visited: set) -> Any:
if isinstance(current, list):
return [_resolve_refs(item, visited) for item in current]
elif isinstance(current, dict):
if "$ref" in current:
ref_path = current["$ref"]
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
if ref_name in visited:
return {
"type": "string",
"description": "Cyclic reference omitted",
}
new_visited = visited | {ref_name}
resolved = _resolve_refs(defs[ref_name], new_visited)
result = current.copy()
result.pop("$ref")
for k, v in resolved.items():
if k not in result:
result[k] = v
return result
return {k: _resolve_refs(v, visited) for k, v in current.items()}
return current
node = _resolve_refs(node, set())
node.pop("$defs", None)
node.pop("definitions", None)
return node
+280
View File
@@ -0,0 +1,280 @@
"""
LLM 模型实现类
包含 LLM 模型的抽象基类和具体实现,负责与各种 AI 提供商的 API 交互。
"""
from __future__ import annotations
from typing import Any, TypeVar
from pydantic import BaseModel
from zhenxun.services.ai.config import ProviderConfig, get_llm_config
from zhenxun.services.ai.core.exceptions import ConfigurationException
from zhenxun.services.ai.core.messages import (
AudioResponse,
BaseRequest,
ChatRequest,
ChatResponse,
EmbeddingRequest,
EmbeddingResponse,
ImageRequest,
ImageResponse,
RerankRequest,
RerankResponse,
SpeechRequest,
)
from zhenxun.services.ai.core.models import (
CancellationToken,
LLMContext,
ModelCapabilities,
ModelDetail,
ModelIdentity,
)
from zhenxun.services.ai.core.options import (
GenerationConfig,
)
from zhenxun.services.ai.core.protocols.llm import (
SupportsChat,
SupportsImageGeneration,
SupportsReranking,
SupportsSpeechSynthesis,
SupportsTextEmbedding,
)
from zhenxun.services.ai.core.protocols.middleware import LLMMiddleware
from zhenxun.services.ai.llm.system.models import RetryConfig
from zhenxun.services.ai.llm.system.network import HealthManager, LLMHttpClient
from zhenxun.services.log import logger
T = TypeVar("T", bound=BaseModel)
class LLMModel(
SupportsChat,
SupportsTextEmbedding,
SupportsSpeechSynthesis,
SupportsReranking,
SupportsImageGeneration,
):
"""LLM 模型实现类"""
def __init__(
self,
provider_config: ProviderConfig,
model_detail: ModelDetail,
health_manager: HealthManager,
http_client: LLMHttpClient,
capabilities: ModelCapabilities,
config_override: GenerationConfig | None = None,
):
self.provider_config = provider_config
self.model_detail = model_detail
self.health_manager = health_manager
self.http_client: LLMHttpClient = http_client
self.capabilities = capabilities
self._generation_config = config_override
self.provider_name = provider_config.name
self.api_type = model_detail.api_type or provider_config.api_type
self.api_base = provider_config.api_base
self.path_prefix = model_detail.path_prefix
self.api_keys = (
[provider_config.api_key]
if isinstance(provider_config.api_key, str)
else provider_config.api_key
)
self.model_name = model_detail.model_name
self.temperature = model_detail.temperature
self.generation_max_tokens = model_detail.generation_max_tokens
self._is_closed = False
self._ref_count = 0
self.identity = ModelIdentity(
provider_name=self.provider_name,
model_name=self.model_name,
api_type=self.api_type,
api_base=self.api_base,
path_prefix=self.path_prefix,
capabilities=self.capabilities,
generation_config=self._generation_config,
)
from zhenxun.services.ai.llm.engine.middlewares import MiddlewarePipeline
self.pipeline = MiddlewarePipeline()
self._setup_default_pipeline()
def add_middleware(self, middleware: LLMMiddleware) -> None:
"""注册一个中间件到处理管道的最外层"""
self.pipeline.add_middleware(middleware)
def _setup_default_pipeline(self) -> None:
from zhenxun.services.ai.llm.adapters.factory import get_adapter_for_api_type
from zhenxun.services.ai.llm.engine.middlewares import (
ConfigMergeMiddleware,
FailoverAndRetryMiddleware,
LLMCacheMiddleware,
LoggingMiddleware,
ModalityFilterMiddleware,
OutputValidationMiddleware,
ResponseRescueMiddleware,
)
client_settings = get_llm_config().client_settings
retry_config = RetryConfig(
max_retries=client_settings.max_retries,
retry_delay=client_settings.retry_delay,
)
adapter = get_adapter_for_api_type(self.api_type)
self.pipeline.add_middleware(LLMCacheMiddleware(self.model_name))
self.pipeline.add_middleware(ConfigMergeMiddleware(self._generation_config))
self.pipeline.add_middleware(
ModalityFilterMiddleware(self.model_name, self.capabilities)
)
self.pipeline.add_middleware(
FailoverAndRetryMiddleware(
retry_config, self.health_manager, self.provider_name, self.api_keys
)
)
self.pipeline.add_middleware(OutputValidationMiddleware())
self.pipeline.add_middleware(ResponseRescueMiddleware())
self.pipeline.add_middleware(
LoggingMiddleware(
self.provider_name, self.model_name, adapter, self.identity
)
)
async def _select_api_key(self, failed_keys: set[str] | None = None) -> str:
"""选择可用的API密钥(使用轮询策略)"""
if not self.api_keys:
raise ConfigurationException(
f"提供商 {self.provider_name} 没有配置API密钥",
)
selected_key = await self.health_manager.get_next_available_key(
self.provider_name, self.api_keys, failed_keys
)
if not selected_key:
raise ConfigurationException(
f"提供商 {self.provider_name} 的所有API密钥当前都不可用",
details={
"total_keys": len(self.api_keys),
"failed_keys": len(failed_keys or set()),
},
)
return selected_key
async def close(self):
"""标记模型实例的当前使用周期结束"""
if self._is_closed:
return
self._is_closed = True
logger.debug(
f"LLMModel实例的使用周期已结束: {self} (共享HTTP客户端状态不受影响)"
)
async def __aenter__(self):
if self._is_closed:
logger.debug(
f"Re-entering context for closed LLMModel {self}. "
f"Resetting _is_closed to False."
)
self._is_closed = False
self._check_not_closed()
self._ref_count += 1
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口"""
_ = exc_type, exc_val, exc_tb
self._ref_count -= 1
if self._ref_count <= 0:
self._ref_count = 0
await self.close()
def _check_not_closed(self):
"""检查实例是否已关闭"""
if self._is_closed:
raise RuntimeError(f"LLMModel实例已关闭: {self}")
async def invoke(
self,
request: BaseRequest,
cancellation_token: CancellationToken | None = None,
) -> Any:
"""
大一统命令执行核心入口 (Command Pattern)。
整合所有中间件执行管线,屏蔽具体模态差异。
"""
self._check_not_closed()
context = LLMContext(
request=request,
cancellation_token=cancellation_token,
)
from zhenxun.services.ai.llm.adapters.factory import get_adapter_for_api_type
from zhenxun.services.ai.llm.engine.middlewares import HttpExecutionMiddleware
adapter = get_adapter_for_api_type(self.api_type)
execution_middleware = HttpExecutionMiddleware(
http_client=self.http_client,
identity=self.identity,
health_manager=self.health_manager,
adapter=adapter,
)
async def terminal_handler(ctx: LLMContext[Any, Any]) -> Any:
async def _noop(c: LLMContext[Any, Any]) -> Any:
raise RuntimeError("HttpExecutionMiddleware 不应调用 next_call")
return await execution_middleware(ctx, _noop)
handler = self.pipeline.build(terminal_handler)
return await handler(context)
async def generate_response(
self,
request: ChatRequest,
cancellation_token: CancellationToken | None = None,
) -> ChatResponse:
return await self.invoke(request, cancellation_token)
async def generate_embeddings(
self,
request: EmbeddingRequest,
) -> EmbeddingResponse:
return await self.invoke(request)
async def rerank(
self,
request: RerankRequest,
) -> RerankResponse:
return await self.invoke(request)
async def generate_image(
self,
request: ImageRequest,
) -> ImageResponse:
return await self.invoke(request)
async def generate_speech(
self,
request: SpeechRequest,
) -> AudioResponse:
return await self.invoke(request)
def __str__(self) -> str:
status = "closed" if self._is_closed else "active"
return f"LLMModel({self.provider_name}/{self.model_name}, {status})"
def __repr__(self) -> str:
status = "closed" if self._is_closed else "active"
return (
f"LLMModel(provider={self.provider_name}, model={self.model_name}, "
f"api_type={self.api_type}, status={status})"
)