mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-12 23:40:03 +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,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
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
)
|
||||
Reference in New Issue
Block a user