""" 意图识别节点 使用 LLM 进行智能意图分类,根据用户输入自动路由到对应分支 支持原生 Function Calling(更准确)和文本解析(回退方案) """ import json import logging import time from typing import Any, Dict, List from ..base import BaseNode, NodeContext, NodeResult from ..registry import NodeRegistry logger = logging.getLogger(__name__) @NodeRegistry.register class IntentNode(BaseNode): """ 意图识别节点 使用 LLM 分析用户输入,识别用户意图,并路由到对应分支 支持定义多个意图,每个意图可配置名称、描述和示例 """ node_type = 'intent' node_name = '意图识别' node_category = 'logic' node_icon = 'brain' node_description = '使用 AI 识别用户意图,自动路由到对应分支' supports_branches = True inputs = [ { 'name': 'user_input', 'type': 'string', 'description': '用户输入文本', }, ] outputs = [ { 'name': 'intent', 'type': 'string', 'description': '识别到的意图名称', }, { 'name': 'confidence', 'type': 'number', 'description': '置信度(0-1)', }, ] # 默认的意图识别 prompt 模板 DEFAULT_SYSTEM_PROMPT = """你是一个意图分类器。根据用户输入,判断用户的意图属于以下哪个类别。 可选的意图类别: {intents_description} 请严格按照以下 JSON 格式输出,不要输出其他任何内容: {{"intent": "意图名称", "confidence": 0.95}} 注意: 1. intent 必须是上述意图类别中的一个名称,如果都不匹配则输出 "other" 2. confidence 是你对这个分类的置信度,范围 0-1 3. 只输出 JSON,不要有任何解释""" def execute(self, context: NodeContext) -> NodeResult: """执行意图识别(同步方法,通过运行异步方法实现)""" import asyncio # 在同步方法中运行异步代码 loop = asyncio.get_event_loop() if loop.is_running(): # 如果已有事件循环在运行,创建新任务 import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, self.execute_async(context)) return future.result() else: return loop.run_until_complete(self.execute_async(context)) async def execute_async(self, context: NodeContext) -> NodeResult: """异步执行意图识别""" start_time = time.time() try: from ai_platform.services.llm_service import LLMService from ai_platform.models import LLMModel # 获取配置 model_id = self.config.get('model_id', '') intents = self.config.get('intents', []) input_variable = self.config.get('input_variable', 'user_input') confidence_threshold = self.config.get('confidence_threshold', 0.6) # 获取用户输入 user_input = context.get_variable(input_variable) if not user_input: # 尝试从 __user_input__ 获取 user_input = context.get_variable('__user_input__') if not user_input: user_input = context.user_input if not user_input: return NodeResult( success=False, error='未获取到用户输入', ) if not intents: return NodeResult( success=False, error='请至少配置一个意图', ) if not model_id: return NodeResult( success=False, error='请选择用于意图识别的模型', ) # 检查模型是否支持 Function Calling supports_fc = self._check_model_supports_function_call(model_id) llm_service = LLMService(context.db_session) if supports_fc: # 使用 Function Calling 方式(更准确) intent_name, confidence, total_tokens = await self._execute_with_function_calling_async( llm_service, model_id, user_input, intents ) else: # 回退到文本解析方式 intent_name, confidence, total_tokens = await self._execute_with_text_parsing_async( llm_service, model_id, user_input, intents ) logger.info(f'意图识别结果: intent_name={intent_name}, confidence={confidence}, use_fc={supports_fc}') elapsed_time = int((time.time() - start_time) * 1000) # 判断是否达到置信度阈值 if confidence < confidence_threshold: logger.info(f'置信度 {confidence} 低于阈值 {confidence_threshold},走 other 分支') intent_name = 'other' # 查找对应的分支 ID next_node_id = self._get_branch_id(intent_name, intents) logger.info(f'意图识别结果: intent={intent_name}, next_node_id={next_node_id}') # 设置输出变量 output_var = self.config.get('output_variable', 'intent_result') return NodeResult( success=True, output=intent_name, next_node_id=next_node_id, output_variables={ output_var: intent_name, f'{output_var}_confidence': confidence, f'{output_var}_input': user_input, }, tokens_used=total_tokens, elapsed_time=elapsed_time, metadata={ 'intent': intent_name, 'confidence': confidence, 'matched_branch': next_node_id, }, ) except Exception as e: logger.exception(f'意图识别节点执行失败: {e}') elapsed_time = int((time.time() - start_time) * 1000) return NodeResult( success=False, error=str(e), elapsed_time=elapsed_time, ) def _check_model_supports_function_call(self, model_id) -> bool: """检查模型是否支持 Function Calling TODO: 此方法需要改造为异步版本,当前使用同步查询作为临时方案 """ # 临时方案:默认返回True,让调用方尝试使用Function Calling # 实际使用时应通过上下文传入模型信息 return True async def _execute_with_function_calling_async( self, llm_service, model_id: str, user_input: str, intents: List[Dict], ) -> tuple: """使用 Function Calling 执行意图识别(更准确,异步版本)""" # 构建意图名称列表(用于 enum) intent_names = [intent.get('name') for intent in intents] + ['other'] # 构建意图描述(用于 LLM 理解) intents_description = self._build_intents_description(intents) # 构建 Function Calling 工具定义 tools = [{ 'name': 'classify_intent', 'description': f'根据用户输入对意图进行分类。可选的意图类别:\n{intents_description}', 'parameters': { 'type': 'object', 'properties': { 'intent': { 'type': 'string', 'enum': intent_names, 'description': '识别到的意图名称', }, 'confidence': { 'type': 'number', 'description': '置信度,范围 0-1', }, }, 'required': ['intent', 'confidence'], }, }] # 简化的系统提示词 system_prompt = "你是一个意图分类器。分析用户输入,调用 classify_intent 函数返回分类结果。" messages = [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_input}, ] # 调用 LLM(带 Function Calling,异步) response = await llm_service.chat_async( model_id=model_id, messages=messages, temperature=0.1, max_tokens=100, tools=tools, tool_choice='required', # 强制使用 Function Calling ) # 从 tool_calls 中提取结果 if response.has_tool_calls and response.tool_calls: tool_call = response.tool_calls[0] args = tool_call.arguments intent_name = args.get('intent', 'other') confidence = float(args.get('confidence', 0.8)) # 验证意图名称 if intent_name not in intent_names: intent_name = 'other' confidence = 0.5 return intent_name, confidence, response.total_tokens else: # Function Calling 失败,返回默认值 logger.warning('Function Calling 未返回 tool_calls,回退到 other') return 'other', 0.5, response.total_tokens async def _execute_with_text_parsing_async( self, llm_service, model_id: str, user_input: str, intents: List[Dict], ) -> tuple: """使用文本解析执行意图识别(回退方案,异步版本)""" # 构建意图描述 intents_description = self._build_intents_description(intents) # 构建 prompt system_prompt = self.DEFAULT_SYSTEM_PROMPT.format( intents_description=intents_description ) messages = [ {'role': 'system', 'content': system_prompt}, {'role': 'user', 'content': user_input}, ] # 调用 LLM(异步) response = await llm_service.chat_async( model_id=model_id, messages=messages, temperature=0.1, max_tokens=100, ) # 解析响应 intent_name, confidence = self._parse_response(response.content, intents) return intent_name, confidence, response.total_tokens def _build_intents_description(self, intents: List[Dict]) -> str: """构建意图描述文本""" lines = [] for i, intent in enumerate(intents, 1): name = intent.get('name', '') description = intent.get('description', '') examples = intent.get('examples', []) line = f"{i}. {name}" if description: line += f" - {description}" lines.append(line) # 添加示例 if examples: for example in examples[:3]: # 最多3个示例 lines.append(f" 示例: \"{example}\"") # 添加 other 选项 lines.append(f"{len(intents) + 1}. other - 以上都不匹配时选择此项") return '\n'.join(lines) def _parse_response(self, response: str, intents: List[Dict]) -> tuple: """解析 LLM 响应""" logger.info(f'开始解析 LLM 响应: {response}') try: # 尝试提取 JSON response = response.strip() # 处理可能的 markdown 代码块 if response.startswith('```'): lines = response.split('\n') json_lines = [] in_json = False for line in lines: if line.startswith('```') and not in_json: in_json = True continue elif line.startswith('```') and in_json: break elif in_json: json_lines.append(line) response = '\n'.join(json_lines) # 解析 JSON result = json.loads(response) intent_name = result.get('intent', 'other') confidence = float(result.get('confidence', 0.5)) # 验证意图名称是否有效 valid_names = [intent.get('name') for intent in intents] + ['other'] if intent_name not in valid_names: # 尝试模糊匹配 intent_name_lower = intent_name.lower() for valid_name in valid_names: if valid_name.lower() == intent_name_lower: intent_name = valid_name break else: intent_name = 'other' confidence = 0.5 return intent_name, confidence except (json.JSONDecodeError, ValueError, KeyError) as e: logger.warning(f'解析意图识别响应失败: {e}, response: {response}') return 'other', 0.5 def _get_branch_id(self, intent_name: str, intents: List[Dict]) -> str: """获取意图对应的分支 ID""" logger.info(f'查找意图分支: intent_name={intent_name}, intents={intents}') for intent in intents: if intent.get('name') == intent_name: branch_id = intent.get('branch_id', intent_name) logger.info(f'找到匹配意图: {intent}, 返回 branch_id={branch_id}') return branch_id logger.info(f'未找到匹配意图,返回 other') return 'other' @classmethod def get_config_schema(cls) -> Dict[str, Any]: """获取配置 Schema""" return { 'type': 'object', 'properties': { 'model_id': { 'type': 'string', 'title': '模型', 'description': '选择用于意图识别的 LLM 模型', }, 'input_variable': { 'type': 'string', 'title': '输入变量', 'description': '包含用户输入的变量名', 'default': 'user_input', }, 'intents': { 'type': 'array', 'title': '意图列表', 'description': '定义要识别的意图', 'items': { 'type': 'object', 'properties': { 'name': { 'type': 'string', 'title': '意图名称', 'description': '唯一标识,如 consult, complaint', }, 'description': { 'type': 'string', 'title': '意图描述', 'description': '描述这个意图的含义', }, 'examples': { 'type': 'array', 'title': '示例', 'description': '用户可能的输入示例', 'items': {'type': 'string'}, }, 'branch_id': { 'type': 'string', 'title': '分支 ID', 'description': '匹配时跳转的分支', }, }, 'required': ['name'], }, }, 'confidence_threshold': { 'type': 'number', 'title': '置信度阈值', 'description': '低于此阈值将走 other 分支', 'default': 0.6, 'minimum': 0, 'maximum': 1, }, 'output_variable': { 'type': 'string', 'title': '输出变量名', 'default': 'intent_result', }, }, 'required': ['model_id', 'intents'], }