← 返回主线
03

第 03 站 · 已发布

工具装配

架构 risk ●●●●
源码锚点 · @0fb18e3

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

先抓住一个判断,后面就容易对齐:这一章讲的不是函数注册表,而是一次 run 的工具可见性计算。 配置、内置能力、模型能力、沙箱、skill、MCP 和子 agent 策略共同决定哪些工具会加入、过滤,哪些 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 不是 Python 语法,而是 DeerFlow 的一个约定:module.path:variable_nameresolve_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 配置变化后错误地放开另一个工具。

动态装配的好处

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

代价

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

会绊倒你的地方


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