第一次读 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)"]
候选集,不是最终集
主入口在 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_name。resolve_variable() 把冒号前交给 import_module(),再从模块里取冒号后的变量,并校验它确实是个 BaseTool 实例。
这带来一个关键区别,记牢它能省你半天排查:
配置里的 name = 人读配置时的标签
tool.name = 真正暴露给模型、用来路由的名字
最终路由看的是 BaseTool.name,不一定等于 config.yaml 里写的 name。两者一旦不一致,模型看到的 schema 名和运行时路由名就会错位——DeerFlow 会为此打 warning。
可见性是好几道门,不是一张白名单
「工具为什么不见了」之所以难查,是因为它要经过好几层策略,每一层都在不同文件里:
tool_groups—— 自定义 agent 的粗粒度筛选条件。配了就只留对应 group 的工具。- host bash 安全闸 —— 即使
config.yaml配了bash,也要先过is_host_bash_allowed(config);不允许就过滤掉。这是条安全边界,不是普通开关。 - 模型能力 ——
view_image_tool只有在选中的模型声明supports_vision时才追加。 - skill 策略 ——
tool_policy.py的filter_tools_by_skill_allowed_tools():没有任何 skill 声明allowed_tools时保持「全放行」,一旦有 skill 显式声明,就只保留声明集合里的名字。这是最小权限在工具层的落点。
工具不止一种形态
读 tools 模块的第二个误区,是以为每个工具都只是「函数调用」。DeerFlow 的工具至少有四种形态,认清它们,后面好几章都省力:
- 普通执行工具 ——
@tool包出来的BaseTool,模型发tool_call、ToolNode 执行函数。read_file、web_search、bash都是。 - 状态更新工具 ——
present_files不只返回文本,而是返回 LangGraphCommand(update={"artifacts": ...}),直接写入图状态,前端再据此渲染产物。 - 控制流工具 ——
ask_clarification的函数体只是个占位,真正行为在ClarificationMiddleware:它拦下调用、Command(..., goto=END)停住当前 run,等用户下一条消息。需要人补充信息时,应当把它建模成一次「中断」,而不是让工具阻塞等待。 - 委派工具 ——
task是子 agent 入口:继承父级沙箱/模型/工具策略,用subagent_enabled=False构造子工具集(避免递归暴露task),后台跑SubagentExecutor,再把结果当 tool result 还回来。
工具存在,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 可能不一致
会绊倒你的地方
config name不等于tool.name。 最终路由看BaseTool.name。两者不一致时,模型看到的名字和运行时路由的名字会错位。- host bash 是安全边界,不是普通开关。 配了不代表放行,得过
is_host_bash_allowed(config)。 - MCP 有独立的「新鲜度」路径。
get_available_tools()收了app_config,但 MCP 扩展会再从磁盘读一次(按 mtime 判断 cache 是否过期),以免跨进程拿到旧配置。 - 「可见」不等于「可执行」。 deferred MCP 工具仍被 ToolNode 持有、可执行,只是 schema 先不暴露给模型——没 promoted 就直接调,会被中间件挡回。
- 去重有优先级。 顺序是 config → 内置 → MCP → ACP,同名时先出现的保留。因为路由按名字走,重名会让 schema 歧义。
工具装配这一章回答的是:这次 run 的工具候选从哪里来,哪些 schema 会暴露给模型,哪些调用会被策略挡住。它不是在讲某个工具函数怎么执行。把来源、可见性和执行策略分开,整个工具系统就容易读多了。等工具齐了、图也装好了,真正决定行为的下一个变量才浮出水面:中间件的顺序。下一站,我们就看这个顺序怎么变成运行时语义。