# 工具体系改造方案(Refactor Plan) > ✅ **方案一(内容工具族)** 和 **方案二(浏览器工具族)** 已于 2026-04-12 完成落地。 > 下方保留原始方案文档供参考。沙箱工具已于此前删除。 > > 当前工具体系的状态请看 [`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)** ```python @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.py` 的 `search_posts` / `select_post` / `get_search_suggestions` 逻辑移到 `content/platforms/aigc_channel.py`,拆成按 channel 分的纯函数 3. 把 `crawler.py` 的 `youtube_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_clip` 和 `import_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_url`、`browser_search_web`、`browser_go_back`、`browser_get_live_url` | | 等待 / 人机协同 | 2 | `browser_wait`、`browser_wait_for_user_action` | | 元素交互 | 6 | `browser_click_element`、`browser_input_text`、`browser_send_keys`、`browser_upload_file`、`browser_get_dropdown_options`、`browser_select_dropdown_option` | | 视口 / 查找 | 2 | `browser_scroll_page`、`browser_find_text` | | 内容读取 | 6 | `browser_screenshot`、`browser_get_visual_selector_map`、`browser_get_selector_map`、`browser_get_page_html`、`browser_read_long_content`、`browser_extract_content` | | 标签页管理 | 2 | `browser_switch_tab`、`browser_close_tab` | | Cookie / 登录 | 3 | `browser_ensure_login_with_cookies`、`browser_export_cookies`、`browser_load_cookies` | | 其他 | 3 | `browser_download_direct_url`、`browser_evaluate`、`browser_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 基础设施**(那是未来的独立决策)。 **目标签名:** ```python @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,但某些组合是运行时强制的: ```python 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_list` 和 `dropdown_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` 两个入口,规模小且清晰,无改造必要