diff --git a/apps/llm/function.py b/apps/llm/function.py index de63c3b927604aa7ff5a5b68861961e88b7f6d1d..71499a9eb3ee9554f7e92b9f427489a36195b3a9 100644 --- a/apps/llm/function.py +++ b/apps/llm/function.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) class FunctionLLM: """用于FunctionCall的模型""" - def __init__(self) -> None: + def __init__(self, llm_config=None) -> None: """ 初始化用于FunctionCall的模型 @@ -38,9 +38,29 @@ class FunctionLLM: - function_call - json_mode - structured_output + + :param llm_config: 可选的LLM配置,如果不提供则使用配置文件中的function_call配置 """ - # 暂存config;这里可以替代为从其他位置获取 - self._config = Config().get_config().function_call + # 使用传入的配置或从配置文件获取 + if llm_config: + self._config = llm_config + # 如果没有backend字段,根据模型特性推断 + if not hasattr(self._config, 'backend'): + # 默认使用json_mode,这对大多数模型都适用 + class ConfigWithBackend: + def __init__(self, base_config): + self.model = base_config.model + self.endpoint = base_config.endpoint + self.api_key = getattr(base_config, 'key', getattr(base_config, 'api_key', '')) + self.max_tokens = getattr(base_config, 'max_tokens', 8192) + self.temperature = getattr(base_config, 'temperature', 0.7) + self.backend = "json_mode" # 默认使用json_mode + + self._config = ConfigWithBackend(llm_config) + else: + # 暂存config;这里可以替代为从其他位置获取 + self._config = Config().get_config().function_call + if not self._config.model: err_msg = "[FunctionCall] 未设置FuntionCall所用模型!" logger.error(err_msg) @@ -88,6 +108,7 @@ class FunctionLLM: schema: dict[str, Any], max_tokens: int | None = None, temperature: float | None = None, + enable_thinking: bool = False, ) -> str: """ 调用openai模型生成JSON @@ -96,6 +117,7 @@ class FunctionLLM: :param dict[str, Any] schema: 输出JSON Schema :param int | None max_tokens: 最大Token长度 :param float | None temperature: 大模型温度 + :param bool enable_thinking: 是否启用思维链 :return: 生成的JSON :rtype: str """ @@ -105,7 +127,16 @@ class FunctionLLM: "temperature": temperature, }) - if self._config.backend == "vllm": + # 🔑 特殊处理:如果provider是siliconflow,强制使用json_mode + if self._provider == "siliconflow": + logger.info("[FunctionCall] 检测到SiliconFlow provider,启用JSON模式") + # 确保系统消息中包含JSON输出提示 + if messages and messages[0]["role"] == "system": + if "JSON" not in messages[0]["content"]: + messages[0]["content"] += "\nYou are a helpful assistant designed to output JSON." + self._params["response_format"] = {"type": "json_object"} + + elif self._config.backend == "vllm": self._params["extra_body"] = {"guided_json": schema} elif self._config.backend == "json_mode": diff --git a/apps/llm/model_types.py b/apps/llm/model_types.py index f7d4e44f417c5cad2aac3b199c18d5dbf85bec10..f09ffa247847b3d70a2130bddeb2a00d7ab92b3d 100644 --- a/apps/llm/model_types.py +++ b/apps/llm/model_types.py @@ -11,6 +11,7 @@ class ModelType(str, Enum): CHAT = "chat" # 文本对话 EMBEDDING = "embedding" # 嵌入 RERANK = "rerank" # 重排序 + FUNCTION_CALL = "function_call" # 函数调用 AUDIO = "audio" # 语音(预留) IMAGE = "image" # 图像(预留) VIDEO = "video" # 视频(预留) diff --git a/apps/scheduler/call/llm/llm.py b/apps/scheduler/call/llm/llm.py index c338c251b7ce365257a3e91232eeb02e4a5df99f..94ff2fa0116903a5006aa9a6fd65595983a28651 100644 --- a/apps/scheduler/call/llm/llm.py +++ b/apps/scheduler/call/llm/llm.py @@ -30,6 +30,12 @@ class LLM(CoreCall, input_model=LLMInput, output_model=LLMOutput): """大模型调用工具""" to_user: bool = Field(default=True) + controlled_output: bool = Field(default=True) + + # 输出参数配置 + output_parameters: dict[str, Any] = Field(description="输出参数配置", default={ + "reply": {"type": "string", "description": "大模型的回复内容"}, + }) # 模型配置 llmId: str = Field(description="大模型ID", default="") @@ -134,6 +140,7 @@ class LLM(CoreCall, input_model=LLMInput, output_model=LLMOutput): ) -> AsyncGenerator[CallOutputChunk, None]: """运行LLM Call""" data = LLMInput(**input_data) + full_reply = "" # 用于累积完整回复 try: # 根据llmId获取模型配置 llm_config = None @@ -182,8 +189,15 @@ class LLM(CoreCall, input_model=LLMInput, output_model=LLMOutput): async for chunk in llm.call(**call_params): if not chunk: continue + full_reply += chunk yield CallOutputChunk(type=CallOutputType.TEXT, content=chunk) self.tokens.input_tokens = llm.input_tokens self.tokens.output_tokens = llm.output_tokens + + # 最后输出一个DATA chunk,包含完整的输出数据,用于保存到变量池 + yield CallOutputChunk( + type=CallOutputType.DATA, + content=LLMOutput(reply=full_reply).model_dump(by_alias=True, exclude_none=True) + ) except Exception as e: raise CallError(message=f"大模型调用失败:{e!s}", data={}) from e diff --git a/apps/scheduler/call/llm/schema.py b/apps/scheduler/call/llm/schema.py index c7bb50541d168a406fa478f25894c769b034ec9c..1ffc3e64052c17f27268232d46187776829d6e90 100644 --- a/apps/scheduler/call/llm/schema.py +++ b/apps/scheduler/call/llm/schema.py @@ -14,3 +14,5 @@ class LLMInput(DataBase): class LLMOutput(DataBase): """定义LLM工具调用的输出""" + + reply: str = Field(description="定义LLM工具调用的输出") \ No newline at end of file diff --git a/apps/scheduler/call/mcp/mcp.py b/apps/scheduler/call/mcp/mcp.py index 22027d2f63a1b3f6e60bf8041efc97258b3a8fcf..7c59448a91d68167d2c05e3a1cd448e5f54fc512 100644 --- a/apps/scheduler/call/mcp/mcp.py +++ b/apps/scheduler/call/mcp/mcp.py @@ -52,6 +52,13 @@ MCP_SUMMARY: dict[str, dict[LanguageType, str]] = { class MCP(CoreCall, input_model=MCPInput, output_model=MCPOutput): """MCP工具""" + controlled_output: bool = Field(default=True) + + # 输出参数配置 + output_parameters: dict[str, Any] = Field(description="输出参数配置", default={ + "message": {"type": "string", "description": "MCP Server的自然语言输出"}, + }) + mcp_list: list[str] = Field(description="MCP Server ID列表", max_length=5, min_length=1) max_steps: int = Field(description="最大步骤数", default=20) text_output: bool = Field(description="是否将结果以文本形式返回", default=True) @@ -177,7 +184,7 @@ class MCP(CoreCall, input_model=MCPInput, output_model=MCPOutput): planner = MCPPlanner(self._call_vars.question, language) answer = await planner.generate_answer(self._plan, await self._host.assemble_memory()) - # 输出结果 + # 输出文本结果 yield self._create_output( MCP_SUMMARY["END"][language].format(answer=answer), MCPMessageType.FINISH_END, @@ -185,6 +192,12 @@ class MCP(CoreCall, input_model=MCPInput, output_model=MCPOutput): message=answer, ).model_dump(), ) + + # 额外输出一个纯DATA chunk用于保存到变量池 + yield CallOutputChunk( + type=CallOutputType.DATA, + content=MCPOutput(message=answer).model_dump(by_alias=True, exclude_none=True) + ) def _create_output( self, diff --git a/apps/scheduler/call/rag/rag.py b/apps/scheduler/call/rag/rag.py index c81a22fe5626de254d09cc1c7312d29c241acf42..32f97b0ff6065ab38e73e6fdfd6854b4d3c3b3ec 100644 --- a/apps/scheduler/call/rag/rag.py +++ b/apps/scheduler/call/rag/rag.py @@ -27,6 +27,14 @@ logger = logging.getLogger(__name__) class RAG(CoreCall, input_model=RAGInput, output_model=RAGOutput): """RAG工具:查询知识库""" + controlled_output: bool = Field(default=True) + + # 输出参数配置 + output_parameters: dict[str, Any] = Field(description="输出参数配置", default={ + "question": {"type": "string", "description": "查询问题"}, + "corpus": {"type": "array", "description": "知识库的语料列表"}, + }) + knowledge_base_ids: list[str] = Field(description="知识库的id列表", default=[]) top_k: int = Field(description="返回的分片数量", default=5) document_ids: list[str] | None = Field(description="文档id列表", default=None) diff --git a/apps/scheduler/executor/flow.py b/apps/scheduler/executor/flow.py index dbb6f073c57fb732deeb01b7e9ad8ec5e93b0f27..3e6d83a61a1372b4a804131d0e2b03f9bcaba741 100644 --- a/apps/scheduler/executor/flow.py +++ b/apps/scheduler/executor/flow.py @@ -224,14 +224,28 @@ class FlowExecutor(BaseExecutor): step=self.flow.steps[self.task.state.step_id], # type: ignore[arg-type] ) + # 🔑 获取function call场景使用的模型ID用于系统步骤(理解上下文、记忆存储) + # 降级顺序:应用配置模型 -> 用户偏好的function call模型 -> 系统默认function call模型 -> 系统默认chat模型 + from apps.services.llm import LLMManager + function_call_model_id = await LLMManager.get_function_call_model_id( + self.task.ids.user_sub, + app_llm_id=self.llm_id # 传递应用配置的模型ID(最高优先级) + ) + # 如果仍然没有找到模型(极端情况),使用应用配置的模型 + if not function_call_model_id: + function_call_model_id = self.llm_id + logger.warning("[FlowExecutor] 未找到任何模型,使用应用配置的模型用于系统步骤") + else: + logger.info(f"[FlowExecutor] 系统步骤(理解上下文、记忆存储)使用模型: {function_call_model_id}") + # 头插开始前的系统步骤,并执行 for step in FIXED_STEPS_BEFORE_START: - # 为系统步骤添加应用配置的模型信息 + # 为系统步骤添加function call模型信息 step_data = step.get(self.task.language, step[LanguageType.CHINESE]) # 将llm_id和enable_thinking添加到step的params中 step_data_with_params = step_data.model_copy() step_data_with_params.params = { - "llm_id": self.llm_id, + "llm_id": function_call_model_id, # 使用function call模型 "enable_thinking": self.enable_thinking, } self.step_queue.append( @@ -322,12 +336,12 @@ class FlowExecutor(BaseExecutor): # 尾插运行结束后的系统步骤 for step in FIXED_STEPS_AFTER_END: - # 为系统步骤添加应用配置的模型信息 + # 为系统步骤添加function call模型信息 step_data = step.get(self.task.language, step[LanguageType.CHINESE]) # 将llm_id和enable_thinking添加到step的params中 step_data_with_params = step_data.model_copy() step_data_with_params.params = { - "llm_id": self.llm_id, + "llm_id": function_call_model_id, # 使用function call模型 "enable_thinking": self.enable_thinking, } self.step_queue.append( diff --git a/apps/scheduler/scheduler/message.py b/apps/scheduler/scheduler/message.py index b229b97e1b2f829d8e9e9d0101eb3f6b8474c21e..31ba13ead6a7fbbf8d88e75eafa7693ddfb13466 100644 --- a/apps/scheduler/scheduler/message.py +++ b/apps/scheduler/scheduler/message.py @@ -67,6 +67,7 @@ async def push_rag_message( history: list[dict[str, str]], doc_ids: list[str], rag_data: RAGQueryReq, + enable_thinking: bool = False, ) -> None: """推送RAG消息""" full_answer = "" @@ -74,7 +75,7 @@ async def push_rag_message( try: async for chunk in RAG.chat_with_llm_base_on_rag( - user_sub, llm, history, doc_ids, rag_data, task.language + user_sub, llm, history, doc_ids, rag_data, task.language, enable_thinking ): try: task, content_obj = await _push_rag_chunk(task, queue, chunk) diff --git a/apps/scheduler/scheduler/scheduler.py b/apps/scheduler/scheduler/scheduler.py index 13940dbce9b4500e432910faf30a15a57570fc52..76fde14818295e9b8eab807e46f33cc2e913f865 100644 --- a/apps/scheduler/scheduler/scheduler.py +++ b/apps/scheduler/scheduler/scheduler.py @@ -155,7 +155,7 @@ class Scheduler: # 启动监控任务和主任务 main_task = asyncio.create_task(push_rag_message( - self.task, self.queue, self.task.ids.user_sub, llm, history, doc_ids, rag_data)) + self.task, self.queue, self.task.ids.user_sub, llm, history, doc_ids, rag_data, self.post_body.enable_thinking)) else: # 查找对应的App元数据 @@ -266,10 +266,21 @@ class Scheduler: ) if background.conversation and self.task.state.flow_status == FlowStatus.INIT: try: - # 使用应用配置的模型进行问题改写 - llm_id_for_rewrite = app_metadata.llm_id if hasattr(app_metadata, 'llm_id') and app_metadata.llm_id != "empty" else None + # 使用function call模型进行问题改写 + # 降级顺序:应用配置模型 -> 用户偏好的function call模型 -> 系统默认function call模型 -> 系统默认chat模型 + app_llm_id = app_metadata.llm_id if hasattr(app_metadata, 'llm_id') and app_metadata.llm_id != "empty" else None + llm_id_for_rewrite = await LLMManager.get_function_call_model_id( + self.task.ids.user_sub, + app_llm_id=app_llm_id # 传递应用配置的模型ID(最高优先级) + ) + # 如果仍然没有找到模型(极端情况),则使用应用配置的模型作为最后降级方案 + if not llm_id_for_rewrite: + llm_id_for_rewrite = app_llm_id + logger.warning("[Scheduler] 未找到任何系统模型,使用应用配置的模型进行问题改写") + enable_thinking_for_rewrite = app_metadata.enable_thinking if hasattr(app_metadata, 'enable_thinking') else False + logger.info(f"[Scheduler] 问题改写使用模型ID: {llm_id_for_rewrite}") question_obj = QuestionRewrite( llm_id=llm_id_for_rewrite, enable_thinking=enable_thinking_for_rewrite, diff --git a/apps/schemas/collection.py b/apps/schemas/collection.py index 0e36b3c10c94384a3395c7c176878dc5365ffd01..ba6c7d882dbbbc39e9353ea54028ea06b9fcb914 100644 --- a/apps/schemas/collection.py +++ b/apps/schemas/collection.py @@ -88,7 +88,7 @@ class LLM(BaseModel): openai_api_key: str = Field(default=Config().get_config().llm.key) model_name: str = Field(default=Config().get_config().llm.model) max_tokens: int | None = Field(default=Config().get_config().llm.max_tokens) - type: Literal['chat', 'image', 'video', 'speech', 'embedding', 'reranker'] = Field(default='chat', description="模型类型") + type: list[str] | str = Field(default=['chat'], description="模型类型,支持单个类型或多个类型") # 模型能力字段 provider: str = Field(default="", description="模型提供商") @@ -101,6 +101,19 @@ class LLM(BaseModel): notes: str = Field(default="", description="备注信息") created_at: float = Field(default_factory=lambda: round(datetime.now(tz=UTC).timestamp(), 3)) + + def normalize_type(self) -> list[str]: + """标准化type字段为列表格式""" + if isinstance(self.type, str): + return [self.type] + return self.type + + def model_dump(self, **kwargs): + """重写model_dump方法,确保type字段存储为列表""" + data = super().model_dump(**kwargs) + if 'type' in data and isinstance(data['type'], str): + data['type'] = [data['type']] + return data class LLMItem(BaseModel): diff --git a/apps/schemas/config.py b/apps/schemas/config.py index 1122a5e9d4e4e0410a09ad2df85019133c2e539a..8b97d08197a76e069cffd6c411dd9e4811a6a049 100644 --- a/apps/schemas/config.py +++ b/apps/schemas/config.py @@ -153,6 +153,7 @@ class FunctionCallConfig(BaseModel): """Function Call配置""" backend: str = Field(description="Function Call 后端") + provider: str | None = Field(default=None, description="Function Call 提供商") model: str = Field(description="Function Call 模型名") endpoint: str = Field(description="Function Call API URL地址") api_key: str = Field(description="Function Call API密钥") diff --git a/apps/schemas/preferences.py b/apps/schemas/preferences.py index d13b232f08df3c622c6355557112addb831af4d0..86f8334a5317fdb0a9e790065b2ffeab535bca00 100644 --- a/apps/schemas/preferences.py +++ b/apps/schemas/preferences.py @@ -46,6 +46,20 @@ class RerankerModelPreference(BaseModel): name: str | None = Field(default=None, description="算法名称") +class FunctionCallModelPreference(BaseModel): + """函数调用模型偏好设置""" + + model_config = {"populate_by_name": True, "protected_namespaces": ()} + + llm_id: str = Field(alias="llmId", description="模型ID") + icon: str = Field(default="", description="模型图标") + openai_base_url: str = Field(alias="openaiBaseUrl", description="OpenAI API基础URL") + openai_api_key: str = Field(alias="openaiApiKey", description="OpenAI API密钥") + model_name: str = Field(alias="modelName", description="模型名称") + max_tokens: int = Field(alias="maxTokens", description="最大token数") + is_editable: bool | None = Field(alias="isEditable", default=None, description="是否可编辑") + + class UserPreferences(BaseModel): """用户偏好设置""" @@ -66,6 +80,16 @@ class UserPreferences(BaseModel): description="重排序模型偏好", alias="rerankerPreference" ) + function_call_model_preference: FunctionCallModelPreference | None = Field( + default=None, + description="函数调用模型偏好", + alias="functionCallModelPreference" + ) + search_method_preference: str | None = Field( + default=None, + description="检索方法偏好", + alias="searchMethodPreference" + ) chain_of_thought_preference: bool = Field( default=True, description="思维链偏好", diff --git a/apps/schemas/request_data.py b/apps/schemas/request_data.py index ef145235ba551e3c332de1ebdc584a0ad1814008..3d9d3805f76dd26ce62420d9bb98bb65bc4c2ea5 100644 --- a/apps/schemas/request_data.py +++ b/apps/schemas/request_data.py @@ -11,7 +11,12 @@ from apps.schemas.enum_var import CommentType, LanguageType from apps.schemas.flow_topology import FlowItem from apps.schemas.mcp import MCPType from apps.schemas.message import FlowParams -from apps.schemas.preferences import ReasoningModelPreference, EmbeddingModelPreference, RerankerModelPreference +from apps.schemas.preferences import ( + ReasoningModelPreference, + EmbeddingModelPreference, + RerankerModelPreference, + FunctionCallModelPreference +) class RequestDataApp(BaseModel): @@ -180,7 +185,7 @@ class UpdateLLMReq(BaseModel): openai_api_key: str = Field(default="", description="OpenAI API Key", alias="openaiApiKey") model_name: str = Field(default="", description="模型名称", alias="modelName") max_tokens: int = Field(default=8192, description="最大token数", alias="maxTokens") - type: Literal['chat', 'image', 'video', 'speech', 'embedding', 'reranker'] = Field(default='chat', description="模型类型") + type: list[str] | str = Field(default=['chat'], description="模型类型,支持单个类型或多个类型") # 可选的能力字段,如果不提供则自动从model_registry推断 provider: str | None = Field(default=None, description="模型提供商") @@ -229,6 +234,16 @@ class UserPreferencesRequest(BaseModel): description="重排序模型偏好", alias="rerankerPreference" ) + function_call_model_preference: FunctionCallModelPreference | None = Field( + default=None, + description="函数调用模型偏好", + alias="functionCallModelPreference" + ) + search_method_preference: str | None = Field( + default=None, + description="检索方法偏好", + alias="searchMethodPreference" + ) chain_of_thought_preference: bool | None = Field( default=None, description="思维链偏好", diff --git a/apps/schemas/response_data.py b/apps/schemas/response_data.py index 86b0f1846179811f239d34b7a1937c1e72d35e61..685dacf2c17f52d9ff1f105ebb26014b245e4332 100644 --- a/apps/schemas/response_data.py +++ b/apps/schemas/response_data.py @@ -623,10 +623,15 @@ class ActiveMCPServiceRsp(ResponseData): class LLMProvider(BaseModel): """LLM提供商数据结构""" + model_config = {"populate_by_name": True} + provider: str = Field(description="LLM提供商") description: str = Field(description="LLM提供商描述") url: str | None = Field(default=None, description="LLM提供商URL") icon: str = Field(description="LLM提供商图标") + alias_zh: str = Field(default="", description="中文名称", alias="aliasZh") + alias_en: str = Field(default="", description="英文名称", alias="aliasEn") + type: str = Field(default="public", description="类型:public(公网) 或 private(私有)") class ListLLMProviderRsp(ResponseData): @@ -655,8 +660,8 @@ class LLMProviderInfo(BaseModel): model_name: str = Field(description="模型名称", alias="modelName") max_tokens: int | None = Field(default=None, description="最大token数", alias="maxTokens") is_editable: bool = Field(default=True, description="是否可编辑", alias="isEditable") - type: str = Field(default="chat", description="模型类型") - + type: list[str] = Field(default=['chat'], description="模型类型列表") + # 模型能力字段 provider: str = Field(default="", description="模型提供商") supports_thinking: bool = Field(default=False, description="是否支持思维链", alias="supportsThinking") diff --git a/apps/services/flow.py b/apps/services/flow.py index 9a8bd7bce569d204339183411904fb3ec59b700e..7a381fa4ca015803e935633cf15e97c7f4c23ccc 100644 --- a/apps/services/flow.py +++ b/apps/services/flow.py @@ -93,10 +93,14 @@ class FlowManager: try: # TODO: 由于现在没有动态表单,所以暂时使用Slot的create_empty_slot方法 # 对于循环节点,输出参数已经是扁平化格式,不需要再次处理 - if node_pool_record["call_id"] == "Loop": + if node_pool_record["call_id"] in ["Loop"]: output_parameters = output_schema else: output_parameters = Slot(output_schema).extract_type_desc_from_schema() + # 如果输出参数是对象类型,直接使用 items 字段,去除顶层包装 + # 这样 {type: 'object', items: {reply: {...}}} 会变成 {reply: {...}} + if isinstance(output_parameters, dict) and output_parameters.get("type") == "object" and "items" in output_parameters: + output_parameters = output_parameters["items"] parameters = { "input_parameters": Slot(params_schema).create_empty_slot(), @@ -318,6 +322,10 @@ class FlowManager: processed_output_parameters = output_parameters else: processed_output_parameters = Slot(output_parameters).extract_type_desc_from_schema() + # 如果输出参数是对象类型,直接使用 items 字段,去除顶层包装 + # 这样 {type: 'object', items: {reply: {...}}} 会变成 {reply: {...}} + if isinstance(processed_output_parameters, dict) and processed_output_parameters.get("type") == "object" and "items" in processed_output_parameters: + processed_output_parameters = processed_output_parameters["items"] parameters = { "input_parameters": input_parameters, diff --git a/apps/services/llm.py b/apps/services/llm.py index e7134fe0c114c5dd7f36e0bdfda046c41f3ccf61..70608e060fbb52dc955c54ef7797ddd4461a3966 100644 --- a/apps/services/llm.py +++ b/apps/services/llm.py @@ -34,6 +34,9 @@ class LLMManager: url=provider["url"], description=provider["description"], icon=provider["icon"], + alias_zh=provider.get("alias_zh", ""), + alias_en=provider.get("alias_en", ""), + type=provider.get("type", "public"), ) provider_list.append(item) return provider_list @@ -62,12 +65,32 @@ class LLMManager: :param llm_id: 大模型ID :return: 大模型对象 """ + from bson import ObjectId + llm_collection = MongoDB().get_collection("llm") - result = await llm_collection.find_one({"_id": llm_id}) + + # 尝试同时使用字符串和ObjectId查询,以兼容不同的存储格式 + result = None + try: + # 首先尝试作为字符串查询 + result = await llm_collection.find_one({"_id": llm_id}) + + # 如果字符串查询失败,尝试转换为ObjectId查询 + if not result and ObjectId.is_valid(llm_id): + result = await llm_collection.find_one({"_id": ObjectId(llm_id)}) + + except Exception as e: + logger.warning(f"[LLMManager] 查询LLM时发生错误: {e}") + if not result: err = f"[LLMManager] LLM {llm_id} 不存在" logger.error(err) raise ValueError(err) + + # 将ObjectId转换为字符串,以兼容LLM模型的验证 + if isinstance(result.get("_id"), ObjectId): + result["_id"] = str(result["_id"]) + return LLM.model_validate(result) @staticmethod @@ -79,12 +102,32 @@ class LLMManager: :param llm_id: 大模型ID :return: 大模型对象 """ + from bson import ObjectId + llm_collection = MongoDB().get_collection("llm") - result = await llm_collection.find_one({"_id": llm_id, "user_sub": user_sub}) + + # 尝试同时使用字符串和ObjectId查询,以兼容不同的存储格式 + result = None + try: + # 首先尝试作为字符串查询 + result = await llm_collection.find_one({"_id": llm_id, "user_sub": user_sub}) + + # 如果字符串查询失败,尝试转换为ObjectId查询 + if not result and ObjectId.is_valid(llm_id): + result = await llm_collection.find_one({"_id": ObjectId(llm_id), "user_sub": user_sub}) + + except Exception as e: + logger.warning(f"[LLMManager] 查询LLM时发生错误: {e}") + if not result: err = f"[LLMManager] LLM {llm_id} 不存在" logger.error(err) raise ValueError(err) + + # 将ObjectId转换为字符串,以兼容LLM模型的验证 + if isinstance(result.get("_id"), ObjectId): + result["_id"] = str(result["_id"]) + return LLM.model_validate(result) @staticmethod @@ -94,7 +137,7 @@ class LLMManager: :param user_sub: 用户ID :param llm_id: 大模型ID - :param model_type: 模型类型,可选值:'chat', 'image', 'video', 'speech', 'embedding', 'reranker' + :param model_type: 模型类型,可选值:'chat', 'image', 'video', 'speech', 'embedding', 'reranker', 'function_call' :return: 大模型列表 """ mongo = MongoDB() @@ -105,6 +148,7 @@ class LLMManager: if llm_id: base_query["llm_id"] = llm_id if model_type: + # 支持type字段既可以是字符串也可以是数组 base_query["type"] = model_type result = await llm_collection.find(base_query).sort({"created_at": 1}).to_list(length=None) @@ -128,6 +172,11 @@ class LLMManager: result = await llm_collection.find(base_query).sort({"created_at": 1}).to_list(length=None) for llm in result: + # 标准化type字段为列表格式 + llm_type = llm.get("type", "chat") + if isinstance(llm_type, str): + llm_type = [llm_type] + llm_item = LLMProviderInfo( llmId=str(llm["_id"]), # 转换ObjectId为字符串 icon=llm["icon"], @@ -136,7 +185,7 @@ class LLMManager: modelName=llm["model_name"], maxTokens=llm["max_tokens"], isEditable=bool(llm.get("user_sub")), # 系统模型(user_sub="")不可编辑 - type=llm.get("type", "chat"), + type=llm_type, # 始终返回列表格式 # 模型能力字段 provider=llm.get("provider", ""), supportsThinking=llm.get("supports_thinking", False), @@ -167,6 +216,11 @@ class LLMManager: provider = req.provider or get_provider_from_endpoint(req.openai_base_url) model_info = model_registry.get_model_info(provider, req.model_name) + # 标准化type字段为列表格式 + model_type = req.type + if isinstance(model_type, str): + model_type = [model_type] + # 使用请求中的能力信息,如果没有则从model_registry获取,最后使用默认值 capabilities = { "provider": provider, @@ -200,7 +254,7 @@ class LLMManager: openai_api_key=req.openai_api_key, model_name=req.model_name, max_tokens=req.max_tokens, - type=req.type, + type=model_type, # 使用标准化后的列表格式 **capabilities ) # 排除_id字段以避免MongoDB的不可变_id字段错误 @@ -214,7 +268,7 @@ class LLMManager: openai_api_key=req.openai_api_key, model_name=req.model_name, max_tokens=req.max_tokens, - type=req.type, + type=model_type, # 使用标准化后的列表格式 **capabilities ) # 排除_id字段让MongoDB自动生成_id,避免冲突 @@ -376,6 +430,11 @@ class LLMManager: llm_list = [] for llm in result: + # 标准化type字段为列表格式 + llm_type = llm.get("type", "embedding") + if isinstance(llm_type, str): + llm_type = [llm_type] + llm_item = LLMProviderInfo( llmId=str(llm["_id"]), # 转换ObjectId为字符串 icon=llm["icon"], @@ -384,7 +443,7 @@ class LLMManager: modelName=llm["model_name"], maxTokens=llm["max_tokens"], isEditable=bool(llm.get("user_sub")), # 有user_sub的是用户创建的,可编辑 - type="embedding", + type=llm_type, ) llm_list.append(llm_item) return llm_list @@ -410,6 +469,11 @@ class LLMManager: llm_list = [] for llm in result: + # 标准化type字段为列表格式 + llm_type = llm.get("type", "embedding") + if isinstance(llm_type, str): + llm_type = [llm_type] + llm_item = LLMProviderInfo( llmId=str(llm["_id"]), # 转换ObjectId为字符串 icon=llm["icon"], @@ -418,7 +482,7 @@ class LLMManager: modelName=llm["model_name"], maxTokens=llm["max_tokens"], isEditable=bool(llm.get("user_sub")), # 系统模型(user_sub="")不可编辑 - type="embedding", + type=llm_type, ) llm_list.append(llm_item) return llm_list @@ -440,6 +504,11 @@ class LLMManager: llm_list = [] for llm in result: + # 标准化type字段为列表格式 + llm_type = llm.get("type", "reranker") + if isinstance(llm_type, str): + llm_type = [llm_type] + llm_item = LLMProviderInfo( llmId=str(llm["_id"]), # 转换ObjectId为字符串 icon=llm["icon"], @@ -448,7 +517,7 @@ class LLMManager: modelName=llm["model_name"], maxTokens=llm["max_tokens"], isEditable=bool(llm.get("user_sub")), # 有user_sub的是用户创建的,可编辑 - type="reranker", + type=llm_type, ) llm_list.append(llm_item) return llm_list @@ -474,6 +543,11 @@ class LLMManager: llm_list = [] for llm in result: + # 标准化type字段为列表格式 + llm_type = llm.get("type", "reranker") + if isinstance(llm_type, str): + llm_type = [llm_type] + llm_item = LLMProviderInfo( llmId=str(llm["_id"]), # 转换ObjectId为字符串 icon=llm["icon"], @@ -482,7 +556,7 @@ class LLMManager: modelName=llm["model_name"], maxTokens=llm["max_tokens"], isEditable=bool(llm.get("user_sub")), # 系统模型(user_sub="")不可编辑 - type="reranker", + type=llm_type, ) llm_list.append(llm_item) return llm_list @@ -495,7 +569,8 @@ class LLMManager: llm_collection = mongo.get_collection("llm") # 推断chat模型能力 - provider = get_provider_from_endpoint(config.llm.endpoint) or config.llm.provider + # 优先使用配置文件中明确指定的provider,如果没有则从endpoint推断 + provider = getattr(config.llm, 'provider', '') or get_provider_from_endpoint(config.llm.endpoint) model_info = model_registry.get_model_info(provider, config.llm.model) # 根据provider获取图标 @@ -510,7 +585,7 @@ class LLMManager: openai_base_url=config.llm.endpoint, model_name=config.llm.model, max_tokens=config.llm.max_tokens or (model_info.max_tokens_param if model_info else 8192), - type="chat", + type=['chat'], # 使用列表格式 provider=provider, supports_thinking=model_info.supports_thinking if model_info else False, can_toggle_thinking=model_info.can_toggle_thinking if model_info else False, @@ -531,7 +606,7 @@ class LLMManager: """ 初始化系统模型的通用方法 - :param model_type: 模型类型 ('embedding' 或 'reranker') + :param model_type: 模型类型 ('embedding', 'reranker', 'function_call') :param model_config: 模型配置对象 :param title: 模型标题 """ @@ -539,7 +614,8 @@ class LLMManager: llm_collection = mongo.get_collection("llm") # 推断模型能力 - provider = get_provider_from_endpoint(model_config.endpoint) or model_config.provider + # 优先使用配置文件中明确指定的provider,如果没有则从endpoint推断 + provider = getattr(model_config, 'provider', '') or getattr(model_config, 'backend', '') or get_provider_from_endpoint(model_config.endpoint) model_info = model_registry.get_model_info(provider, model_config.model) # 根据provider获取图标,如果没有则使用配置文件中的图标 @@ -551,10 +627,10 @@ class LLMManager: title=title, icon=provider_icon, openai_base_url=model_config.endpoint, - openai_api_key=model_config.api_key, + openai_api_key=getattr(model_config, 'api_key', ''), model_name=model_config.model, - max_tokens=None, - type=model_type, + max_tokens=getattr(model_config, 'max_tokens', None), + type=[model_type], # 使用列表格式 provider=provider, supports_thinking=model_info.supports_thinking if model_info else False, can_toggle_thinking=model_info.can_toggle_thinking if model_info else False, @@ -568,7 +644,6 @@ class LLMManager: # 使用upsert模式:如果model_name已存在就更新,否则插入 filter_query = { "user_sub": "", - "type": model_type, "model_name": model_config.model } @@ -587,12 +662,128 @@ class LLMManager: else: logger.info(f"[LLMManager] 更新系统{model_type}模型: {model_config.model}") + @staticmethod + async def get_function_call_model_id(user_sub: str, app_llm_id: str | None = None) -> str | None: + """ + 获取function call场景使用的模型ID + + 优先级顺序(从高到低): + 1. 应用配置的模型 (app_llm_id) - 最高优先级 + 2. 用户偏好的 function call 模型 + 3. 系统默认的 function call 模型 + 4. 系统默认的 chat 模型 + + :param user_sub: 用户ID + :param app_llm_id: 应用配置的模型ID(可选) + :return: function call模型ID或chat模型ID,如果都不存在则返回None + """ + try: + mongo = MongoDB() + llm_collection = mongo.get_collection("llm") + + # 🔑 第一优先级:应用配置的模型(最高优先级) + if app_llm_id: + logger.info(f"[LLMManager] 使用应用配置的模型用于函数调用: {app_llm_id}") + return app_llm_id + + # 第二优先级:获取用户偏好的function call模型 + from apps.services.user import UserManager + user_preferences = await UserManager.get_user_preferences_by_user_sub(user_sub) + + # 如果用户配置了function call模型偏好,检查该模型是否真正支持函数调用 + if user_preferences.function_call_model_preference: + llm_id = user_preferences.function_call_model_preference.llm_id + # 检查该模型是否支持函数调用 + llm_data = await llm_collection.find_one({"_id": llm_id}) + if llm_data: + supports_fc = llm_data.get("supports_function_calling", True) + if supports_fc: + logger.info(f"[LLMManager] 使用用户偏好的function call模型: {llm_id}") + return llm_id + else: + logger.warning(f"[LLMManager] 用户偏好的模型 {llm_id} 不支持函数调用,将使用其他模型") + + # 第三优先级:获取系统默认的function call模型 + # 注意:type字段可能是数组或字符串,需要同时支持两种格式 + system_function_call_model = await llm_collection.find_one({ + "user_sub": "", + "$or": [ + {"type": "function_call"}, # 兼容字符串格式 + {"type": {"$in": ["function_call"]}} # 兼容数组格式 + ], + "supports_function_calling": True # 确保支持函数调用 + }) + + if system_function_call_model: + llm_id = str(system_function_call_model["_id"]) + logger.info(f"[LLMManager] 使用系统默认的function call模型: {llm_id}") + return llm_id + + # 第四优先级:如果没有专门的function call模型,尝试找支持函数调用的chat模型 + logger.warning("[LLMManager] 未找到专门的function call模型,寻找支持函数调用的chat模型") + system_chat_with_fc = await llm_collection.find_one({ + "user_sub": "", + "$or": [ + {"type": "chat"}, # 兼容字符串格式 + {"type": {"$in": ["chat"]}} # 兼容数组格式 + ], + "supports_function_calling": True + }) + + if system_chat_with_fc: + llm_id = str(system_chat_with_fc["_id"]) + logger.info(f"[LLMManager] 使用支持函数调用的chat模型: {llm_id}") + return llm_id + + # 最后降级:使用系统默认的chat模型 + logger.warning("[LLMManager] 未找到支持函数调用的模型,降级使用系统默认的chat模型") + config = Config().get_config() + system_chat_model = await llm_collection.find_one({ + "user_sub": "", + "$or": [ + {"type": "chat"}, # 兼容字符串格式 + {"type": {"$in": ["chat"]}} # 兼容数组格式 + ], + "model_name": config.llm.model + }) + + if not system_chat_model: + # 如果系统chat模型不存在,创建它 + await LLMManager.init_system_chat_model() + system_chat_model = await llm_collection.find_one({ + "user_sub": "", + "$or": [ + {"type": "chat"}, # 兼容字符串格式 + {"type": {"$in": ["chat"]}} # 兼容数组格式 + ], + "model_name": config.llm.model + }) + + if system_chat_model: + llm_id = str(system_chat_model["_id"]) + logger.info(f"[LLMManager] 降级使用系统默认的chat模型: {llm_id}") + return llm_id + + logger.error("[LLMManager] 未找到任何可用的模型") + return None + + except Exception as e: + logger.error(f"[LLMManager] 获取模型失败: {e}") + return None + @staticmethod async def init_system_models(): """ - 初始化系统模型(从配置文件读取embedding和reranker配置并插入数据库) + 初始化系统模型(从配置文件读取embedding、reranker和function_call配置并插入数据库) + 在初始化之前,先清理所有系统模型 """ config = Config().get_config() + mongo = MongoDB() + llm_collection = mongo.get_collection("llm") + + # 清理所有系统模型(user_sub为空的模型) + delete_result = await llm_collection.delete_many({"user_sub": ""}) + logger.info(f"[LLMManager] 清理了 {delete_result.deleted_count} 个旧系统模型") # 初始化embedding模型 await LLMManager._init_system_model( @@ -607,6 +798,13 @@ class LLMManager: config.reranker, "System Reranker Model" ) + + # 初始化function_call模型 + await LLMManager._init_system_model( + "function_call", + config.function_call, + "System Function Call Model" + ) @staticmethod async def get_model_capabilities(user_sub: str, llm_id: str) -> dict: @@ -623,17 +821,36 @@ class LLMManager: mongo = MongoDB() llm_collection = mongo.get_collection("llm") - # 查询用户可访问的模型(包括系统模型和自己的模型) - result = await llm_collection.find_one({ - "_id": llm_id, - "$or": [{"user_sub": user_sub}, {"user_sub": ""}] - }) + # 尝试将llm_id转换为ObjectId(如果适用) + from bson import ObjectId + from bson.errors import InvalidId + + # 先尝试作为字符串查询,如果失败再尝试ObjectId + try: + # 首先尝试作为字符串ID查询(UUID格式) + result = await llm_collection.find_one({ + "_id": llm_id, + "$or": [{"user_sub": user_sub}, {"user_sub": ""}] + }) + + # 如果没找到且llm_id可以转换为ObjectId,则尝试作为ObjectId查询 + if not result and ObjectId.is_valid(llm_id): + result = await llm_collection.find_one({ + "_id": ObjectId(llm_id), + "$or": [{"user_sub": user_sub}, {"user_sub": ""}] + }) + except InvalidId: + result = None if not result: err = f"[LLMManager] LLM {llm_id} 不存在或无权限访问" logger.error(err) raise ValueError(err) + # 将ObjectId转换为字符串,以兼容LLM模型的验证 + if isinstance(result.get("_id"), ObjectId): + result["_id"] = str(result["_id"]) + llm = LLM.model_validate(result) # 从注册表获取模型能力 diff --git a/apps/services/rag.py b/apps/services/rag.py index e7870dd0f221d67579f2b0ab970e608d000689c4..ac9ac0db3f8ed04ada6a7c07fdfa5cdf7af329a8 100644 --- a/apps/services/rag.py +++ b/apps/services/rag.py @@ -357,8 +357,10 @@ Please generate a detailed, well-structured, and clearly formatted answer based doc_ids: list[str], data: RAGQueryReq, language: LanguageType = LanguageType.CHINESE, + enable_thinking: bool = False, ) -> AsyncGenerator[str, None]: """获取RAG服务的结果""" + # 用于最终回答的LLM(使用用户选择的对话模型) reasion_llm = ReasoningLLM( LLMConfig( provider=llm.provider, @@ -368,11 +370,29 @@ Please generate a detailed, well-structured, and clearly formatted answer based max_tokens=llm.max_tokens, ) ) + + # 用于问题改写的LLM if history: try: - question_obj = QuestionRewrite() + # 获取function call场景使用的模型ID用于问题改写 + # 在RAG场景中,将对话模型ID作为应用配置模型传递(最高优先级) + # 降级顺序:对话模型 -> 用户偏好的function call模型 -> 系统默认function call模型 -> 系统默认chat模型 + from apps.services.llm import LLMManager + function_call_model_id = await LLMManager.get_function_call_model_id( + user_sub, + app_llm_id=llm.id # 传递对话模型ID作为应用配置(最高优先级) + ) + + # 如果没有找到函数调用模型,使用对话模型作为降级方案 + if not function_call_model_id: + logger.warning("[RAG] 未找到函数调用模型,使用对话模型进行问题改写") + function_call_model_id = llm.id + else: + logger.info(f"[RAG] 问题改写使用模型: {function_call_model_id}") + + question_obj = QuestionRewrite(llm_id=function_call_model_id, enable_thinking=False) data.query = await question_obj.generate( - history=history, question=data.query, llm=reasion_llm, language=language + history=history, question=data.query, language=language ) except Exception: logger.exception("[RAG] 问题重写失败") @@ -427,7 +447,7 @@ Please generate a detailed, well-structured, and clearly formatted answer based temperature=0.7, result_only=False, model=llm.model_name, - enable_thinking=True + enable_thinking=enable_thinking ): chunk = buffer + chunk # 防止脚注被截断 diff --git a/apps/services/user.py b/apps/services/user.py index 13520fdd3d3da30bfb22bf9f768af12985d9878c..8ba8ee2d2ef12887aca54884563d129839c4a4ef 100644 --- a/apps/services/user.py +++ b/apps/services/user.py @@ -193,6 +193,10 @@ class UserManager: preferences_update["preferences.embeddingModelPreference"] = data.embedding_model_preference.model_dump(by_alias=True) if data.reranker_preference is not None: preferences_update["preferences.rerankerPreference"] = data.reranker_preference.model_dump(by_alias=True) + if data.function_call_model_preference is not None: + preferences_update["preferences.functionCallModelPreference"] = data.function_call_model_preference.model_dump(by_alias=True) + if data.search_method_preference is not None: + preferences_update["preferences.searchMethodPreference"] = data.search_method_preference if data.chain_of_thought_preference is not None: preferences_update["preferences.chainOfThoughtPreference"] = data.chain_of_thought_preference if data.auto_execute_preference is not None: @@ -227,6 +231,12 @@ class UserManager: if "reranker_preference" in preferences_data: preferences_data["rerankerPreference"] = preferences_data.pop("reranker_preference") migration_needed = True + if "function_call_model_preference" in preferences_data: + preferences_data["functionCallModelPreference"] = preferences_data.pop("function_call_model_preference") + migration_needed = True + if "search_method_preference" in preferences_data: + preferences_data["searchMethodPreference"] = preferences_data.pop("search_method_preference") + migration_needed = True if "chain_of_thought_preference" in preferences_data: preferences_data["chainOfThoughtPreference"] = preferences_data.pop("chain_of_thought_preference") migration_needed = True diff --git a/apps/templates/generate_llm_operator_config.py b/apps/templates/generate_llm_operator_config.py index 0a1db3526d19aa28828b51c646c1d9c7b8f6ba6c..5beaccf31b0623cdc03197be95cba2aa631c4cae 100644 --- a/apps/templates/generate_llm_operator_config.py +++ b/apps/templates/generate_llm_operator_config.py @@ -7,60 +7,90 @@ import os llm_provider_dict={ "baichuan":{ "provider":"baichuan", + "alias_zh": "百川", + "alias_en": "BaiChuan", + "type": "public", "url":"https://api.baichuan-ai.com/v1", "description":"百川大模型平台", "icon":"", }, "siliconflow":{ "provider":"siliconflow", + "alias_zh": "硅基流动", + "alias_en": "SiliconFlow", + "type": "public", "url":"https://api.siliconflow.cn/v1", - "description":"硅基流动", + "description":"硅基流动大模型平台", "icon":"", }, "modelscope":{ "provider":"modelscope", + "alias_zh": "魔塔", + "alias_en": "ModelScope", + "type": "private", "url":None, "description":"基于魔塔部署的本地大模型服务", "icon":"", }, "ollama":{ "provider":"ollama", + "alias_zh": "Ollama", + "alias_en": "Ollama", + "type": "private", "url":None, "description":"基于Ollama部署的本地大模型服务", "icon":"", }, "openai":{ "provider":"openai", + "alias_zh": "openAI", + "alias_en": "openAI", + "type": "public", "url":"https://api.openai.com/v1", "description":"OpenAI大模型平台", "icon":"", }, - "qwen":{ - "provider":"qwen", + "bailian":{ + "provider":"bailian", + "alias_zh": "阿里云百炼", + "alias_en": "Aliyun Bailian", + "type": "private", "url":"https://dashscope.aliyuncs.com/compatible-mode/v1", "description":"阿里百炼大模型平台", "icon":"", }, "spark":{ "provider":"spark", + "alias_zh": "讯飞星火", + "alias_en": "Spark", + "type": "public", "url":"https://spark-api-open.xf-yun.com/v1", "description":"讯飞星火大模型平台", "icon":"", }, "vllm":{ "provider":"vllm", + "alias_zh": "vLLM", + "alias_en": "vLLM", + "type": "private", "url":None, "description":"基于VLLM部署的本地大模型服务", "icon":"", }, "mindie":{ "provider":"mindie", + "alias_zh": "MindIE", + "alias_en": "MindIE", + "type": "private", "url":None, "description":"基于MindIE部署的本地大模型服务", "icon":"", }, "wenxin":{ "provider":"wenxin", + "alias_zh": "百度文心", + "alias_en": "Baidu Wenxin", + "type": "public", "url":"https://qianfan.baidubce.com/v2", "description":"百度文心大模型平台", "icon":"", diff --git a/apps/templates/llm_provider_icon/qwen.svg b/apps/templates/llm_provider_icon/bailian.svg similarity index 100% rename from apps/templates/llm_provider_icon/qwen.svg rename to apps/templates/llm_provider_icon/bailian.svg