Skip to content

Commit 8b30382

Browse files
committed
fix: stabilize qa routing and runtime fallbacks
1 parent abf5a5d commit 8b30382

7 files changed

Lines changed: 130 additions & 89 deletions

File tree

secbot_agent/core/agents/intent_router.py

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,6 @@ def _fallback(
217217
intent = "task_complex"
218218
else:
219219
intent = "qa"
220-
if force_agent and intent == "small_talk":
221-
intent = "qa"
222220
return IntentDecision(
223221
intent=intent,
224222
confidence=0.4,
@@ -249,7 +247,7 @@ async def classify(
249247
focus=h["focus"],
250248
direct_response=None,
251249
clarify_question=None,
252-
rationale="forceQA mode",
250+
rationale="forced qa",
253251
)
254252

255253
parts = [f"本轮用户输入:\n{user_input}\n"]
@@ -286,24 +284,6 @@ async def classify(
286284
decision = self._merge_heuristic(
287285
parsed, h, session_focus
288286
)
289-
if force_agent and decision.intent == "small_talk":
290-
new_intent = (
291-
"qa" if decision.direct_response else "task_complex"
292-
)
293-
decision = IntentDecision(
294-
intent=new_intent,
295-
confidence=decision.confidence,
296-
needs_explore=decision.needs_explore
297-
or (
298-
new_intent == "task_complex"
299-
and h["has_unknown_entity"]
300-
),
301-
needs_report=(new_intent == "task_complex"),
302-
focus=decision.focus,
303-
direct_response=decision.direct_response,
304-
clarify_question=decision.clarify_question,
305-
rationale=(decision.rationale or "") + " (forceAgent)",
306-
)
307287
return decision
308288
except Exception as e:
309289
logger.warning(f"IntentRouter LLM 失败,启发式回退: {e}")

secbot_agent/core/agents/qa_agent.py

Lines changed: 90 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
QAAgent:专门处理简单问候与项目/上下文问答
33
- 所有回复均通过 LLM 生成,不设规则快捷回复
44
- 问候、闲聊、项目能力、帮助等均走 LLM
5-
- Ask 模式:带上下文的 LLM 问答,可选用通用工具(搜索、系统信息、CVE、文件分析)以更准确回答
5+
- 问答:带上下文的 LLM 问答,可选用通用工具(搜索、系统信息、CVE、文件分析)以更准确回答
66
"""
77

88
import asyncio
@@ -12,8 +12,26 @@
1212
from utils.logger import logger
1313

1414

15-
# Ask 模式系统提示词(无工具)
16-
ASK_SYSTEM_PROMPT = """你是 Hackbot 的 Ask 模式助手。你的任务是**仅根据当前对话上下文**来回答用户的问题。
15+
_AUTH_ERROR_REPLY = "当前推理后端 API Key 无效或已过期,请使用 /model 重新配置后再试。"
16+
17+
18+
def _is_auth_error(error: Exception) -> bool:
19+
text = f"{type(error).__name__} {error}".lower()
20+
return any(
21+
marker in text
22+
for marker in (
23+
"401",
24+
"unauthorized",
25+
"authentication",
26+
"invalid api key",
27+
"api key is invalid",
28+
"invalid_request_error",
29+
)
30+
)
31+
32+
33+
# 问答系统提示词(无工具)
34+
ASK_SYSTEM_PROMPT = """你是 Hackbot 的问答助手。你的任务是**仅根据当前对话上下文**来回答用户的问题。
1735
1836
规则:
1937
- 仅根据对话上下文中已有的信息来回答
@@ -24,8 +42,8 @@
2442
- 如果涉及扫描结果、漏洞发现等安全数据,引用上下文中的具体内容
2543
- 使用 Markdown 格式化输出以提高可读性"""
2644

27-
# Ask 模式系统提示词(带工具:用于更确切回答)
28-
ASK_SYSTEM_PROMPT_WITH_TOOLS = """你是 Hackbot 的 Ask 模式助手。你的任务是根据当前对话上下文**并结合可选工具**来准确回答用户问题。
45+
# 问答系统提示词(带工具:用于更确切回答)
46+
ASK_SYSTEM_PROMPT_WITH_TOOLS = """你是 Hackbot 的问答助手。你的任务是根据当前对话上下文**并结合可选工具**来准确回答用户问题。
2947
3048
规则:
3149
- 优先根据对话上下文中已有信息回答;若信息不足或用户问题涉及实时/外部数据,可调用工具获取后再回答
@@ -37,30 +55,30 @@
3755

3856

3957
def get_ask_tools() -> List[Any]:
40-
"""返回 Ask 模式可用的通用工具列表(只读/低敏感:搜索、系统信息、CVE、文件分析)。"""
58+
"""返回问答可用的通用工具列表(只读/低敏感:搜索、系统信息、CVE、文件分析)。"""
4159
from tools.base import BaseTool
4260

4361
tools: List[BaseTool] = []
4462
try:
4563
from tools.web_search import WebSearchTool
4664
tools.append(WebSearchTool())
4765
except Exception as e:
48-
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"Ask 工具 web_search 未加载: {e}")
66+
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"问答工具 web_search 未加载: {e}")
4967
try:
5068
from tools.defense.system_info_tool import SystemInfoTool
5169
tools.append(SystemInfoTool())
5270
except Exception as e:
53-
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"Ask 工具 system_info 未加载: {e}")
71+
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"问答工具 system_info 未加载: {e}")
5472
try:
5573
from tools.utility.cve_lookup_tool import CveLookupTool
5674
tools.append(CveLookupTool())
5775
except Exception as e:
58-
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"Ask 工具 cve_lookup 未加载: {e}")
76+
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"问答工具 cve_lookup 未加载: {e}")
5977
try:
6078
from tools.utility.file_analyze_tool import FileAnalyzeTool
6179
tools.append(FileAnalyzeTool())
6280
except Exception as e:
63-
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"Ask 工具 file_analyze 未加载: {e}")
81+
logger.bind(agent="qa", event="agent_error", attempt=1).debug(f"问答工具 file_analyze 未加载: {e}")
6482
return tools
6583

6684

@@ -69,7 +87,7 @@ class QAAgent(BaseAgent):
6987
问答 Agent:仅做简短回复,不调用工具、不生成执行计划。
7088
用于:问候、闲聊、了解项目能力、了解对话上下文等。
7189
72-
Ask 模式:带上下文的 LLM 问答;可选接入通用工具以更确切回答。
90+
问答:带上下文的 LLM 问答;可选接入通用工具以更确切回答。
7391
"""
7492

7593
def __init__(self, name: str = "QAAgent"):
@@ -81,23 +99,23 @@ def __init__(self, name: str = "QAAgent"):
8199
回复应简洁,不要展开长篇说明,不要调用任何工具。"""
82100
super().__init__(name=name, system_prompt=system_prompt)
83101
self._llm = None # 延迟初始化
84-
self._ask_tools: Optional[List[Any]] = None # Ask 模式通用工具,延迟加载
102+
self._ask_tools: Optional[List[Any]] = None # 问答通用工具,延迟加载
85103
logger.bind(agent=self.name, event="stage_start", attempt=1).info("初始化 QAAgent")
86104

87105
def _ensure_llm(self):
88-
"""延迟创建 LLM 实例(仅 ask 模式需要)"""
106+
"""延迟创建 LLM 实例(仅问答需要)"""
89107
if self._llm is None:
90108
try:
91109
from secbot_agent.core.patterns.security_react import _create_llm
92110

93111
self._llm = _create_llm()
94-
logger.bind(agent=self.name, event="stage_start", attempt=1).info("QAAgent: LLM 实例已创建(用于 Ask 模式)")
112+
logger.bind(agent=self.name, event="stage_start", attempt=1).info("QAAgent: LLM 实例已创建(用于问答)")
95113
except Exception as e:
96114
logger.bind(agent=self.name, event="llm_error", attempt=1).error(f"QAAgent: 创建 LLM 实例失败: {e}")
97115
raise
98116

99117
def _get_ask_tools_langchain(self):
100-
"""返回 Ask 工具经 LangChain 包装后的列表,用于 bind_tools。"""
118+
"""返回问答工具经 LangChain 包装后的列表,用于 bind_tools。"""
101119
from secbot_agent.core.agents.tool_calling_agent import LangChainToolWrapper
102120

103121
if self._ask_tools is None:
@@ -144,22 +162,22 @@ async def answer_with_context(
144162
self,
145163
user_input: str,
146164
conversation_history: List[dict],
165+
context_block: str = "",
147166
) -> str:
148167
"""
149-
Ask 模式:带对话上下文的 LLM 问答。
168+
问答:带对话上下文的 LLM 问答。
150169
仅根据上下文回答问题,不执行任何动作。
151170
152171
Args:
153172
user_input: 用户当前的问题
154173
conversation_history: 对话历史,格式 [{"role": "user"|"assistant", "content": "..."}]
174+
context_block: ContextAssembler 组装的预算上下文
155175
156176
Returns:
157177
LLM 根据上下文生成的回答
158178
"""
159179
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
160180

161-
self._ensure_llm()
162-
163181
# 构建消息列表
164182
messages = [SystemMessage(content=ASK_SYSTEM_PROMPT)]
165183

@@ -176,29 +194,41 @@ async def answer_with_context(
176194
content = content[:2000] + "\n... (已截断)"
177195
context_lines.append(f"[{role_label}]: {content}")
178196

179-
context_block = "\n\n".join(context_lines)
197+
conversation_block = "\n\n".join(context_lines)
180198
messages.append(
181-
HumanMessage(content=f"以下是当前对话的上下文记录:\n\n{context_block}")
199+
HumanMessage(content=f"以下是当前对话的上下文记录:\n\n{conversation_block}")
182200
)
183201
messages.append(
184202
AIMessage(content="好的,我已了解当前对话上下文。请问你想了解什么?")
185203
)
186204

205+
context_block = (context_block or "").strip()
206+
if context_block:
207+
if len(context_block) > 6000:
208+
context_block = context_block[:6000] + "\n... (已截断)"
209+
messages.append(
210+
HumanMessage(content=f"以下是当前请求可用的补充上下文:\n\n{context_block}")
211+
)
212+
messages.append(
213+
AIMessage(content="好的,我会结合这些补充上下文回答,并避免编造不存在的信息。")
214+
)
215+
187216
# 用户的实际问题
188217
messages.append(HumanMessage(content=user_input))
189218

190219
try:
220+
self._ensure_llm()
191221
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=30.0)
192222
if isinstance(response, str):
193223
return response.strip()
194224
if hasattr(response, "content") and response.content is not None:
195225
return str(response.content)
196226
return str(response)
197227
except asyncio.TimeoutError:
198-
return "Ask 模式回答超时,请稍后重试。"
228+
return "问答回复超时,请稍后重试。"
199229
except (AttributeError, TypeError) as e:
200230
if "model_dump" in str(e) or "model_dump" in type(e).__name__:
201-
logger.warning(f"QAAgent ask_with_context 解析触发 model_dump 异常,改用 HTTP 直连回退: {e}")
231+
logger.warning(f"QAAgent answer_with_context 解析触发 model_dump 异常,改用 HTTP 直连回退: {e}")
202232
fallback_payload = []
203233
for m in messages:
204234
role = getattr(m, "type", None) or "user"
@@ -209,11 +239,17 @@ async def answer_with_context(
209239
else:
210240
fallback_payload.append({"role": "user", "content": getattr(m, "content", "") or ""})
211241
return await self._answer_via_http_fallback(fallback_payload)
212-
logger.error(f"QAAgent ask_with_context 错误: {e}")
213-
return f"Ask 模式回答出错: {e}"
242+
if _is_auth_error(e):
243+
logger.warning(f"QAAgent answer_with_context 鉴权失败: {e}")
244+
return _AUTH_ERROR_REPLY
245+
logger.error(f"QAAgent answer_with_context 错误: {e}")
246+
return f"问答回复出错: {e}"
214247
except Exception as e:
215-
logger.error(f"QAAgent ask_with_context 错误: {e}")
216-
return f"Ask 模式回答出错: {e}"
248+
if _is_auth_error(e):
249+
logger.warning(f"QAAgent answer_with_context 鉴权失败: {e}")
250+
return _AUTH_ERROR_REPLY
251+
logger.error(f"QAAgent answer_with_context 错误: {e}")
252+
return f"问答回复出错: {e}"
217253

218254
async def answer_with_context_and_tools(
219255
self,
@@ -222,21 +258,27 @@ async def answer_with_context_and_tools(
222258
max_tool_rounds: int = 5,
223259
) -> str:
224260
"""
225-
Ask 模式:带对话上下文,并可调用通用工具(搜索、系统信息、CVE、文件分析)以更准确回答。
261+
问答:带对话上下文,并可调用通用工具(搜索、系统信息、CVE、文件分析)以更准确回答。
226262
若模型不支持 bind_tools 或无可用工具,则回退到纯 answer_with_context。
227263
"""
228264
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
229265

230-
self._ensure_llm()
231-
langchain_tools = self._get_ask_tools_langchain()
266+
try:
267+
self._ensure_llm()
268+
langchain_tools = self._get_ask_tools_langchain()
269+
except Exception as e:
270+
if _is_auth_error(e):
271+
logger.warning(f"QAAgent answer_with_context_and_tools 鉴权失败: {e}")
272+
return _AUTH_ERROR_REPLY
273+
raise
232274
if not langchain_tools:
233-
logger.info("Ask 模式无可用工具,回退到纯上下文问答")
275+
logger.info("问答无可用工具,回退到纯上下文问答")
234276
return await self.answer_with_context(user_input, conversation_history)
235277

236278
try:
237279
llm_with_tools = self._llm.bind_tools(langchain_tools)
238280
except (NotImplementedError, AttributeError, Exception) as e:
239-
logger.info("Ask 模式 bind_tools 不可用,回退到纯上下文问答: %s", e)
281+
logger.info("问答 bind_tools 不可用,回退到纯上下文问答: %s", e)
240282
return await self.answer_with_context(user_input, conversation_history)
241283

242284
tools_dict: Dict[str, Any] = {t.name: t for t in langchain_tools}
@@ -297,13 +339,13 @@ async def answer_with_context_and_tools(
297339
result = await tools_dict[tool_name]._arun(**(tool_args or {}))
298340
tool_results.append(f"工具 {tool_name} 执行结果: {result}")
299341
except Exception as e:
300-
logger.warning("Ask 工具 %s 执行失败: %s", tool_name, e)
342+
logger.warning("问答工具 %s 执行失败: %s", tool_name, e)
301343
tool_results.append(f"工具 {tool_name} 执行失败: {str(e)}")
302344
for i, res in enumerate(tool_results):
303345
messages.append(ToolMessage(content=res, tool_call_id=tool_calls[i].get("id", f"call_{i}")))
304346
return (content or "").strip() or "抱歉,已达到工具调用轮数上限,未能生成最终回复。"
305347
except asyncio.TimeoutError:
306-
return "Ask 模式回答超时,请稍后重试。"
348+
return "问答回复超时,请稍后重试。"
307349
except (AttributeError, TypeError) as e:
308350
if "model_dump" in str(e).lower():
309351
fallback_payload = [{"role": "system", "content": ASK_SYSTEM_PROMPT_WITH_TOOLS}]
@@ -314,11 +356,17 @@ async def answer_with_context_and_tools(
314356
role = "user"
315357
fallback_payload.append({"role": role, "content": getattr(m, "content", "") or ""})
316358
return await self._answer_via_http_fallback(fallback_payload)
359+
if _is_auth_error(e):
360+
logger.warning("QAAgent answer_with_context_and_tools 鉴权失败: %s", e)
361+
return _AUTH_ERROR_REPLY
317362
logger.error("QAAgent answer_with_context_and_tools 错误: %s", e)
318-
return f"Ask 模式回答出错: {e}"
363+
return f"问答回复出错: {e}"
319364
except Exception as e:
365+
if _is_auth_error(e):
366+
logger.warning("QAAgent answer_with_context_and_tools 鉴权失败: %s", e)
367+
return _AUTH_ERROR_REPLY
320368
logger.error("QAAgent answer_with_context_and_tools 错误: %s", e)
321-
return f"Ask 模式回答出错: {e}"
369+
return f"问答回复出错: {e}"
322370

323371
@staticmethod
324372
def _extract_ask_response_content(response: Any) -> str:
@@ -348,8 +396,6 @@ async def _answer_via_llm(
348396
"""通过 LLM 生成回复,不设规则快捷回复"""
349397
from langchain_core.messages import SystemMessage, HumanMessage
350398

351-
self._ensure_llm()
352-
353399
user_content = user_input.strip()
354400
if context and isinstance(context, list):
355401
recent = context[-10:]
@@ -369,6 +415,7 @@ async def _answer_via_llm(
369415
]
370416

371417
try:
418+
self._ensure_llm()
372419
response = await asyncio.wait_for(
373420
self._llm.ainvoke(messages), timeout=30.0
374421
)
@@ -385,8 +432,14 @@ async def _answer_via_llm(
385432
return await self._answer_via_http_fallback(
386433
[{"role": "system", "content": self.system_prompt or ""}, {"role": "user", "content": user_content}]
387434
)
435+
if _is_auth_error(e):
436+
logger.warning(f"QAAgent answer 鉴权失败: {e}")
437+
return _AUTH_ERROR_REPLY
388438
logger.error(f"QAAgent answer LLM 错误: {e}")
389439
return f"回复出错: {e}"
390440
except Exception as e:
441+
if _is_auth_error(e):
442+
logger.warning(f"QAAgent answer 鉴权失败: {e}")
443+
return _AUTH_ERROR_REPLY
391444
logger.error(f"QAAgent answer LLM 错误: {e}")
392445
return f"回复出错: {e}"

0 commit comments

Comments
 (0)