✨ 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
+25
View File
@@ -0,0 +1,25 @@
from .exceptions import LLMException
from .messages import (
AgentEvent,
AgentMessage,
HandoffEvent,
LLMMessage,
TaskLifecycleEvent,
)
from .options import (
GenerationConfig,
)
from .templates import (
PromptTemplate,
)
__all__ = [
"AgentEvent",
"AgentMessage",
"GenerationConfig",
"HandoffEvent",
"LLMException",
"LLMMessage",
"PromptTemplate",
"TaskLifecycleEvent",
]
@@ -0,0 +1,178 @@
from dataclasses import dataclass
import hashlib
import json
from typing import Any
from zhenxun.utils.pydantic_compat import model_dump
@dataclass
class StablePrefixSnapshot:
"""系统提示词与工具的稳定前缀快照"""
system_prompt: list[str]
tools: list[Any]
fingerprint: str
class StablePrefix:
"""
一个冻结 of 系统前缀(系统提示词 + 工具)。
通过比对特征指纹,在内容未改变时避免重新构建。
"""
def __init__(self):
self._snapshot: StablePrefixSnapshot | None = None
self._version = 0
@property
def fingerprint(self) -> str:
return self._snapshot.fingerprint if self._snapshot else "<unbuilt>"
@property
def version(self) -> int:
return self._version
@property
def built(self) -> bool:
return self._snapshot is not None
def build(self, system_prompt: list[str], tools: list[Any]) -> bool:
"""
构建或重新构建前缀。
返回 True 表示内容发生实质变化(缓存可能失效),False 表示使用旧快照。
"""
snapshot = self._take_snapshot(system_prompt, tools)
if self._snapshot and self._snapshot.fingerprint == snapshot.fingerprint:
return False
self._snapshot = snapshot
self._version += 1
return True
def invalidate(self):
self._snapshot = None
def to_context(self) -> tuple[list[str], list[Any]]:
if not self._snapshot:
raise RuntimeError("StablePrefix.to_context() called before build()")
return self._snapshot.system_prompt, self._snapshot.tools
def _take_snapshot(
self, system_prompt: list[str], tools: list[Any]
) -> StablePrefixSnapshot:
parsed_tools = []
for t in tools:
if hasattr(t, "name"):
parsed_tools.append((t.name, getattr(t, "description", "")))
elif isinstance(t, dict):
parsed_tools.append((t.get("name", ""), t.get("description", "")))
else:
parsed_tools.append(str(t))
payload = {"s": system_prompt, "t": parsed_tools}
json_str = json.dumps(payload, default=str, sort_keys=True)
fingerprint = hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8]
return StablePrefixSnapshot(
system_prompt=list(system_prompt),
tools=list(tools),
fingerprint=fingerprint,
)
class AppendOnlyLog:
"""追加写入模式 of 对话日志管理器"""
def __init__(self):
self._entries: list[Any] = []
@property
def length(self) -> int:
return len(self._entries)
def append(self, message: Any):
self._entries.append(message)
def extend(self, messages: list[Any]):
self._entries.extend(messages)
def clear(self):
self._entries.clear()
def to_messages(self) -> list[Any]:
"""返回浅拷贝 of 消息列表,防止外部意外修改"""
return list(self._entries)
class AppendOnlyContextManager:
"""
为大模型 Prefix Cache 深度定制 of 上下文管理器。
将上下文拆分为绝对稳定 of Prefix (系统提示/工具) 和只增不减 of Log (对话历史)。
"""
def __init__(self):
self.prefix = StablePrefix()
self.log = AppendOnlyLog()
self._last_sync_count = 0
self._synced_digest = 0
def build(
self, system_prompt: list[str], tools: list[Any]
) -> tuple[list[str], list[Any], list[Any]]:
"""装配并获取当前 of 完整上下文元组:(系统提示词, 历史消息, 工具)"""
self.prefix.build(system_prompt, tools)
sys_p, ts = self.prefix.to_context()
return sys_p, self.log.to_messages(), ts
def sync_messages(self, normalized_messages: list[Any]):
"""
同步消息游标。
通过滚动摘要算法(Rolling Digest)自动检测历史消息是否被就地篡改或截断。
如果是,则自动重置基线;否则执行极速追加写入。
"""
if 0 < self._last_sync_count <= len(normalized_messages):
synced_part = normalized_messages[: self._last_sync_count]
if self._compute_digest(synced_part) != self._synced_digest:
self.log.clear()
self._last_sync_count = 0
if len(normalized_messages) < self._last_sync_count:
self.log.clear()
self._last_sync_count = 0
new_msgs = normalized_messages[self._last_sync_count :]
for msg in new_msgs:
self.log.append(msg)
self._last_sync_count = len(normalized_messages)
self._synced_digest = self._compute_digest(normalized_messages)
def invalidate(self):
"""使前缀快照失效(通常在模型发生变更时调用)"""
self.prefix.invalidate()
def reset_sync_cursor(self):
"""强制重置对话历史游标和日志"""
self.log.clear()
self._last_sync_count = 0
self._synced_digest = 0
def _compute_digest(self, messages: list[Any]) -> int:
"""核心:计算消息列表 of 指纹,用于识别内容篡改。包含 role 与 content。"""
from zhenxun.services.ai.core.engine.context_renderer import ContextConverter
payloads = []
flattened = ContextConverter.flatten_to_llm_messages(messages)
for msg in flattened:
try:
d = model_dump(msg, include={"role", "content"})
payloads.append(d)
except Exception:
payloads.append(str(msg))
def _default(obj):
if isinstance(obj, bytes):
return "<bytes>"
return str(obj)
json_str = json.dumps(payloads, default=_default, sort_keys=True)
return int(hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8], 16)
@@ -0,0 +1,52 @@
from collections.abc import Sequence
from typing import Any
from zhenxun.services.ai.core.messages import AgentEvent, AgentMessage, LLMMessage
from zhenxun.services.log import logger
class ContextConverter:
"""
上下文边界降维转换器。
负责将内存中混合了 AgentEvent 与 LLMMessage 的业务事件流,
安全拍平为底层大模型可读的原生 API 载体。
"""
@staticmethod
def flatten_to_llm_messages(
messages: Sequence[AgentMessage], context: Any | None = None
) -> list[LLMMessage]:
flattened: list[LLMMessage] = []
for msg in messages:
if isinstance(msg, LLMMessage):
flattened.append(msg)
elif isinstance(msg, AgentEvent):
try:
res = msg.to_llm_message(context)
if res is None:
continue
if isinstance(res, str):
flattened.append(LLMMessage.system(res))
elif isinstance(res, LLMMessage):
flattened.append(res)
elif isinstance(res, list):
flattened.extend(res)
else:
logger.warning(
f"事件 {msg.__class__.__name__} 的 to_llm_message "
f"返回了不支持的类型: {type(res)}"
)
except Exception as e:
logger.error(
f"业务事件 [{msg.__class__.__name__}] "
f"在降维渲染为大模型 Prompt 时发生崩溃: {e}\n"
f"防呆拦截:请检查该事件 to_llm_message 方法的实现。"
)
else:
logger.warning(
f"ContextConverter 遇到未知类型的消息,已跳过: {type(msg)}"
)
return flattened
@@ -0,0 +1,214 @@
import types
from typing import Any, Generic, Union, cast, get_origin
import json_repair
from nonebot.compat import type_validate_json
from pydantic import BaseModel, Field, ValidationError, create_model
from zhenxun.services.ai.core.exceptions import (
ControlFlowExit,
ModelRetry,
SchemaParseError,
)
from zhenxun.services.ai.core.models import ToolDefinition
from zhenxun.services.ai.run.models import OutputDataT
from zhenxun.services.ai.tools.core.tool import BaseTool
from zhenxun.services.ai.tools.models import StructuredSubmissionResult, ToolResult
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_json_schema, model_validate
DEFAULT_IVR_TEMPLATE = (
"### ❌ [输出内容或格式验证失败]\n"
"你的上一次输出未能通过系统的校验与规则检查。请立即启动修正流程:\n\n"
"**错误反馈报告:**\n"
"> {error_msg}\n\n"
"**修正要求:** 请结合反馈报告,"
"仔细反思你的输出内容或格式,\n"
"并重新生成正确的数据以满足所有的规则与规范。"
)
class BaseOutputProcessor(Generic[OutputDataT]):
"""
统一的结构化输出处理器。
负责管理 Schema 生成、Prompt 约束注入以及最终的
JSON 反序列化和业务校验。
"""
def __init__(
self,
response_model: type[Any] | None = None,
error_template: str | None = None,
raw_schema: dict[str, Any] | None = None,
):
"""
初始化结构化输出处理器。
参数:
response_model: 期望的输出目标 Pydantic 模型类或 Union 类型,默认 None。
error_template: 当 JSON 解析或模型验证失败时,反馈给大模型的 IVR 纠错提示词模板,默认 None。
raw_schema: 显式传入的原始 JSON Schema 字典,
如果不为 None 则跳过根据 response_model 生成,默认 None。
""" # noqa: E501
self.original_model = response_model
self.error_template = error_template or DEFAULT_IVR_TEMPLATE
self.raw_schema = raw_schema
self.target_model = None
self.is_union_wrapped = False
if response_model is not None:
self.target_model, self.is_union_wrapped = self._create_union_wrapper(
response_model
)
@staticmethod
def _create_union_wrapper(union_type: Any) -> tuple[type[BaseModel], bool]:
"""[私有方法] 如果是 Union 类型,
动态构建带 kind 区分字段的模型"""
origin = get_origin(union_type)
union_types = [Union]
if hasattr(types, "UnionType"):
union_types.append(types.UnionType)
if origin not in union_types:
return union_type, False
UnionWrapper = create_model(
"UnionResponseWrapper",
result=(
union_type,
Field(..., description="根据你的决策,输出对应的结构化数据"),
),
)
return UnionWrapper, True
def get_json_schema(self) -> dict[str, Any]:
"""提取目标模型的 JSON Schema"""
if self.raw_schema is not None:
return self.raw_schema
if self.target_model is None:
raise ValueError("未提供 response_model 或 raw_schema")
try:
return model_json_schema(self.target_model)
except AttributeError:
return self.target_model.schema()
def _parse_and_validate(self, text: str) -> Any:
"""[私有方法] 执行带有容错修复的 JSON 解析与模型验证"""
if self.raw_schema is not None:
import json
try:
return json.loads(text)
except Exception:
try:
return json_repair.loads(text, skip_json_loads=True)
except Exception as repair_error:
raise SchemaParseError(f"JSON格式损坏: {repair_error}")
if self.target_model is None:
raise SchemaParseError("未提供 response_model 或 raw_schema")
try:
return type_validate_json(self.target_model, text)
except (ValidationError, ValueError) as e:
try:
logger.warning(f"标准JSON解析失败,尝试使用json_repair修复: {e}")
repaired_obj = json_repair.loads(text, skip_json_loads=True)
return model_validate(self.target_model, repaired_obj)
except Exception as repair_error:
logger.error(
f"LLM结构化输出校验最终失败: {repair_error}",
e=repair_error,
)
raise SchemaParseError(
f"JSON格式损坏或字段不匹配,未能通过Schema验证: {repair_error}"
)
except Exception as e:
logger.error(f"解析LLM结构化输出时发生未知错误: {e}", e=e)
raise SchemaParseError(f"解析LLM的JSON输出时失败: {e}")
async def validate_and_parse(self, text: str, context: Any = None) -> OutputDataT:
"""执行 JSON 解析与回调验证"""
try:
parsed_obj = self._parse_and_validate(text)
current_obj = parsed_obj
if getattr(self, "is_union_wrapped", False):
current_obj = getattr(current_obj, "result")
final_obj = cast(OutputDataT, current_obj)
return final_obj
except Exception as e:
raise e
class SubmitFinalResultExecutable(BaseTool):
"""
动态生成的提交最终结果工具。
用于将大模型的结构化输出拦截并终止 AgentExecutor 的循环。
"""
def __init__(
self,
output_processor: BaseOutputProcessor,
guardrails: list[Any] | None = None,
):
"""
初始化提交最终结果的动态执行工具。
参数:
output_processor: 绑定的结构化输出处理器,用于验证提交的最终结果。
guardrails: 用于在结果输出前进行安全合规拦截的护栏中间件列表,默认 None。
"""
super().__init__(
name="submit_final_result",
description=(
"当你完成所有必要的调查 and 思考后,"
"必须且只能调用此工具来提交最终的结构化结果。"
"提交后任务将立刻结束。"
),
)
self.output_processor = output_processor
self.guardrails = guardrails or []
async def get_definition(self, context: Any | None = None) -> ToolDefinition | None:
if getattr(self, "_dynamic_def", None) is not None:
return self._dynamic_def
schema = self.output_processor.get_json_schema()
return ToolDefinition(
name=self.name,
description=self.description,
parameters=schema,
)
async def execute(self, context: Any | None = None, **kwargs) -> ToolResult:
parse_target = kwargs
if isinstance(kwargs, dict):
if "kwargs" in kwargs and len(kwargs) == 1:
parse_target = kwargs["kwargs"]
elif "result" in kwargs and len(kwargs) == 1:
parse_target = kwargs["result"]
try:
json_str = __import__("json").dumps(parse_target, ensure_ascii=False)
final_obj = await self.output_processor.validate_and_parse(
json_str, context=context
)
from zhenxun.services.ai.guardrails import GuardrailPipeline
pipeline = GuardrailPipeline(self.guardrails)
json_str, final_obj = await pipeline.run_output_pipeline(
json_str, final_obj, context
)
return StructuredSubmissionResult(
output="结构化数据已成功提交", parsed_obj=final_obj
)
except ControlFlowExit as e:
raise e
except ModelRetry as e:
raise e
except Exception as e:
error_msg = f"系统捕获到解析异常:\n{e}"
raise SchemaParseError(error_msg)
@@ -0,0 +1,191 @@
"""
LLM Token 动态预估与上下文管理模块
"""
from collections.abc import Sequence
import math
import re
from typing import Any
from zhenxun.services.ai.core.messages import (
AgentEvent,
AgentMessage,
AudioPart,
FilePart,
ImagePart,
LLMMessage,
TextPart,
ThoughtPart,
ToolCallPart,
ToolMessage,
ToolReturnPart,
UsageInfo,
VideoPart,
)
class TokenCounter:
"""
Token 计数器
基于确定性规则,摆脱外部库依赖,提供绝对稳定的 Token 消耗预估基线。
"""
@staticmethod
def _count_text(text: str) -> int:
"""基于字符类型近似计算纯文本的 Token 消耗量。"""
if not text:
return 0
cjk_chars = len(re.findall(r"[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]", text))
ascii_chars = len(text) - cjk_chars
return math.ceil(cjk_chars * 1.2 + ascii_chars * 0.3)
@staticmethod
def _count_image(resolution_hint: str | None, model_name: str) -> int:
"""根据分辨率策略和模型厂商计算单张图片的 Token 消耗量。"""
if "gemini" in model_name.lower():
res = (resolution_hint or "").upper()
if "ULTRA_HIGH" in res:
return 6192
if "HIGH" in res:
return 3096
if "LOW" in res:
return 258
return 1032
return 765
@classmethod
def count_tools_schema(cls, obj: dict | list | str | Any) -> int:
"""递归计算 JSON Schema 结构在被大模型作为工具时的 Token 开销。"""
if isinstance(obj, dict):
cost = len(obj.keys()) * 12
for k, v in obj.items():
if k == "description" and isinstance(v, str):
cost += int(len(v) * 0.3)
else:
cost += cls.count_tools_schema(v)
return cost
elif isinstance(obj, list):
return sum(cls.count_tools_schema(item) for item in obj)
return 0
@classmethod
def count_message(cls, msg: LLMMessage, model_name: str) -> int:
"""累加计算单条包含多模态片段和工具调用的消息 Token 总数。"""
if msg.token_cost is not None:
return msg.token_cost
total_tokens = 4
if isinstance(msg, ToolMessage):
total_tokens += 40
if isinstance(msg.content, str):
total_tokens += cls._count_text(msg.content)
elif isinstance(msg.content, list):
for part in msg.content:
if isinstance(part, TextPart) and part.text:
total_tokens += cls._count_text(part.text)
elif isinstance(part, ImagePart):
total_tokens += cls._count_image(
getattr(part, "media_resolution", None), model_name
)
elif isinstance(part, VideoPart | AudioPart | FilePart):
total_tokens += 1032
elif isinstance(part, ThoughtPart) and part.thought_text:
total_tokens += cls._count_text(part.thought_text)
elif isinstance(part, ToolCallPart) and part.args:
total_tokens += cls._count_text(str(part.args))
elif isinstance(part, ToolReturnPart) and part.output:
total_tokens += cls._count_text(str(part.output))
msg.token_cost = total_tokens
return total_tokens
@classmethod
def count_context(
cls, messages: Sequence[AgentMessage], model_name: str, base_overhead: int = 0
) -> int:
"""计算整个对话历史上下文的 Token 总和。"""
if not messages:
return base_overhead
total = base_overhead
for msg in messages:
if isinstance(msg, AgentEvent):
try:
res = msg.to_llm_message(None)
if res is None:
continue
if isinstance(res, str):
total += cls.count_message(LLMMessage.system(res), model_name)
elif isinstance(res, list):
total += sum(cls.count_message(m, model_name) for m in res)
elif isinstance(res, LLMMessage):
total += cls.count_message(res, model_name)
except Exception:
pass
else:
total += cls.count_message(msg, model_name)
return total
token_counter = TokenCounter
def parse_usage_info(usage_info: dict | None) -> UsageInfo:
"""
全协议统一遥测解析器 (Universal Telemetry Parser)
兼容 OpenAI Standard、OpenAI Responses (v1/responses) 以及 Gemini (usageMetadata)。
"""
if not usage_info or not isinstance(usage_info, dict):
return UsageInfo()
prompt = 0
completion = 0
total = 0
cache_hit = 0
cache_miss = 0
reasoning = 0
if "promptTokenCount" in usage_info or "candidatesTokenCount" in usage_info:
prompt = usage_info.get("promptTokenCount", 0)
completion = usage_info.get("candidatesTokenCount", 0)
total = usage_info.get("totalTokenCount", 0)
reasoning = usage_info.get("thoughtsTokenCount", 0)
cache_hit = usage_info.get("cachedContentTokenCount", 0)
elif "input_tokens" in usage_info or "output_tokens" in usage_info:
prompt = usage_info.get("input_tokens", 0)
completion = usage_info.get("output_tokens", 0)
total = usage_info.get("total_tokens", 0)
cache_hit = (usage_info.get("input_tokens_details") or {}).get(
"cached_tokens", 0
)
reasoning = (usage_info.get("output_tokens_details") or {}).get(
"reasoning_tokens", 0
)
else:
prompt = usage_info.get("prompt_tokens", 0)
completion = usage_info.get("completion_tokens", 0)
total = usage_info.get("total_tokens", 0)
cache_hit = usage_info.get("prompt_cache_hit_tokens") or (
usage_info.get("prompt_tokens_details") or {}
).get("cached_tokens", 0)
cache_miss = usage_info.get("prompt_cache_miss_tokens", 0)
reasoning = (usage_info.get("completion_tokens_details") or {}).get(
"reasoning_tokens", 0
)
if cache_miss == 0 and prompt > 0:
cache_miss = max(0, prompt - cache_hit)
return UsageInfo(
prompt_tokens=prompt,
completion_tokens=completion,
total_tokens=total,
prompt_cache_hit_tokens=cache_hit,
prompt_cache_miss_tokens=cache_miss,
reasoning_tokens=reasoning,
)
+349
View File
@@ -0,0 +1,349 @@
"""
自定义异常与错误码定义
"""
from typing import Any
class ModelRetry(Exception):
"""用于通知大模型修正并重试的异常"""
def __init__(self, message: str):
self.message = message
super().__init__(message)
class SchemaParseError(ModelRetry):
"""格式解析异常。当大模型返回的 JSON 损坏或不符合 Schema 时抛出。"""
def __init__(self, message: str):
super().__init__(message)
class GuardrailViolationError(ModelRetry):
"""护栏违规异常。当大模型返回的数据格式正确,但违反业务规则时抛出。"""
def __init__(self, message: str):
super().__init__(message)
class ControlFlowExit(BaseException):
"""控制流退出基类,继承自BaseException以避免被常规Exception捕获,用于静默中断。"""
pass
class ToolFatalError(ControlFlowExit):
"""
致命工具异常(不可恢复)。
当工具执行遇到权限不足、严重系统故障等大模型无法通过重试解决的问题时抛出。
这会直接熔断 Agent 推理流,并将 display_content 抛给用户。
"""
def __init__(self, message: str, display_content: str | None = None):
self.message = message
self.display_content = display_content or f"❌ 工具遇到致命错误: {message}"
super().__init__(self.message)
class GuardrailFatalException(ControlFlowExit):
"""护栏致命拦截异常 (触发 ABORT/REJECT 时抛出)"""
def __init__(self, guard_name: str, reason: str, display: str | None = None):
self.guard_name = guard_name
self.reason = reason
self.display = display or f"🛡️ 安全拦截: {reason}"
super().__init__(f"Guardrail '{guard_name}' aborted execution: {reason}")
class ToolRetryError(Exception):
"""
可恢复工具异常。
当参数解析错误、业务逻辑校验失败、网络超时等问题发生时抛出。
会被 ToolExecutor 捕获并转化为引导大模型自我反思 (Reflexion) 的 ToolResult。
"""
def __init__(self, message: str):
self.message = message
super().__init__(self.message)
class ToolFinishException(ToolFatalError):
"""
工具执行中止异常。
当工具开发者希望立刻停止大模型的思考循环,并直接将错误/提示信息返回给用户时抛出。
此异常不会被大模型进行"影子自愈(Reflexion)",而是直接熔断 Agent 执行流。
"""
def __init__(self, message: str, display_content: str | None = None):
super().__init__(message, display_content)
class AbortException(ControlFlowExit):
"""异常中止当前 Agent 思考流。"""
def __init__(self, reason: str, display: Any = None):
self.reason = reason
self.display = display
super().__init__(f"Aborted: {reason}")
class InterventionHandledException(ControlFlowExit):
"""
干预成功处理异常。
当用户的消息被成功作为 STEER 或 FOLLOW_UP 注入到后台运行中的 Agent 队列时抛出,
用于中断当前的新请求生命周期,避免重复启动。
"""
def __init__(self, message: str, display_content: str | None = None):
self.message = message
self.display_content = display_content
super().__init__(self.message)
class ConcurrencyRejectException(ControlFlowExit):
"""并发拒绝异常。当 Agent 设置为 REJECT 且正在忙碌时抛出。"""
def __init__(self, message: str, display: Any = None):
self.message = message
self.display = display or "⏳ 智能体正在处理您的上一个请求,请稍后再试~"
super().__init__(message)
class ConcurrencyInterruptException(ControlFlowExit):
"""并发打断异常。当 Agent 设置为 INTERRUPT 且被新请求打断时抛出。"""
def __init__(self, message: str):
self.message = message
super().__init__(message)
class NeedsInputException(Exception):
"""
当工具配置了 interactive=True 且缺少必要参数(或参数验证失败)时抛出此异常,
用于交由外部中间件捕获并进行 HITL (Human-in-the-loop) 参数补充。
"""
def __init__(
self, missing_field: str, missing_description: str, original_kwargs: dict
):
self.missing_field = missing_field
self.missing_description = missing_description
self.original_kwargs = original_kwargs
super().__init__(
f"Need input for parameter: {missing_field} - {missing_description}"
)
class NeedsAuthException(Exception):
"""
当工具在执行过程中发现授权失效或凭证过期时主动抛出。
用于交由外部中间件捕获并重新发起授权 (HITL) 流程。
"""
def __init__(self, provider: str, message: str):
self.provider = provider
self.message = message
super().__init__(f"Needs auth for: {provider} - {message}")
class SandboxPathEscapeError(Exception):
"""当沙箱内的路径解析结果试图逃逸出允许的工作区根目录时抛出"""
def __init__(self, path: str, resolved_path: str | None = None, reason: str = ""):
self.path = path
self.resolved_path = resolved_path
self.reason = reason
msg = f"沙箱路径逃逸拦截: {path}"
if resolved_path:
msg += f" (解析至 {resolved_path})"
if reason:
msg += f" - {reason}"
super().__init__(msg)
class WorkspaceIOError(Exception):
"""沙箱文件系统读写操作失败"""
def __init__(self, path: str, message: str, cause: Exception | None = None):
self.path = path
self.cause = cause
super().__init__(f"沙箱 IO 异常 [{path}]: {message}")
class SandboxFatalError(ToolFatalError):
"""沙箱底层容器发生致命崩溃(如 OOM, 被宿主机强杀等)"""
def __init__(self, message: str, display_content: str | None = None):
display = display_content or f"❌ 沙箱不可用: {message}"
super().__init__(message, display_content=display)
class LLMException(Exception):
"""LLM 服务相关的基础异常类 (多态基类)"""
def __init__(
self,
message: str,
details: dict[str, Any] | None = None,
cause: Exception | None = None,
):
self.message = message
self.details = details or {}
self.cause = cause
super().__init__(message)
@property
def is_retryable(self) -> bool:
"""是否允许在当前节点进行退避重试(如偶发网络抖动)"""
return False
@property
def should_failover(self) -> bool:
"""是否允许触发节点故障转移(切换到下一个备用模型)"""
return False
@property
def should_rotate_key(self) -> bool:
"""是否应该标记当前 Key 失效并轮换 API Key"""
return False
@property
def user_friendly_message(self) -> str:
"""返回适合向用户展示的错误消息"""
return "AI服务暂时不可用,请稍后再试。"
def __str__(self) -> str:
if self.details:
safe_details = {k: v for k, v in self.details.items() if k != "api_key"}
if safe_details:
return f"{self.message} (详情: {safe_details})"
return self.message
class InvalidRequestException(LLMException):
@property
def user_friendly_message(self) -> str:
return "请求参数错误或API类型不支持,请检查输入内容。"
class ContextLengthExceededException(LLMException):
@property
def user_friendly_message(self) -> str:
return "输入内容过长,请缩短后重试。"
class ContentFilteredException(LLMException):
@property
def user_friendly_message(self) -> str:
return "内容被安全过滤,请修改后重试。"
class ConfigurationException(LLMException):
@property
def user_friendly_message(self) -> str:
return "AI模型配置错误或未找到,请联系管理员检查配置。"
class AuthenticationException(LLMException):
@property
def should_rotate_key(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "API密钥无效或权限不足,请联系管理员更新配置。"
class QuotaExceededException(LLMException):
@property
def should_rotate_key(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "API使用配额已用尽,请稍后再试或联系管理员。"
class LocationNotSupportedException(LLMException):
@property
def should_failover(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return (
"当前网络环境不支持此 AI 模型。\n"
"建议: 请尝试更换代理节点至支持的地区或切换备用模型。"
)
class RateLimitException(LLMException):
@property
def is_retryable(self) -> bool:
return True
@property
def should_rotate_key(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "请求过于频繁,已被AI服务限流,请稍后再试。"
class UpstreamServerException(LLMException):
@property
def is_retryable(self) -> bool:
return True
@property
def should_failover(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "AI服务响应异常或端点宕机,请稍后再试。"
class NetworkTimeoutException(LLMException):
@property
def is_retryable(self) -> bool:
return True
@property
def should_failover(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "AI服务请求超时,请稍后再试。"
class ResponseParseException(LLMException):
@property
def is_retryable(self) -> bool:
return True
@property
def user_friendly_message(self) -> str:
return "AI服务响应解析失败,请稍后再试。"
def get_user_friendly_error_message(error: Exception) -> str:
"""将任何异常转换为用户友好的错误消息"""
if isinstance(error, LLMException):
return error.user_friendly_message
error_str = str(error).lower()
if "timeout" in error_str or "timed out" in error_str:
return "网络请求超时,请检查服务器网络或代理连接。"
if "connect" in error_str and ("refused" in error_str or "error" in error_str):
return "无法连接到 AI 服务商,请检查网络连接或代理设置。"
if "proxy" in error_str:
return "代理连接失败,请检查代理服务器是否正常运行。"
if "ssl" in error_str or "certificate" in error_str:
return "SSL 证书验证失败,请检查网络环境。"
return f"服务暂时不可用 ({type(error).__name__}),请稍后再试。"
@@ -0,0 +1,131 @@
"""
消息与响应域类型定义 - 统一导出门面
"""
from nonebot.compat import PYDANTIC_V2
from .context_events import (
AgentEvent,
HandoffEvent,
TaskLifecycleEvent,
)
from .models import (
AssistantMessage,
LLMMessage,
SystemMessage,
ToolMessage,
UserMessage,
)
from .parts import (
AudioPart,
BaseContentPart,
EmbedBatch,
EmbedPayload,
FilePart,
ImagePart,
LLMContentPart,
TextDeltaPart,
TextPart,
ThoughtDeltaPart,
ThoughtPart,
ToolCallDeltaPart,
ToolCallPart,
ToolReturnPart,
VideoPart,
)
from .requests import (
BaseRequest,
ChatRequest,
EmbeddingRequest,
ImageRequest,
RerankRequest,
SpeechRequest,
)
from .responses import (
AudioResponse,
ChatResponse,
EmbeddingResponse,
ImageResponse,
RerankResponse,
)
from .shared import (
LLMCodeExecution,
LLMGroundingAttribution,
LLMGroundingMetadata,
RerankDocument,
RerankResult,
UsageInfo,
)
from .types import (
AgentMessage,
AnyLLMMessage,
AssistantContentUnion,
ContentT,
PromptInput,
RoleT,
SystemContentUnion,
ToolContentUnion,
UserContentUnion,
)
if PYDANTIC_V2:
ChatResponse.model_rebuild()
LLMMessage.model_rebuild()
SystemMessage.model_rebuild()
UserMessage.model_rebuild()
AssistantMessage.model_rebuild()
ToolMessage.model_rebuild()
RerankResponse.model_rebuild()
__all__ = [
"AgentEvent",
"AgentMessage",
"AnyLLMMessage",
"AssistantContentUnion",
"AssistantMessage",
"AudioPart",
"AudioResponse",
"BaseContentPart",
"BaseRequest",
"ChatRequest",
"ChatResponse",
"ContentT",
"EmbedBatch",
"EmbedPayload",
"EmbeddingRequest",
"EmbeddingResponse",
"FilePart",
"HandoffEvent",
"ImagePart",
"ImageRequest",
"ImageResponse",
"LLMCodeExecution",
"LLMContentPart",
"LLMGroundingAttribution",
"LLMGroundingMetadata",
"LLMMessage",
"PromptInput",
"RerankDocument",
"RerankRequest",
"RerankResponse",
"RerankResult",
"RoleT",
"SpeechRequest",
"SystemContentUnion",
"SystemMessage",
"TaskLifecycleEvent",
"TextDeltaPart",
"TextPart",
"ThoughtDeltaPart",
"ThoughtPart",
"ToolCallDeltaPart",
"ToolCallPart",
"ToolContentUnion",
"ToolMessage",
"ToolReturnPart",
"UsageInfo",
"UserContentUnion",
"UserMessage",
"VideoPart",
]
@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from .models import LLMMessage
class AgentEvent(BaseModel):
"""
业务事件抽象基类/协议。
支持作为一种特殊的消息,直接被混入到大模型的上下文(记忆)中。
"""
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore
def to_llm_message(
self, context: Any | None = None
) -> LLMMessage | list[LLMMessage] | str | None:
"""
将业务事件渲染为大模型能看懂的 API 原生消息。
子类必须重写此方法。
返回 None 代表此事件对大模型不可见(例如:纯后台打点或审计日志)。
如果返回 str,系统将默认包装为 SystemMessage 发送给大模型。
"""
return None
class TaskLifecycleEvent(AgentEvent):
"""内置业务事件:任务状态打点追踪"""
task_name: str
"""任务名称"""
action: Literal["start", "complete", "fail"]
"""任务状态动作,支持 "start"(开始)、"complete"(完成)、"fail"(失败)"""
error_msg: str | None = None
"""任务执行失败时的具体错误描述,可选"""
def to_llm_message(self, context: Any | None = None) -> str | None:
if self.action == "start":
return f"[任务生命周期] 开始执行任务:{self.task_name}"
elif self.action == "complete":
return f"[任务生命周期] 任务已完美达成:{self.task_name}"
elif self.action == "fail":
return (
f"[任务生命周期] 任务执行失败:{self.task_name},原因:{self.error_msg}"
)
return None
class HandoffEvent(AgentEvent):
"""内置业务事件:控制权移交记录"""
target: str
"""目标接收节点或 Agent 的名称"""
reason: str
"""移交控制权的具体原因说明"""
context_data: Any = None
"""移交时附带的上下文数据,默认为 None"""
def to_llm_message(self, context: Any | None = None) -> str | None:
return (
f"[控制权移交] 任务及会话控制权已被系统转移至节点 "
f"'{self.target}'。移交原因:{self.reason}"
)
__all__ = [
"AgentEvent",
"HandoffEvent",
"TaskLifecycleEvent",
]
+366
View File
@@ -0,0 +1,366 @@
"""
标准消息实体 - 依赖 parts.py
"""
from __future__ import annotations
import base64
from collections.abc import Sequence
import time
from typing import Annotated, Any, Generic, Literal, cast
from typing_extensions import Self, TypeVar
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validator
from .parts import (
BaseContentPart,
FilePart,
ImagePart,
LLMContentPart,
TextPart,
ToolCallPart,
ToolReturnPart,
)
RoleT = TypeVar("RoleT", default=str, covariant=True)
"""泛型:消息参与者角色类型变量"""
ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True)
"""泛型:多模态片段数组的元素内容类型变量"""
class LLMMessage(BaseModel, Generic[RoleT, ContentT]):
"""
LLM 消息基类与门面工厂。
提供统一的元数据访问、魔法加法重载以及极简实例化方法。
"""
role: RoleT
"""消息参与者角色 (如 user, assistant, system, tool)"""
content: list[ContentT] = Field(default_factory=list)
"""容纳实际数据的多模态片段数组"""
created_at: float = Field(default_factory=time.time)
"""消息最初被构建的 Unix 时间戳"""
metadata: dict[str, Any] | None = Field(default=None)
"""自由存取字典,供系统内穿透传递额外状态数据"""
@property
def tool_calls(self) -> list[ToolCallPart]:
"""获取当前消息中包含的所有工具调用请求片段"""
return [p for p in self.content if isinstance(p, ToolCallPart)]
@property
def tool_returns(self) -> list[ToolReturnPart]:
"""获取当前消息中包含的所有工具执行结果片段"""
return [p for p in self.content if isinstance(p, ToolReturnPart)]
@model_validator(mode="before")
@classmethod
def _normalize_content(cls, data: Any) -> Any:
"""核心拦截:外部传入 str 时自动转为 Part,保持内部类型绝对纯净"""
if isinstance(data, dict):
content = data.get("content")
if isinstance(content, str):
data["content"] = (
[{"type": "text", "text": content}] if content.strip() else []
)
elif content is None:
data["content"] = []
elif isinstance(content, list):
new_content = []
for item in content:
if isinstance(item, str):
new_content.append({"type": "text", "text": item})
else:
new_content.append(item)
data["content"] = new_content
elif not isinstance(content, list):
import json
try:
text_val = json.dumps(content, ensure_ascii=False)
except Exception:
text_val = str(content)
data["content"] = [{"type": "text", "text": text_val}]
return data
def __add__(self, other: str | LLMContentPart | "LLMMessage") -> Self:
"""极简语法糖:支持通过加号拼接文本或多模态片段。
示例: msg = LLMMessage.user("查看图片:") + ImagePart(url="...")
"""
new_msg = cast(Self, model_copy(self, deep=True))
if isinstance(other, str):
new_msg.content.append(cast(Any, TextPart(text=other)))
elif isinstance(other, BaseContentPart):
new_msg.content.append(cast(Any, other))
elif isinstance(other, LLMMessage):
new_msg.content.extend(cast(Any, other.content))
return new_msg
@property
def extract_text(self) -> str:
"""便捷属性:提取当前消息中所有的纯文本"""
return "".join(p.text for p in self.content if isinstance(p, TextPart))
@property
def source(self) -> str | None:
"""获取消息的来源标识 (存取于 metadata 中)"""
return self.metadata.get("source") if self.metadata else None
@source.setter
def source(self, value: str | None):
"""设置消息的来源标识"""
if self.metadata is None:
self.metadata = {}
self.metadata["source"] = value
@property
def source_name(self) -> str | None:
"""获取消息来源的具体名称 (例如真实用户的昵称)"""
return self.metadata.get("source_name") if self.metadata else None
@source_name.setter
def source_name(self, value: str | None):
"""设置消息来源的具体名称"""
if self.metadata is None:
self.metadata = {}
self.metadata["source_name"] = value
@property
def scope(self) -> str | None:
"""获取该消息所绑定的作用域或会话ID"""
return self.metadata.get("scope") if self.metadata else None
@scope.setter
def scope(self, value: str | None):
"""设置该消息的作用域"""
if self.metadata is None:
self.metadata = {}
self.metadata["scope"] = value
@property
def thought_signature(self) -> str | None:
"""获取连续对话中用于保持思考一致性的加密签名 (Gemini 专属)"""
return self.metadata.get("thought_signature") if self.metadata else None
@thought_signature.setter
def thought_signature(self, value: str | None):
"""设置思考加密签名"""
if self.metadata is None:
self.metadata = {}
self.metadata["thought_signature"] = value
@property
def token_cost(self) -> int | None:
"""获取该消息自身消耗的预估或真实 Token 数"""
return self.metadata.get("token_cost") if self.metadata else None
@token_cost.setter
def token_cost(self, value: int | None):
"""设置 Token 消耗数"""
if self.metadata is None:
self.metadata = {}
self.metadata["token_cost"] = value
@classmethod
def user(
cls,
content: str | Sequence[Any],
source: str | None = None,
source_name: str | None = None,
scope: str | None = None,
) -> "UserMessage":
"""
工厂方法:创建一条 User (用户) 角色的消息。
参数:
content: 消息内容,支持纯字符串或多模态片段数组。
source: 可选,消息来源标识。
source_name: 可选,消息来源的可读名称。
scope: 可选,消息关联的会话作用域。
"""
if isinstance(content, str):
content = [TextPart(text=content)] if content.strip() else []
elif not isinstance(content, list):
content = list(content)
msg = UserMessage(content=cast(Any, content))
msg.source = source
msg.source_name = source_name
msg.scope = scope
return msg
@classmethod
def assistant_tool_calls(
cls,
tool_calls: list[ToolCallPart],
content: str | Sequence[Any] = "",
scope: str | None = None,
) -> "AssistantMessage":
"""
工厂方法:创建一条包含工具调用请求的 Assistant (助手) 角色消息。
参数:
tool_calls: 大模型发出的工具调用片段列表。
content: 伴随工具调用的其他文本或思维过程。
scope: 可选,消息关联的会话作用域。
"""
if isinstance(content, str):
_content: list[Any] = [TextPart(text=content)] if content else []
else:
_content = list(content)
_content.extend(tool_calls)
msg = AssistantMessage(content=cast(Any, _content))
msg.scope = scope
return msg
@classmethod
def assistant_text_response(
cls,
content: str | Sequence[Any],
scope: str | None = None,
) -> "AssistantMessage":
"""
工厂方法:创建一条仅包含普通文本/思维过程的 Assistant (助手) 角色消息。
参数:
content: 大模型生成的文本回复内容。
scope: 可选,消息关联的会话作用域。
"""
if isinstance(content, str):
content = [TextPart(text=content)] if content and content.strip() else []
elif not isinstance(content, list):
content = list(content)
msg = AssistantMessage(content=cast(Any, content))
msg.scope = scope
return msg
def add_text(self, text: str) -> Self:
"""链式添加文本内容"""
self.content.append(cast(Any, TextPart(text=text)))
return self
def add_image_url(self, url: str) -> Self:
"""链式添加网络图片"""
self.content.append(cast(Any, ImagePart(url=url)))
return self
def add_image_base64(self, b64_data: str, mime_type: str = "image/png") -> Self:
"""链式添加 Base64 图片"""
self.content.append(
cast(Any, ImagePart(raw=base64.b64decode(b64_data), mime_type=mime_type))
)
return self
def add_file_url(self, url: str, mime_type: str | None = None) -> Self:
"""链式添加文件链接"""
self.content.append(cast(Any, FilePart(url=url, mime_type=mime_type)))
return self
@classmethod
def tool_response(
cls,
tool_call_id: str,
function_name: str,
result: Any,
scope: str | None = None,
) -> "ToolMessage":
"""
工厂方法:创建一条 Tool (工具) 角色消息,用于承载工具执行完毕后的返回结果。
参数:
tool_call_id: 对应的大模型发出调用请求时的 ID。
function_name: 执行的工具名称。
result: 工具执行的结果负载 (会被自动 JSON 序列化)。
scope: 可选,消息关联的会话作用域。
"""
_content = [
ToolReturnPart(
tool_call_id=tool_call_id, tool_name=function_name, output=result
)
]
msg = ToolMessage(content=cast(Any, _content))
msg.scope = scope
return msg
@classmethod
def system(
cls,
content: str | Sequence[Any],
scope: str | None = None,
) -> "SystemMessage":
"""
工厂方法:创建一条 System (系统) 角色的设定消息。
参数:
content: 系统的 Prompt 指令。
scope: 可选,消息关联的会话作用域。
"""
if isinstance(content, str):
content = [TextPart(text=content)] if content.strip() else []
elif not isinstance(content, list):
content = list(content)
msg = SystemMessage(content=cast(Any, content))
msg.scope = scope
return msg
def to_storage_dict(self) -> dict[str, Any]:
"""数据库瘦身存储,仅保留核心字段"""
return model_dump(
self,
exclude_none=True,
include={
"role",
"content",
"metadata",
},
)
class SystemMessage(LLMMessage[Literal["system"], TextPart]):
"""系统消息:通常用于在对话开头向模型提供系统级指令 (System Prompt)、
角色设定或背景上下文。"""
role: Literal["system"] = "system"
content: list[Annotated[TextPart, Field(discriminator="type")]] = Field(
default_factory=list
)
class UserMessage(LLMMessage[Literal["user"], LLMContentPart]):
"""用户消息:代表来自最终用户或外部触发源的输入,支持包含文本、图片、文件等多模态数据。"""
role: Literal["user"] = "user"
content: list[LLMContentPart] = Field(default_factory=list)
class AssistantMessage(LLMMessage[Literal["assistant"], LLMContentPart]):
"""助手消息:代表大模型 (AI) 生成的回复。
可能包含纯文本、思维链过程或工具调用请求。"""
role: Literal["assistant"] = "assistant"
content: list[LLMContentPart] = Field(default_factory=list)
class ToolMessage(LLMMessage[Literal["tool"], LLMContentPart]):
"""工具消息:用于承载由用户端执行工具后,将结果返回给大模型的消息容器。"""
role: Literal["tool"] = "tool"
content: list[LLMContentPart] = Field(default_factory=list)
def model_post_init(self, context: Any, /) -> None:
"""验证消息的有效性"""
if not self.tool_returns:
raise ValueError("工具角色的消息必须包含 ToolReturnPart")
__all__ = [
"AssistantMessage",
"ContentT",
"LLMMessage",
"RoleT",
"SystemMessage",
"ToolMessage",
"UserMessage",
]
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Annotated, Any, Literal
from typing_extensions import Self
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import model_validator
class BaseContentPart(BaseModel):
"""多态消息内容的底层基类"""
metadata: dict[str, Any] | None = Field(default=None)
"""该部件的内部元数据,提供如 thought_signature、解析结果等非展示用数据"""
@classmethod
def text_part(cls, text: str) -> "TextPart":
"""创建一个纯文本片段"""
return TextPart(text=text)
@classmethod
def thought_part(cls, text: str) -> "ThoughtPart":
"""创建一个思维链 (CoT) 思考片段"""
return ThoughtPart(thought_text=text)
@classmethod
def image_url_part(cls, url: str) -> "ImagePart":
"""创建一个基于外网 URL 的图片片段"""
return ImagePart(url=url)
@classmethod
def image_base64_part(cls, data: str, mime_type: str = "image/png") -> "ImagePart":
"""创建一个基于 Base64 编码数据的图片片段"""
return ImagePart(raw=base64.b64decode(data), mime_type=mime_type)
@classmethod
def audio_url_part(cls, url: str, mime_type: str = "audio/wav") -> "AudioPart":
"""创建一个基于外网 URL 的音频片段"""
return AudioPart(url=url, mime_type=mime_type)
@classmethod
def video_url_part(cls, url: str, mime_type: str = "video/mp4") -> "VideoPart":
"""创建一个基于外网 URL 的视频片段"""
return VideoPart(url=url, mime_type=mime_type)
@classmethod
def video_base64_part(cls, data: str, mime_type: str = "video/mp4") -> "VideoPart":
"""创建一个基于 Base64 编码数据的视频片段"""
return VideoPart(raw=base64.b64decode(data), mime_type=mime_type)
@classmethod
def audio_base64_part(cls, data: str, mime_type: str = "audio/wav") -> "AudioPart":
"""创建一个基于 Base64 编码数据的音频片段"""
return AudioPart(raw=base64.b64decode(data), mime_type=mime_type)
@classmethod
def file_uri_part(
cls,
file_uri: str,
mime_type: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "FilePart":
"""创建一个基于云端 URI (如 Google Cloud Storage gs://) 的通用文件片段"""
return FilePart(url=file_uri, mime_type=mime_type, metadata=metadata or {})
@classmethod
def tool_call_part(
cls, id: str, tool_name: str, args: dict[str, Any] | str
) -> "ToolCallPart":
"""创建一个工具调用请求片段"""
return ToolCallPart(id=id, tool_name=tool_name, args=args)
@classmethod
def tool_return_part(cls, call_id: str, name: str, result: Any) -> "ToolReturnPart":
"""创建一个工具执行结果片段"""
return ToolReturnPart(
tool_call_id=call_id,
tool_name=name,
output=result,
)
async def get_raw_bytes(self) -> bytes:
"""统一获取多模态原始字节数据 (自动适配 raw、本地 path 或自动下载公网 url)"""
raw_data = getattr(self, "raw", None)
if isinstance(raw_data, bytes):
return raw_data
path_data = getattr(self, "path", None)
if path_data is not None:
from pathlib import Path
if isinstance(path_data, Path):
return path_data.read_bytes()
url_data = getattr(self, "url", None)
if isinstance(url_data, str):
from zhenxun.utils.http_utils import AsyncHttpx
content = await AsyncHttpx.get_content(url_data)
if isinstance(content, bytes):
return content
return b""
raise ValueError(
f"{self.__class__.__name__} 未提供有效的数据源 (url, raw, path)"
)
async def get_base64_data(self) -> str:
"""统一获取 Base64 编码的字符串数据"""
return base64.b64encode(await self.get_raw_bytes()).decode("utf-8")
async def get_data_uri(self, default_mime: str = "application/octet-stream") -> str:
"""统一获取 Data URI (data:mime;base64,...) 格式的字符串"""
mime = getattr(self, "mime_type", None) or default_mime
return f"data:{mime};base64,{await self.get_base64_data()}"
class ImagePart(BaseContentPart):
"""图片媒体片段"""
type: Literal["image"] = "image"
url: str | None = None
"""外网图片 URL (需能直接访问)"""
raw: bytes | None = None
"""图片二进制裸数据"""
path: Path | None = None
"""本地文件系统中的图片路径"""
mime_type: str | None = None
"""图片 MIME 类型 (如 image/jpeg)"""
media_resolution: str | None = None
"""媒体处理强制分辨率策略"""
@model_validator(mode="after")
def _validate_source(self) -> Self:
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
if len(sources) != 1:
raise ValueError("ImagePart 必须且只能提供 url, raw, path 中的一个")
return self
class AudioPart(BaseContentPart):
"""音频媒体片段"""
type: Literal["audio"] = "audio"
url: str | None = None
"""外网音频 URL"""
raw: bytes | None = None
"""音频二进制裸数据"""
path: Path | None = None
"""本地文件系统中的音频路径"""
mime_type: str | None = None
"""音频 MIME 类型 (如 audio/mp3)"""
@model_validator(mode="after")
def _validate_source(self) -> Self:
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
if len(sources) != 1:
raise ValueError("AudioPart 必须且只能提供 url, raw, path 中的一个")
return self
class VideoPart(BaseContentPart):
"""视频媒体片段"""
type: Literal["video"] = "video"
url: str | None = None
"""外网视频 URL"""
raw: bytes | None = None
"""视频二进制裸数据"""
path: Path | None = None
"""本地文件系统中的视频路径"""
mime_type: str | None = None
"""视频 MIME 类型 (如 video/mp4)"""
@model_validator(mode="after")
def _validate_source(self) -> Self:
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
if len(sources) != 1:
raise ValueError("VideoPart 必须且只能提供 url, raw, path 中的一个")
return self
class FilePart(BaseContentPart):
"""通用文件片段 (如 PDF、代码文件等)"""
type: Literal["file"] = "file"
url: str | None = None
"""外网文件 URL (或 Google Cloud gs:// URI)"""
raw: bytes | None = None
"""文件二进制裸数据"""
path: Path | None = None
"""本地文件系统中的文件路径"""
mime_type: str | None = None
"""文件 MIME 类型 (如 application/pdf)"""
@model_validator(mode="after")
def _validate_source(self) -> Self:
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
if len(sources) != 1:
raise ValueError("FilePart 必须且只能提供 url, raw, path 中的一个")
return self
class TextPart(BaseContentPart):
"""基础文本片段"""
type: Literal["text"] = "text"
text: str
"""具体的纯文本内容"""
class ThoughtPart(BaseContentPart):
"""思考链 (Chain of Thought) 片段,用于容纳模型的内部思维过程"""
type: Literal["thought"] = "thought"
thought_text: str
"""思考过程的文本"""
class ToolCallPart(BaseContentPart):
"""工具调用请求片段 (由模型发出)"""
type: Literal["tool_call"] = "tool_call"
id: str
"""工具调用唯一 ID"""
tool_name: str
"""调用的函数名称"""
args: dict[str, Any] | str
"""传递给工具的参数 (解析后的字典或原始 JSON 字符串)"""
class ToolReturnPart(BaseContentPart):
"""工具调用结果片段 (发给模型)"""
type: Literal["tool_return"] = "tool_return"
tool_call_id: str
"""关联的原始调用 ID"""
tool_name: str
"""执行的工具名称"""
output: Any
"""工具执行结果的有效载荷"""
class TextDeltaPart(BaseModel):
"""流式文本增量片段"""
type: Literal["text_delta"] = "text_delta"
content_delta: str
"""流式返回的文本增量字符串"""
class ThoughtDeltaPart(BaseModel):
"""流式思考链增量片段"""
type: Literal["thought_delta"] = "thought_delta"
content_delta: str
"""流式返回的思考过程增量字符串"""
class ToolCallDeltaPart(BaseModel):
"""流式工具调用增量片段"""
type: Literal["tool_call_delta"] = "tool_call_delta"
tool_call_id: str | None = None
"""(可选) 流式返回的工具调用唯一ID"""
tool_name_delta: str | None = None
"""(可选) 流式返回的工具名称增量"""
args_delta: str | None = None
"""(可选) 流式返回的 JSON 格式参数增量"""
LLMContentPart = Annotated[
TextPart
| ImagePart
| AudioPart
| VideoPart
| FilePart
| ThoughtPart
| ToolCallPart
| ToolReturnPart,
Field(discriminator="type"),
]
"""大模型底层标准内容片段的 Annotated 联合类型"""
class EmbedPayload(BaseModel):
"""单一的嵌入载体,包含一个或多个多模态片段(融合向量)"""
parts: list[LLMContentPart] = Field(default_factory=list)
@property
def text(self) -> str:
"""快速提取纯文本(用于向下兼容不支持多模态的模型)"""
return "".join(p.text for p in self.parts if isinstance(p, TextPart)).strip()
@property
def has_multimodal(self) -> bool:
"""判断是否包含图像/音频/视频/文件等非文本模态"""
return any(not isinstance(p, TextPart) for p in self.parts)
class EmbedBatch(BaseModel):
"""一次嵌入 API 请求的批次载体"""
payloads: list[EmbedPayload] = Field(default_factory=list)
def to_text_only(self, context_name: str) -> list[str]:
"""降级工具:将多模态的批量向量安全剔除图片等内容,回退为纯文本数组"""
from zhenxun.services.log import logger
texts = []
for payload in self.payloads:
if payload.has_multimodal:
logger.warning(
f"⚠️ 模型 {context_name} "
"不支持多模态嵌入,已自动剔除富媒体内容,静默降级为纯文本进行向量化..."
)
texts.append(payload.text if payload.text else " ")
return texts
__all__ = [
"AudioPart",
"BaseContentPart",
"EmbedBatch",
"EmbedPayload",
"FilePart",
"ImagePart",
"LLMContentPart",
"TextDeltaPart",
"TextPart",
"ThoughtDeltaPart",
"ThoughtPart",
"ToolCallDeltaPart",
"ToolCallPart",
"ToolReturnPart",
"VideoPart",
]
@@ -0,0 +1,121 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.core.models import ToolChoice
from zhenxun.services.ai.core.options import (
GenerationConfig,
LLMEmbeddingConfig,
TTSConfig,
)
from zhenxun.utils.pydantic_compat import model_dump
from .models import LLMMessage
from .parts import EmbedBatch
class BaseRequest(BaseModel):
"""基础请求 DTO"""
timeout: float | None = Field(default=None)
extra: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(arbitrary_types_allowed=True)
def get_cache_hash_payload(self) -> dict[str, Any]:
"""获取用于计算缓存 Hash 的安全载荷,排除所有运行时动态变量"""
request_dict = model_dump(self, exclude_none=True)
if "config" in request_dict and isinstance(request_dict["config"], dict):
custom_kwargs = request_dict["config"].get("custom_kwargs", {})
if "__cache_ttl__" in custom_kwargs:
custom_kwargs.pop("__cache_ttl__")
if "extra" in request_dict:
request_dict["extra"] = {
k: v
for k, v in request_dict["extra"].items()
if not k.startswith("_")
and k not in ("run_context", "output_processor", "guardrails")
}
return request_dict
class ChatRequest(BaseRequest):
"""对话生成请求 DTO"""
messages: list[LLMMessage]
config: GenerationConfig | None = None
tools: list[Any] | None = None
tool_choice: str | dict[str, Any] | ToolChoice | None = None
def get_cache_hash_payload(self) -> dict[str, Any]:
payload = super().get_cache_hash_payload()
for msg in payload.get("messages", []):
msg.pop("created_at", None)
msg.pop("token_cost", None)
msg.pop("metadata", None)
if "tools" in payload and isinstance(payload["tools"], list):
safe_tools = []
for t in payload["tools"]:
if isinstance(t, dict | str):
safe_tools.append(t)
else:
safe_tools.append(getattr(t, "name", type(t).__name__))
payload["tools"] = safe_tools
return payload
class EmbeddingRequest(BaseRequest):
"""向量嵌入请求 DTO"""
batch: EmbedBatch
"""向量嵌入的批次载体"""
config: LLMEmbeddingConfig | None = None
"""向量嵌入配置"""
class ImageRequest(BaseRequest):
"""图像生成请求 DTO"""
prompt: str
"""图像生成提示词"""
images: list[Any] | None = None
"""输入参考图像列表"""
config: GenerationConfig | None = None
"""图像生成配置"""
class SpeechRequest(BaseRequest):
"""语音合成请求 DTO"""
input_text: str
"""待合成的文本内容"""
voice: str | None = None
"""发音人/音色标识 (快捷覆盖参数,为空则使用模型默认音色)"""
config: TTSConfig | None = None
"""语音合成配置"""
class RerankRequest(BaseRequest):
"""文本重排请求 DTO"""
query: str
"""检索查询词"""
documents: list[str | dict[str, str]]
"""待排序的候选文档列表"""
top_n: int = 3
"""返回的最相关文档数量"""
__all__ = [
"BaseRequest",
"ChatRequest",
"EmbeddingRequest",
"ImageRequest",
"RerankRequest",
"SpeechRequest",
]
@@ -0,0 +1,189 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, TypeVar
from pydantic import BaseModel, Field
from zhenxun.utils.pydantic_compat import parse_as
from .parts import (
AudioPart,
ImagePart,
LLMContentPart,
TextPart,
ThoughtPart,
ToolCallPart,
ToolReturnPart,
)
from .shared import RerankResult, UsageInfo
T = TypeVar("T", bound=BaseModel)
class ChatResponse(BaseModel):
"""
文本/多模态对话响应对象,确立 SSOT (单一数据源) 架构。
"""
content_parts: list[LLMContentPart] = Field(default_factory=list)
"""经由解析、统一封装后的标准内容部件列表"""
usage_info: dict[str, Any] | None = None
"""厂商返回的原始用量遥测字段"""
raw_response: dict[str, Any] | None = None
"""完整的 API 层级原生响应字典 (未经框架清洗)"""
grounding_metadata: Any | None = None
"""联网搜索、位置等基底事实归因元数据"""
parsed_obj: Any | None = Field(default=None)
"""在结构化输出模式下,由中间件反序列化得出的强类型 Pydantic 对象实例"""
def get_parsed_obj(self, model_class: type[T]) -> T | None:
"""
从结构化生成结果中获取强类型的 Pydantic 解析对象,提供完善的 IDE 类型推导支持。
参数:
model_class: 期望提取的 Pydantic 模型类。
返回:
强类型的模型实例,如果不存在则返回 None
"""
if self.parsed_obj is None:
return None
if isinstance(self.parsed_obj, model_class):
return self.parsed_obj
try:
return parse_as(model_class, self.parsed_obj)
except Exception:
return self.parsed_obj
@property
def tool_calls(self) -> list[ToolCallPart]:
return [p for p in self.content_parts if isinstance(p, ToolCallPart)]
@property
def text(self) -> str:
"""动态视图:提取并拼接所有文本块"""
return "".join(
p.text for p in self.content_parts if isinstance(p, TextPart)
).strip()
@property
def thought_text(self) -> str | None:
"""动态视图:提取并拼接所有思考/推理块"""
thoughts = [
p.thought_text for p in self.content_parts if isinstance(p, ThoughtPart)
]
return "\n".join(thoughts).strip() if thoughts else None
@property
def thought_signature(self) -> str | None:
"""动态视图:获取当前响应中的思考指纹"""
for p in reversed(self.content_parts):
if isinstance(p, ThoughtPart | ToolCallPart | ToolReturnPart):
if p.metadata and "thought_signature" in p.metadata:
return p.metadata["thought_signature"]
return None
@property
def images(self) -> list[bytes | Path | str]:
"""动态视图:提取响应中包含的所有图片数据"""
imgs = []
for p in self.content_parts:
if isinstance(p, ImagePart):
if p.url:
imgs.append(p.url)
elif p.raw:
imgs.append(p.raw)
elif p.path:
imgs.append(p.path)
return imgs
@property
def audios(self) -> list[bytes | Path | str]:
"""动态视图:提取音频数据"""
audios = []
for p in self.content_parts:
if isinstance(p, AudioPart):
if p.url:
audios.append(p.url)
elif p.raw:
audios.append(p.raw)
elif p.path:
audios.append(p.path)
return audios
class EmbeddingResponse(BaseModel):
"""Embedding 向量富响应对象"""
embeddings: list[list[float]]
"""多段输入文本对应生成的高维浮点数向量数组 (二维)"""
usage: UsageInfo = Field(default_factory=UsageInfo)
"""执行向量化任务产生的 Token 用量开销统计"""
model_name: str
"""实际用于执行本次编码任务的模型名称"""
@property
def vector(self) -> list[float]:
"""便捷属性:当且仅当只需提取单一文本向量时,直接返回一维向量"""
return self.embeddings[0] if self.embeddings else []
class AudioResponse(BaseModel):
"""统一的语音合成响应对象"""
audio_bytes: bytes
"""生成的音频二进制裸数据,可直接用于发送或保存"""
audio_format: str
"""实际返回的音频格式 (如 mp3, wav)"""
usage: UsageInfo = Field(default_factory=UsageInfo)
"""Token 或 字符数消耗统计"""
raw_response: Any | None = None
"""原生响应体,供高级调试使用"""
model_name: str
"""实际执行任务的模型名称"""
class ImageResponse(BaseModel):
"""图像生成响应对象"""
content_parts: list[LLMContentPart] = Field(default_factory=list)
raw_response: dict[str, Any] | None = None
@property
def images(self) -> list[bytes | Path | str]:
"""动态视图:提取响应中包含的所有图片数据"""
imgs = []
for p in self.content_parts:
if isinstance(p, ImagePart):
if p.url:
imgs.append(p.url)
elif p.raw:
imgs.append(p.raw)
elif p.path:
imgs.append(p.path)
return imgs
@property
def text(self) -> str:
"""动态视图:提取并拼接所有 TextPart 文本内容"""
return "".join(
p.text for p in self.content_parts if isinstance(p, TextPart)
).strip()
class RerankResponse(BaseModel):
"""文本重排响应对象"""
results: list[RerankResult]
"""重排后的文档结果列表"""
__all__ = [
"AudioResponse",
"ChatResponse",
"EmbeddingResponse",
"ImageResponse",
"RerankResponse",
]
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel, Field
@dataclass
class UsageInfo:
"""使用信息数据类"""
prompt_tokens: int = 0
"""请求发送的 Token 消耗数 (含系统提示词与历史记录)"""
completion_tokens: int = 0
"""模型回复生成的 Token 消耗数"""
total_tokens: int = 0
"""本次交互总计产生的 Token 数"""
cost: float = 0.0
"""(可选) 本次交互产生的实际账单估价"""
prompt_cache_hit_tokens: int = 0
"""被上下文缓存系统命中的 Prompt Token 数 (往往价格更低)"""
prompt_cache_miss_tokens: int = 0
"""未能命中缓存、实际执行了计算的 Prompt Token 数"""
reasoning_tokens: int = 0
"""专门用于内部思考/推理链 (CoT) 消耗的 Token 数"""
@property
def efficiency_ratio(self) -> float:
return self.completion_tokens / max(self.prompt_tokens, 1)
def __add__(self, other: "UsageInfo") -> "UsageInfo":
"""支持 UsageInfo 相加,用于汇聚子智能体的 Token 消耗"""
if not isinstance(other, UsageInfo):
return self
return UsageInfo(
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
completion_tokens=self.completion_tokens + other.completion_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cost=self.cost + other.cost,
prompt_cache_hit_tokens=self.prompt_cache_hit_tokens
+ other.prompt_cache_hit_tokens,
prompt_cache_miss_tokens=self.prompt_cache_miss_tokens
+ other.prompt_cache_miss_tokens,
reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens,
)
class LLMCodeExecution(BaseModel):
"""大模型代码执行(沙箱/本地)结果实体"""
code: str
"""被执行的原始代码"""
output: str | None = None
"""标准输出 (stdout)"""
error: str | None = None
"""标准错误 (stderr) 或框架抛出的异常"""
execution_time: float | None = None
"""代码执行耗时 (秒)"""
files_generated: list[str] | None = None
"""代码执行过程中生成的工件(Artifacts)文件路径或名称列表"""
class LLMGroundingAttribution(BaseModel):
"""基础事实溯源引用对象 (Grounding Attribution)"""
title: str | None = None
"""来源网页或文档的标题"""
uri: str | None = None
"""来源内容的统一资源标识符 (URL)"""
snippet: str | None = None
"""从来源网页中提取的、支撑当前生成内容的文本片段"""
confidence_score: float | None = None
"""该引用来源与生成内容之间相关性的置信度分数"""
class LLMGroundingMetadata(BaseModel):
"""检索增强/搜索引擎溯源 (Grounding) 的完整元数据字典,
用于为大模型返回的信息提供可信背书"""
web_search_queries: list[str] | None = None
"""模型在执行检索时,实际使用的底层搜索引擎 Query 查询词列表"""
grounding_attributions: list[LLMGroundingAttribution] | None = None
"""溯源引用的详情列表,用于在 UI 端构建点击跳转链接或角标"""
search_suggestions: list[dict[str, Any]] | None = None
"""随搜索返回的相关搜索建议 (Search Suggestions)"""
search_entry_point: str | None = None
"""一段 HTML/CSS 内容,可用于在客户端渲染标准的搜索引擎入口/建议组件"""
map_widget_token: str | None = None
"""用于渲染 Google Maps 交互式地点小组件 (Places widget) 的
上下文 Token (针对 googleMaps 工具)"""
class RerankDocument(BaseModel):
"""重排候选文档 (支持纯文本或图文字典)"""
text: str | None = None
"""被用于重排检索的文本内容"""
image: str | None = None
"""用于多模态重排的图片内容"""
class RerankResult(BaseModel):
"""重排返回结果"""
index: int
"""此记录对应于输入时的原始文档数组中的索引位置"""
relevance_score: float
"""计算出的相关性得分 (越大相关度通常越高)"""
document: RerankDocument | None = Field(default=None)
"""实际被命中的文档数据"""
__all__ = [
"LLMCodeExecution",
"LLMGroundingAttribution",
"LLMGroundingMetadata",
"RerankDocument",
"RerankResult",
"UsageInfo",
]
@@ -0,0 +1,83 @@
from __future__ import annotations
from typing import Annotated, Any
from typing_extensions import TypeVar
from nonebot_plugin_alconna import UniMessage
from pydantic import Field
from .parts import (
AudioPart,
FilePart,
ImagePart,
LLMContentPart,
TextPart,
ThoughtPart,
ToolCallPart,
ToolReturnPart,
VideoPart,
)
SystemContentUnion = TextPart
"""系统消息允许的内容片段联合类型"""
UserContentUnion = TextPart | ImagePart | AudioPart | VideoPart | FilePart
"""用户消息允许的多模态内容片段联合类型"""
AssistantContentUnion = (
TextPart
| ThoughtPart
| ToolCallPart
| ToolReturnPart
| ImagePart
| AudioPart
| VideoPart
| FilePart
)
"""助手回复允许的内容片段联合类型"""
ToolContentUnion = ToolReturnPart | ImagePart | AudioPart | VideoPart | FilePart
"""工具消息允许的内容片段联合类型"""
RoleT = TypeVar("RoleT", default=str, covariant=True)
"""泛型:消息参与者角色类型变量"""
ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True)
"""泛型:多模态片段数组的元素内容类型变量"""
from .context_events import AgentEvent
from .models import (
AssistantMessage,
LLMMessage,
SystemMessage,
ToolMessage,
UserMessage,
)
AnyLLMMessage = Annotated[
SystemMessage | UserMessage | AssistantMessage | ToolMessage,
Field(discriminator="role"),
]
"""LLM 消息类型的合集联合类型"""
PromptInput = str | UniMessage | LLMMessage | list[LLMContentPart] | Any
"""支持作为 LLM 输入的提示词对象联合类型,包括纯文本、UniMessage、LLMMessage 消息实体"""
AgentMessage = LLMMessage | AgentEvent
"""Agent 上下文业务事件载体与原生网络载体的联合类型"""
__all__ = [
"AgentMessage",
"AnyLLMMessage",
"AssistantContentUnion",
"ContentT",
"LLMContentPart",
"PromptInput",
"RoleT",
"SystemContentUnion",
"ToolContentUnion",
"UserContentUnion",
]
+213
View File
@@ -0,0 +1,213 @@
"""
模型自身设定域类型定义
"""
import asyncio
from dataclasses import dataclass
from enum import Enum
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.services.ai.core.options import GenerationConfig
ModelName = str | None
TReq = TypeVar("TReq", bound=BaseModel)
"""泛型:LLM 请求体模型约束 (必须继承自 BaseModel)"""
TRes = TypeVar("TRes", bound=BaseModel)
"""泛型:LLM 响应体模型约束 (必须继承自 BaseModel)"""
@dataclass
class ModelIdentity:
"""模型的身份标识与基础能力数据传输对象 (DTO),剥离运行时状态"""
provider_name: str
"""模型提供商名称"""
model_name: str
"""模型名称"""
api_type: str
"""API 适配器类型"""
api_base: str | None
"""API 基础请求地址/网关终结点"""
path_prefix: str | None
"""中转路由的 URL 前缀"""
capabilities: "ModelCapabilities"
"""模型的能力配置定义描述"""
generation_config: GenerationConfig | None
"""模型的默认生成配置"""
class CancellationToken:
"""全局取消令牌,用于在异步链路中传递中止信号"""
def __init__(self):
self._cancelled = False
self._futures: list[asyncio.Future] = []
def cancel(self) -> None:
self._cancelled = True
for f in self._futures:
if not f.done():
f.cancel()
def is_cancelled(self) -> bool:
return self._cancelled
def raise_if_cancelled(self) -> None:
if self._cancelled:
raise asyncio.CancelledError(
"任务已被主动取消 (CancellationToken triggered)"
)
def link_future(self, future: asyncio.Future) -> None:
if self._cancelled:
future.cancel()
else:
self._futures.append(future)
class LLMContext(BaseModel, Generic[TReq, TRes]):
"""LLM 执行上下文,用于在中间件管道中传递请求状态"""
request: TReq
"""强类型的各模态请求对象实体。"""
runtime_state: dict[str, Any] = Field(default_factory=dict)
"""中间件运行时的临时状态存储。"""
cancellation_token: CancellationToken | None = Field(default=None)
"""全局取消令牌。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
class ToolDefinition(BaseModel):
"""结构化的工具定义模型"""
name: str = Field(...)
"""工具名称"""
description: str = Field(...)
"""工具描述"""
parameters: dict[str, Any] = Field(default_factory=dict)
"""JSON Schema 参数"""
metadata: dict[str, Any] = Field(default_factory=dict)
"""元数据"""
class ToolChoice(BaseModel):
"""工具选择配置"""
mode: Literal["auto", "none", "any", "required"] = Field(default="auto")
"""工具选择模式"""
allowed_function_names: list[str] | None = Field(default=None)
"""允许调用的函数名称列表"""
class ModelModality(str, Enum):
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
FILE = "file"
EMBEDDING = "embedding"
class ReasoningMode(str, Enum):
"""推理/思考模式类型"""
NONE = "none"
BUDGET = "budget"
LEVEL = "level"
EFFORT = "effort"
class ModelCapabilities(BaseModel):
"""定义一个模型的核心能力。"""
input_modalities: set[ModelModality] = Field(default={ModelModality.TEXT})
"""模型支持的输入模态集合。"""
output_modalities: set[ModelModality] = Field(default={ModelModality.TEXT})
"""模型支持的输出模态集合。"""
supports_tool_calling: bool = False
"""是否支持工具调用能力。"""
is_embedding_model: bool = False
"""是否为嵌入模型。"""
is_rerank_model: bool = False
"""是否为重排序模型。"""
reasoning_mode: ReasoningMode = ReasoningMode.NONE
"""推理模式类型。"""
reasoning_visibility: Literal["visible", "hidden", "none"] = "none"
"""推理过程可见性设置。"""
reasoning_effort_map: dict[str, str] = Field(default_factory=dict)
"""思考强度参数(reasoning_effort)的降级映射矩阵,如 {"max": "xhigh"}。"""
max_input_tokens: int = Field(default=256000)
"""最大输入 Token 数量(用于触发上下文压缩策略,未显式声明则默认为 256K)。"""
supported_native_tools: set[str] = Field(default_factory=set)
"""该模型实际支持的云端原生能力/内置工具。"""
default_voice_id: str | None = None
"""默认的语音合成音色 ID(TTS模型专用)。"""
features: set[str] = Field(default_factory=set)
"""用于第三方插件动态注入的自定义能力标签。"""
def supports_task(self, task: str) -> bool:
"""判断模型是否支持指定的底层任务类型"""
if task == "embedding":
return self.is_embedding_model
elif task == "rerank":
return self.is_rerank_model
elif task == "tts":
return ModelModality.AUDIO in self.output_modalities
elif task == "image":
return ModelModality.IMAGE in self.output_modalities
elif task == "chat":
return ModelModality.TEXT in self.output_modalities
return False
def accepts_input(self, modality: ModelModality) -> bool:
"""检查模型是否支持某种输入模态"""
return modality in self.input_modalities
def accepts_output(self, modality: ModelModality) -> bool:
"""检查模型是否支持某种输出模态"""
return modality in self.output_modalities
def has_feature(self, feature: str) -> bool:
"""检查模型是否具备某个扩展特性"""
return feature in self.features
class ModelDetail(BaseModel):
"""模型详细信息"""
model_name: str
"""模型名称。"""
is_available: bool = True
"""模型是否可用。"""
temperature: float | None = None
"""采样温度参数。"""
generation_max_tokens: int | None = None
"""单次生成最大 Token 数。"""
api_type: str | None = None
"""API 类型标识。"""
endpoint: str | None = None
"""模型服务端点地址。"""
task_type: str | None = Field(default=None)
"""显式声明的主任务类型 (如 'image_generation')。"""
path_prefix: str | None = Field(default=None)
"""中转路由前缀,例如 '/cogvideox' 或 '/minimax'。"""
__all__ = [
"CancellationToken",
"LLMContext",
"ModelCapabilities",
"ModelDetail",
"ModelIdentity",
"ModelModality",
"ModelName",
"ReasoningMode",
"ToolChoice",
"ToolDefinition",
]
+408
View File
@@ -0,0 +1,408 @@
"""
AI 模块配置数据域类型定义
"""
from __future__ import annotations
from collections.abc import Callable
from enum import Enum
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validate
if TYPE_CHECKING:
from zhenxun.services.ai.llm.builder import IntentBuilder
T = TypeVar("T")
class ResponseFormat(Enum):
"""响应格式枚举"""
TEXT = "text"
JSON = "json"
MULTIMODAL = "multimodal"
class StructuredOutputStrategy(str, Enum):
"""结构化输出策略"""
NATIVE = "native"
"""使用原生 API (如 OpenAI json_object/json_schema, Gemini mime_type)"""
TOOL_CALL = "tool_call"
"""构造虚假工具调用来强制输出结构化数据 (适用于指令跟随弱但工具调用强的模型)"""
PROMPT = "prompt"
"""仅在 Prompt 中追加 Schema 说明,依赖文本补全"""
class EmbeddingTaskType(str, Enum):
"""
文本嵌入任务类型 (对应 Gemini embedding 模型的 task_type 参数)
"""
RETRIEVAL_QUERY = "RETRIEVAL_QUERY"
"""指定给定的文本是搜索/检索设置中的查询 (Query)。"""
RETRIEVAL_DOCUMENT = "RETRIEVAL_DOCUMENT"
"""指定给定的文本是被搜索语料库中的文档 (Document)。"""
SEMANTIC_SIMILARITY = "SEMANTIC_SIMILARITY"
"""指定文本将用于语义文本相似度 (STS) 计算。"""
CLASSIFICATION = "CLASSIFICATION"
"""指定嵌入向量将用于文本分类任务。"""
CLUSTERING = "CLUSTERING"
"""指定嵌入向量将用于聚类任务。"""
QUESTION_ANSWERING = "QUESTION_ANSWERING"
"""指定文本将用于问答任务。"""
FACT_VERIFICATION = "FACT_VERIFICATION"
"""指定文本将用于事实核查任务。"""
class BaseOutputDefinition(Generic[T]):
"""声明式结构化输出基类"""
type_: type[T]
class ToolOutput(BaseOutputDefinition[T]):
"""工具输出标记:使用强制工具调用 (Tool Call) 结束任务并返回指定结构"""
name: str | None = None
description: str | None = None
strict: bool | None = None
def __init__(
self,
type_: type[T],
name: str | None = None,
description: str | None = None,
strict: bool | None = None,
):
self.type_ = type_
self.name = name
self.description = description
self.strict = strict
class ReasoningEffort(str, Enum):
"""推理努力程度枚举"""
NONE = "NONE"
"""不开启推理思考"""
MINIMAL = "MINIMAL"
"""极低推理努力,追求最快响应"""
LOW = "LOW"
"""较低推理努力"""
MEDIUM = "MEDIUM"
"""中等推理努力(通常是默认值)"""
HIGH = "HIGH"
"""高推理努力"""
XHIGH = "XHIGH"
"""极高推理努力"""
MAX = "MAX"
"""最大推理努力 (如 GLM / DeepSeek)"""
class ImageAspectRatio(str, Enum):
"""图像宽高比枚举"""
SQUARE = "1:1"
"""正方形"""
LANDSCAPE_16_9 = "16:9"
"""横向宽屏 16:9"""
PORTRAIT_9_16 = "9:16"
"""竖向全屏 9:16"""
LANDSCAPE_4_3 = "4:3"
"""横向标准 4:3"""
PORTRAIT_3_4 = "3:4"
"""竖向标准 3:4"""
LANDSCAPE_3_2 = "3:2"
"""横向 3:2"""
PORTRAIT_2_3 = "2:3"
"""竖向 2:3"""
class ImageResolution(str, Enum):
"""图像分辨率/质量枚举"""
STANDARD = "STANDARD"
"""标准分辨率"""
HD = "HD"
"""高清分辨率"""
class CommonLLMConfig(BaseModel):
"""三大厂商通用基础生成参数"""
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
"""采样温度。较高的值会使输出更加随机,较低的值会使其更加集中和确定。"""
max_tokens: int | None = Field(default=None, gt=0)
"""聊天完成时生成的最大 Token 数。"""
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
"""核采样 (Nucleus sampling) 概率阈值。"""
top_k: int | None = Field(default=None, gt=0)
"""仅从概率最高的前 K 个 Token 中采样 (并非所有模型支持)。"""
frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
"""频率惩罚。正值根据新 Token 在文本中的现有频率对其进行惩罚,
降低模型逐字重复同一行的可能性。"""
presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
"""存在惩罚。正值根据新 Token 到目前为止是否出现在文本中对其进行惩罚,
增加模型谈论新主题的可能性。"""
repetition_penalty: float | None = Field(default=None, ge=0.0, le=2.0)
"""重复惩罚系数 (部分非 OpenAI 兼容模型独有)。"""
stop: list[str] | str | None = Field(default=None)
"""API 停止生成后续 Token 的停止词序列。"""
reasoning_effort: ReasoningEffort | str | None = Field(default=None)
"""跨厂商统一的思考/推理等级意图(如 'low', 'high', 'max')。
具体映射由底层的 Adapter 执行。"""
class OutputFormatConfig(BaseModel):
"""输出格式与结构化控制"""
response_format: ResponseFormat | dict[str, Any] | None = Field(default=None)
"""响应格式类型 (枚举或字典形式的 json_schema 对象)。"""
response_mime_type: str | None = Field(default=None)
"""指定 MIME 类型 (如 application/json),主要用于 Gemini。"""
response_schema: dict[str, Any] | None = Field(default=None)
"""JSON Schema 字典,用于强制约束模型返回的 JSON 结构。"""
response_modalities: list[str] | None = Field(default=None)
"""允许的响应模态 (如 ["TEXT", "IMAGE"]),主要用于 Gemini。"""
structured_output_strategy: StructuredOutputStrategy | str | None = Field(
default=None
)
"""结构化输出所采用的内部策略。"""
class ToolCallConfig(BaseModel):
"""工具调用统一策略配置"""
mode: Literal["AUTO", "ANY", "NONE"] = Field(default="AUTO")
"""工具调用模式 (AUTO: 自动, ANY: 强制至少调一个, NONE: 禁用)。"""
allowed_function_names: list[str] | None = Field(default=None)
"""允许被调用的特定函数名称白名单。"""
include_server_side_tool_invocations: bool | None = Field(default=None)
"""是否包含服务端侧的工具调用日志流转 (主要用于 Gemini)。"""
class BaseProviderOption(BaseModel):
"""厂商配置逃生舱基类"""
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore
class OpenAIOptions(BaseProviderOption):
"""OpenAI 专属特权参数 (适配 Responses API)"""
store: bool | None = Field(default=None)
"""是否允许服务端留存本次请求的选项记录"""
metadata: dict[str, str] | None = Field(default=None)
"""附加在请求上的自定义元数据"""
class GeminiOptions(BaseProviderOption):
"""Gemini 专属特权参数"""
include_thoughts: bool | None = Field(default=None)
"""是否在最终响应中包含模型的内部思考过程 (Thoughts)"""
safety_settings: dict[str, str] | None = Field(default=None)
"""Gemini 专有的各个维度的安全过滤阈值配置"""
retrieval_config: dict[str, Any] | None = Field(default=None)
"""检索定位配置,如 LBS 经纬度信息,配合 Google Maps 工具使用"""
class DeepSeekOptions(BaseProviderOption):
"""DeepSeek 专属特权参数"""
thinking: bool | None = Field(default=None)
"""是否强制开启或关闭 R1 模型的思维链过程"""
class OpenAITTSOptions(BaseProviderOption):
"""OpenAI TTS 专属特权参数"""
voice_id: str | None = Field(default=None)
"""专属的音色 ID 配置"""
class GeminiTTSOptions(BaseProviderOption):
"""Gemini TTS 专属特权参数"""
voice_id: str | None = Field(default=None)
"""专属的音色 ID 配置"""
multi_speaker: bool | None = Field(default=None)
"""是否开启多说话人模式"""
second_voice: str | None = Field(default=None)
"""多说话人模式下的第二音色名称"""
class MiniMaxTTSOptions(BaseProviderOption):
"""MiniMax TTS 专属特权参数 (控制极度精细)"""
voice_id: str | None = Field(default=None)
"""专属的音色 ID 配置"""
vol: float | None = Field(default=None, gt=0.0, le=10.0)
"""音量,范围 (0, 10]"""
pitch: int | None = Field(default=None, ge=-12, le=12)
"""语调,范围 [-12, 12]"""
emotion: (
Literal[
"happy",
"sad",
"angry",
"fearful",
"disgusted",
"surprised",
"calm",
"fluent",
"whisper",
]
| None
) = Field(default=None)
"""情感控制"""
timbre_weights: list[dict[str, Any]] | None = Field(default=None)
"""音色混合权重 (最多4种)"""
pronunciation_dict: dict[str, list[str]] | None = Field(default=None)
"""自定义发音字典 (如: {"tone": ["处理/(chu3)(li3)"]})"""
class MiMoTTSOptions(BaseProviderOption):
"""MiMo TTS 专属特权参数"""
voice_id: str | None = Field(default=None)
"""专属的音色 ID 配置"""
class MediaGenerationConfig(BaseModel):
"""多模态媒体生成/优化的全局配置"""
aspect_ratio: ImageAspectRatio | str | None = Field(default=None)
"""生成的图像/视频宽高比 (如 '16:9')"""
resolution: str | None = Field(default=None)
"""生成的图像/视频分辨率 (如 '1K', '4K' 或 '1024x1024')"""
quality: Literal["low", "medium", "high", "standard", "hd"] | None = Field(
default=None
)
"""渲染质量及细节丰富水平"""
class TTSConfig(BaseModel):
"""文本转语音 (TTS) 全局配置"""
response_format: Literal["mp3", "wav", "pcm", "flac", "opus", "aac"] = Field(
default="mp3"
)
"""输出音频格式"""
speed: float = Field(default=1.0, ge=0.25, le=4.0)
"""语速 (通用映射)"""
openai_options: OpenAITTSOptions = Field(default_factory=OpenAITTSOptions)
gemini_options: GeminiTTSOptions = Field(default_factory=GeminiTTSOptions)
minimax_options: MiniMaxTTSOptions = Field(default_factory=MiniMaxTTSOptions)
mimo_options: MiMoTTSOptions = Field(default_factory=MiMoTTSOptions)
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)"""
model_config = ConfigDict(arbitrary_types_allowed=True)
class GenerationConfig(BaseModel):
"""
现代化 LLM 生成基座 (Intent-Driven Base)。
隔离通用参数与厂商私有参数,彻底终结"上帝类"。
"""
common: CommonLLMConfig = Field(default_factory=CommonLLMConfig)
output: OutputFormatConfig = Field(default_factory=OutputFormatConfig)
tools: ToolCallConfig = Field(default_factory=ToolCallConfig)
media: MediaGenerationConfig = Field(default_factory=MediaGenerationConfig)
openai_options: OpenAIOptions = Field(default_factory=OpenAIOptions)
gemini_options: GeminiOptions = Field(default_factory=GeminiOptions)
deepseek_options: DeepSeekOptions = Field(default_factory=DeepSeekOptions)
enable_caching: bool | None = Field(default=None)
"""是否在此次生成中开启上下文缓存 (Context Caching)"""
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中"""
validation_policy: dict[str, Any] | None = Field(default=None)
"""自定义验证策略字典"""
response_validator: Callable[[Any], None] | None = Field(default=None)
"""针对原始返回对象的自定义回调验证器"""
model_config = ConfigDict(arbitrary_types_allowed=True)
@classmethod
def builder(cls) -> "IntentBuilder":
from zhenxun.services.ai.llm.builder import IntentBuilder
return IntentBuilder()
def to_dict(self) -> dict[str, Any]:
return model_dump(self, exclude_none=True)
def merge_with(self, other: "GenerationConfig | None") -> "GenerationConfig":
"""深度合并两个配置,实现配置的无损叠加"""
if not other:
return model_copy(self, deep=True)
base_dump = model_dump(self, exclude_none=True)
other_dump = model_dump(other, exclude_none=True)
def deep_merge(d1: dict, d2: dict) -> dict:
res = d1.copy()
for k, v in d2.items():
if isinstance(v, dict) and k in res and isinstance(res[k], dict):
res[k] = deep_merge(res[k], v)
else:
res[k] = v
return res
merged_dump = deep_merge(base_dump, other_dump)
return model_validate(GenerationConfig, merged_dump)
class LLMEmbeddingConfig(BaseModel):
"""Embedding 专用配置"""
task_type: str | None = Field(default=None)
"""生成意图的任务类型,参考 EmbeddingTaskType (主要用于 Gemini 和 Jina)"""
output_dimensionality: int | None = Field(default=None)
"""请求模型强制输出(或截断)的较低维度数,实现维度压缩"""
title: str | None = Field(default=None)
"""提供该文档的标题以供底层优化。仅在 task_type 为 RETRIEVAL_DOCUMENT 时有效。"""
encoding_format: str | None = Field(default="float")
"""向量数据在响应中的编码格式 (通常为 float 或 base64)"""
multimodal: bool | list[str] = Field(default=False)
"""是否允许多模态向量化。False 表示纯文本(极速安全);True 表示全部放行;
也可传入 ['image', 'text'] 细粒度控制。"""
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)"""
model_config = ConfigDict(arbitrary_types_allowed=True)
__all__ = [
"BaseProviderOption",
"CommonLLMConfig",
"EmbeddingTaskType",
"GeminiOptions",
"GeminiTTSOptions",
"GenerationConfig",
"ImageAspectRatio",
"ImageResolution",
"LLMEmbeddingConfig",
"MiMoTTSOptions",
"MiniMaxTTSOptions",
"OpenAIOptions",
"OpenAITTSOptions",
"OutputFormatConfig",
"ReasoningEffort",
"ResponseFormat",
"StructuredOutputStrategy",
"TTSConfig",
"ToolCallConfig",
"ToolOutput",
]
@@ -0,0 +1,11 @@
"""
AI 服务协议统一导出
"""
from .tool import ToolExecutable, ToolProvider, ToolResolvable
__all__ = [
"ToolExecutable",
"ToolProvider",
"ToolResolvable",
]
+76
View File
@@ -0,0 +1,76 @@
from typing import Protocol, runtime_checkable
from zhenxun.services.ai.core.messages import (
AudioResponse,
ChatRequest,
ChatResponse,
EmbeddingRequest,
EmbeddingResponse,
ImageRequest,
ImageResponse,
RerankRequest,
RerankResponse,
SpeechRequest,
)
from zhenxun.services.ai.core.models import CancellationToken
@runtime_checkable
class SupportsChat(Protocol):
"""支持文本/多模态对话生成的协议"""
async def generate_response(
self,
request: ChatRequest,
cancellation_token: CancellationToken | None = None,
) -> ChatResponse:
"""生成文本或多模态对话的回复。"""
...
@runtime_checkable
class SupportsTextEmbedding(Protocol):
"""支持文本/多模态向量嵌入的协议"""
async def generate_embeddings(
self,
request: EmbeddingRequest,
) -> EmbeddingResponse:
"""生成文本或多模态向量嵌入。"""
...
@runtime_checkable
class SupportsSpeechSynthesis(Protocol):
"""支持文本转语音(TTS)的协议"""
async def generate_speech(
self,
request: SpeechRequest,
) -> AudioResponse:
"""将文本转换为语音(TTS)。"""
...
@runtime_checkable
class SupportsReranking(Protocol):
"""支持文档交叉注意力重排的协议"""
async def rerank(
self,
request: RerankRequest,
) -> RerankResponse:
"""对候选文档进行交叉注意力重排。"""
...
@runtime_checkable
class SupportsImageGeneration(Protocol):
"""支持图像生成与编辑的协议"""
async def generate_image(
self,
request: ImageRequest,
) -> ImageResponse:
"""根据请求生成或编辑图像。"""
...
@@ -0,0 +1,23 @@
"""
LLM 中间件协议定义
"""
from __future__ import annotations
from typing import Protocol
from zhenxun.services.ai.core.models import LLMContext, TReq, TRes
class NextCall(Protocol[TReq, TRes]):
"""中间件管道中下一个节点(或最终执行函数)的调用签名"""
async def __call__(self, context: LLMContext[TReq, TRes], /) -> TRes: ...
class LLMMiddleware(Protocol[TReq, TRes]):
"""LLM 中间件函数的调用签名,遵循洋葱模型嵌套包裹设计"""
async def __call__(
self, context: LLMContext[TReq, TRes], next_call: NextCall[TReq, TRes], /
) -> TRes: ...
@@ -0,0 +1,84 @@
"""
工具执行与管理协议定义
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
from zhenxun.services.ai.core.models import ToolDefinition
if TYPE_CHECKING:
from zhenxun.services.ai.run import RunContext
from zhenxun.services.ai.tools.models import (
ResolvedToolPayload,
ToolResult,
)
class ToolExecutable(Protocol):
"""
一个协议,定义了所有可被LLM调用的工具必须实现的行为。
"""
name: str
"""工具的名称标识"""
async def get_definition(
self, context: "RunContext | None" = None
) -> ToolDefinition | None:
"""
异步地获取一个结构化的工具定义。如果返回 None,则该工具对大模型不可见。
"""
...
async def execute(
self, context: "RunContext | None" = None, **kwargs: Any
) -> ToolResult:
"""
异步执行工具并返回一个结构化的结果。
"""
...
@runtime_checkable
class ToolResolvable(Protocol):
"""
鸭子类型解析协议。任何实现了此协议的对象,
都可以直接被 Agent 或 LLM 的 tools 参数接收。
"""
async def resolve(
self, context: "RunContext | None" = None
) -> "ResolvedToolPayload": ...
class ToolProvider(Protocol):
"""
一个协议,定义了"工具提供者"的行为。
工具提供者负责发现或实例化具体的 ToolExecutable 对象。
"""
async def initialize(self) -> None:
"""
异步初始化提供者。
"""
...
async def discover_tools(
self,
allowed_servers: list[str] | None = None,
excluded_servers: list[str] | None = None,
) -> dict[str, ToolExecutable]:
"""
异步发现此提供者提供的所有工具。
"""
...
async def get_tool_executable(
self, name: str, config: dict[str, Any]
) -> ToolExecutable | None:
"""
如果此提供者能处理名为 'name' 的工具,则返回一个可执行实例。
"""
...
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Callable
from typing import Any, TypeVar
from nonebot.utils import is_coroutine_callable
from pydantic import BaseModel, ConfigDict
from zhenxun.services.log import logger
class AgentStreamEvent(BaseModel):
"""
Agent 局部流事件与生命周期事件的绝对统一基类
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
class LLMStartEvent(AgentStreamEvent):
"""大模型网络请求开始事件"""
model_name: str
"""请求的大模型名称"""
messages: list[Any]
"""发往大模型的历史消息列表"""
class LLMEndEvent(AgentStreamEvent):
"""大模型网络请求结束事件"""
response: Any
"""大模型返回的完整响应 (ChatResponse)"""
class ToolCallStartEvent(AgentStreamEvent):
"""工具调用开始事件"""
tool_name: str
"""调用的工具名称"""
arguments: dict[str, Any]
"""工具调用参数"""
intent: str | None = None
"""从大模型调用参数中剥离出的意图 (_intent)"""
class ToolCallEndEvent(AgentStreamEvent):
"""工具调用结束事件"""
tool_name: str
"""工具名称"""
result: Any
"""工具最终返回的结果"""
is_error: bool
"""工具执行是否失败"""
class ToolStreamChunkEvent(AgentStreamEvent):
"""工具或后台任务流式进度反馈事件"""
tool_name: str
"""工具名称"""
content: str
"""当前流式输出的文本片段"""
metadata: dict[str, Any] | None = None
"""流式的附加元数据 (如进度比例等)"""
class UserCustomEvent(AgentStreamEvent):
"""自定义用户界面交互事件"""
display: Any
"""用于前端渲染的展示对象 (如 UniMessage, str, ImagePart)"""
log_content: str | None = None
"""用于后台打印的日志摘要"""
class ControlFlowEvent(AgentStreamEvent):
"""控制流中断与流转事件"""
action: str
"""控制流动作,如 'handoff', 'abort', 'end_run'"""
payload: Any = None
"""附加的数据载荷"""
T_Event = TypeVar("T_Event", bound=AgentStreamEvent)
"""泛型变量:用于绑定具体的事件类型,提供完美的 IDE 类型推导"""
class EventBus:
"""
局部事件总线 (Event Bus)
EventBus 支持异步迭代与发布-订阅(Pub/Sub)模式
"""
def __init__(self):
"""
初始化 EventBus 实例。
"""
self._queue = asyncio.Queue()
self._finished = False
self._subscribers: dict[type[AgentStreamEvent], list[Callable[[Any], Any]]] = (
defaultdict(list)
)
self._background_tasks = set()
def subscribe(
self, event_type: type[T_Event], handler: Callable[[T_Event], Any]
) -> None:
"""
注册事件监听器,用于订阅特定类型的事件。
参数:
event_type: 要订阅的事件类型(需为 AgentStreamEvent 的子类)。
handler: 事件处理回调函数。当对应事件发布时被触发,参数为事件实例。
"""
self._subscribers[event_type].append(handler)
async def emit(self, event: AgentStreamEvent) -> None:
"""
发布事件,触发所有匹配的订阅者,并将事件放入迭代队列中。
参数:
event: 要发布的事件实例(需继承自 AgentStreamEvent)。
"""
handlers = []
for ev_type, cb_list in self._subscribers.items():
if isinstance(event, ev_type):
handlers.extend(cb_list)
for handler in handlers:
if is_coroutine_callable(handler):
async def _run_handler(h=handler, e=event):
try:
await h(e)
except Exception as err:
logger.error(f"EventBus 订阅者执行异常: {err}")
task = asyncio.create_task(_run_handler())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
else:
try:
handler(event)
except Exception as err:
logger.error(f"EventBus 订阅者执行异常: {err}")
if not self._finished:
await self._queue.put(event)
async def end(self):
"""
结束事件总线。等待所有后台任务执行完毕,并向队列中投放结束标记以终止异步迭代。
"""
if self._background_tasks:
tasks = list(self._background_tasks)
await asyncio.gather(*tasks, return_exceptions=True)
self._finished = True
await self._queue.put(None)
async def __aiter__(self):
"""
支持异步迭代,可通过 async for 循环消费事件总线中的事件。
返回:
AsyncIterator: 异步事件流生成器。
"""
while True:
event = await self._queue.get()
if event is None:
break
yield event
+103
View File
@@ -0,0 +1,103 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Generic, cast
from typing_extensions import TypeVar
from jinja2 import Environment
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_dump
if TYPE_CHECKING:
from zhenxun.services.ai.run.context import RunContext
AgentDepsT = TypeVar("AgentDepsT", default=Any)
class PromptTemplate(Generic[AgentDepsT]):
"""
核心 Prompt 渲染引擎。
基于 Jinja2 提供强大的模板变量替换功能。
支持沙盒隔离的自定义过滤器(filters)、全局函数(globals),
"""
def __init__(
self,
template_string: str,
custom_filters: dict[str, Callable] | None = None,
custom_globals: dict[str, Any] | None = None,
):
"""
初始化 Prompt 渲染引擎。
参数:
template_string: Jinja2 模板格式的提示词原文本。
custom_filters: 挂载到 Jinja2 渲染环境的自定义过滤器字典,默认 None。
custom_globals: 挂载到 Jinja2 渲染环境的全局变量或辅助函数字典,默认 None。
"""
self.template_string = template_string
self._env = Environment(autoescape=False)
if custom_filters:
self._env.filters.update(custom_filters)
if custom_globals:
self._env.globals.update(custom_globals)
try:
self._template = (
self._env.from_string(template_string) if template_string else None
)
except Exception as e:
logger.error(f"PromptTemplate 模板语法编译失败: {e}")
self._template = None
def format_with_context(self, context: "RunContext[AgentDepsT]") -> str:
"""从运行上下文中自动提取依赖和状态字典,用于模板渲染"""
vars_dict: dict[str, Any] = {
"ctx": context,
"ctx_deps": context.deps,
"ctx_state": context.state,
"ctx_shared": context.shared_state,
}
flat_deps = {}
if context.deps is not None:
if hasattr(context.deps, "model_dump"):
flat_deps.update(model_dump(cast(Any, context.deps), exclude_none=True))
elif hasattr(context.deps, "__dict__"):
flat_deps.update(context.deps.__dict__)
elif isinstance(context.deps, dict):
flat_deps.update(context.deps)
vars_dict.update(flat_deps)
if context.state:
collisions = set(flat_deps.keys()) & set(context.state.keys())
if collisions:
logger.warning(
f"Prompt 模板变量发生名称冲突: {collisions}。"
"`state` 已覆盖 `deps` 中的同名变量。"
"建议在模板中使用安全隔离对象获取(如 {{ ctx_state.xxx }})。"
)
vars_dict.update(context.state)
return self.render(**vars_dict)
def render(self, **variables: Any) -> str:
if not self._template:
return self.template_string or ""
try:
return self._template.render(**variables)
except Exception as e:
logger.error(
f"Jinja2 Prompt 模板渲染失败!\n"
f"错误信息: {e}\n"
f"请检查传入的变量或 Prompt 模板语法是否有误。\n"
f"模板原内容截断: {self.template_string[:150]}...",
e=e,
)
raise ValueError(f"Prompt 模板渲染异常: {e}") from e
def __str__(self) -> str:
return self.template_string