From 25aa4f75d245624d78f894cfa774622794b32876 Mon Sep 17 00:00:00 2001 From: 666ghj <670939375@qq.com> Date: Tue, 24 Feb 2026 17:47:44 +0800 Subject: [PATCH] fix(report_agent): refine tool call handling and response validation; enforce strict separation between tool calls and final answers --- .gitignore | 1 + backend/app/services/report_agent.py | 141 +++++++++++++++++++-------- 2 files changed, 103 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 0bb6697..55d3ef1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ htmlcov/ # Cursor .cursor/ +.claude/ # 文档与测试程序 mydoc/ diff --git a/backend/app/services/report_agent.py b/backend/app/services/report_agent.py index 845dc9f..02ca5bd 100644 --- a/backend/app/services/report_agent.py +++ b/backend/app/services/report_agent.py @@ -714,21 +714,25 @@ SECTION_SYSTEM_PROMPT_TEMPLATE = """\ - interview_agents: 采访模拟Agent,获取不同角色的第一人称观点和真实反应 ═══════════════════════════════════════════════════════════════ -【ReACT工作流程】 +【工作流程】 ═══════════════════════════════════════════════════════════════ -1. Thought: [分析需要什么信息,规划检索策略] -2. Action: [调用一个工具获取信息](每轮只能调用一个工具!) - - {{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}} - -3. Observation: [系统返回工具结果] -4. 重复步骤1-3,直到收集到足够信息 -5. Final Answer: [基于检索结果撰写章节内容] +每次回复你只能做以下两件事之一(不可同时做): -⚠️ 重要规则: -- 每轮只能调用一个工具,不要在一次回复中放多个 -- 当你认为信息足够时,必须以 "Final Answer:" 开头输出最终内容 +选项A - 调用工具: +输出你的思考,然后用以下格式调用一个工具: + +{{"name": "工具名称", "parameters": {{"参数名": "参数值"}}}} + +系统会执行工具并把结果返回给你。你不需要也不能自己编写工具返回结果。 + +选项B - 输出最终内容: +当你已通过工具获取了足够信息,以 "Final Answer:" 开头输出章节内容。 + +⚠️ 严格禁止: +- 禁止在一次回复中同时包含工具调用和 Final Answer +- 禁止自己编造工具返回结果(Observation),所有工具结果由系统注入 +- 每次回复最多调用一个工具 ═══════════════════════════════════════════════════════════════ 【章节内容要求】 @@ -1056,21 +1060,20 @@ class ReportAgent: logger.error(f"工具执行失败: {tool_name}, 错误: {str(e)}") return f"工具执行失败: {str(e)}" + # 合法的工具名称集合,用于裸 JSON 兜底解析时校验 + VALID_TOOL_NAMES = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} + def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]: """ 从LLM响应中解析工具调用 - - 支持的格式: - - {"name": "tool_name", "parameters": {"param1": "value1"}} - - - 或者: - [TOOL_CALL] tool_name(param1="value1", param2="value2") + + 支持的格式(按优先级): + 1. {"name": "tool_name", "parameters": {...}} + 2. 裸 JSON(响应整体或单行就是一个工具调用 JSON) """ tool_calls = [] - - # 格式1: XML风格 + + # 格式1: XML风格(标准格式) xml_pattern = r'\s*(\{.*?\})\s*' for match in re.finditer(xml_pattern, response, re.DOTALL): try: @@ -1078,24 +1081,47 @@ class ReportAgent: tool_calls.append(call_data) except json.JSONDecodeError: pass - - # 格式2: 函数调用风格 - func_pattern = r'\[TOOL_CALL\]\s*(\w+)\s*\((.*?)\)' - for match in re.finditer(func_pattern, response, re.DOTALL): - tool_name = match.group(1) - params_str = match.group(2) - - # 解析参数 - params = {} - for param_match in re.finditer(r'(\w+)\s*=\s*["\']([^"\']*)["\']', params_str): - params[param_match.group(1)] = param_match.group(2) - - tool_calls.append({ - "name": tool_name, - "parameters": params - }) - + + if tool_calls: + return tool_calls + + # 格式2: 兜底 - LLM 直接输出裸 JSON(没包 标签) + # 只在格式1未匹配时尝试,避免误匹配正文中的 JSON + stripped = response.strip() + if stripped.startswith('{') and stripped.endswith('}'): + try: + call_data = json.loads(stripped) + if self._is_valid_tool_call(call_data): + tool_calls.append(call_data) + return tool_calls + except json.JSONDecodeError: + pass + + # 响应可能包含思考文字 + 裸 JSON,尝试提取最后一个 JSON 对象 + json_pattern = r'(\{"(?:name|tool)"\s*:.*?\})\s*$' + match = re.search(json_pattern, stripped, re.DOTALL) + if match: + try: + call_data = json.loads(match.group(1)) + if self._is_valid_tool_call(call_data): + tool_calls.append(call_data) + except json.JSONDecodeError: + pass + return tool_calls + + def _is_valid_tool_call(self, data: dict) -> bool: + """校验解析出的 JSON 是否是合法的工具调用""" + # 支持 {"name": ..., "parameters": ...} 和 {"tool": ..., "params": ...} 两种键名 + tool_name = data.get("name") or data.get("tool") + if tool_name and tool_name in self.VALID_TOOL_NAMES: + # 统一键名为 name / parameters + if "tool" in data: + data["name"] = data.pop("tool") + if "params" in data and "parameters" not in data: + data["parameters"] = data.pop("params") + return True + return False def _get_tools_description(self) -> str: """生成工具描述文本""" @@ -1258,6 +1284,7 @@ class ReportAgent: tool_calls_count = 0 max_iterations = 5 # 最大迭代轮数 min_tool_calls = 3 # 最少工具调用次数 + conflict_retries = 0 # 工具调用与Final Answer同时出现的连续冲突次数 used_tools = set() # 记录已调用过的工具名 all_tools = {"insight_forge", "panorama_search", "quick_search", "interview_agents"} @@ -1297,6 +1324,42 @@ class ReportAgent: has_tool_calls = bool(tool_calls) has_final_answer = "Final Answer:" in response + # ── 冲突处理:LLM 同时输出了工具调用和 Final Answer ── + if has_tool_calls and has_final_answer: + conflict_retries += 1 + logger.warning( + f"章节 {section.title} 第 {iteration+1} 轮: " + f"LLM 同时输出工具调用和 Final Answer(第 {conflict_retries} 次冲突)" + ) + + if conflict_retries <= 2: + # 前两次:丢弃本次响应,要求 LLM 重新回复 + messages.append({"role": "assistant", "content": response}) + messages.append({ + "role": "user", + "content": ( + "【格式错误】你在一次回复中同时包含了工具调用和 Final Answer,这是不允许的。\n" + "每次回复只能做以下两件事之一:\n" + "- 调用一个工具(输出一个 块,不要写 Final Answer)\n" + "- 输出最终内容(以 'Final Answer:' 开头,不要包含 )\n" + "请重新回复,只做其中一件事。" + ), + }) + continue + else: + # 第三次:降级处理,截断到第一个工具调用,强制执行 + logger.warning( + f"章节 {section.title}: 连续 {conflict_retries} 次冲突," + "降级为截断执行第一个工具调用" + ) + first_tool_end = response.find('') + if first_tool_end != -1: + response = response[:first_tool_end + len('')] + tool_calls = self._parse_tool_calls(response) + has_tool_calls = bool(tool_calls) + has_final_answer = False + conflict_retries = 0 + # 记录 LLM 响应日志 if self.report_logger: self.report_logger.log_llm_response(