工具系统:让大模型学会"用工具"
大模型自己不会查天气、不会算数、不会调你公司的内部 API。它唯一能做的是"说出一段话"。工具系统就是把这段话翻译成"真的去执行一个动作",再把结果塞回给模型。今天进 core/tools/,弄清三件事:①Dify 支持哪几种工具、它们如何共享一个抽象基类;②一个工具从"配置"到"能被调用"的运行时是怎么造出来的(ToolManager);③Agent 真正执行一个工具时,中间那层"引擎"(ToolEngine)做了什么。
ToolEngine 就是站在传菜口的那个人——把服务员的喊单递进去、把做好的菜端出来、还顺手记下这道菜花了多久、有没有出错。类比二:五种工具共享一个 Tool 基类,就像各国插头都能插进"万能转换插座"。内置工具、你自己填的 API、别人的工作流、插件、MCP,长相千差万别,但对上层 Agent 来说都只是"一个能 invoke() 的东西"——上层永远不用关心它到底是哪一种。痛点:模型不会算数、不会查实时信息
238476 × 918273,它经常算错。因为大模型的本质是"预测下一个词",它没有联网、没有计算器、更连不上你公司的数据库。可现实业务偏偏需要这些。于是问题变成:怎么让一个"只会说话"的模型,去触发一个"真实的动作",并把动作结果拿回来继续说话?core/tools/ 下的一套体系:所有工具都实现同一个 Tool 基类,由 ToolManager 制造、由 ToolEngine 执行。模型只管"喊",剩下的脏活这套体系全包了。五种工具,一个抽象基类
翻开 core/tools/,你会看到五个"工具家族",每家一个目录:
| 工具类型 | 目录/类 | 是什么 |
|---|---|---|
| 内置工具 BUILT_IN | builtin_tool/tool.py | Dify 自带的常用工具(搜索、绘图等),随代码发布 |
| API 工具 API | ApiTool custom_tool/tool.py:39 | 你贴一份 OpenAPI/Swagger,Dify 帮你把每个接口变成一个工具 |
| 工作流工具 WORKFLOW | WorkflowTool workflow_as_tool/tool.py:40 | 把一整条 Dify 工作流"打包"成一个工具给别人调 |
| 插件工具 PLUGIN | plugin_tool/tool.py | 第三方插件提供的工具(Day18 展开) |
| MCP 工具 MCP | mcp_tool/tool.py | 通过 MCP 协议接入的外部工具(Day18 展开) |
它们全部继承同一个抽象基类 Tool(core/tools/__base/tool.py:22)。基类只强制两件事:一是必须声明"我属于哪种类型"(抽象方法 tool_provider_type),二是必须实现"我被调用时具体干什么"(抽象方法 _invoke,tool.py:102)。
invoke()。这样 Agent 写一遍调用逻辑,就能驱动所有工具。这就是"面向抽象编程"最实在的好处。Tool.invoke:所有工具的统一入口
抽象基类里最关键的是 invoke 方法(core/tools/__base/tool.py:49)——它是"模板方法":统一处理参数、然后把真正干活的部分甩给子类的 _invoke:
# api/core/tools/__base/tool.py:49
def invoke(self, session, user_id, tool_parameters,
conversation_id=None, app_id=None, message_id=None):
if self.runtime and self.runtime.runtime_parameters:
tool_parameters.update(self.runtime.runtime_parameters) # ① 合并运行时预设参数
# try parse tool parameters into the correct type
tool_parameters = self._transform_tool_parameters_type(tool_parameters) # ② 参数类型矫正
result = self._invoke( # ③ ★真正干活:子类实现
session=session, user_id=user_id, tool_parameters=tool_parameters,
conversation_id=conversation_id, app_id=app_id, message_id=message_id,
)
match result: # ④ 统一成 Generator 返回
case ToolInvokeMessage():
def single_generator(): yield result
return single_generator()
case list():
def generator(): yield from result
return generator()
case _:
return result
runtime_parameters工具在运行时可能有"预设参数"(比如某个 API 工具已经绑好了 API Key、baseURL)。这里先把它们合进模型传来的参数里。_transform_tool_parameters_type模型吐出来的参数可能都是字符串(比如 "3"),但工具声明这个参数是数字。这一步按声明把 "3" 转成 3——防止类型不匹配报错。self._invoke(...)★核心委托:基类不知道"这个工具具体怎么执行",交给子类。API 工具就发 HTTP,工作流工具就跑工作流,内置工具就跑那段 Python。这就是模板方法模式:骨架在基类,细节在子类。match result子类返回可能是单条消息、列表、或生成器。基类统一包成 Generator——这样上层永远按"流"来消费,不用判断三种情况。ToolManager:按类型制造"能用的工具"
光有基类还不够——运行时你手上只有一堆配置("用哪家 provider、哪个 tool、哪个租户"),得有人把它变成一个真正能 invoke 的对象。这就是 ToolManager.get_tool_runtime(core/tools/tool_manager.py:178)。它内部用一个大 match 按类型分流:
# api/core/tools/tool_manager.py:178
def get_tool_runtime(cls, provider_type: ToolProviderType, provider_id, tool_name,
tenant_id, user_id=None, invoke_from=InvokeFrom.DEBUGGER,
tool_invoke_from=ToolInvokeFrom.AGENT, credential_id=None
) -> BuiltinTool | PluginTool | ApiTool | WorkflowTool | MCPTool:
match provider_type: # ★按工具类型分流
case ToolProviderType.BUILT_IN:
provider_controller = cls.get_builtin_provider(provider_id, tenant_id)
builtin_tool = provider_controller.get_tool(tool_name)
if not provider_controller.need_credentials: # 不需要凭据 → 直接 fork 一个运行时
return builtin_tool.fork_tool_runtime(runtime=ToolRuntime(...))
... # 需要凭据 → 从库里查 BuiltinToolProvider、校验合规
case ToolProviderType.API: ... # API 工具:加载 OpenAPI + 凭据
case ToolProviderType.WORKFLOW: ... # 工作流工具:绑定目标工作流 app
case ToolProviderType.PLUGIN: ... # 插件工具
case ToolProviderType.MCP: ... # MCP 工具
返回类型是个联合方法签名 -> BuiltinTool | PluginTool | ApiTool | WorkflowTool | MCPTool——五种具体工具类,但都是 Tool 的子类。上层拿到后只当 Tool 用。fork_tool_runtime关键动词。工具的"定义"(叫什么、有哪些参数)是共享的模板,但每次调用的"运行时"(哪个租户、带哪把凭据、从哪触发)是独立的。fork 就是"照着模板复制一份、装上这次的运行时配置"。见 core/tools/__base/tool.py:30 的 fork_tool_runtime。need_credentials有的内置工具(如简单计算)不需要凭据,直接给个空凭据的运行时;需要凭据的(如某搜索 API)才去数据库查 BuiltinToolProvider、还要做凭据合规检查。常见简单情况走快路径,又一次见到这个设计原则。ToolManager类定义在 tool_manager.py:94。它是整个工具体系的"工厂总管"——配置进、运行时工具出。ToolEngine:Agent 调工具的中间层
有了"能 invoke 的工具",Agent 为什么不直接调、非要经过 ToolEngine?因为调用一次工具需要一堆"周边工作":记耗时、抓异常、把二进制结果(图片/文件)落库、把结果转成模型能读的文本。这些统一放进 ToolEngine.agent_invoke(core/tools/tool_engine.py:49)。它内部先走 _invoke(tool_engine.py:206):
# api/core/tools/tool_engine.py:206
def _invoke(session, tool, tool_parameters, user_id,
conversation_id=None, app_id=None, message_id=None):
started_at = datetime.now(UTC)
meta = ToolInvokeMeta(time_cost=0.0, error=None, tool_config={ # ① 准备一张"调用小票"
"tool_name": tool.entity.identity.name,
"tool_provider": tool.entity.identity.provider,
"tool_provider_type": tool.tool_provider_type().value,
"tool_parameters": deepcopy(tool.runtime.runtime_parameters),
"tool_icon": tool.entity.identity.icon,
})
try:
yield from tool.invoke(session, user_id, tool_parameters, # ② ★真正调工具(L03 的 invoke)
conversation_id, app_id, message_id)
except Exception as e:
meta.error = str(e) # ③ 出错也记在小票上
raise ToolEngineInvokeError(meta)
finally:
ended_at = datetime.now(UTC)
meta.time_cost = (ended_at - started_at).total_seconds() # ④ 无论成败都记耗时
yield meta # 小票也当一条消息 yield 出去
ToolInvokeMeta(小票)每次调用都建一张"小票",记下工具名、provider、参数、耗时、错误。这就是 Day19 可观测的数据来源之一——你在 Dify 界面看到的"某工具耗时 1.2s / 报错",就来自它。yield from tool.invoke(...)★这里把 L03 那个统一入口接上了。工具吐出的每条 ToolInvokeMessage 都被透传出去(流式)。try/except/finally异常被捕获、包进 ToolEngineInvokeError 再抛——错误信息带着"小票"一起走,方便定位。finally 保证耗时一定被记录,哪怕工具崩了。agent_invoke 外层调 _invoke 后还做:把图片等二进制结果落库成 MessageFile、用 tool_response_to_str 把结构化结果转成一段"观察文本"喂回模型。这段文本正是 Day17 Agent 循环里的 Observation。工作流也能当工具:套娃的威力
最能体现"统一抽象"威力的是 WorkflowTool(core/tools/workflow_as_tool/tool.py:40)——它把你 Day08-15 学的一整条工作流,伪装成一个普通工具:
# api/core/tools/workflow_as_tool/tool.py:81
def _invoke(self, session, user_id, tool_parameters, ...):
...
from core.app.apps.workflow.app_generator import WorkflowAppGenerator # ★ 就地起一整条工作流
generator = WorkflowAppGenerator()
... # 用 tool_parameters 当工作流输入,跑完把最终输出当工具结果返回
tool_provider_type它照样实现基类要求的 tool_provider_type()(tool.py:72),返回 WORKFLOW。所以上层完全把它当一个普通工具看待。WorkflowAppGenerator_invoke 里直接调起了 Day08 那套工作流引擎。也就是说:"工具里可以跑工作流,工作流的节点里又可以调工具"——形成套娃。意义你把"查订单→算折扣→生成话术"这条工作流打包成工具,别的 Agent 就能一句话调用它,不必知道内部有多少节点。这就是"能力复用"。ToolInvokeMeta)和追踪(Day19)——抽象越深,可观测性越重要。串起来 + 今日小结
{"tool":"calculator","input":{"expression":"238476*918273"}} → Agent 让 ToolManager.get_tool_runtime(BUILT_IN, "...calculator", "calculator", tenant_id) 造出运行时工具 → ToolEngine.agent_invoke(tool, {"expression":"238476*918273"}) → 开小票、计时 → Tool.invoke → 内置工具 _invoke 真算出 219,076,540,748 → 转成观察文本 "219076540748" → 小票记 time_cost=0.003s, error=None → 结果回喂模型 → 模型接着说"计算结果是 2190 亿多"。模型负责"说要算",工具负责"真的算对"。👶 小白:模型是怎么知道"有哪些工具、每个工具要传什么参数"的?
👨🏫 老师:靠"工具描述"。每个工具都有名字、说明、参数 schema,Agent 会把这些描述拼进给模型的提示词里(Day17 的 _convert_tool_to_prompt_message_tool 就干这个)。模型读到"有个 calculator 工具,需要 expression 参数",才会照格式喊单。所以工具的"说明写得好不好"直接影响模型用得对不对——这也是为什么 Dify 里工具描述那么重要。
🧠 今天你应该能回答
- 为什么大模型需要工具?(它只会说话,不能联网/算数/连库)
- Dify 有哪五种工具?它们靠什么统一?(内置/API/工作流/插件/MCP,共享
Tool基类) Tool.invoke和_invoke什么关系?(模板方法:invoke 管通用流程,_invoke 由子类实现具体执行)ToolManager干什么?(按类型match制造"带运行时的工具")- 为什么要有
ToolEngine这层?(记小票/计时/抓错/结果转文本,统一周边工作) - 工作流为什么能当工具?(
WorkflowTool也实现Tool基类,_invoke 里跑工作流)
✋ 10 分钟动手
cd /Users/bitmart/work/codes/github/AI_WORK/dify
# 1. 抽象基类:统一入口
sed -n '22,110p' api/core/tools/__base/tool.py # Tool / invoke / _invoke
# 2. 制造工具:按类型分流
sed -n '178,210p' api/core/tools/tool_manager.py # get_tool_runtime 的 match
# 3. 执行引擎:小票/计时/抓错
sed -n '206,237p' api/core/tools/tool_engine.py # _invoke
# 4. 工作流当工具
sed -n '81,102p' api/core/tools/workflow_as_tool/tool.py
# 看看五种工具目录
ls api/core/tools/
core/agent/,拆开两种 Agent 策略——CoT(思考-行动-观察循环)和 Function Calling,看它们怎么驱动今天这套工具体系,实现"自己想、自己做"。