feat: FC 工具去重 + 按场景动态裁剪,降低 AI 每轮负担

方案 A(去重):
- mcp_to_openai_tools 末尾按 function.name 去重,消除 cache+builtin 叠加
  造成的同名重复(memory/scheduler/lsp__* 等),FC 工具 42→36,去掉
  约 14% 的垃圾 schema token
- get_liugin_usage_prompts 的 plugins 段由逐条 name:desc 改为工具名清单,
  消除与 FC tools 字段的双重曝光,保留无 FC 模型的回退清单

方案 B(分层裁剪):
- 新增 prune_tools(fc_tools, user_input): 常驻 ALWAYS(tool_search/memory/
  scheduler) + 外部桥接(mcp__)默认保留 + 按关键词信号追加对应工具
  (代码/文件->code_editor+file_manager+lsp__*; git/shell/浏览器/搜索/子agent/
  工程化各按信号)
- cli_core 两处 FC 构建点(TUI/CLI)接入 prune_tools(current_input)

实测(AICLI): 纯聊天 FC token ~4310->285(降93%),写代码 ~973(降77%)

测试: 新增 tests/test_prune_tools.py (7 passed),全量 324 passed 无回归
This commit is contained in:
shuiyu67 committed 2026-08-06 20:06:51 +08:00
1 parent 1054932921
commit b577b0ff21
3 files changed
+239 -13

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
"""
测试:FC 工具去重 + 按场景动态裁剪(方案 A+B)。
不依赖真实插件/引擎加载,用构造的 fake 数据验证:
- mcp_to_openai_tools 按 name 去重(消除 cache + builtin 叠加重复)
- prune_tools 分层裁剪:纯聊天极简、代码信号追加 lsp__*、外部 mcp 默认保留
"""
import pytest
from xcli_core.fc_tools import mcp_to_openai_tools, prune_tools
def _fc(name, desc="d"):
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
def _mcp(name, desc="d"):
"""MCP 格式(get_mcp_tools 返回的就是这种)"""
return {
"name": name,
"description": desc,
"inputSchema": {"type": "object", "properties": {}, "required": []},
}
class _FakePM:
"""最小 plugin_manager:get_mcp_tools 返回预置列表。"""
def __init__(self, tools):
self._tools = tools
def get_mcp_tools(self):
return self._tools
# ───────────── 去重 ─────────────
def test_mcp_to_openai_tools_dedup():
# 模拟真实场景:cache 里已有 memory/scheduler,_get_builtin_tools 又追加一遍
pm = _FakePM([
_mcp("a"), _mcp("a"), # 同名重复
_mcp("b"),
_mcp("memory"), _mcp("scheduler"), # cache 已有
])
out = mcp_to_openai_tools(pm)
names = [t["function"]["name"] for t in out]
assert names.count("a") == 1, "同名工具应去重为 1"
assert names.count("memory") == 1, "builtin 与 cache 重复应去重"
assert names.count("scheduler") == 1
assert "b" in names
# builtin 追加的 memory/scheduler 不应再产生第二份
assert names.count("memory") == 1
# ───────────── 分层裁剪 ─────────────
_CORE = ["tool_search", "memory", "scheduler", "code_editor", "code_search",
"file_manager", "lsp__diagnostics", "lsp__hover", "lsp__definition",
"lsp__references", "lsp__completion", "browser_auto", "network_tools",
"ai_search", "git_tools", "cmd_executor", "sub_agent", "auto_engineer"]
def _full_set():
return [_fc(n) for n in _CORE]
def test_prune_pure_chat_keeps_only_always():
out = prune_tools(_full_set(), "你好啊,今天天气真不错")
names = {t["function"]["name"] for t in out}
assert names == {"tool_search", "memory", "scheduler"}, \
f"纯聊天应只留常驻工具,实际: {names}"
def test_prune_code_signal_adds_lsp():
out = prune_tools(_full_set(), "帮我写一个 python 脚本读取文件")
names = {t["function"]["name"] for t in out}
assert "code_editor" in names
assert "file_manager" in names
assert "lsp__diagnostics" in names, "写代码应追加 pylsp 的 lsp__* 工具"
assert "lsp__completion" in names
assert "tool_search" in names
def test_prune_git_signal():
out = prune_tools(_full_set(), "提交代码并查看 git 状态")
names = {t["function"]["name"] for t in out}
assert "git_tools" in names
assert "tool_search" in names
def test_prune_external_mcp_kept_even_in_chat():
tools = [_fc("my_mcp__search"), _fc("code_editor"), _fc("tool_search")]
out = prune_tools(tools, "你好")
names = {t["function"]["name"] for t in out}
assert "my_mcp__search" in names, "外部桥接工具(mcp__)应默认保留"
assert "code_editor" not in names, "未命中信号的低频工具应被裁剪"
assert "tool_search" in names
def test_prune_empty_input_no_crash():
out = prune_tools(_full_set(), "")
names = {t["function"]["name"] for t in out}
assert names == {"tool_search", "memory", "scheduler"}
def test_prune_none_tools_returns_none():
assert prune_tools(None, "任意输入") is None
assert prune_tools([], "任意输入") == []