feat: 更新配置版本至 8.4.0,新增工具筛选相关字段和方法

This commit is contained in:
DrSmoothl
2026-03-31 00:03:49 +08:00
parent 42dbd5462a
commit 5ac088ded8
3 changed files with 394 additions and 7 deletions

View File

@@ -57,7 +57,7 @@ CONFIG_DIR: Path = PROJECT_ROOT / "config"
BOT_CONFIG_PATH: Path = (CONFIG_DIR / "bot_config.toml").resolve().absolute()
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
MMC_VERSION: str = "1.0.0"
CONFIG_VERSION: str = "8.3.0"
CONFIG_VERSION: str = "8.2.0"
MODEL_CONFIG_VERSION: str = "1.13.1"
logger = get_logger("config")

View File

@@ -1550,6 +1550,35 @@ class MaiSakaConfig(ConfigBase):
)
"""每个入站消息的最大内部规划轮数"""
tool_filter_task_name: str = Field(
default="utils",
json_schema_extra={
"x-widget": "input",
"x-icon": "sparkles",
},
)
"""工具筛选预判使用的模型任务名"""
tool_filter_threshold: int = Field(
default=20,
ge=1,
json_schema_extra={
"x-widget": "input",
"x-icon": "filter",
},
)
"""当可用工具总数超过该阈值时,先进行一轮工具筛选"""
tool_filter_max_keep: int = Field(
default=5,
ge=1,
json_schema_extra={
"x-widget": "input",
"x-icon": "list-filter",
},
)
"""工具筛选阶段最多保留的非内置工具数量"""
terminal_image_preview: bool = Field(
default=False,
json_schema_extra={

View File

@@ -8,9 +8,11 @@ from time import perf_counter
from typing import Any, Dict, List, Optional, Sequence
import asyncio
import json
import random
from PIL import Image as PILImage
from pydantic import BaseModel, Field as PydanticField
from rich.console import Group, RenderableType
from rich.panel import Panel
from rich.pretty import Pretty
@@ -22,10 +24,11 @@ from src.common.data_models.message_component_data_model import MessageSequence,
from src.common.logger import get_logger
from src.common.prompt_i18n import load_prompt
from src.config.config import global_config
from src.core.tooling import ToolRegistry
from src.core.tooling import ToolRegistry, ToolSpec
from src.know_u.knowledge import extract_category_ids_from_result
from src.llm_models.model_client.base_client import BaseClient
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
from src.llm_models.payload_content.resp_format import RespFormat, RespFormatType
from src.llm_models.payload_content.tool_option import ToolCall, ToolDefinitionInput, ToolOption, normalize_tool_options
from src.services.llm_service import LLMServiceClient
@@ -43,6 +46,13 @@ class ChatResponse:
raw_message: AssistantMessage
class ToolFilterSelection(BaseModel):
"""工具筛选响应。"""
selected_tool_names: list[str] = PydanticField(default_factory=list)
"""经过预筛后保留的候选工具名称列表。"""
logger = get_logger("maisaka_chat_loop")
@@ -76,6 +86,10 @@ class MaisakaChatLoopService:
else:
self._chat_system_prompt = chat_system_prompt
self._llm_chat = LLMServiceClient(task_name="planner", request_type="maisaka_planner")
self._tool_filter_llm = LLMServiceClient(
task_name=global_config.maisaka.tool_filter_task_name,
request_type="maisaka_tool_filter",
)
@property
def personality_prompt(self) -> str:
@@ -156,7 +170,15 @@ class MaisakaChatLoopService:
self._interrupt_flag = interrupt_flag
def _build_request_messages(self, selected_history: List[LLMContextMessage]) -> List[Message]:
"""构造发给大模型的消息列表。"""
"""构造发给大模型的消息列表。
Args:
selected_history: 已选中的上下文消息列表。
Returns:
List[Message]: 发送给大模型的消息列表。
"""
messages: List[Message] = []
system_msg = MessageBuilder().set_role(RoleType.System)
system_msg.add_text_content(self._chat_system_prompt)
@@ -169,6 +191,248 @@ class MaisakaChatLoopService:
return messages
@staticmethod
def _is_builtin_tool_spec(tool_spec: ToolSpec) -> bool:
"""判断一个工具是否属于默认内置工具。
Args:
tool_spec: 待判断的工具声明。
Returns:
bool: 是否为默认内置工具。
"""
return tool_spec.provider_type == "builtin" or tool_spec.provider_name == "maisaka_builtin"
@classmethod
def _split_builtin_and_candidate_tools(
cls,
tool_specs: List[ToolSpec],
) -> tuple[List[ToolSpec], List[ToolSpec]]:
"""拆分内置工具与可筛选工具列表。
Args:
tool_specs: 当前全部工具声明。
Returns:
tuple[List[ToolSpec], List[ToolSpec]]: `(内置工具, 可筛选工具)`。
"""
builtin_tool_specs: List[ToolSpec] = []
candidate_tool_specs: List[ToolSpec] = []
for tool_spec in tool_specs:
if cls._is_builtin_tool_spec(tool_spec):
builtin_tool_specs.append(tool_spec)
else:
candidate_tool_specs.append(tool_spec)
return builtin_tool_specs, candidate_tool_specs
@staticmethod
def _truncate_tool_filter_text(text: str, max_length: int = 180) -> str:
"""截断工具筛选阶段展示的文本。
Args:
text: 原始文本。
max_length: 最长保留字符数。
Returns:
str: 截断后的文本。
"""
normalized_text = text.strip()
if len(normalized_text) <= max_length:
return normalized_text
return f"{normalized_text[: max_length - 1]}"
def _build_tool_filter_prompt(
self,
selected_history: List[LLMContextMessage],
candidate_tool_specs: List[ToolSpec],
max_keep: int,
) -> str:
"""构造小模型工具预筛选提示词。
Args:
selected_history: 已选中的对话上下文。
candidate_tool_specs: 非内置候选工具列表。
max_keep: 最多保留的候选工具数量。
Returns:
str: 用于工具预筛的小模型提示词。
"""
history_lines: List[str] = []
for message in selected_history[-10:]:
plain_text = message.processed_plain_text.strip()
if not plain_text:
continue
history_lines.append(
f"- {message.role}: {self._truncate_tool_filter_text(plain_text, max_length=200)}"
)
if history_lines:
history_section = "\n".join(history_lines)
else:
history_section = "- 当前没有可用的对话上下文。"
tool_lines = [
f"- {tool_spec.name}: {tool_spec.brief_description.strip() or '无简要描述'}"
for tool_spec in candidate_tool_specs
]
tool_section = "\n".join(tool_lines) if tool_lines else "- 当前没有候选工具。"
return (
"你是 Maisaka 的工具预筛选器。\n"
"你的任务是在正式进入 planner 前,根据当前情景从候选工具中挑出最可能马上会用到的工具。\n"
"默认内置工具已经自动保留,不在候选列表中,你不需要再次选择它们。\n"
"你只能参考工具的简要描述,不要假设未描述的隐藏能力。\n"
f"最多保留 {max_keep} 个候选工具;如果都不合适,可以返回空数组。\n"
"请严格返回 JSON 对象,格式为:"
'{"selected_tool_names":["工具名1","工具名2"]}\n\n'
f"【最近对话】\n{history_section}\n\n"
f"【候选工具(仅简要描述)】\n{tool_section}"
)
@staticmethod
def _parse_tool_filter_response(
response_text: str,
candidate_tool_specs: List[ToolSpec],
max_keep: int,
) -> List[ToolSpec] | None:
"""解析工具预筛选响应。
Args:
response_text: 小模型返回的原始文本。
candidate_tool_specs: 非内置候选工具列表。
max_keep: 最多保留的候选工具数量。
Returns:
List[ToolSpec] | None: 成功解析时返回筛选后的工具列表;解析失败时返回 ``None``。
"""
normalized_response = response_text.strip()
if not normalized_response:
return None
selected_tool_names: List[str]
try:
selected_tool_names = ToolFilterSelection.model_validate_json(normalized_response).selected_tool_names
except Exception:
try:
parsed_payload = json.loads(normalized_response)
except json.JSONDecodeError:
return None
if isinstance(parsed_payload, dict):
raw_tool_names = parsed_payload.get("selected_tool_names", [])
elif isinstance(parsed_payload, list):
raw_tool_names = parsed_payload
else:
return None
if not isinstance(raw_tool_names, list):
return None
selected_tool_names = []
for item in raw_tool_names:
normalized_name = str(item).strip()
if normalized_name:
selected_tool_names.append(normalized_name)
candidate_map = {tool_spec.name: tool_spec for tool_spec in candidate_tool_specs}
filtered_tool_specs: List[ToolSpec] = []
seen_names: set[str] = set()
for tool_name in selected_tool_names:
normalized_name = tool_name.strip()
if not normalized_name or normalized_name in seen_names:
continue
tool_spec = candidate_map.get(normalized_name)
if tool_spec is None:
continue
seen_names.add(normalized_name)
filtered_tool_specs.append(tool_spec)
if len(filtered_tool_specs) >= max_keep:
break
return filtered_tool_specs
async def _filter_tool_specs_for_planner(
self,
selected_history: List[LLMContextMessage],
tool_specs: List[ToolSpec],
) -> List[ToolSpec]:
"""在将工具交给 planner 前进行快速预筛选。
Args:
selected_history: 已选中的对话上下文。
tool_specs: 当前全部可用工具声明。
Returns:
List[ToolSpec]: 最终交给 planner 的工具声明列表。
"""
threshold = max(1, int(global_config.maisaka.tool_filter_threshold))
max_keep = max(1, int(global_config.maisaka.tool_filter_max_keep))
if len(tool_specs) <= threshold:
return tool_specs
builtin_tool_specs, candidate_tool_specs = self._split_builtin_and_candidate_tools(tool_specs)
if not candidate_tool_specs:
return tool_specs
if len(candidate_tool_specs) <= max_keep:
return [*builtin_tool_specs, *candidate_tool_specs]
filter_prompt = self._build_tool_filter_prompt(selected_history, candidate_tool_specs, max_keep)
logger.info(
"工具预筛选开始: "
f"总工具数={len(tool_specs)} "
f"内置工具数={len(builtin_tool_specs)} "
f"候选工具数={len(candidate_tool_specs)} "
f"最多保留候选数={max_keep}"
)
try:
generation_result = await self._tool_filter_llm.generate_response(
prompt=filter_prompt,
options=LLMGenerationOptions(
temperature=0.0,
max_tokens=256,
response_format=RespFormat(
format_type=RespFormatType.JSON_SCHEMA,
schema=ToolFilterSelection,
),
),
)
except Exception as exc:
logger.warning(f"工具预筛选失败,保留全部工具。错误={exc}")
return tool_specs
filtered_candidate_tool_specs = self._parse_tool_filter_response(
generation_result.response or "",
candidate_tool_specs,
max_keep,
)
if filtered_candidate_tool_specs is None:
logger.warning(
"工具预筛选返回结果无法解析,保留全部工具。"
f" 原始返回={generation_result.response or ''!r}"
)
return tool_specs
filtered_tool_specs = [*builtin_tool_specs, *filtered_candidate_tool_specs]
if not filtered_tool_specs:
logger.warning("工具预筛选得到空结果,保留全部工具以避免主流程失去工具能力。")
return tool_specs
logger.info(
"工具预筛选完成: "
f"筛选前总数={len(tool_specs)} "
f"筛选后总数={len(filtered_tool_specs)} "
f"保留候选工具={[tool_spec.name for tool_spec in filtered_candidate_tool_specs]}"
)
return filtered_tool_specs
async def analyze_knowledge_need(
self,
chat_history: List[LLMContextMessage],
@@ -206,6 +470,15 @@ class MaisakaChatLoopService:
@staticmethod
def _get_role_badge_style(role: str) -> str:
"""返回终端中角色标签的样式。
Args:
role: 消息角色名称。
Returns:
str: Rich 可识别的样式字符串。
"""
if role == "system":
return "bold white on blue"
if role == "user":
@@ -218,6 +491,15 @@ class MaisakaChatLoopService:
@staticmethod
def _get_role_badge_label(role: str) -> str:
"""返回终端中角色标签的中文名称。
Args:
role: 消息角色名称。
Returns:
str: 用于展示的中文角色名称。
"""
if role == "system":
return "系统"
if role == "user":
@@ -230,6 +512,15 @@ class MaisakaChatLoopService:
@staticmethod
def _build_terminal_image_preview(image_base64: str) -> Optional[str]:
"""构造终端图片预览字符画。
Args:
image_base64: 图片的 Base64 编码。
Returns:
Optional[str]: 生成成功时返回字符画文本,否则返回 ``None``。
"""
ascii_chars = " .:-=+*#%@"
try:
@@ -257,6 +548,15 @@ class MaisakaChatLoopService:
@classmethod
def _render_message_content(cls, content: Any) -> RenderableType:
"""将消息内容渲染为终端可展示对象。
Args:
content: 原始消息内容。
Returns:
RenderableType: Rich 可渲染对象。
"""
if isinstance(content, str):
return Text(content)
@@ -299,6 +599,15 @@ class MaisakaChatLoopService:
@staticmethod
def _format_tool_call_for_display(tool_call: Any) -> Dict[str, Any]:
"""将工具调用对象格式化为易读字典。
Args:
tool_call: 原始工具调用对象或字典。
Returns:
Dict[str, Any]: 适合终端展示的工具调用字典。
"""
if isinstance(tool_call, dict):
function_info = tool_call.get("function", {})
return {
@@ -314,6 +623,17 @@ class MaisakaChatLoopService:
}
def _render_tool_call_panel(self, tool_call: Any, index: int, parent_index: int) -> Panel:
"""渲染单个工具调用面板。
Args:
tool_call: 原始工具调用对象。
index: 工具调用在当前消息中的序号。
parent_index: 所属消息的序号。
Returns:
Panel: 工具调用展示面板。
"""
title = Text.assemble(
Text(" 工具调用 ", style="bold white on magenta"),
Text(f" #{parent_index}.{index}", style="muted"),
@@ -326,6 +646,16 @@ class MaisakaChatLoopService:
)
def _render_message_panel(self, message: Any, index: int) -> Panel:
"""渲染单条消息面板。
Args:
message: 原始消息对象或字典。
index: 消息序号。
Returns:
Panel: 终端展示面板。
"""
if isinstance(message, dict):
raw_role = message.get("role", "unknown")
content = message.get("content")
@@ -377,17 +707,28 @@ class MaisakaChatLoopService:
await self.ensure_chat_prompt_loaded()
selected_history, selection_reason = self._select_llm_context_messages(chat_history)
built_messages = self._build_request_messages(selected_history)
def message_factory(_client: BaseClient) -> List[Message]:
"""返回当前轮次已经构建好的请求消息。
Args:
_client: 当前模型客户端;此处不依赖客户端能力。
Returns:
List[Message]: 已经构建好的消息列表。
"""
del _client
return self._build_request_messages(selected_history)
return built_messages
all_tools: List[ToolDefinitionInput]
if self._tool_registry is not None:
all_tools = await self._tool_registry.get_llm_definitions()
tool_specs = await self._tool_registry.list_tools()
filtered_tool_specs = await self._filter_tool_specs_for_planner(selected_history, tool_specs)
all_tools = [tool_spec.to_llm_definition() for tool_spec in filtered_tool_specs]
else:
all_tools = [*get_builtin_tools(), *self._extra_tools]
built_messages = self._build_request_messages(selected_history)
ordered_panels: List[Panel] = []
for index, msg in enumerate(built_messages, start=1):
@@ -454,7 +795,15 @@ class MaisakaChatLoopService:
@staticmethod
def _select_llm_context_messages(chat_history: List[LLMContextMessage]) -> tuple[List[LLMContextMessage], str]:
"""选择真正发送给 LLM 的上下文消息。"""
"""选择真正发送给 LLM 的上下文消息。
Args:
chat_history: 当前全部对话历史。
Returns:
tuple[List[LLMContextMessage], str]: `(已选上下文, 选择说明)`。
"""
max_context_size = max(1, int(global_config.chat.max_context_size))
selected_indices: List[int] = []
counted_message_count = 0
@@ -485,6 +834,15 @@ class MaisakaChatLoopService:
@staticmethod
def build_chat_context(user_text: str) -> List[LLMContextMessage]:
"""根据用户输入构造最小对话上下文。
Args:
user_text: 用户输入文本。
Returns:
List[LLMContextMessage]: 构造好的上下文消息列表。
"""
timestamp = datetime.now()
visible_text = format_speaker_content(
global_config.maisaka.user_name.strip() or "用户",