tools-refactor-plan.md 20 KB

工具体系改造方案(Refactor Plan)

本文档是未来规划,不是现状描述。当前工具体系的状态请看 tools.md

当方案落地后,记得把本文档对应的章节删除或合并到 tools.md

背景

本框架的 @tool 注册体系经过一段时间的积累后,暴露了几个结构性问题:

  1. 工具粒度和组织方式是按"后端架构"而不是"任务语义"划分的。典型表现:search_posts(聚合 9 个中文平台)和 x_search(独立)本质上是同一类任务,却因为后端一个统一 endpoint、一个独立 endpoint 就被分成了两个工具
  2. 浏览器工具有 28 个 @tool,LLM 选择负担严重超标
  3. 沙箱工具已经不再需要(原本是给运行工具准备的,但工具已经被提取出来单独处理)
  4. 同一套哲学没有贯彻到所有工具族——toolhub 已经用了"动态发现"模式(search → call),但其他多后端的工具族还是"每个后端一个工具"

本方案解决前两个问题(沙箱直接删除,不需要方案),确立一套统一的工具设计哲学供未来所有新工具族参考。


核心设计哲学:按任务语义划分 + 按规模选择模式

哲学 1:LLM 心智负担分四类

  • 选择负担:一堆工具中挑一个
  • 参数构造负担:知道该工具要哪些参数
  • 流程负担:需要按什么顺序调几次工具
  • 错误恢复负担:失败时怎么修复

工具设计要平衡这四类负担,而不是只优化其中一类。

哲学 2:按"任务语义"而非"后端架构"划分工具

工具的边界应该跟 LLM 的心智模型对齐,而不是跟后端服务的架构对齐。

反例(现状):LLM 看到 search_posts / youtube_search / x_search 三个并列工具,需要记住"中文平台用前者,YouTube 用中者,X 用后者"——这是后端知识泄露到工具层。

正确姿势:LLM 看到一个统一的 content_search(platform, keyword, ...),后端路由对 LLM 不可见。

哲学 3:按"工具族规模"选择静态或动态模式

场景 模式 代表
单一职责工具(正交能力) 静态扁平 read_file / bash_command
小规模异构工具族(3-10 个) 静态扁平 + 良好命名 knowledge_* / sandbox_*
中等规模异构工具族(10-20 个) 语义合并(Literal 枚举动词) 浏览器工具
大规模多实例工具族(20+ 个同类异质) 动态发现(toolhub 模式) 内容搜索、远程工具库

判断标准:工具之间的差异主要在"参数"还是"能力"?

  • 差异在参数(navigate/click/type 都是"DOM 操作",只是参数不同)→ 静态合并,用 Literal 动词
  • 差异在能力(9 个平台各有各的搜索语义和专用参数)→ 动态发现

方案一:内容工具族 → 动态发现模式

现状

工具 后端 平台
search_posts(keyword, channel, ...) aigc-channel.aiddit.com/data 9 个中文平台
select_post(index) 内存缓存 同上
get_search_suggestions(keyword, channel) aigc-channel.aiddit.com/suggest 同上
youtube_search(keyword) crawler.aiddit.com/youtube/keyword YouTube
youtube_detail(content_id, ...) crawler.aiddit.com/youtube/detail + yt-dlp YouTube
x_search(keyword) crawler.aiddit.com/x/keyword X
import_content(plan, data) aigc-channel.aiddit.com/weixin/auto_insert 长文导入(非搜索)
extract_video_clip(...) 本地 ffmpeg 媒体处理(非搜索)

新方案

3 个统一入口 + N 个内部实现函数(非 @tool

@tool()
async def content_platforms() -> ToolResult:
    """列出所有支持的内容平台及其搜索参数 schema。

    建议在 session 开始时调一次,后续 content_search / content_detail 调用时
    依据此返回构造参数。返回内容在 session 内可以缓存。
    """
    return ToolResult(output=json.dumps({
        "xhs": {
            "name": "小红书",
            "backend": "aigc-channel",
            "search_params": {
                "sort_type": {
                    "values": ["综合排序", "最新发布", "最多点赞"],
                    "default": "综合排序"
                },
                "publish_time": {
                    "values": ["不限", "近1天", "近7天", "近30天"],
                    "default": "不限"
                },
                "content_type": {
                    "values": ["不限", "图文", "视频", "文章"],
                    "default": "不限"
                },
                "filter_note_range": {
                    "values": ["不限", "1分钟以内", "1-5分钟", "5分钟以上"],
                    "default": "不限",
                    "note": "仅视频内容生效"
                }
            },
            "detail_mode": "cache_index",
            "extras_example": {
                "sort_type": "最新发布",
                "publish_time": "近7天"
            }
        },
        "youtube": {
            "name": "YouTube",
            "backend": "crawler",
            "search_params": {},
            "detail_mode": "content_id",
            "detail_extras": {
                "include_captions": {"type": "bool", "default": True},
                "download_video": {"type": "bool", "default": False}
            },
            "extras_example": {}
        },
        "x": {
            "name": "X (Twitter)",
            "backend": "crawler",
            "search_params": {},
            "detail_mode": "not_supported",
            "extras_example": {}
        },
        # ... 9 个中文平台 + YouTube + X,总共 11 个
    }))


@tool()
async def content_search(
    platform: str,
    keyword: str,
    max_count: int = 20,
    extras: Optional[Dict[str, Any]] = None,
) -> ToolResult:
    """跨平台内容搜索,返回带索引编号的封面拼图 + 结构化列表。

    参数说明:
        platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
        keyword: 搜索关键词。
        max_count: 返回条数上限,默认 20。
        extras: 平台专用参数。如果不清楚某平台支持什么,先调用 content_platforms
                查看 search_params 字段。xhs 支持 sort_type / publish_time /
                content_type / filter_note_range;YouTube / X 当前无额外参数。

    返回:
        ToolResult.images 含 1 张带索引编号的封面拼图;output 含列表和每条记录的
        元数据。拼图遵循 read_images 的自适应布局规则(最多 12 张)。
    """


@tool()
async def content_detail(
    platform: str,
    identifier: str,
    extras: Optional[Dict[str, Any]] = None,
) -> ToolResult:
    """查看内容详情。identifier 的含义因平台而异(见 content_platforms 的 detail_mode)。

    - xhs / gzh / douyin / ...: identifier 是 content_search 返回的索引(1-based),
      从 session 级缓存取完整记录
    - youtube: identifier 是 video_id;extras 可传 include_captions / download_video
    - x: 当前不支持详情查看
    """

内部实现(不注册给 LLM)

agent/tools/builtin/content/
├── __init__.py           # 空
├── tools.py              # 3 个 @tool 入口
├── registry.py           # PLATFORM_IMPLS 路由表
└── platforms/
    ├── aigc_channel.py   # 9 个中文平台的 search / detail / suggest 实现
    ├── youtube.py        # youtube_search / youtube_detail 纯函数
    └── x.py              # x_search 纯函数

迁移步骤

  1. 新建 agent/tools/builtin/content/ 目录结构
  2. search.pysearch_posts / select_post / get_search_suggestions 逻辑移到 content/platforms/aigc_channel.py,拆成按 channel 分的纯函数
  3. crawler.pyyoutube_search / youtube_detail / x_search 移到 content/platforms/
  4. content/tools.py 写 3 个 @tool 入口,内部调用路由
  5. builtin/__init__.py 删除旧的 search_posts / youtube_search / x_search 等导出
  6. 添加新的 content_platforms / content_search / content_detail 导出
  7. 更新现有 prompt 里对旧工具的引用(破坏性改动
  8. extract_video_clipimport_content 不搬——它们不是搜索工具,保留在原位或移到 content/media.py / content/ingestion.py

未决策的设计问题

  1. extras 的 schema 怎么处理?

    • 方案 i:声明为 Optional[Dict[str, Any]],LLM 从 content_platforms() 返回的 schema 文本里学参数——推荐
    • 方案 ii:为每个平台单独生成 schema(discriminated union),本框架 SchemaGenerator 当前不支持
    • 方案 iii:把常用的平台专用参数都显式列在 content_search 签名里,用 Optional——签名臃肿,不如方案 i
  2. 缓存 _search_cache 的生命周期

    • 现状:进程内字典,进程重启就丢
    • 问题:CLI 模式下每次进程都是新的,content_detail(platform="xhs", identifier=3) 拿不到缓存
    • 方案:用磁盘持久化缓存 /tmp/content_cache_{trace_id}.json,配合之前给 toolhub/librarian 做的 trace_id 三级回退机制,同 session 内 CLI 调用也能复用
  3. 拼图的图片数量是否和 read_images 一致(12 张上限)?

    • read_images 是"让 LLM 看清",12 是硬上限
    • 内容搜索是"让 LLM 浏览",可能需要更多(20-30 条也常见)
    • 建议:区分"详查模式"(layout=detail,≤12)和"概览模式"(layout=overview,≤25,每格更小)
  4. X 的 content_detail 怎么处理?

    • 当前 x_search 没有配对的 detail 工具
    • 要么 content_platforms 里标明 detail_mode: "not_supported",要么后端补一个 X 详情接口

新增后的用户流程

Step 0(session 开始时一次): content_platforms() → 所有平台 schemas
Step 1(每次任务):          content_search(platform, keyword, extras)
Step 2(需要细看时):        content_detail(platform, id, extras)

方案二:浏览器工具族 → 语义合并

现状

28 个 @tool(在 agent/tools/builtin/browser/baseClass.py),按任务语义分组:

类别 数量 工具
导航 4 browser_navigate_to_urlbrowser_search_webbrowser_go_backbrowser_get_live_url
等待 / 人机协同 2 browser_waitbrowser_wait_for_user_action
元素交互 6 browser_click_elementbrowser_input_textbrowser_send_keysbrowser_upload_filebrowser_get_dropdown_optionsbrowser_select_dropdown_option
视口 / 查找 2 browser_scroll_pagebrowser_find_text
内容读取 6 browser_screenshotbrowser_get_visual_selector_mapbrowser_get_selector_mapbrowser_get_page_htmlbrowser_read_long_contentbrowser_extract_content
标签页管理 2 browser_switch_tabbrowser_close_tab
Cookie / 登录 3 browser_ensure_login_with_cookiesbrowser_export_cookiesbrowser_load_cookies
其他 3 browser_download_direct_urlbrowser_evaluatebrowser_done

与 browser-use MCP 的重叠分析

约一半工具和 browser-use 原生 MCP 提供的能力重叠:

你们的 @tool browser-use MCP 状态
browser_navigate_to_url browser_navigate 重复
browser_click_element browser_click 重复
browser_input_text browser_type 重复
browser_scroll_page browser_scroll 重复
browser_go_back browser_go_back 重复
browser_switch_tab / close_tab 同名 重复
browser_extract_content 同名 重复
browser_get_selector_map browser_get_state 部分重复

剩余 14 个是自研扩展(cookie 全家桶、upload、send_keys、dropdown、visual_selector_map、read_long_content、find_text、evaluate、download、done、wait_for_user_action、get_live_url、search_web、get_page_html)。

新方案:语义合并(方案 A)

采用"按动词合并 + Literal 枚举 action"模式,从 28 个 @tool 压缩到约 11 个。不引入 MCP Client 基础设施(那是未来的独立决策)。

目标签名:

@tool()
async def browser_navigate(
    target: str,
    mode: Literal["url", "search", "back"] = "url",
    engine: str = "bing",
    new_tab: bool = False,
) -> ToolResult:
    """导航工具。
    - mode="url": target 为 URL,直接访问
    - mode="search": target 为搜索词,通过 engine 搜索
    - mode="back": 浏览器后退,target 和 engine 忽略
    """


@tool()
async def browser_interact(
    action: Literal["click", "type", "send_keys", "upload", "dropdown_list", "dropdown_select"],
    index: Optional[int] = None,
    text: Optional[str] = None,
    path: Optional[str] = None,
    keys: Optional[str] = None,
    clear: bool = True,
) -> ToolResult:
    """元素交互:
    - click: 需要 index
    - type: 需要 index + text
    - send_keys: 需要 keys(如 'Enter'、'Ctrl+A'),不依赖 index
    - upload: 需要 index + path
    - dropdown_list: 需要 index(列出选项)
    - dropdown_select: 需要 index + text(按文字选中)
    """


@tool()
async def browser_screenshot(highlight_elements: bool = False) -> ToolResult:
    """截图。
    - highlight_elements=False: 纯截图
    - highlight_elements=True: 带交互元素编号的标注截图(原 visual_selector_map)
    """


@tool()
async def browser_elements() -> ToolResult:
    """获取当前页面的可交互元素列表(文本版,不截图)。用于 LLM 按 index 与元素交互。"""


@tool()
async def browser_read(
    mode: Literal["html", "find", "long_content", "extract"],
    query: Optional[str] = None,
    start_line: int = 0,
    lines_per_page: int = 100,
    extract_links: bool = False,
) -> ToolResult:
    """页面内容读取:
    - html: 整页 HTML(大页面慎用)
    - find: 在页面中查找 query 文本
    - long_content: 分页读取长内容,配合 start_line / lines_per_page
    - extract: 用 LLM 根据 query 抽取结构化信息,可选 extract_links
    """


@tool()
async def browser_scroll(
    down: bool = True,
    pages: float = 1.0,
    into_view_index: Optional[int] = None,
) -> ToolResult:
    """滚动页面。down=True 向下,pages 是滚动的页面数;
    传 into_view_index 则滚动到指定元素可见(忽略 down 和 pages)。
    """


@tool()
async def browser_tabs(
    action: Literal["switch", "close", "list"],
    tab_id: Optional[str] = None,
) -> ToolResult:
    """标签页管理。list 不需要 tab_id,switch 和 close 需要。"""


@tool()
async def browser_cookies(
    action: Literal["load", "export", "ensure_login"],
    name: str = "",
    account: str = "",
    url: str = "",
    cookie_type: str = "",
    auto_navigate: bool = True,
) -> ToolResult:
    """Cookie 管理:
    - load: 加载已保存的 cookie(url + name, auto_navigate 控制是否自动导航)
    - export: 导出当前 cookie 保存(name + account 标识)
    - ensure_login: 检查登录状态,未登录时自动加载 cookie_type 对应的 cookie
    """


@tool()
async def browser_wait(
    seconds: Optional[int] = None,
    user_action_message: Optional[str] = None,
) -> ToolResult:
    """等待:
    - 传 seconds: 纯等待指定秒数
    - 传 user_action_message: 暂停并提示用户在浏览器里手动操作,用户完成后 Agent 继续
    - 两者都不传: 报错
    """


@tool()
async def browser_evaluate(code: str) -> ToolResult:
    """在当前页面上下文执行 JavaScript。"""


@tool()
async def browser_download(url: str, save_name: str = "") -> ToolResult:
    """直接下载给定 URL 的文件到本地。"""

可选保留(视使用频率决定):

  • browser_get_live_url() — 远程浏览器场景专用,可能删除
  • browser_done(text, success) — 任务完成信号,可能删除(让 agent 用普通 completion 输出)

28 → 约 11 个 @tool,下降 60%。

条件必填参数的处理

Python 函数签名里所有参数都声明为 Optional,但某些组合是运行时强制的:

async def browser_interact(action, index, text, path, keys, clear):
    if action == "click" and index is None:
        return ToolResult(error="click action requires index")
    if action == "type" and (index is None or text is None):
        return ToolResult(error="type action requires both index and text")
    ...

静态 schema 表达不了这个,只能靠 docstring 说清楚 + 运行时 validate。

迁移步骤

  1. baseClass.py 里先保留所有现有的非 @tool 内部辅助函数(它们负责实际调用 browser-use)
  2. 把 30 个原 @tool 函数去掉 @tool 装饰器,降级为内部函数 _navigate_to_url / _click_element
  3. baseClass.py 底部新增 11 个 @tool 入口函数,每个内部根据 action 路由到对应的内部函数
  4. browser/__init__.py 更新导出列表
  5. 更新 agent/docs/tools.md 的浏览器工具小节
  6. 更新现有的浏览器 prompt(破坏性)

未决策的设计问题

  1. browser_read 的 4 个 mode 是否要再拆分? extract 是 LLM 驱动的,和其他 3 个差异较大。可以拆成 browser_read(mode="html|find|long") + browser_extract(query, ...)
  2. browser_interact 的 6 个 action 都合并合适吗? dropdown_listdropdown_select 与其他 action 的参数差异较大,可以独立出 browser_dropdown(index, select_text=None)
  3. browser_done 的去留 — 这个是给上层 Agent 发任务完成信号的协议约定,不是浏览器操作。建议移到框架通用的 task 信号机制里,或删除。
  4. browser_search_web 要不要作为 mode 合并到 browser_navigate 搜索引擎的具体实现(engine: "bing"|"google"|...)和 URL 导航差异较大,合并后签名变乱。可能独立保留更好。
  5. 重命名的破坏性改动 — 所有 browser_navigate_to_url 等现存引用都要更新。需要在 PR 描述里列出 before/after 对照表。

方案三(暂不采用):引入 MCP Client 基础设施

思路: 让本框架的 Agent Runner 作为 MCP Client 连接 browser-use 原生 MCP,删除所有与 browser-use MCP 重叠的 @tool,只保留自研扩展(cookie、wait_for_user_action、dropdown 等约 14 个)。

优点: 消除代码重复;未来 browser-use 升级自动获益;和 Claude Code 的浏览器体验一致。

缺点: 需要给框架的 Agent Runner 新增 MCP Client 基础设施;启动时需要管理 MCP server 进程生命周期;双路共存(部分本地 @tool + 部分远程 MCP)增加复杂度。

结论: 当前不做,视未来框架是否引入通用 MCP Client 基础设施再议。方案二(语义合并)的收益已经够大,投入更小。


共同原则(所有工具族改造都要遵守)

  1. 破坏性改动集中做——所有重命名、删除、合并都在同一个 PR 里完成,不要分期做。分期反而让用户迁移更痛苦
  2. 每个工具族都要有对应的 CLI 入口 + 自包含 if __name__ == "__main__"——参考 toolhub / librarian 已有的模式
  3. 对应的 skill 写到 ~/.claude/skills/——让 Claude Code 等外部 Agent 能用
  4. 破坏性改动后同步更新 agent/docs/tools.md 和所有现存 prompt

待决策清单(落地前必须定)

内容工具族

  • extras schema 处理方式(推荐方案 i)
  • 缓存持久化方案(推荐磁盘 + trace_id)
  • 拼图上限策略(推荐分 detail/overview 两档)
  • X 是否补 detail 接口(取决于后端支持)

浏览器工具族

  • browser_read 是否拆成 read + extract 两个
  • browser_interact 是否拆出 dropdown
  • browser_done 去留
  • browser_search_web 是否合并到 navigate
  • 重命名破坏性改动的迁移策略

哲学选择

  • 是否未来引入 MCP Client 基础设施(影响浏览器工具的最终形态)

不做的事情

  • 沙箱工具:直接删除,不改造(参考 tools.md 对应修改记录)
  • 文件工具、bash、skill 等正交单能力工具:保持现状
  • knowledge 工具族:已经是 ask_knowledge / upload_knowledge 两个入口,规模小且清晰,无改造必要