✨ 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,688 @@
from abc import abstractmethod
from collections.abc import Callable
from typing import Any, Generic, TypeVar
from pydantic import BaseModel, Field
from zhenxun.services.ai.context.memory.storage.interfaces import (
BaseMemoryReducer,
)
from zhenxun.services.ai.core.engine.token_counter import token_counter
from zhenxun.services.ai.core.messages import (
AudioPart,
FilePart,
ImagePart,
LLMMessage,
SystemMessage,
TextPart,
VideoPart,
)
from zhenxun.services.ai.llm.manager import get_default_model
from zhenxun.services.log import logger
from zhenxun.utils.pydantic_compat import model_copy
class MultimodalPlaceholderReducer(BaseMemoryReducer):
"""视觉媒体降级:将超过一定轮数的老图片/视频替换为 <图片> 占位符文本"""
def __init__(self, window_size: int = 5):
"""
初始化多模态占位符修剪器。
参数:
window_size: 多模态视窗大小。在保留最近的指定数量的多模态消息对后,
超出的旧消息中多模态内容(如图片、视频等)将被替换为占位文本。
"""
self.window_size = window_size
@staticmethod
def apply_multimodal_placeholder(message: LLMMessage) -> LLMMessage:
sanitized_message = model_copy(message, deep=False)
new_content_parts = []
for part in sanitized_message.content:
if isinstance(part, ImagePart):
new_content_parts.append(TextPart(text="<图片>"))
elif isinstance(part, AudioPart):
new_content_parts.append(TextPart(text="<音频>"))
elif isinstance(part, VideoPart):
new_content_parts.append(TextPart(text="<视频>"))
elif isinstance(part, FilePart):
new_content_parts.append(TextPart(text="<文件>"))
elif isinstance(part, TextPart) and "[多模态内容:" in part.text:
new_content_parts.append(TextPart(text="<图片>"))
else:
new_content_parts.append(part)
merged_parts = []
for part in new_content_parts:
if (
isinstance(part, TextPart)
and merged_parts
and isinstance(merged_parts[-1], TextPart)
):
new_text = (merged_parts[-1].text or "") + " " + (part.text or "")
merged_parts[-1] = TextPart(text=new_text.strip())
else:
merged_parts.append(part)
sanitized_message.content = merged_parts
sanitized_message.token_cost = None
return sanitized_message
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
if self.window_size <= 0:
return messages, False, current_tokens
processed_messages = []
user_multimodal_count = 0
changed = False
for msg in reversed(messages):
has_multimodal = False
if isinstance(msg.content, list):
has_multimodal = any(
isinstance(p, ImagePart | AudioPart | VideoPart | FilePart)
or (isinstance(p, TextPart) and "[多模态内容:" in p.text)
for p in msg.content
)
if has_multimodal:
if msg.role == "user":
user_multimodal_count += 1
if user_multimodal_count > self.window_size:
processed_messages.append(self.apply_multimodal_placeholder(msg))
changed = True
else:
processed_messages.append(msg)
else:
processed_messages.append(msg)
if not changed:
return messages, False, current_tokens
processed_messages.reverse()
new_tokens = token_counter.count_context(
processed_messages, model_name, base_overhead
)
return processed_messages, True, new_tokens
class MessageDropper(BaseMemoryReducer):
"""消息丢弃器:在 Token 超过阈值时丢弃最早的非置顶消息对。"""
def __init__(self, trigger_tokens: int = 4000):
"""
初始化消息丢弃器。
参数:
trigger_tokens: 触发丢弃策略的 Token 阈值上限。
当当前对话 Token 总数超过此值时,将触发硬截断。
"""
self.trigger_tokens = trigger_tokens
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
if current_tokens <= self.trigger_tokens:
return messages, False, current_tokens
logger.info(
"✂️ [MemoryCompression] 触发硬截断丢弃策略 | 原因: "
f"当前 Token 预估 ({current_tokens}) 仍超过硬性上限 ({self.trigger_tokens})," # noqa: E501
"开始丢弃最旧的历史对话..."
)
new_messages = list(messages)
changed = False
while current_tokens > self.trigger_tokens:
user_indices = [
i
for i, m in enumerate(new_messages)
if m.role == "user"
and not (m.metadata and m.metadata.get("pinned", False))
]
if len(user_indices) < 2:
break
start_idx = user_indices[0]
end_idx = user_indices[1]
del new_messages[start_idx:end_idx]
changed = True
current_tokens = token_counter.count_context(
new_messages, model_name, base_overhead
)
return new_messages, changed, current_tokens
class ToolPrunerReducer(BaseMemoryReducer):
"""工具结果修剪器:纯粹计算工具输出的 Token 和轮数,超标时剔除老旧工具返回结果"""
def __init__(
self,
keep_recent_turns: int = 3,
trigger_tokens: int = 4000,
max_turns: int = 0,
):
"""
初始化工具结果修剪器。
参数:
keep_recent_turns: 保留最近的工具调用轮数(不进行内容截断的轮数)。
trigger_tokens: 触发工具修剪策略的工具总 Token 阈值上限。
max_turns: 触发工具修剪的最大工具调用轮数上限。若为 0,则不限制轮数。
"""
self.keep_recent_turns = keep_recent_turns
self.trigger_tokens = trigger_tokens
self.max_turns = max_turns
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
tool_msgs = [m for m in messages if m.role == "tool"]
tool_turns = len(tool_msgs)
if tool_turns == 0:
return messages, False, current_tokens
tool_tokens = sum(token_counter.count_message(m, model_name) for m in tool_msgs)
is_token_exceeded = tool_tokens > self.trigger_tokens
is_turn_exceeded = self.max_turns > 0 and tool_turns > self.max_turns
if not (is_token_exceeded or is_turn_exceeded):
return messages, False, current_tokens
reasons = []
if is_token_exceeded:
reasons.append(f"工具Token超标 ({tool_tokens} > {self.trigger_tokens})")
if is_turn_exceeded:
reasons.append(f"工具调用轮数超限 ({tool_turns} > {self.max_turns})")
logger.info(
f"✂️ [MemoryCompression] 触发工具结果修剪策略 | 原因: {' 且 '.join(reasons)}"
)
from zhenxun.services.ai.core.messages import ToolReturnPart
new_messages = []
tools_kept = 0
changed = False
for msg in reversed(messages):
if msg.role != "tool":
new_messages.append(msg)
continue
if tools_kept < self.keep_recent_turns:
tools_kept += 1
new_messages.append(msg)
continue
new_content = []
part_changed = False
for p in msg.content:
if isinstance(p, ToolReturnPart):
old_len = len(str(p.output))
new_p = model_copy(
p,
update={
"output": f"[数据过载自动截断 - 原长度: {old_len} 字符]"
},
)
new_content.append(new_p)
part_changed = True
changed = True
else:
new_content.append(p)
if part_changed:
new_msg = model_copy(
msg, update={"content": new_content, "token_cost": None}
)
new_messages.append(new_msg)
else:
new_messages.append(msg)
if not changed:
return messages, False, current_tokens
new_messages.reverse()
new_total = token_counter.count_context(new_messages, model_name, base_overhead)
return new_messages, True, new_total
class AbstractSummarizerReducer(BaseMemoryReducer):
"""抽象总结压缩器:提取阈值判断与上下文分流的公共逻辑"""
def __init__(
self,
strategy_name: str,
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
):
"""
初始化抽象总结压缩基类。
参数:
strategy_name: 压缩策略的名称,用于日志输出和追踪。
keep_recent_turns: 压缩时需要保留的最新的对话轮数(不参与总结的轮数)。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩大模型请求的模型名称,若为 None 则使用默认模型。
""" # noqa: E501
self.strategy_name = strategy_name
self.keep_recent_turns = keep_recent_turns
self.trigger_tokens = trigger_tokens
self.max_turns = max_turns
self.summarization_model = summarization_model
@abstractmethod
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
"""由子类实现具体的 LLM 调用逻辑,返回新的总结消息"""
pass
async def reduce(self, messages, current_tokens, model_name, base_overhead=0):
user_turns = sum(
1
for m in messages
if m.role == "user"
and not (m.metadata and m.metadata.get("is_summary", False))
)
is_token_exceeded = current_tokens > self.trigger_tokens
is_turn_exceeded = (
self.max_turns is not None
and self.max_turns > 0
and user_turns > self.max_turns
)
if not (is_token_exceeded or is_turn_exceeded):
return messages, False, current_tokens
reasons = []
if is_token_exceeded:
reasons.append(f"Token 预估超限 ({current_tokens} > {self.trigger_tokens})")
if is_turn_exceeded:
reasons.append(f"有效对话轮次超限 ({user_turns} > {self.max_turns})")
logger.info(
f"🔄 [MemoryCompression] 触发{self.strategy_name}策略 | 原因: "
f"{' 且 '.join(reasons)}"
)
pinned_msgs, working_msgs, prev_summary = [], [], ""
for msg in messages:
is_pinned = isinstance(msg, SystemMessage) or (
msg.metadata and msg.metadata.get("pinned", False)
)
if msg.metadata and msg.metadata.get("is_summary", False):
prev_summary = msg.extract_text
elif is_pinned:
pinned_msgs.append(msg)
else:
working_msgs.append(msg)
user_indices = [i for i, m in enumerate(working_msgs) if m.role == "user"]
if len(user_indices) <= self.keep_recent_turns:
return messages, False, current_tokens
split_idx = (
user_indices[-self.keep_recent_turns]
if self.keep_recent_turns > 0
else len(working_msgs)
)
to_summarize = working_msgs[:split_idx]
to_keep = working_msgs[split_idx:]
new_summary_msg = await self._execute_summarization(to_summarize, prev_summary)
if not new_summary_msg:
return messages, False, current_tokens
new_messages = [*pinned_msgs, new_summary_msg, *to_keep]
return (
new_messages,
True,
token_counter.count_context(new_messages, model_name, base_overhead),
)
class LLMSummarizerReducer(AbstractSummarizerReducer):
"""大模型总结压缩器:将较早的历史对话记录通过 LLM 压缩合并为一段文本摘要。"""
def __init__(
self,
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
summarization_prompt: str = (
"请概括以下对话内容,保留关键的约束条件、用户偏好、"
"已完成的任务状态和未解决的问题。"
),
):
"""
初始化大模型总结压缩器。
参数:
keep_recent_turns: 压缩时需要保留的最新的对话轮数。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩的大模型名称。
summarization_prompt: 发送给大模型的总结引导 Prompt 提示词。
"""
super().__init__(
strategy_name="历史对话合并总结",
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
)
self.summarization_prompt = summarization_prompt
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
prompt_text = f"### 📋 [对话摘要任务]\n{self.summarization_prompt}\n\n"
if prev_summary:
prompt_text += "#### önceki_summary (参考先前的快照):\n"
prompt_text += f"> {prev_summary}\n\n"
prompt_text += "#### 待处理的历史消息流:\n"
for m in to_summarize:
c_str = m.extract_text[:1500]
speaker = m.source_name if m.source_name else m.role.capitalize()
prompt_text += f"[{speaker}]: {c_str}\n"
prompt_text += "</需要合并的旧对话记录>\n"
from zhenxun.services.ai.llm.api import chat
try:
model_to_use = self.summarization_model or get_default_model("chat")
response = await chat(
prompt_text,
model=model_to_use,
instruction="你是后台记忆整理引擎。请客观、简明输出当前对话全局摘要。",
)
new_summary_msg = LLMMessage.assistant_text_response(
f"【历史对话摘要记忆】\n{response.text}"
)
new_summary_msg.metadata = {"is_summary": True, "pinned": True}
return new_summary_msg
except Exception as e:
logger.error(
f"[{self.__class__.__name__}] 压缩总结调用失败,已跳过本次压缩: {e}"
)
return None
_T_Summary = TypeVar("_T_Summary", bound=BaseModel)
class StructuredSummaryReducer(AbstractSummarizerReducer, Generic[_T_Summary]):
"""结构化总结压缩器:基于 JSON Schema 格式化抽取长上下文状态信息并合并"""
def __init__(
self,
response_model: type[_T_Summary],
prompt_template: str,
format_callback: Callable[[_T_Summary], str],
keep_recent_turns: int = 0,
trigger_tokens: int = 4000,
max_turns: int | None = None,
summarization_model: str | None = None,
instruction: str = (
"请提取并合并先前的状态和最新的对话内容,保持精简,不要编造事实"
),
):
"""
初始化结构化总结压缩器。
参数:
response_model: 接收结构化输出的 Pydantic 模型类,需继承自 BaseModel。
prompt_template: 用于抽取合并状态的 Prompt 模板,包含 {prev_summary} 和 {dialogue} 占位符。
format_callback: 格式化回调函数,用于将结构化 Pydantic 响应对象转换为便于大模型阅读的字符串。
keep_recent_turns: 压缩时需要保留的最新的对话轮数。
trigger_tokens: 触发总结策略的 Token 阈值上限。
max_turns: 触发总结策略的最大对话轮数上限。
summarization_model: 用于执行总结压缩的大模型名称。
instruction: 指导大模型生成结构化数据时的系统指令说明。
""" # noqa: E501
super().__init__(
strategy_name="结构化状态抽取压缩",
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
)
self.response_model = response_model
self.prompt_template = prompt_template
self.format_callback = format_callback
self.instruction = instruction
async def _execute_summarization(
self, to_summarize: list[LLMMessage], prev_summary: str
) -> LLMMessage | None:
dialogue_text = ""
for m in to_summarize:
c_str = m.extract_text[:1500]
speaker = m.source_name if m.source_name else m.role.capitalize()
dialogue_text += f"[{speaker}]: {c_str}\n"
prompt_text = self.prompt_template.format(
prev_summary=prev_summary, dialogue=dialogue_text
)
from zhenxun.services.ai.llm.api import generate_structured
try:
model_to_use = self.summarization_model or get_default_model("chat")
summary_obj = await generate_structured(
prompt_text,
response_model=self.response_model,
model=model_to_use,
instruction=self.instruction,
)
summary_text = self.format_callback(summary_obj)
new_summary_msg = LLMMessage.assistant_text_response(
f"【历史状态摘要记忆】\n{summary_text}"
)
new_summary_msg.metadata = {"is_summary": True, "pinned": True}
return new_summary_msg
except Exception as e:
logger.error(
f"[{self.__class__.__name__}] 结构化总结失败,已跳过本次压缩: {e}"
)
return None
class CondenserPipeline:
"""上下文压缩流水线:按顺序依次执行各阶段的记忆压缩减项。"""
def __init__(self, reducers: list[BaseMemoryReducer]):
"""
初始化上下文压缩流水线。
参数:
reducers: 压缩减项器列表,将按顺序对记忆进行多阶段修剪和压缩。
"""
self.reducers = reducers
@classmethod
def create_from_configs(
cls, memory_config: Any, model_name: str
) -> "CondenserPipeline":
"""基于全局和局部配置组装压缩管线工厂方法"""
from zhenxun.services.ai.config import get_llm_config
from zhenxun.services.ai.llm.system.capabilities import get_model_capabilities
config = get_llm_config().context_settings
pipeline_reducers = []
caps = get_model_capabilities(model_name)
vw = config.vision_window_size
if memory_config and memory_config.compression.vision_window is not None:
vw = memory_config.compression.vision_window
if vw > 0:
pipeline_reducers.append(MultimodalPlaceholderReducer(window_size=vw))
tp = config.tool_pruning
if tp.enable:
tp_limit = (
int(caps.max_input_tokens * tp.trigger_threshold)
if tp.trigger_threshold <= 1.0
else int(tp.trigger_threshold)
)
pipeline_reducers.append(
ToolPrunerReducer(
keep_recent_turns=tp.keep_recent_turns,
trigger_tokens=tp_limit,
max_turns=tp.max_history_turns,
)
)
policy = memory_config.compression.policy if memory_config else None
if policy is not None:
pipeline_reducers.extend(policy)
else:
threshold = config.llm_summary.trigger_threshold
if memory_config and memory_config.compression.threshold is not None:
threshold = memory_config.compression.threshold
limit = (
int(caps.max_input_tokens * threshold)
if threshold <= 1.0
else int(threshold)
)
max_turns = config.llm_summary.max_history_turns
if (
memory_config
and memory_config.compression.max_history_turns is not None
):
max_turns = memory_config.compression.max_history_turns
if config.llm_summary.enable:
pipeline_reducers.extend(
MemoryPolicy.llm_summarize(
trigger_tokens=limit,
max_turns=max_turns,
keep_recent_turns=config.llm_summary.keep_recent_turns,
summarization_model=config.llm_summary.summarization_model,
summarization_prompt=config.llm_summary.summarization_prompt,
)
)
else:
pipeline_reducers.extend(MemoryPolicy.unlimited())
return cls(pipeline_reducers)
async def run(
self, messages, model_name, base_overhead=0
) -> tuple[list[LLMMessage], bool]:
current_tokens = token_counter.count_context(
messages, model_name, base_overhead
)
current_messages = messages
any_changed = False
for reducer in self.reducers:
current_messages, changed, current_tokens = await reducer.reduce(
current_messages,
current_tokens,
model_name,
base_overhead,
)
if changed:
any_changed = True
return current_messages, any_changed
class MemoryPolicy:
"""
记忆策略工厂 (Strategy Factory Facade)。
为开发者提供开箱即用的上下文压缩管线组装方案。
"""
@staticmethod
def unlimited() -> list[BaseMemoryReducer]:
"""无限制模式。不进行任何形式的截断和总结,适用于短对话或纯 Agent 内部流转。"""
return []
@staticmethod
def llm_summarize(
trigger_tokens: int = 4000,
max_turns: int | None = None,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
summarization_prompt: str = (
"请概括以下对话内容,保留关键的约束条件、用户偏好、"
"已完成的任务状态和未解决的问题。"
),
) -> list[BaseMemoryReducer]:
"""LLM 总结压缩模式。Token 达标后,自动将历史对话合并为一段 Summary。"""
return [
LLMSummarizerReducer(
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
summarization_prompt=summarization_prompt,
),
MessageDropper(trigger_tokens=trigger_tokens),
]
@staticmethod
def structured_summarize(
trigger_tokens: int = 4000,
max_turns: int | None = None,
keep_recent_turns: int = 0,
summarization_model: str | None = None,
response_model: type[BaseModel] | None = None,
prompt_template: str | None = None,
format_callback: Callable[[Any], str] | None = None,
) -> list[BaseMemoryReducer]:
"""结构化总结压缩模式。使用 JSON Schema 强制大模型提取核心状态。"""
class DefaultStateSummary(BaseModel):
user_context: str = Field(
description="用户的核心意图、诉求、人设或长期记忆规则。"
)
completed_tasks: str = Field(description="已完成的操作或已经确认的情节。")
pending_tasks: str = Field(description="正在进行中的任务或尚未解答的问题。")
current_state: str = Field(
description="当前状态,如重要变量、玩家血量、关键物品坐标等。"
)
def default_format(obj: DefaultStateSummary) -> str:
return (
f"👤 用户上下文: {obj.user_context}\n"
f"✅ 已完成/确认: {obj.completed_tasks}\n"
f"⏳ 待处理/疑问: {obj.pending_tasks}\n"
f"📌 当前状态: {obj.current_state}"
)
default_prompt = (
"你是一个专门用于长上下文状态压缩的引擎。请阅读以下先前的总结和旧对话,"
"提取核心状态信息,并合并它们。\n\n"
"<之前的状态摘要>\n{prev_summary}\n</之前的状态摘要>\n\n"
"<需要合并的旧对话记录>\n"
"{dialogue}"
"</需要合并的旧对话记录>\n"
)
return [
StructuredSummaryReducer(
response_model=response_model or DefaultStateSummary,
prompt_template=prompt_template or default_prompt,
format_callback=format_callback or default_format,
keep_recent_turns=keep_recent_turns,
trigger_tokens=trigger_tokens,
max_turns=max_turns,
summarization_model=summarization_model,
),
MessageDropper(trigger_tokens=trigger_tokens),
]