← 返回主线
03

第 03 站 · 已发布

工具装配

源码锚点 · @7e7f041

第一次读 tools 模块,很多人会以为这里只是注册了一批函数。这个理解只说对了一部分。关键问题是:为什么有的工具明明配置了,却没有暴露给模型?顺着这个问题往下看,才会发现工具的「可见性」不是由单个列表决定的。

先抓住一个判断,后面就容易对齐:工具注册了,不代表这次 agent 就能用。 配置、内置能力、模型能力、沙箱、skill、MCP 和子 agent 策略共同决定这次 run 能动用哪些工具、哪些工具会被过滤、哪些 schema 暂时不暴露给模型。完成这次计算后,才得到传给 create_agent(..., tools=...) 的最终列表。

flowchart TD
SRC["config.yaml · 内置 · MCP · ACP"] --> GA["get_available_tools()"]
GA --> G1["tool_groups 过滤"]
G1 --> G2["host bash 安全闸"]
G2 --> G3["模型能力 · vision"]
G3 --> DD["按 tool.name 去重"]
DD --> SK["skill allowed_tools 过滤"]
SK --> DF["assemble_deferred_tools · MCP 延迟暴露"]
DF --> CA["create_agent(tools=final_tools)"]
从『工具来源』到『这次 run 到底暴露什么』——两段式装配

候选集,不是最终集

主入口在 tools.py get_available_tools()。但请注意:它产出的还不是最终工具列表,只是候选集。 它回答的是一个有限的问题:

按系统配置、内置能力和 runtime 开关,这次 agent 构建最多能拿到哪些候选工具?

它大致按这个顺序走:从 AppConfig.tools 起步 → 按自定义 agent 的 tool_groups 过滤 → 不允许 host bash 就先过滤掉 → 用 resolve_variable(cfg.use, BaseTool) 动态导入 → 补内置工具 → 按开关加 task_tool、按模型能力加 view_image_tool → 接入 MCP / ACP → 最后tool.name 去重

之后回到 agent.py _make_lead_agent(),候选集还要再过两道:skill 级的 allowed_tools 过滤,和 MCP deferred tool 的装配。所以「最终暴露什么」是两段式的——先收集候选,再应用策略。

配置不是工具本身

config.yaml 里的工具长这样:

tools:
  - name: read_file
    group: file:read
    use: deerflow.sandbox.tools:read_file_tool

这里的 use 是 DeerFlow 的约定,格式是 module.path:variable_name,不是 Python 语法。resolve_variable() 把冒号前交给 import_module(),再从模块里取冒号后的变量,并校验它确实是个 BaseTool 实例。

这带来一个关键区别,记住它能少走很多排查弯路:

配置里的 name  =  人读配置时的标签
tool.name      =  暴露给模型、用来路由的名字

最终路由看的是 BaseTool.name,不一定等于 config.yaml 里写的 name。两者一旦不一致,模型看到的 schema 名和运行时路由名就会错位——DeerFlow 会为此打 warning。

可见性是好几道门,不是一张白名单

「工具为什么不见了」之所以难查,是因为它要经过好几层策略,每一层都在不同文件里:

工具不止一种形态

读 tools 模块的第二个误区,是以为每个工具都只是「函数调用」。DeerFlow 的工具至少有四种形态,先认清它们,后面几章会更容易理解:

工具存在,schema 却不一定暴露

MCP 工具最能说明「在列表里,也不一定会暴露给模型」这件事。打开 tool_search.enabled 后,MCP 工具不会把完整 schema 一次性塞给模型——prompt 里只列名字,等模型需要时再调 tool_search 取 schema。理由很实际:

一个 MCP server 可能暴露几十个工具。
全量绑定 schema 会让 prompt 变大、成本变高,也会增加模型选择工具时的噪声。

这条链路横跨多个模块,正是 DeerFlow「一个能力由多层协作完成」的典型( tool_search.py ):

get_available_tools  ->  tag_mcp_tool(t) 打标
assemble_deferred_tools  ->  建 catalog + tool_search,返回 DeferredToolSetup
apply_prompt_template  ->  prompt 只列 deferred 工具名
model 调 tool_search  ->  返回匹配 schema + 写 state.promoted
DeferredToolFilterMiddleware  ->  未 promoted 的 schema 不给模型;直接调就回 error

ThreadState.promoted 的 reducer 用 catalog_hash 做作用域:hash 没变就合并 promoted 名字,hash 变了就替换——这样旧状态里的同名 promotion,不会在 MCP 配置变化后错误地放开另一个工具。

MCP 还有一层运行时适配( tools.py::_make_session_pool_tool ):stdio MCP server 会按 user_id + thread_id 复用 session,并把进程 cwd 和临时目录固定在线程 workspace 下。这样 Playwright 这类 MCP 工具生成截图、trace 或临时文件时,文件会落在 /mnt/user-data/... 能映射到的位置;工具结果里出现的本地文件路径,也会尽量改写成虚拟路径。也就是说,MCP 除了延迟暴露 schema,还要把外部工具产生的文件重新接回 DeerFlow 的 sandbox 路径体系。

动态装配的好处

  • 来源可插拔:config / 内置 / MCP / ACP 一视同仁
  • 可见性按 run 计算,能按模型、沙箱、skill 收敛
  • deferred 避免大量 MCP schema 直接撑大 prompt

代价

  • 动态导入失败得晚(运行期才报错)
  • 一个能力分布在 metadata / 装配 / prompt / state / 中间件多处
  • tool.name 与 config name 可能不一致

易误解的要点


工具装配这一章回答的是:这次 run 的工具候选从哪里来,哪些 schema 会暴露给模型,哪些调用会被策略挡住。它不展开某个工具函数的执行细节。把来源、可见性和执行策略分开,整个工具系统就容易读多了。等工具齐了、图也装好了,下一个会影响运行行为的变量是:中间件的顺序。下一站,我们就看这个顺序怎么变成运行时语义。