跳到主要内容

工具系统:四类工具来源 + 两种函数调用模式

30 秒导读: 一个聊天模型本身只会"说话",不会真的查网页、跑代码、调外部 API。Open WebUI 的工具系统负责两件事:① 把来自四个不同地方的工具(内置、用户写的 Python、OpenAPI 服务器、MCP 服务器)统一装配成一张 tools_dict;② 把这张表按两种方式递给模型——要么交给模型的原生 function calling,要么用一段 prompt 让一个"选工具"的模型替它挑。本章讲清"工具从哪来、怎么变成模型能读的规格、以什么姿势喂给模型"。

本章聚焦装配与规格转换。工具被模型调用之后、结果如何回灌进对话再迭代——那是原生工具调用循环,属于 04 智能体循环;本章讲到"把 tools 交出去 / 单次执行完毕"就停。想先看整条请求怎么走,回 01 请求生命周期;工具只是"特性→能力"装配的一部分,全景见 02 入口装配


1. 这是什么(零基础也能懂)

先建立直觉:模型缺"手脚"

大语言模型只能生成文本。你问它"北京现在几点""帮我查今天的新闻",它没有时钟也没有联网,只能瞎编。工具(tool / function) 就是给模型配的"手脚":我们提前告诉模型"你有一个叫 get_current_timestamp 的函数,不带参数,返回当前时间",模型想用时就吐出一段结构化的"我要调用它"的意图,后端真的去执行,再把结果塞回去。

一个工具在系统里长什么样,其实很简单——两半:

是什么给谁看
spec(规格)一段 JSON:工具叫什么、干什么、要哪些参数模型看,让它决定调不调、怎么填参数
callable(可执行体)一个真正能 await 的 async 函数后端用,模型说要调时后端真的去跑它

Open WebUI 把每个工具都表示成 {'spec': ..., 'callable': ...} 这样一个小字典,再用工具名做 key 汇成一张大表 tools_dict本章的全部内容,就是这张表怎么攒出来、怎么用出去。

两个核心问题

这一套要解决两个不平凡的问题:

  1. 工具来自四个完全不同的世界,格式各异——怎么把它们都翻译成同一种 spec?
  2. 不是所有模型都支持原生 function calling(老模型、本地小模型常常不支持)——不支持怎么办?

这两个问题分别对应本章两条主线:四类来源的装配两种调用模式的分叉


2. 顶层全景(它大概怎么转)

一张图看懂装配流水线

下面这张图从上到下就是一次聊天请求里工具的旅程。怎么读: 左边四个来源各自被"翻译"成统一 spec,汇进中间的 tools_dict;右边是最后的分叉——根据模型支不支持原生 FC,走两条完全不同的路。

四类来源 统一装配 两种调用模式
────────── ──────── ────────────

① 内置工具 ─┐
get_builtin_tools│

② 用户 Python │ ┌──────────────┐ ┌─ native ────────────┐
DB 里的工具模块 ├──翻译成spec─▶│ tools_dict │──分叉──┬──▶│ form_data['tools'] │
│ │ {name→{spec, │ │ │ = 规格数组,交给模型 │
③ OpenAPI 服务器 │ │ callable}} │ │ │ 由模型自己发起调用 │
远端 openapi.json│ └──────────────┘ │ │ (迭代循环见 04 章) │
│ │ └─────────────────────┘
④ MCP 服务器 ─┘ │
MCPClient 连接 │ ┌─ 非 native ─────────┐
└──▶│ 一个 task 模型读 │
统一入口: │ prompt 选工具,产出 │
get_tools() utils/tools.py:221 │ {tool_calls:[...]}, │
(① 在 middleware 单独注入) │ 后端解析并单次执行 │
└─────────────────────┘

部件一句话职责

部件干什么在哪
get_tools统一装配入口:装②用户工具 + ③OpenAPI,逐个转 spec、包 callable、处理重名utils/tools.py:221
get_builtin_tools装①内置工具:按 model meta / 全局开关 / 能力 / 用户权限四重门控挑函数utils/tools.py:430
connect_mcp_server + MCPClient装④MCP:连服务器、列工具、把每个包成 callableutils/middleware.py:2260 / utils/mcp/client.py:54
convert_function_to_pydantic_model把 Python 函数 → JSON schema(规格转换链)utils/tools.py:707
process_chat_payload 尾段真正把四路合并进 tools_dict,再按模式分叉utils/middleware.py:2724-2884
chat_completion_tools_handler非 native 模式的 prompt 式工具选择器utils/middleware.py:1246

主线走一遍(高层)

process_chat_payload 里,合并顺序是固定的(utils/middleware.py:2724-2862):先 MCP(④),再 get_tools(②③),再终端工具,再直连服务器,最后才注入内置(①,且只在 native 模式下)。合完得到 tools_dict,存进 metadata['tools'],然后在 2869 处按 params.function_calling 是否等于 'native' 分叉。


3. 核心原理

3.1 统一的"工具"长什么样:tools_dict

无论工具来自哪一路,装配后都是 tools_dict 里的一个条目,以函数名为 key。以用户 Python 工具为例,一个条目大致是(utils/tools.py:302-311):

# 示意,非源码 —— 一个 tools_dict 条目的形状
tools_dict['get_weather'] = {
'tool_id': 'my_weather_tool', # 来源工具的 id
'callable': callable, # 已冻结好额外参数的 async 函数,后端真正执行它
'spec': spec, # 交给模型看的 JSON 规格(name/description/parameters)
'metadata': { # 杂项:是否是文件处理器、是否引用溯源
'file_handler': ...,
'citation': ...,
},
}

不同来源的条目会多带几个字段做区分:OpenAPI 条目带 'type': 'external'(:413),MCP 条目带 'type': 'mcp''client'(middleware.py:2762),内置带 'type': 'builtin'(:646)。下游就靠这些 type 决定执行时走哪条路。

重名怎么办? 四路工具可能撞名。装配时用一个 while 循环检测:一旦 key 已存在,就给函数名加前缀(工具 id 或 server id)直到不撞(utils/tools.py:314-317:417-420)。

"冻结"额外参数是关键一招。 工具函数常需要 __user____request____id__ 这类后端才知道的上下文,但绝不能让模型去填get_async_tool_function_and_apply_extra_params(utils/tools.py:168)用 functools.partial 把这些参数预先绑死,再从函数签名里删掉它们:

# 示意,非源码 —— 冻结上下文参数,只把模型该填的留在签名里
partial_func = partial(function, **extra_params) # 绑死 __user__ 等
# 重建签名:凡是被冻结的参数,都从签名里剔除
parameters = [p for name, p in sig.parameters.items() if name not in extra_params]
new_function.__signature__ = inspect.Signature(parameters=parameters, ...)

为什么要改签名?注释点破了(:176-178):python-genai 做原生 function calling 时直接读函数签名推断工具参数,不删干净模型就会看到 __user__ 这种内部参数。同理,spec 里所有 __ 开头的属性也会被剔除(utils/tools.py:279-281)。

3.2 四类工具来源

这是本章的核心分类。四类来源在"谁定义、在哪执行、何时装配"上各不相同:

来源工具怎么定义在哪执行装配入口tool_id 形态
① 内置Open WebUI 自带的 Python 函数本进程内get_builtin_toolsbuiltin:<name>
② 用户 Python用户在 UI 里写的 Python 模块本进程内(加载用户模块)get_tools 主循环用户给的工具 id
③ OpenAPI 服务器远端服务的 openapi.json远端 HTTP 调用get_toolsserver: 分支server:<id>
④ MCP 服务器远端 MCP 服务器远端 MCP 协议调用connect_mcp_serverserver:mcp:<id>

① 内置工具(builtin)

内置工具是 Open WebUI 亲手写好的一批函数——时间计算、记忆、联网搜索、生成图片、跑代码、查知识库、笔记、日历……实现全在 tools/builtin.py(如 get_current_timestamptools/builtin.py:77),装配逻辑在 get_builtin_tools(utils/tools.py:430)。

它的精髓是多重门控:一个内置工具要被启用,往往要同时满足四个条件。以联网搜索为例(utils/tools.py:534-540):

# 示意贴近源码 —— 四重门控,缺一不可
if (
is_builtin_tool_enabled('web_search') # ① 模型 meta.builtinTools 开关
and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH') # ② 全局配置开
and get_model_capability('web_search') # ③ 模型声明有此能力
and features.get('web_search') # ④ 本次对话开了这个特性
and await has_user_permission('web_search') # ⑤ 用户有权限(admin 直通)
):
builtin_functions.extend([search_web, fetch_url])
  • is_builtin_tool_enabled(category) 读的是模型的 info.meta.builtinTools[category],默认 True(:448-450)——这就是任务里说的"按 model meta.builtinTools 开关启用"。
  • 时间类工具没有那么多前置条件,builtinTools.time 一开就给(:465-466)。
  • 知识库工具是条件注入:模型挂了知识就只给 query_knowledge_files 之类,没挂就给全套浏览工具(:468-511)。

挑完函数后,get_builtin_tools 逐个:冻结上下文参数 → 用 convert_function_to_pydantic_model 生成 spec → clean_openai_tool_schema 清洗 → 存进 tools_dict(:622-647)。

一个重要边界:内置工具只在 native 模式注入。 它由 process_chat_payloadmiddleware.py:2846 处调用,前置判断就是 function_calling == 'native'。非 native 路径另有一套等价门控(见 middleware.py:3876 附近的注释)。

② 用户 Python 函数工具

用户能在 UI 里写 Python 工具模块,存进数据库。get_tools 的主循环(utils/tools.py:234-319)处理它们:

  1. 鉴权:非本人拥有且无 read 授权就跳过(:238-250)。
  2. 加载模块:load_tool_module_by_id 把用户代码加载成模块,并按内容哈希缓存(:252-256)。
  3. 注入 valves:工具的可配置项 Valves / UserValves(:263-269)。
  4. 逐 spec 装配:注意 spec 不是现算的——它在工具保存时就算好存进 tool.specs(见下方 3.3 的 get_tool_specs)。这里只做几件事:把 'str' 类型修成 'string'(OpenAI 的坑,:274-276)、剔除 __ 内部参数、从 docstring 切出 description(:296-300)。

③ OpenAPI 工具服务器

这类工具是一个远端 HTTP 服务,只要它暴露 openapi.json,Open WebUI 就能把每个 operation 变成一个工具。tool_id 形如 server:<id>,由 get_toolselse 分支处理(utils/tools.py:321 起)。

数据流分两个时机:

  • 缓存期(装配前): get_tool_servers_data(:1278)并发去每个服务器拉 spec——具体抓取在 get_tool_server_data(:1236,支持 JSON/YAML),拿到后用 convert_openapi_to_tool_payload(:860)把 OpenAPI 的 paths/operations 翻译成统一 spec 列表,缓存进 TOOL_SERVERS(必要时落 Redis)。
  • 请求期(装配时): get_tools 从缓存取 specs,鉴权(has_connection_access)、按 function_name_filter_list 过滤,再给每个 spec 包一个 callable——这个 callable 闭包捕获了函数名和鉴权头,被调用时走 execute_tool_server(:1377)真正发 HTTP 请求(:388-406)。
  • 执行期: execute_tool_server(:1377)按 operationId 在 OpenAPI paths 里找到路由,把参数拆成 path / query / body,拼 URL、发请求、解析响应。

convert_openapi_to_tool_payload 里一个值得看的细节是 resolve_schema(:814):OpenAPI 的 requestBody 常用 $ref 指向 components 里的 schema,它会递归展开 $ref,并用一个 resolved_schemas 集合防止循环引用死循环(:828-832)。

④ MCP 服务器

MCP(Model Context Protocol,模型上下文协议——一种标准化的"给模型接外部工具/资源"的协议)服务器的 tool_id 形如 server:mcp:<id>。它不走 get_tools,而是在 process_chat_payload 里单独用 connect_mcp_server(middleware.py:2260)处理:

  1. TOOL_SERVER_CONNECTIONS 里按 id 找到 type == 'mcp' 的连接,鉴权(:2272-2283)。
  2. MCPClient(utils/mcp/client.py:54),connect 用 streamable-HTTP 传输建会话并 initialize(client.py:59-78)。
  3. list_tool_specs(client.py:83)向服务器要工具清单,把每个 MCP tool 的 inputSchema 直接当作 parameters 组成 spec(client.py:100)。
  4. 回到 middleware,给每个 spec 包一个调 client.call_tool 的 callable,函数名统一加 <server_id>_ 前缀防撞,存进 mcp_tools_dict(middleware.py:2743-2765)。

MCPClient.disconnect(client.py:144)那段注释很有教育意义:MCP SDK 要求它的 TaskGroup 必须在同一个 asyncio task 里退出,所以这里刻意不用 asyncio.shield / wait_for / anyio.CancelScope(它们会新建 task 或压新的 cancel scope,破坏 LIFO 退出顺序),而是直接 await exit_stack.aclose()。这是与 MCP SDK 协作的一处硬约束。

3.3 规格转换链:Python 函数 → JSON schema

四类来源里,①②的工具本质是 Python 函数,要变成模型能读的 JSON schema。这条转换链是:

Python 函数
│ convert_function_to_pydantic_model (utils/tools.py:707)
│ 读 type hints + docstring(:param/:return),create_model 生成 Pydantic 模型

Pydantic 模型
│ convert_pydantic_model_to_openai_function_spec (langchain 的 convert_to_openai_function)

OpenAI function spec (原始)
│ clean_openai_tool_schema (utils/tools.py:776) → clean_properties (:749)

清洗后的 spec ── 存进 tool.specs / tools_dict
  • convert_function_to_pydantic_model(:707):核心是读函数签名和类型注解,再用 parse_description(:652)/parse_docstring(:678)从 reST 风格 docstring(:param name: 说明)里抽出函数说明和每个参数的描述,组装成 Pydantic 字段。
  • clean_openai_tool_schema(:776)/clean_properties(:749):干三件脏活——把 Optional[X] 产生的 anyOf: [X, null] 收敛成单类型(:753-759)、删掉值为 None 的 default、给缺 type 的字段补上 'string'(:761-766)。这些都是为了让规格被各家模型 API 接受。
  • get_tool_specs(:798):用户工具保存时的入口——用 get_functions_from_tool(:786)列出模块里所有公开函数(过滤 _ 开头和类),对每个跑一遍上面的链,得到 specs 列表存库。所以 get_tools 请求期能直接读 tool.specs 而不必现算。

OpenAPI(③)和 MCP(④)不走这条 Python 链:③用 convert_openapi_to_tool_payload 从 OpenAPI 文档直接转;④直接拿 MCP 服务器给的 inputSchema

3.4 两种函数调用模式的分叉

tools_dict 攒好后,process_chat_payloadmiddleware.py:2869 分叉。判据只有一个:metadata['params']['function_calling'] == 'native'

native:把规格交给模型自己调

原生模式极简——把每个工具的 spec 包成 OpenAI 的 {type, function} 格式,塞进 form_data['tools'],然后就交给模型 provider 了(middleware.py:2869-2875):

# 示意贴近源码 —— native 模式只是把规格数组交出去
form_data['tools'] = [
{'type': 'function', 'function': tool.get('spec', {})}
for tool in tools_dict.values()
]

之后模型自己决定调不调、返回 tool_calls,后端执行、把结果回灌、再让模型继续——这个迭代循环04 智能体循环 的主题,本章不展开。

非 native:用一个 task 模型当"选工具器"

模型不支持原生 FC 时,走 chat_completion_tools_handler(middleware.py:1246)。思路很聪明:既然模型不会自己调工具,那就让另一个模型(task 模型)读一段 prompt,替它把该调的工具挑出来。

步骤:

  1. 拼 prompt:把所有工具 spec 序列化成 JSON,用 tools_function_calling_generation_template(utils/task.py:401,就是把模板里的 {{TOOLS}} 替换成 specs)填进模板。默认模板(config.py:3372DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE)明确要求模型只返回 JSON,格式是 {"tool_calls": [{"name": ..., "parameters": {...}}]}
  2. 喂给 task 模型:用近 4 条对话历史 + 当前 query 组成 user 消息,stream: False 单次请求(:1263-1310)。
  3. 解析:从返回文本里截出 {...} 那段 JSON,json.loads,拿到 tool_calls 数组(:1321-1326)。
  4. 执行:对每个 tool_call,tool_call_handler(:1328)先按 spec 过滤掉模型多填的非法参数(:1350-1351),再从 tools[name]['callable'] 取出函数直接执行(:1367-1368);结果经 process_tool_result 处理后,作为 citation source 追加进 sources(:1418-1432)。

两种模式最本质的区别在这里:

维度native非 native
谁决定调用模型自己(原生 FC)一个额外的 task 模型读 prompt 挑
交互轮次多轮迭代(调用→回灌→再调,见 04)单次:选一批、执行、结果作为上下文
结果怎么进对话作为 tool 消息回灌,模型接着推理作为 citation/source 拼进后续 prompt
内置工具注入(middleware.py:2846)不走本路径注入

换句话说:native 是"给模型工具,让它边想边用";非 native 是"提前替模型把工具用一遍,把结果当资料塞给它"。后者没有本章 04 讲的那种回灌迭代循环。


4. 巧妙之处(可借鉴的技术)

  • 签名手术让原生 FC 只看见该看的参数。 get_async_tool_function_and_apply_extra_params(utils/tools.py:168)不只是 partial 绑参数,还重建 __signature__ 删掉被绑的参数——因为 genai 靠签名反推工具形状(:176-178)。绑参数容易,想到"还要瞒过读签名的 SDK"才是精髓。
  • spec 预计算,请求期零转换。 用户 Python 工具的 JSON schema 在保存时就由 get_tool_specs(:798)算好存库,请求期 get_tools 直接读 tool.specs,只做几处轻量修补。把重活挪到写路径,读路径就快。
  • 闭包工厂造 callable,避免 late-binding 陷阱。 OpenAPI(:388)和 MCP(middleware.py:2745)都用 make_tool_function(...) 内层函数把 function_name/client 捕获进闭包——这是 Python 循环里生成函数的经典正确写法,防止所有 callable 都绑到最后一次迭代的变量。
  • 非 native 模式:让"选工具"成为一次独立的模型调用。 把工具选择外包给 task 模型 + 严格 JSON 模板(config.py:3372),用一个通用 LLM 模拟了 function calling 能力,让不支持 FC 的模型也能用工具。
  • MCP 断连遵守 SDK 的 task 约束。 MCPClient.disconnect(client.py:144-169)刻意避开 shield/wait_for/CancelScope,直接 aclose(),尊重 MCP SDK 的 TaskGroup 同 task 退出要求——一处不得不"反直觉"的正确写法。

5. 边界与局限(诚实)

  • 内置工具几乎只服务 native 模式。 get_builtin_toolsmiddleware.py:2846function_calling == 'native' 时才调;非 native 路径需要另一套等价门控(middleware.py:3876 附近)才能用上同类能力。
  • 非 native 是单次选择,不迭代。 chat_completion_tools_handler 选一批、执行、把结果当 source——不会像 native 那样"看到工具结果后再决定下一步调什么"。多步工具编排本质上依赖 native 循环。
  • 非 native 把整批 spec 塞进一个 prompt。 工具越多、schema 越大,选工具 prompt 越长,task 模型越容易选错或超上下文;它还依赖模型严格吐 JSON,吐歪了就靠 content.find('{')…rfind('}') 兜底截取(middleware.py:1322),不够稳。
  • OpenAPI spec 是缓存的。 get_tool_servers_data 抓一次缓存进 TOOL_SERVERS/Redis;远端服务器改了接口,不刷新缓存就用的是旧 spec。
  • 重名靠加前缀,不保证语义清晰。 撞名时机械地加 tool_id/server_id 前缀(utils/tools.py:314),模型看到的函数名可能变得冗长。

6. 代码地图(导航索引)

主题文件路径符号名
统一装配入口(②③)backend/open_webui/utils/tools.py:221get_tools
冻结上下文参数 + 签名手术backend/open_webui/utils/tools.py:168get_async_tool_function_and_apply_extra_params
内置工具装配 + 多重门控backend/open_webui/utils/tools.py:430get_builtin_tools
内置工具实现backend/open_webui/tools/builtin.py:77get_current_timestamp
函数→Pydantic 模型backend/open_webui/utils/tools.py:707convert_function_to_pydantic_model
用户工具保存期算 specsbackend/open_webui/utils/tools.py:798get_tool_specs
清洗 OpenAI schemabackend/open_webui/utils/tools.py:776clean_openai_tool_schema / clean_properties
展开 OpenAPI $refbackend/open_webui/utils/tools.py:814resolve_schema
OpenAPI → 工具 payloadbackend/open_webui/utils/tools.py:860convert_openapi_to_tool_payload
拉取 OpenAPI spec(并发/单个)backend/open_webui/utils/tools.py:1278 / :1236get_tool_servers_data / get_tool_server_data
执行 OpenAPI 工具(发 HTTP)backend/open_webui/utils/tools.py:1377execute_tool_server
连接 MCP 服务器backend/open_webui/utils/middleware.py:2260connect_mcp_server
MCP 客户端(连接/列工具/调用/断连)backend/open_webui/utils/mcp/client.py:54MCPClient
四路合并 + native/非 native 分叉backend/open_webui/utils/middleware.py:2724-2884process_chat_payload(尾段)
非 native 的 prompt 式工具选择器backend/open_webui/utils/middleware.py:1246chat_completion_tools_handler
工具选择 prompt 模板backend/open_webui/config.py:3372DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
模板填充backend/open_webui/utils/task.py:401tools_function_calling_generation_template

相邻章节: 工具装配的上游(把"特性"变成能力)见 02 入口装配;工具结果如何回灌模型、原生调用如何迭代见 04 智能体循环;想看用户如何用 pipe/filter 扩展见 05 插件框架。全景与阅读地图见 index