Day 16 / 共 20 天 · 阶段5 工具与 Agent

工具系统:让大模型学会"用工具"

大模型自己不会查天气、不会算数、不会调你公司的内部 API。它唯一能做的是"说出一段话"。工具系统就是把这段话翻译成"真的去执行一个动作",再把结果塞回给模型。今天进 core/tools/,弄清三件事:①Dify 支持哪几种工具、它们如何共享一个抽象基类;②一个工具从"配置"到"能被调用"的运行时是怎么造出来的(ToolManager);③Agent 真正执行一个工具时,中间那层"引擎"(ToolEngine)做了什么。

📍 你在 20 天里的位置(阶段5:工具与 Agent · D16-18)
D15 工作流收尾 D16 工具系统 D17 Agent D18 插件/MCP D19 任务队列/可观测 D20 收官全景
💡 先用两个类比兜住今天 类比一:工具系统像餐厅后厨的"传菜口"。大模型是前厅服务员,它只会"喊单"(说出要调哪个工具、参数是什么);真正下厨的是各个工具(查天气、发邮件、跑 SQL)。ToolEngine 就是站在传菜口的那个人——把服务员的喊单递进去、把做好的菜端出来、还顺手记下这道菜花了多久、有没有出错。类比二:五种工具共享一个 Tool 基类,就像各国插头都能插进"万能转换插座"。内置工具、你自己填的 API、别人的工作流、插件、MCP,长相千差万别,但对上层 Agent 来说都只是"一个能 invoke() 的东西"——上层永远不用关心它到底是哪一种。
L01

痛点:模型不会算数、不会查实时信息

🤔 痛点你问 GPT "今天上海几度",它要么瞎编一个数,要么老实说"我无法获取实时信息"。你让它算 238476 × 918273,它经常算错。因为大模型的本质是"预测下一个词",它没有联网、没有计算器、更连不上你公司的数据库。可现实业务偏偏需要这些。于是问题变成:怎么让一个"只会说话"的模型,去触发一个"真实的动作",并把动作结果拿回来继续说话?
💡 本质:工具 = 把"函数调用"包装成模型能理解的东西解法叫 Tool(工具)/ Function Calling。核心分两半:①把每个工具"描述"成模型看得懂的格式(名字、干嘛用的、需要哪些参数)——模型据此决定"要不要调、传什么参数";②真有一段代码在后端等着执行这次调用。Dify 把第二半统一抽象成 core/tools/ 下的一套体系:所有工具都实现同一个 Tool 基类,由 ToolManager 制造、由 ToolEngine 执行。模型只管"喊",剩下的脏活这套体系全包了。
L02

五种工具,一个抽象基类

翻开 core/tools/,你会看到五个"工具家族",每家一个目录:

工具类型目录/类是什么
内置工具 BUILT_INbuiltin_tool/tool.pyDify 自带的常用工具(搜索、绘图等),随代码发布
API 工具 APIApiTool custom_tool/tool.py:39你贴一份 OpenAPI/Swagger,Dify 帮你把每个接口变成一个工具
工作流工具 WORKFLOWWorkflowTool workflow_as_tool/tool.py:40把一整条 Dify 工作流"打包"成一个工具给别人调
插件工具 PLUGINplugin_tool/tool.py第三方插件提供的工具(Day18 展开)
MCP 工具 MCPmcp_tool/tool.py通过 MCP 协议接入的外部工具(Day18 展开)

它们全部继承同一个抽象基类 Toolcore/tools/__base/tool.py:22)。基类只强制两件事:一是必须声明"我属于哪种类型"(抽象方法 tool_provider_type),二是必须实现"我被调用时具体干什么"(抽象方法 _invoketool.py:102)。

大白话记住这句话就够:五种工具"外形各异、接口统一"。不管它背后是一段内置代码、一个 HTTP 接口、还是一整条工作流,对上层来说都长一个样——都能 invoke()。这样 Agent 写一遍调用逻辑,就能驱动所有工具。这就是"面向抽象编程"最实在的好处。
L03

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——这样上层永远按"流"来消费,不用判断三种情况。
💡 为什么统一返回 Generator(流)?工具的输出可能很大(比如爬一个网页、返回一张图),也可能是一段段产生的。用生成器"边产生边消费",内存占用小、还能让前端尽早看到中间结果。这和 Day05 里模型流式输出是同一个思路——Dify 全链路都偏爱"流"而不是"一次性大块"
L04

ToolManager:按类型制造"能用的工具"

光有基类还不够——运行时你手上只有一堆配置("用哪家 provider、哪个 tool、哪个租户"),得有人把它变成一个真正能 invoke 的对象。这就是 ToolManager.get_tool_runtimecore/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:30fork_tool_runtime
need_credentials有的内置工具(如简单计算)不需要凭据,直接给个空凭据的运行时;需要凭据的(如某搜索 API)才去数据库查 BuiltinToolProvider、还要做凭据合规检查。常见简单情况走快路径,又一次见到这个设计原则。
ToolManager类定义在 tool_manager.py:94。它是整个工具体系的"工厂总管"——配置进、运行时工具出。
L05

ToolEngine:Agent 调工具的中间层

有了"能 invoke 的工具",Agent 为什么不直接调、非要经过 ToolEngine?因为调用一次工具需要一堆"周边工作":记耗时、抓异常、把二进制结果(图片/文件)落库、把结果转成模型能读的文本。这些统一放进 ToolEngine.agent_invokecore/tools/tool_engine.py:49)。它内部先走 _invoketool_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
一次工具调用的传菜口(ToolEngine) Agent 喊单工具名+参数 ToolEngine开小票·计时·抓错 Tool.invoke_invoke 真执行 外部世界API/DB 结果 → 转成 Observation 文本 + 图片落库 + 小票(耗时/错误) 回喂给模型继续思考(Day17)
图注:Agent 喊单 → 引擎开小票/计时 → 工具真执行 → 结果转成观察文本回喂。小票就是 Day19 可观测的原料。
L06

工作流也能当工具:套娃的威力

最能体现"统一抽象"威力的是 WorkflowToolcore/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 就能一句话调用它,不必知道内部有多少节点。这就是"能力复用"。
💡 设计取舍:抽象带来的复用 vs. 调试难度统一抽象让"工作流当工具、工具里跑工作流"变得优雅,复用性极强。代价是调用链变深:一次报错可能穿过 Agent → ToolEngine → WorkflowTool → 工作流引擎 → 里面的另一个工具,栈很长。所以 Dify 才要在每层都留"小票"(ToolInvokeMeta)和追踪(Day19)——抽象越深,可观测性越重要
L07

串起来 + 今日小结

📝 真实值:Agent 调"计算器工具"算 238476 × 918273 模型输出 {"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/
明日预告 · Day 17:工具准备好了,谁来"决定用哪个工具、按什么顺序用"?答案是 Agent。明天进 core/agent/,拆开两种 Agent 策略——CoT(思考-行动-观察循环)Function Calling,看它们怎么驱动今天这套工具体系,实现"自己想、自己做"。
← Day 15 Day 17 · Agent 两种策略 →