✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)

* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架

- 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片
- 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式
- 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统
- 【新增】引入基于 Docker 的安全代码执行沙箱环境
- 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务
- 【新增】引入输入输出安全合规护栏与自愈反思机制
- 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等)
- 【优化】优化日志脱敏与 Token 预估机制
- 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令

* 🔧 chore(deps): 更新项目依赖与配置

- 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt
- 在 pyright 配置中设置 reportMissingImports 为 none
- 调整 .gitignore 中 resources 目录的忽略规则

* ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出

- 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束
- 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用
- 调整指令处理器(Directive)的返回值为 `tool_res.output`
- 修复部分类型检查警告并优化联合类型判断语法

* ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制

- 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用
- 移除通过 `context.state` 传递魔术变量的隐式控制流设计
- 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult`
- 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令
- 补全 `MessageBuilder` 中部分核心方法的文档注释

* 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑

-【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值
-【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题
-【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求
-【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建
-【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常

* 📝 docs(core): 补充核心模块初始化方法的文档注释

* 🚨 auto fix by pre-commit hooks

---------

Co-authored-by: webjoin111 <455457521@qq.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
@@ -0,0 +1,21 @@
from .backends import (
AbstractMemoryRecord,
AbstractSlotRecord,
InMemoryChatContext,
MemoryScope,
TortoiseChatContext,
TortoiseSlotContext,
get_orm_chat_context,
get_orm_slot_context,
)
__all__ = [
"AbstractMemoryRecord",
"AbstractSlotRecord",
"InMemoryChatContext",
"MemoryScope",
"TortoiseChatContext",
"TortoiseSlotContext",
"get_orm_chat_context",
"get_orm_slot_context",
]
@@ -0,0 +1,465 @@
import asyncio
import base64
from collections.abc import Callable
import datetime
import time
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from zhenxun.services.ai.context.rag.engine import ScopedRAGClient
from nonebot.utils import is_coroutine_callable
from tortoise import fields
from tortoise.timezone import now
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseChatContext,
BaseSlotContext,
)
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.context.rag.models import BaseRecord, SearchResult
from zhenxun.services.ai.core.messages import (
AssistantMessage,
LLMContentPart,
LLMMessage,
SystemMessage,
ToolMessage,
UserMessage,
)
from zhenxun.services.ai.utils.scope import ScopeSelector
from zhenxun.services.db_context import Model
from zhenxun.utils.pydantic_compat import TypeAdapter, model_dump
class DBMessageSerializer:
"""将 LLMMessage 与数据库 JSON 格式进行序列化/反序列化的帮助类"""
@staticmethod
def deserialize_content(content_raw: Any) -> list[LLMContentPart]:
from zhenxun.services.ai.core.messages import TextPart
content_parts: list[LLMContentPart] = []
if isinstance(content_raw, list):
adapter = TypeAdapter(LLMContentPart)
for p in content_raw:
if isinstance(p, dict):
for k in list(p.keys()):
if k.startswith("_is_b64_"):
orig_k = k[8:]
if orig_k in p and isinstance(p[orig_k], str):
p[orig_k] = base64.b64decode(p[orig_k])
p.pop(k, None)
content_parts.append(adapter.validate_python(p))
elif isinstance(content_raw, str):
content_parts.append(TextPart(text=content_raw))
return content_parts
@staticmethod
def serialize_content(content_payload: Any) -> list[dict[str, Any]]:
from pathlib import Path
if isinstance(content_payload, str):
return [{"type": "text", "text": content_payload}]
elif isinstance(content_payload, list):
processed_content = []
for p in content_payload:
p_dump = (
model_dump(p, exclude_none=True)
if hasattr(p, "model_dump")
else (p.copy() if isinstance(p, dict) else p)
)
if isinstance(p_dump, dict):
for k, v in list(p_dump.items()):
if isinstance(v, bytes):
p_dump[k] = base64.b64encode(v).decode("utf-8")
p_dump[f"_is_b64_{k}"] = True
elif isinstance(v, Path):
p_dump[k] = str(v)
processed_content.append(p_dump)
return (
processed_content
if processed_content
else [
{"type": "text", "text": "[仅包含思维链或工具调度,无实质文本输出]"}
]
)
return []
class MemoryScope:
"""长期记忆的作用域视图与 RAG 管线。"""
def __init__(
self,
rag_client: "ScopedRAGClient",
):
self.rag_client = rag_client
self._background_tasks: set[Any] = set()
async def remember(
self,
session: SessionMetadata,
content: str,
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
) -> None:
"""通过 RAG Ingestion Pipeline 完成记忆落盘"""
meta = metadata.copy() if metadata else {}
meta.update(
{
"scope": session.scope_prefix,
"importance": importance,
"created_at": time.time(),
}
)
record = BaseRecord(content=content, metadata=meta)
await self.rag_client.ingest([record])
async def recall(
self,
session: SessionMetadata,
query: str,
limit: int = 10,
metadata_filter: dict[str, Any] | None = None,
) -> list[SearchResult]:
"""委托至 Retriever 检索与重排,并触发读时惰性强化"""
matches = await self.rag_client.search(
query=query,
limit=limit,
scopes=session.accessible_scopes,
metadata_filters=metadata_filter,
)
if matches:
task = asyncio.create_task(
self._reinforce_memories([m.record for m in matches])
)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return matches
async def update(
self,
session: SessionMetadata,
record_id: str,
new_content: str,
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
) -> bool:
"""原子更新:通过先删后插,确保底层向量(Embedding)能根据新文本被正确刷新"""
deleted_count = await self.forget(session, record_ids=[record_id])
if deleted_count > 0:
await self.remember(
session=session,
content=new_content,
importance=importance,
metadata=metadata,
)
return True
return False
async def forget(
self, session: SessionMetadata, record_ids: list[str] | None = None
) -> int:
return await self.rag_client.delete(
record_ids=record_ids,
)
async def _reinforce_memories(self, records: list[BaseRecord]):
import time
now = time.time()
for r in records:
r.metadata["access_count"] = r.metadata.get("access_count", 0) + 1
r.metadata["last_accessed_at"] = now
await self.rag_client.storage.update(r)
class InMemoryChatContext(BaseChatContext):
def __init__(self):
self._messages: dict[str, list[LLMMessage]] = {}
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
return list(self._messages.get(session.session_id, []))
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
results = []
for msg in self._messages.get(session.session_id, []):
if query in msg.extract_text:
results.append(msg)
if len(results) >= limit:
break
return results
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
if session.session_id not in self._messages:
self._messages[session.session_id] = []
self._messages[session.session_id].extend(messages)
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
self._messages[session.session_id] = list(messages)
async def clear(self, session: SessionMetadata) -> None:
self._messages.pop(session.session_id, None)
async def clear_by_query(self, query: ScopeSelector) -> None:
"""内存级:前缀匹配清理所有符合要求的短期会话"""
scope_prefix = query.scope_prefix
keys_to_delete = [
sid for sid in self._messages.keys() if sid.startswith(scope_prefix)
]
for sid in keys_to_delete:
self._messages.pop(sid, None)
class AbstractMemoryRecord(Model):
"""Tortoise ORM 短期记忆持久化基类 (Mixin)。"""
id = fields.UUIDField(pk=True, description="主键")
session_id = fields.CharField(max_length=255, index=True)
role = fields.CharField(max_length=32)
content = fields.JSONField()
api_context = fields.JSONField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
metadata = fields.JSONField(null=True)
class Meta: # type: ignore
abstract = True
class TortoiseChatContext(BaseChatContext):
def __init__(
self,
model_class: type[AbstractMemoryRecord],
custom_save_hook: Callable[
[AbstractMemoryRecord, LLMMessage, SessionMetadata], Any
]
| None = None,
):
self.model_class = model_class
self.custom_save_hook = custom_save_hook
def _row_to_message(self, row: AbstractMemoryRecord) -> LLMMessage:
content_parts = DBMessageSerializer.deserialize_content(row.content)
metadata: dict[str, Any] | None = (
row.metadata if isinstance(row.metadata, dict) else None
)
kwargs = {
"content": content_parts,
"metadata": metadata,
"created_at": row.created_at.timestamp() if row.created_at else time.time(),
}
role = row.role
if role == "system":
return cast(LLMMessage, SystemMessage(**kwargs))
elif role == "user":
return cast(LLMMessage, UserMessage(**kwargs))
elif role == "assistant":
return cast(LLMMessage, AssistantMessage(**kwargs))
elif role == "tool":
return cast(LLMMessage, ToolMessage(**kwargs))
return cast(LLMMessage, LLMMessage(role=role, **kwargs))
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
rows = (
await self.model_class.filter(session_id=session.session_id)
.order_by("created_at")
.all()
)
return [self._row_to_message(row) for row in rows]
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
rows = (
await self.model_class.filter(
session_id=session.session_id, content__icontains=query
)
.order_by("-created_at")
.limit(limit)
.all()
)
return [self._row_to_message(row) for row in reversed(rows)]
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
if not messages:
return
base_time = now()
last_msg = (
await self.model_class.filter(session_id=session.session_id)
.order_by("-created_at")
.first()
)
if last_msg and last_msg.created_at and last_msg.created_at >= base_time:
base_time = last_msg.created_at + datetime.timedelta(milliseconds=10)
orm_objects = []
for i, msg in enumerate(messages):
content_payload = DBMessageSerializer.serialize_content(msg.content)
msg_time = base_time + datetime.timedelta(milliseconds=i * 10)
orm_obj = self.model_class(
session_id=session.session_id,
role=msg.role,
content=content_payload,
api_context=None,
metadata=msg.metadata,
created_at=msg_time,
)
if self.custom_save_hook:
if is_coroutine_callable(self.custom_save_hook):
await self.custom_save_hook(orm_obj, msg, session)
else:
self.custom_save_hook(orm_obj, msg, session)
orm_objects.append(orm_obj)
if orm_objects:
await self.model_class.bulk_create(orm_objects)
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
await self.clear(session)
await self.add_messages(session, messages)
async def clear(self, session: SessionMetadata) -> None:
await self.model_class.filter(session_id=session.session_id).delete()
async def clear_by_query(self, query: ScopeSelector) -> None:
"""ORM 级:利用数据库 startswith 原生语法批量级联删除短期记忆"""
scope_prefix = query.scope_prefix
await self.model_class.filter(session_id__startswith=scope_prefix).delete()
def get_orm_chat_context(
model_class: type[AbstractMemoryRecord],
custom_save_hook: Callable[[AbstractMemoryRecord, LLMMessage, SessionMetadata], Any]
| None = None,
) -> TortoiseChatContext:
"""
[工厂方法] 供第三方开发者调用,
将 Tortoise ORM 表直接包装为对话历史记录系统。
"""
return TortoiseChatContext(
model_class=model_class, custom_save_hook=custom_save_hook
)
class AbstractSlotRecord(Model):
"""Tortoise ORM 记忆槽持久化基类 (Mixin)。"""
id = fields.CharField(
pk=True, max_length=128, description="复合主键: session_id + label"
)
session_id = fields.CharField(max_length=255, index=True)
label = fields.CharField(max_length=64, index=True)
content = fields.TextField()
size_limit = fields.IntField(default=2000)
pinned = fields.BooleanField(default=True)
scope = fields.CharField(max_length=255)
description = fields.CharField(max_length=255, default="")
created_at = fields.FloatField()
updated_at = fields.FloatField()
class Meta: # type: ignore
abstract = True
class TortoiseSlotContext(BaseSlotContext):
def __init__(self, model_class: type[AbstractSlotRecord]):
self.model_class = model_class
def _row_to_slot(self, row: AbstractSlotRecord) -> MemorySlot:
return MemorySlot(
label=row.label,
content=row.content,
size_limit=row.size_limit,
pinned=row.pinned,
scope=row.scope,
description=row.description,
created_at=row.created_at,
updated_at=row.updated_at,
)
async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes, label=label
).all()
row_map = {r.session_id: r for r in rows}
for scope in reversed(session.accessible_scopes):
if scope in row_map:
return self._row_to_slot(row_map[scope])
return None
async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None:
composite_id = f"{slot.scope}_{slot.label}"
await self.model_class.update_or_create(
id=composite_id,
defaults={
"session_id": slot.scope,
"label": slot.label,
"content": slot.content,
"size_limit": slot.size_limit,
"pinned": slot.pinned,
"scope": slot.scope,
"description": slot.description,
"created_at": slot.created_at,
"updated_at": slot.updated_at,
},
)
async def delete_slot(
self, session: SessionMetadata, label: str, scope: str
) -> None:
composite_id = f"{scope}_{label}"
await self.model_class.filter(id=composite_id).delete()
async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes, pinned=True
).all()
merged = {}
for scope in session.accessible_scopes:
for row in rows:
if row.session_id == scope:
merged[row.label] = self._row_to_slot(row)
return [s for s in merged.values() if s.content.strip()]
async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]:
rows = await self.model_class.filter(
session_id__in=session.accessible_scopes
).all()
merged = {}
for scope in session.accessible_scopes:
for row in rows:
if row.session_id == scope:
merged[row.label] = self._row_to_slot(row)
return list(merged.values())
async def clear_by_query(self, query: ScopeSelector) -> None:
scope_prefix = query.scope_prefix
await self.model_class.filter(session_id__startswith=scope_prefix).delete()
def get_orm_slot_context(model_class: type[AbstractSlotRecord]) -> TortoiseSlotContext:
"""
[工厂方法] 供第三方开发者调用,将 Tortoise ORM 表直接包装为记忆槽存储系统。
"""
return TortoiseSlotContext(model_class=model_class)
@@ -0,0 +1,110 @@
from abc import ABC, abstractmethod
from collections.abc import Sequence
from zhenxun.services.ai.context.memory.types import (
MemorySlot,
SessionMetadata,
)
from zhenxun.services.ai.core.messages import AgentMessage, LLMMessage
from zhenxun.services.ai.run.context import RunContext
from zhenxun.services.ai.utils.scope import ScopeSelector
class BaseChatContext(ABC):
"""短期对话历史记忆接口"""
@abstractmethod
async def get_messages(self, session: SessionMetadata) -> list[LLMMessage]:
"""获取当前会话的所有历史消息。"""
...
@abstractmethod
async def search(
self, query: str, session: SessionMetadata, limit: int = 10
) -> list[LLMMessage]:
"""根据查询词搜索当前会话的历史消息。"""
...
@abstractmethod
async def add_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
"""向当前会话追加消息。"""
...
@abstractmethod
async def set_messages(
self, session: SessionMetadata, messages: list[LLMMessage]
) -> None:
"""重置并设置当前会话的消息列表。"""
...
@abstractmethod
async def clear(self, session: SessionMetadata) -> None:
"""清空当前会话的历史消息。"""
...
@abstractmethod
async def clear_by_query(self, query: ScopeSelector) -> None:
"""根据条件领域查询对象清理对话历史。"""
...
class BaseSlotContext(ABC):
"""中期记忆槽持久化接口"""
@abstractmethod
async def get_slot(self, session: SessionMetadata, label: str) -> MemorySlot | None:
"""获取指定会话下特定标签的记忆槽。"""
...
@abstractmethod
async def set_slot(self, session: SessionMetadata, slot: MemorySlot) -> None:
"""设置或更新指定会话下的记忆槽。"""
...
@abstractmethod
async def delete_slot(
self, session: SessionMetadata, label: str, scope: str
) -> None:
"""删除指定会话下特定标签和作用域的记忆槽。"""
...
@abstractmethod
async def list_pinned_slots(self, session: SessionMetadata) -> list[MemorySlot]:
"""列出当前会话所有固定的记忆槽。"""
...
@abstractmethod
async def list_all_slots(self, session: SessionMetadata) -> list[MemorySlot]:
"""列出当前会话的所有记忆槽(包括未置顶的)。"""
...
@abstractmethod
async def clear_by_query(self, query: ScopeSelector) -> None:
"""根据条件领域查询对象清理记忆槽。"""
...
class BaseMemoryReducer(ABC):
"""记忆压缩器基类"""
@abstractmethod
async def reduce(
self,
messages: list[LLMMessage],
current_tokens: int,
model_name: str,
base_overhead: int = 0,
) -> tuple[list[LLMMessage], bool, int]:
"""对消息列表进行压缩处理。"""
...
class BaseMemoryIngestionMiddleware(ABC):
"""记忆入库中间件基类,在写入数据库前拦截并修改/清洗消息"""
@abstractmethod
async def process(
self, messages: Sequence[AgentMessage], context: RunContext
) -> list[AgentMessage]: ...