refactor(tools_factory): 重构工具工厂模块,优化工具管理和错误处理

- 重命名工具获取函数为 get_buildin_tools 和 get_buildin_tools_info
- 改进工具ID生成和描述构建逻辑
- 添加详细的日志记录和错误处理
- 优化知识库工具创建流程,避免闭包问题
- 分离静态工具和知识库工具获取逻辑
- 增强计算器和知识图谱查询工具的错误处理
This commit is contained in:
Wenjie Zhang 2025-08-24 20:12:15 +08:00
parent fbe85702a9
commit 402ff52739
8 changed files with 234 additions and 149 deletions

View File

@ -25,6 +25,8 @@
下面的功能会放在后续版本实现,暂时未定
- [ ] 使用其他的聊天记录管理方法(现在是基于 LangGraph 的 Memory 实现的)
- [ ] 封装现有工具为 mcpstdio调用
- [ ] 支持额外 mcp 配置(代码端)
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息

40
docs/vibe/AGENT.md Normal file
View File

@ -0,0 +1,40 @@
# 项目目录结构
```
Yuxi-Know/
├── docker/ # Docker 配置文件
├── docs/ # 项目文档
├── scripts/ # 脚本文件,如批量上传等
├── server/ # 服务端代码(部分)
├── src/ # 主要源代码目录
│ ├── agents/ # 智能体应用
│ ├── config/ # 配置文件
│ ├── knowledge/ # 知识库相关
│ ├── models/ # 数据模型
│ ├── plugins/ # 插件
│ ├── static/ # 静态资源
│ └── utils/ # 工具函数
├── web/ # 前端代码
└── docker-compose.yml # Docker Compose 配置
```
# 主要容器名称
项目使用 Docker Compose 管理多个服务,主要容器名称如下:
- `api-dev` - FastAPI 后端服务
- `web-dev` - Vue.js 前端开发服务器
- `graph` - Neo4j 图数据库
- `milvus` - 向量数据库,包含 etcd 和 MinIO 依赖
- `mineru` - 可选的 MinerU OCR 服务(需要 GPU
- `paddlex` - 可选的 PaddleX OCR 服务(需要 GPU
## 项目调试
此项目是使用 Docker 进行部署的,使用 `docker compose up -d` 命令进行构建和启动。因此当进行任何修改的时候,不要尝试启动这个项目,应该先检查项目是否已经在后台启动(`docker ps`),具体的可以阅读 [docker-compose.yml](docker-compose.yml).
前端和后端都是配置了自动启动的,因此当修改完成后,会自动更新,可以使用 docker logs 查看日志。对于部分场景可以创建一个 test_router.py 在 [server/routers](server/routers) 中,然后通过 API 测试功能场景。对于前端的 UI 修改,则不用测试。
## 风格说明
UI风格要简洁同时要保持一致性颜色要尽量参考 [base.css](web/src/assets/css/base.css) 中的颜色。不要悬停位移,不要过度使用阴影以及渐变色。

View File

@ -14,7 +14,7 @@ from src import executor, config
from src.agents import agent_manager
from src.models import select_model
from src.utils.logging_config import logger
from src.agents.tools_factory import get_runnable_tools
from src.agents.tools_factory import get_buildin_tools
from server.routers.auth_router import get_admin_user
from server.utils.auth_middleware import get_required_user, get_db
from server.models.user_model import User
@ -181,7 +181,7 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren
@chat.get("/tools")
async def get_tools(current_user: User = Depends(get_admin_user)):
"""获取所有可用工具(需要登录)"""
return {"tools": list(get_runnable_tools().keys())}
return {"tools": list(get_buildin_tools().keys())}
@chat.post("/agent/{agent_id}/config")
async def save_agent_config(

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends
from src.agents.tools_factory import get_all_tools_info
from src.agents.tools_factory import get_buildin_tools_info
from server.models.user_model import User
from server.utils.auth_middleware import get_required_user
@ -9,7 +9,7 @@ tool = chat = APIRouter(prefix="/tool", tags=["tool"])
async def get_tools(current_user: User = Depends(get_required_user)):
"""获取所有可用工具的信息"""
try:
tools_info = get_all_tools_info()
tools_info = get_buildin_tools_info()
return {"tools": tools_info}
except Exception as e:
return {"error": str(e)}

View File

@ -3,7 +3,7 @@ import uuid
from dataclasses import dataclass, field
from src.agents.registry import Configuration
from src.agents.tools_factory import get_runnable_tools
from src.agents.tools_factory import get_buildin_tools
@dataclass(kw_only=True)
class ChatbotConfiguration(Configuration):
@ -37,7 +37,7 @@ class ChatbotConfiguration(Configuration):
default_factory=list,
metadata={
"name": "工具",
"options": list(get_runnable_tools().keys()), # 这里的选择是所有的工具
"options": list(get_buildin_tools().keys()), # 这里的选择是所有的工具
"description": "工具列表"
},
)

View File

@ -15,7 +15,7 @@ from src.utils import logger
from src.agents.registry import State, BaseAgent
from src.agents.utils import load_chat_model, get_cur_time_with_utc
from src.agents.chatbot.configuration import ChatbotConfiguration
from src.agents.tools_factory import get_runnable_tools
from src.agents.tools_factory import get_buildin_tools
class ChatbotAgent(BaseAgent):
name = "智能体助手"
@ -33,7 +33,7 @@ class ChatbotAgent(BaseAgent):
默认不使用任何工具
如果配置为列表则使用列表中的工具
"""
platform_tools = get_runnable_tools()
platform_tools = get_buildin_tools()
if tools is None or not isinstance(tools, list) or len(tools) == 0:
# 默认不使用任何工具
logger.info("未配置工具或配置为空,不使用任何工具")
@ -65,7 +65,7 @@ class ChatbotAgent(BaseAgent):
if self.graph:
return self.graph
runnable_tools = get_runnable_tools()
runnable_tools = get_buildin_tools()
tools = list(runnable_tools.values())
tools_name = list(runnable_tools.keys())
logger.debug(f"build graph `{self.id}` with tools: {tools_name}")

View File

@ -1,8 +1,6 @@
import json
import asyncio
import inspect
import types
from collections.abc import Callable
import hashlib
import os
from typing import Annotated, Any
from pydantic import BaseModel, Field
@ -13,10 +11,6 @@ from src import config, graph_base, knowledge_base
from src.utils import logger
# 工具注册表 - 移到前面以避免NameError
_TOOLS_REGISTRY = {}
class KnowledgeRetrieverModel(BaseModel):
query_text: str = Field(
description=(
@ -25,128 +19,176 @@ class KnowledgeRetrieverModel(BaseModel):
)
)
def get_runnable_tools():
def _create_retriever_wrapper(db_id: str, retriever_info: dict[str, Any]):
"""创建检索器包装函数的工厂函数,避免闭包变量捕获问题"""
async def async_retriever_wrapper(query_text: str) -> Any:
"""异步检索器包装函数"""
retriever = retriever_info["retriever"]
try:
logger.debug(f"Retrieving from database {db_id} with query: {query_text}")
if asyncio.iscoroutinefunction(retriever):
result = await retriever(query_text)
else:
result = retriever(query_text)
logger.debug(f"Retrieved {len(result) if isinstance(result, list) else 'N/A'} results from {db_id}")
return result
except Exception as e:
logger.error(f"Error in retriever {db_id}: {e}")
return f"检索失败: {str(e)}"
return async_retriever_wrapper
def get_buildin_tools() -> dict[str, Any]:
"""获取所有可运行的工具(给大模型使用)"""
tools = _TOOLS_REGISTRY.copy()
tools = {}
# 获取所有知识库
for db_Id, retrieve_info in knowledge_base.get_retrievers().items():
_id = f"retrieve_{db_Id[:8]}" # Deepseek does not support non-alphanumeric characters in tool names
description = (
f"使用 {retrieve_info['name']} 知识库进行检索。\n"
f"下面是这个知识库的描述:\n{retrieve_info['description']}"
)
try:
# 获取所有知识库基于的工具
kb_tools = get_kb_based_tools()
static_tools = get_static_tools()
# 创建异步工具,确保正确处理异步检索器
async def async_retriever_wrapper(query_text: str, db_id=db_Id, retriever_info=retrieve_info):
"""异步检索器包装函数"""
retriever = retriever_info["retriever"]
try:
if asyncio.iscoroutinefunction(retriever):
result = await retriever(query_text)
else:
result = retriever(query_text)
return result
except Exception as e:
logger.error(f"Error in retriever {db_id}: {e}")
return f"检索失败: {str(e)}"
tools.update(kb_tools)
tools.update(static_tools)
# 使用 StructuredTool.from_function 创建异步工具
tools[_id] = StructuredTool.from_function(
coroutine=async_retriever_wrapper, # 指定为协程
name=_id,
description=description,
args_schema=KnowledgeRetrieverModel,
metadata=retrieve_info
)
except Exception as e:
logger.error(f"Failed to get knowledge base retrievers: {e}")
logger.info(f"Total tools available: {len(tools)}")
return tools
def get_all_tools_info():
def get_kb_based_tools() -> dict[str, Any]:
"""获取所有知识库基于的工具"""
# 获取所有知识库
kb_tools = {}
retrievers = knowledge_base.get_retrievers()
logger.debug(f"Found {len(retrievers)} knowledge base retrievers")
for db_id, retrieve_info in retrievers.items():
try:
# 使用改进的工具ID生成策略
tool_id = f"query_{db_id[:8]}"
# 构建工具描述
description = (
f"使用 {retrieve_info['name']} 知识库进行检索。\n"
f"下面是这个知识库的描述:\n{retrieve_info['description'] or '没有描述。'}"
)
# 使用工厂函数创建检索器包装函数,避免闭包问题
retriever_wrapper = _create_retriever_wrapper(db_id, retrieve_info)
# 使用 StructuredTool.from_function 创建异步工具
tool = StructuredTool.from_function(
coroutine=retriever_wrapper,
name=tool_id,
description=description,
args_schema=KnowledgeRetrieverModel,
metadata=retrieve_info | {
"tag": "knowledgebase"
}
)
kb_tools[tool_id] = tool
logger.debug(f"Successfully created tool {tool_id} for database {db_id}")
except Exception as e:
logger.error(f"Failed to create tool for database {db_id}: {e}")
continue
return kb_tools
def get_buildin_tools_info() -> dict[str, dict[str, Any]]:
"""获取所有工具的信息(用于前端展示)"""
tools_info = {}
tools = get_runnable_tools()
try:
tools = get_buildin_tools()
logger.debug(f"Processing {len(tools)} tools for info extraction")
# 获取注册的工具信息
for _id, tool_obj in tools.items():
# 获取注册的工具信息
for tool_id, tool_obj in tools.items():
try:
metadata = getattr(tool_obj, 'metadata', {}) or {}
info = {
"id": tool_id,
"name": metadata.get('name', tool_id),
"description": metadata.get('description') or getattr(tool_obj, 'description', ''),
'metadata': metadata,
"args": []
}
metadata = getattr(tool_obj, 'metadata', {}) or {}
info = {
"id": _id,
"name": metadata.get('name', _id),
"description": metadata.get('description') or getattr(tool_obj, 'description', ''),
'metadata': metadata,
"args": []
}
# 获取工具参数信息
try:
if hasattr(tool_obj, 'args_schema') and tool_obj.args_schema:
schema = tool_obj.args_schema.schema()
if 'properties' in schema:
for arg_name, arg_info in schema['properties'].items():
info["args"].append({
"name": arg_name,
"type": arg_info.get('type', ''),
"description": arg_info.get('description', '')
})
except Exception as e:
logger.warning(f"Failed to extract args schema for tool {tool_id}: {e}")
# 获取工具参数信息
if hasattr(tool_obj, 'args_schema') and tool_obj.args_schema:
schema = tool_obj.args_schema.schema()
if 'properties' in schema:
for arg_name, arg_info in schema['properties'].items():
info["args"].append({
"name": arg_name,
"type": arg_info.get('type', ''),
"description": arg_info.get('description', '')
})
tools_info[info['id']] = info
tools_info[tool_id] = info
logger.debug(f"Successfully processed tool info for {tool_id}")
except Exception as e:
logger.error(f"Failed to process tool {tool_id}: {e}")
continue
except Exception as e:
logger.error(f"Failed to get tools info: {e}")
return {}
logger.info(f"Successfully extracted info for {len(tools_info)} tools")
return tools_info
class BaseToolOutput:
"""
LLM 要求 Tool 的输出为 str Tool 用在别处时希望它正常返回结构化数据
只需要将 Tool 返回值用该类封装能同时满足两者的需要
基类简单的将返回值字符串化或指定 format="json" 将其转为 json
用户也可以继承该类定义自己的转换方法
"""
def __init__(
self,
data: Any,
format: str | Callable | None = None,
data_alias: str = "",
**extras: Any,
) -> None:
self.data = data
self.format = format
self.extras = extras
if data_alias:
setattr(self, data_alias, property(lambda obj: obj.data))
def __str__(self) -> str:
if self.format == "json":
return json.dumps(self.data, ensure_ascii=False, indent=2)
elif callable(self.format):
return self.format(self)
else:
return str(self.data)
@tool
def calculator(a: float, b: float, operation: str) -> float:
"""Calculate two numbers. operation: add, subtract, multiply, divide"""
if operation == "add":
return a + b
elif operation == "subtract":
return a - b
elif operation == "multiply":
return a * b
elif operation == "divide":
return a / b
else:
raise ValueError(f"Invalid operation: {operation}, only support add, subtract, multiply, divide")
try:
if operation == "add":
return a + b
elif operation == "subtract":
return a - b
elif operation == "multiply":
return a * b
elif operation == "divide":
if b == 0:
raise ZeroDivisionError("除数不能为零")
return a / b
else:
raise ValueError(f"不支持的运算类型: {operation},仅支持 add, subtract, multiply, divide")
except Exception as e:
logger.error(f"Calculator error: {e}")
raise
@tool
def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]):
def query_knowledge_graph(query: Annotated[str, "The keyword to query knowledge graph."]) -> Any:
"""Use this to query knowledge graph, which include some food domain knowledge."""
return graph_base.query_node(query, hops=2, return_format='triples')
try:
logger.debug(f"Querying knowledge graph with: {query}")
result = graph_base.query_node(query, hops=2, return_format='triples')
logger.debug(f"Knowledge graph query returned {len(result.get('triples', [])) if isinstance(result, d) else 'N/A'} triples")
return result
except Exception as e:
logger.error(f"Knowledge graph query error: {e}")
return f"知识图谱查询失败: {str(e)}"
# 更新工具注册表
_TOOLS_REGISTRY.update({
"Calculator": calculator,
"QueryKnowledgeGraph": query_knowledge_graph,
})
def get_static_tools() -> dict[str, Any]:
"""注册静态工具"""
static_tools = {
"Calculator": calculator,
"QueryKnowledgeGraph": query_knowledge_graph,
}
if config.enable_web_search:
_TOOLS_REGISTRY["WebSearchWithTavily"] = TavilySearch(max_results=10)
# 检查是否启用网页搜索
if config.enable_web_search:
static_tools["WebSearchWithTavily"] = TavilySearch(max_results=10)
return static_tools

View File

@ -894,7 +894,7 @@ watch(() => props.isOpen, (newVal) => {
&:focus-within {
border-color: var(--main-color);
box-shadow: 0 0 0 2px rgba(var(--main-color-rgb), 0.1);
.search-icon {
color: var(--main-color);
}
@ -907,13 +907,19 @@ watch(() => props.isOpen, (newVal) => {
}
.tools-list {
max-height: 300px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
max-height: max(60vh, 800px);
overflow-y: auto;
border: 1px solid var(--gray-200);
border-radius: 8px;
margin-bottom: 16px;
background: white;
padding: 4px;
//
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
&::-webkit-scrollbar {
width: 6px;
@ -943,38 +949,14 @@ watch(() => props.isOpen, (newVal) => {
background: white;
border: 1px solid var(--gray-200);
&:last-child {
margin-bottom: 0;
}
&:hover {
border-color: var(--main-color);
background: var(--gray-50);
border-color: var(--gray-300);
background: var(--gray-20);
}
&.selected {
background: var(--main-10);
border-color: var(--main-color);
.tool-name {
color: var(--main-color);
font-weight: 600;
}
.tool-description {
color: var(--gray-700);
}
.tool-indicator {
color: var(--main-color);
}
}
.tool-content {
.tool-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
margin-bottom: 6px;
gap: 8px;
@ -1005,6 +987,25 @@ watch(() => props.isOpen, (newVal) => {
text-overflow: ellipsis;
}
}
&.selected {
background: var(--main-30);
// border-color: var(--main-color);
.tool-content {
.tool-name {
color: var(--main-color);
}
.tool-indicator {
color: var(--main-color);
}
}
.tool-description {
color: var(--gray-700);
}
}
}
}