fix: 双Claw能力对齐 + MCP/LLM代理绕过(P0止血)

- 新增 app/core/capability_discovery.py:统一能力发现层,Web/IM 双Claw共用单一实现
  (三级降级:用户安装 → 平台published → 相关性裁剪;MCP 自动挂载已启用服务)
- im_llm.py:IM Bot Claw 改用统一发现层,恢复 skills(12) + MCP(26工具) 能力,
  修复 #866 部署回退导致的 agents=[]/mcp=[] 回归;mcp__ 前缀分流到 call_mcp_tool
- mcp_client.py:注入 httpx_client_factory(trust_env=False) 绕过容器 HTTP_PROXY,
  修复外部 MCP(xiaobenyang.com / anysearch.com) tools/list 全部静默失败(mcp_tools 0→26)
- llm_client.py / im_bot_user.py:2026-07-31 代理劫持内部请求 + 媒体断连修复(随镜像已上线)
This commit is contained in:
QihuAI Design committed 2026-08-04 10:21:19 +08:00
1 parent 9a43ee244d
commit 29a1ce2e54
5 files changed
+575 -55

No files matched your search

+114 -12
View File
@@ -59,6 +59,17 @@ def _proxy_for_url(url: str) -> str | None:
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
_DEBUG_LOG_PATH = "/tmp/im_llm_debug.log"
def _debug_log(msg: str):
"""写磁盘调试日志(不依赖 uvicorn logging 配置)。"""
try:
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as _f:
_f.write(datetime.now(timezone(timedelta(hours=8))).isoformat() + " " + msg + "\n")
except Exception:
pass
def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
@@ -126,7 +137,15 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_discover_installed_skills,
_build_system_prompt,
_execute_skill_tool,
_list_enabled_mcp_servers,
)
# 2026-08-04 双 Claw 对齐:能力发现收敛为唯一实现,Web / IM 共用
from app.core.capability_discovery import (
build_mcp_tool_defs,
discover_mcp_servers,
discover_skills,
)
from app.mcp_client import call_mcp_tool
# ===== 1) 媒体意图路由(Issue 7)=====
# 2026-07-30 fix: 只对当前用户消息做媒体意图检测,避免历史中的图像请求污染
@@ -169,22 +188,58 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_model = resolve_auto_model("text", db)
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
# Auto 模式附加备选(IM Bot 始终 Auto)
# 2026-07-31 根因修复:仅选用「纯文本」模型作为 failover 备选。
# 此前按 is_enabled 顺序取前 4 个,会混入图像/视频/语音(ASR)模型
# (这些模型 capability 含 "text" 但本质是媒体模型,收到文本 prompt 返回空内容、
# 不抛异常 → 被误判为「模型失败」,最终导致「已尝试N个模型均调用失败」)。
_MEDIA_CAPS = {"image", "video", "audio", "asr", "embedding", "rerank", "ocr"}
def _is_text_only(m):
_caps = m.capabilities or []
return "text" in _caps and not any(c in _MEDIA_CAPS for c in _caps)
_fallbacks = db.query(AIModel).filter(
AIModel.is_enabled == True,
AIModel.id != (ai_model.id if ai_model else ""),
).order_by(AIModel.is_default.desc()).limit(4).all()
_try_models.extend([m for m in _fallbacks if m not in _try_models])
).order_by(AIModel.is_default.desc()).all()
_fallbacks = [m for m in _fallbacks if _is_text_only(m)]
_try_models.extend([m for m in _fallbacks[:4] if m not in _try_models])
_debug_log(f"TRY_MODELS user={user_id} count={len(_try_models)} names={[m.name for m in _try_models]}")
if not _try_models:
return "(未配置可用文本模型,无法生成回复)"
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
# 能力自动发现(提前执行一次,不必每个候选模型重复做)
# 2026-08-04 根因修复:
# 旧实现 `_discover_installed_skills(db, user_id)[:15]` 强依赖
# app_installations 表,线上该表 0 行 → 97 个 published skill
# 在 Web / IM 双端同时发现不到;`[:15]` 截断的是一个空列表。
# 现改走统一发现层(安装优先 → published 降级 → 相关性裁剪)。
_query_hint = _cur_msg or prompt
skills = []
if user_id:
try:
skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
skills = []
try:
skills = discover_skills(db, user_id, query=_query_hint, claw="im")
except Exception as _e:
logger.warning(f"[im_llm] 技能发现失败(降级为空): {_e}")
skills = []
# MCP 自动挂载:IM Bot Claw 没有勾选 UI,自动发现即其等价通道。
# (Web Claw 靠用户在界面勾选 → effective_mcp_ids)
mcp_servers = []
mcp_function_definitions = []
mcp_tool_map = {}
try:
mcp_servers = discover_mcp_servers(db, None, claw="im")
if mcp_servers:
mcp_function_definitions, mcp_tool_map = _run_async(
build_mcp_tool_defs(mcp_servers), timeout=45
)
except Exception as _e:
logger.warning(f"[im_llm] MCP 发现失败(降级为空): {_e}")
mcp_servers, mcp_function_definitions, mcp_tool_map = [], [], {}
_debug_log(
f"CAP skills={len(skills)} mcp_servers={len(mcp_servers)} "
f"mcp_tools={len(mcp_function_definitions)}"
)
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
kb_context = ""
@@ -213,13 +268,18 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
continue
# agents 暂留空:线上 market_apps 中 type='agent' 记录为 0,
# 且 IM 侧尚无 agent 执行分支;待薄壳化接入 EngineCore 后自动获得。
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[],
kb_context=kb_context,
user_content=prompt,
mcp=[],
mcp=mcp_servers,
mcp_functions=mcp_function_definitions,
auto_discovered=bool(skills),
all_mcp=_list_enabled_mcp_servers(db),
db=db,
)
messages = [
{"role": "system", "content": system_prompt},
@@ -233,7 +293,7 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_http_ok = True
for round_num in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
if function_definitions and skills:
if function_definitions:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
@@ -241,6 +301,7 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
try:
import logging as _l5
_l5.warning(f'[im_llm REQ] {ai_model.name}: url={api_url[:60]}, proxy={_proxy_for_url(api_url)}, api_key={api_key[:20]}..., messages_count={len(request_body.get("messages", []))}, tools_count={len(request_body.get("tools", []))}')
_debug_log(f"REQ model={ai_model.name} url={api_url} proxy={_proxy_for_url(api_url)} body={json.dumps(request_body, ensure_ascii=False)[:600]}")
data = chat_complete(
api_url,
api_key,
@@ -249,11 +310,13 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
timeout=ai_model.timeout or 120,
)
_l5.warning(f'[im_llm RES] {ai_model.name}: data_keys={list(data.keys()) if data else None}, resp_type={type(data).__name__}')
_debug_log(f"RES model={ai_model.name} data={json.dumps(data, ensure_ascii=False)[:600]}")
except Exception as _exc:
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
import traceback as _tb
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
logger.warning(f"[im_llm] {ai_model.name} 异常详情: {_tb.format_exc()[:2000]}")
_debug_log(f"EXC model={ai_model.name} err={str(_exc)[:200]} trace={_tb.format_exc()[:500]}")
_http_ok = False
break
message = data.get("choices", [{}])[0].get("message", {})
@@ -265,10 +328,11 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_msg_keys = list(message.keys()) if message else []
_tool_calls = tool_calls
_lcr.warning(f'[CRITICAL] MODEL={ai_model.name} msg_keys={_msg_keys} content_len={len(_raw_content or "")} reasoning_len={len(_raw_reasoning or "")} has_tool_calls={bool(_tool_calls)}')
_debug_log(f"PARSE model={ai_model.name} content_len={len(_raw_content or '')} reasoning_len={len(_raw_reasoning or '')} content_preview={repr(_raw_content)[:120]}")
ai_content = message.get("content", "") or message.get("reasoning_content") or ""
if not ai_content and (message.get("reasoning_content") or ""):
ai_content = f"(AI 思考:{message.get('reasoning_content')[:500]}...)"
if tool_calls and skills:
if tool_calls and (skills or mcp_tool_map):
messages.append({
"role": "assistant",
"content": message.get("content") or message.get("reasoning_content") or "",
@@ -280,7 +344,40 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
if func_name.startswith("mcp__"):
# MCP 工具:命名规则与 Web Claw 完全一致
# mcp__{server}__{tool}__{idx}
_info = mcp_tool_map.get(func_name)
if not _info:
tool_result = (
"[MCP 工具未找到] 可用工具: "
+ str(list(mcp_tool_map.keys())[:5])
)
else:
_srv = next(
(s for s in mcp_servers
if s.get("id") == _info.get("server_id")),
None,
)
if not _srv:
tool_result = f"[MCP 服务未找到: {_info.get('server_name')}]"
else:
try:
tool_result = _run_async(
call_mcp_tool(_srv, _info["name"], func_args),
timeout=90,
)
except Exception as _mexc:
tool_result = (
f"[MCP 工具 {_info.get('name')} 执行失败:"
f"{str(_mexc)[:160]}]"
)
_debug_log(
f"MCP_CALL name={func_name} "
f"result_len={len(tool_result or '')}"
)
else:
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
@@ -294,20 +391,25 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
# 检查本轮调用是否成功
import logging as _log2
_log2.warning(f'[im_llm trace] {ai_model.name}: http_ok={_http_ok}, ai_content_len={len(ai_content) if ai_content else 0}, ai_content_preview={repr(ai_content)[:200]}')
_debug_log(f"CHECK model={ai_model.name} http_ok={_http_ok} ai_content_len={len(ai_content) if ai_content else 0}")
if _http_ok and ai_content:
_debug_log(f"SUCCESS model={ai_model.name} returning len={len(ai_content)}")
return ai_content or "(AI 回复为空)"
# 本轮失败:记录日志,切换备选
logger.warning('[im_llm] 模型 %s 失败 (http_ok=%s, content=%s), 切换备选...' % (
ai_model.name, _http_ok, repr(ai_content)[:100]
))
_debug_log(f"FAIL model={ai_model.name} http_ok={_http_ok} content_preview={repr(ai_content)[:100]}")
continue
except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
_debug_log(f"OUTER_EXC model={ai_model.name} err={str(exc)[:120]}")
continue
# 全部失败
_summary = "; ".join(_retry_log[-3:])
_debug_log(f"ALL_FAILED try_models={len(_try_models)} retry_log={_retry_log}")
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
except Exception as e:
return f"(AI 回复生成失败:{e})"