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

+81 -22
View File
@@ -143,12 +143,33 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
f"{hist}\n\n"
f"用户当前问题:{text}"
)
# 磁盘调试日志(不依赖 uvicorn logger)
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply user={user_id} prompt_len={len(prompt)} prompt={repr(prompt)[:500]}\n")
except Exception:
pass
db = None
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
result = _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply RESULT len={len(result) if result else 0} preview={repr(result)[:200]}\n")
except Exception:
pass
return result
except Exception as e:
import traceback as _tb2
import logging
_log = logging.getLogger('im_bot_user')
_log.warning(f'[_qihua_reply] 异常: {e}\n{_tb2.format_exc()[:1000]}')
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply EXCEPTION: {e}\n{_tb2.format_exc()[:800]}\n")
except Exception:
pass
return f"(AI 回复生成失败:{e})"
finally:
if db:
@@ -164,7 +185,7 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getupdates",
headers=_wechat_headers(bot_token),
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}},
json={"get_updates_buf": cursor},
timeout=40,
)
if not resp.ok:
@@ -178,20 +199,26 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list):
"""向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。
ret=-14=会话过期需重扫;ret=-2=token 失效。"""
import uuid as _uuid
payload = {
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": "py-" + str(_uuid.uuid4()),
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": item_list,
},
"base_info": {"channel_version": "1.0.2"},
}
}
last_body = {}
# 07-30 diagnostic
import json as _json3
try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SEND_DEBUG token=" + repr(bot_token[:20] if bot_token else "NULL")
+ " to=" + repr(to_user_id[:30]) + " ctx=" + repr(context_token[:30]) + "\n")
_f.write(" payload_keys=" + str(list(payload.get("msg", {}).keys())) + "\n")
except Exception:
pass
for _ in range(2):
try:
resp = requests.post(
@@ -239,21 +266,21 @@ def _platform_relay_url(raw_url: str) -> str:
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str):
"""发送媒体(图片/视频)并在微信内联渲染。
"""发送媒体(图片/视频)。
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时,
微信里只能看到"链接"而无法内联播放。
解决:改写为平台中转地址(经香港代理可达 AGNES),微信即可内联显示图片/视频。
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时。
解决:改写为平台中转地址(经香港代理可达 AGNES)。
2026-07-31 兼容修复:iLink 对 type=2/5 的媒体 item 返回 ret=-2 invalid arguments,
且旧代码把 ret=-2 误判为 token 失效,导致连接假性断开。先以纯文本链接形式发送,
确保用户可点击访问且不会破坏会话;待拿到 iLink 媒体协议文档后再恢复内联 item。
"""
relay_url = _platform_relay_url(url)
if media_type == "image":
media_item = {"type": 2, "image_item": {"url": relay_url}}
caption = "🖼️ 图片已生成(若未直接显示,请点此查看):" + relay_url
caption = "🖼️ 图片已生成,点击查看:" + relay_url
else:
media_item = {"type": 5, "video_item": {"url": relay_url}}
caption = "🎬 视频已生成(若未直接播放,请点此查看):" + relay_url
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[media_item, {"type": 1, "text_item": {"text": caption}}])
caption = "🎬 视频已生成,点击查看:" + relay_url
return _wechat_send_message(baseurl, bot_token, to_user_id, context_token, caption)
def _wechat_extract_text(msg: dict) -> str:
@@ -326,7 +353,6 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
), {"uid": user_id})
if rows:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
now = _beijing_now()
_execute_raw(db, text(
@@ -338,7 +364,6 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)"
@@ -425,7 +450,14 @@ def _wechat_receiver_loop(account_id: int):
except Exception:
pass
# 先生成回复(此时历史尚未包含本条,避免重复)
import logging as _lrx, inspect as _insp
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})')
# 验证使用的 _call_real_llm 版本
from app.api.im_llm import _call_real_llm as _crl
_src = _insp.getsource(_crl)
_lrx.warning(f'[receiver] _call_real_llm source len={len(_src)}, has _try_models={"_try_models" in _src}')
reply = _qihua_reply(content, owner_uid)
_lrx.warning(f'[receiver] reply ({type(reply).__name__}): {repr(reply)[:200]}')
# 媒体结构化返回:_call_real_llm 生成图片/视频时返回
# {"__media__": True, "media_type": "image"/"video", "url": "..."}
if isinstance(reply, dict) and reply.get("__media__"):
@@ -457,11 +489,23 @@ def _wechat_receiver_loop(account_id: int):
% (ret, repr(sender)))
except Exception:
pass
if ret in (-2, -14):
# 会话/鉴权失效:标记需重扫但继续循环,让下一轮 poll 自然检测过期
if ret == -14:
# -14=会话过期,需重扫
_mark_wechat_expired(d["id"])
elif ret == -2:
# -2=参数错误(如媒体消息格式不被 iLink 接受),并非 token 失效。
# 标记 expired 会导致连接假性断开,后续消息全部丢失,故仅记录。
pass
time.sleep(1)
except Exception:
# 2026-07-31 修复:此前裸 except 静默吞掉所有异常,导致接收器出错时毫无日志、
# 完全无法排查。改为记录异常堆栈到磁盘,便于定位。
import traceback as _tb
try:
with open("/tmp/wechat_receiver_err.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " RECEIVER EXCEPTION:\n" + _tb.format_exc() + "\n")
except Exception:
pass
time.sleep(5)
finally:
db.close()
@@ -555,6 +599,13 @@ async def wechat_qrcode_status(
data = j.get("data", j) if isinstance(j, dict) else j
status = data.get("status")
if status == "confirmed":
# 07-30 diagnostic: log full iLink confirm response
import json as _json
try:
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " CONFIRM_RAW=" + _json.dumps(j, ensure_ascii=False)[:2000] + "\n")
except Exception:
pass
creds = data.get("credentials", {}) or {}
bot_token = data.get("bot_token") or creds.get("bot_token")
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
@@ -590,7 +641,15 @@ async def wechat_qrcode_status(
"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"],
})
row = _wechat_row_for_user(db, user.id)
aid = _row_to_dict(row)["id"] if row else 1
saved = _row_to_dict(row) if row else {}
import json as _json2
try:
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SAVED bot_token=" + repr(saved.get("bot_token", "NULL")[:20])
+ " ilink_bot_id=" + repr(saved.get("ilink_bot_id")) + "\n")
except Exception:
pass
aid = saved.get("id", 1)
_start_wechat_receiver(aid)
finally:
db.close()
+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})"