数据截至 (上游 commit 01f4282f1ffe)
工具系统:四类工具来源 + 两种函数调用模式
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。本章的全部内容,就是这张表怎么攒出来、怎么用出去。
两个核心问题
这一套要解决两个不平凡的问题:
- 工具来自四个完全不同的世界,格式各异——怎么把它们都翻译成同一种 spec?
- 不是所有模型都支持原生 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:267 |
get_builtin_tools | 装①内置工具:按 model meta / 全局开关 / 能力 / 用户权限四重门控挑函数 | utils/tools.py:520 |
connect_mcp_server + MCPClient | 装④MCP:连服务器、列工具、把每个包成 callable | utils/middleware.py:2197 / utils/mcp/client.py:59 |
convert_function_to_pydantic_model 等 | 把 Python 函数 → JSON schema(规格转换链) | utils/tools.py:860 起 |
process_chat_payload 尾段 | 真正把四路合并进 tools_dict,再按模式分叉 | utils/middleware.py:2738-2922 |
chat_completion_tools_handler | 非 native 模式的 prompt 式工具选择器 | utils/middleware.py:1139 |
主线走一遍(高层)
在 process_chat_payload 里,合并顺序是固定的(utils/middleware.py:2738-2900):先 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:353-362):
# 示意,非源码 —— 一个 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:2776),内置带 'type': 'builtin'(:646)。下游就靠这些 type 决定执行时走哪条路。
重名怎么办? 四路工具可能撞名。装配时用一个 while 循环检测:一旦 key 已存在,就给函数名加前缀(工具 id 或 server id)直到不撞(utils/tools.py:365-368、:417-420)。
"冻结"额外参数是关键一招。 工具函数常需要 __user__、__request__、__id__ 这类后端才知道的上下文,但绝不能让模型去填。get_async_tool_function_and_apply_extra_params(utils/tools.py:189)用 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:330-332)。
3.2 四类工具来源
这是本章的核心分类。四类来源在"谁定义、在哪执行、何时装配"上各不相同:
| 来源 | 工具怎么定义 | 在哪执行 | 装配入口 | tool_id 形态 |
|---|---|---|---|---|
| ① 内置 | Open WebUI 自带的 Python 函数 | 本进程内 | get_builtin_tools | builtin:<name> |
| ② 用户 Python | 用户在 UI 里写的 Python 模块 | 本进程内(加载用户模块) | get_tools 主循环 | 用户给的工具 id |
| ③ OpenAPI 服务器 | 远端服务的 openapi.json | 远端 HTTP 调用 | get_tools 的 server: 分支 | server:<id> |
| ④ MCP 服务器 | 远端 MCP 服务器 | 远端 MCP 协议调用 | connect_mcp_server | server:mcp:<id> |
① 内置工具(builtin)
内置工具是 Open WebUI 亲手写好的一批函数——时间计算、记忆、联网搜索、生成图片、跑代码、查知识库、笔记、日历……实现全在 tools/builtin.py(如 get_current_timestamp 在 tools/builtin.py:149),装配逻辑在 get_builtin_tools(utils/tools.py:520)。
它的精髓是多重门控:一个内置工具要被启用,往往要同时满足四个条件。以联网搜索为例(utils/tools.py:673-679):
# 示意贴近源码 —— 四重门控,缺一不可
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_payload 在 middleware.py:2862 处(if use_builtin_tools:)调用,前置判断 use_builtin_tools(middleware.py:2643-2649)要求 function_calling != 'legacy'(native 为默认)且模型开了 builtin_tools 能力。legacy 路径另有一套等价门控(见 middleware.py:4125 附近的注释)。
② 用户 Python 函数工具
用户能在 UI 里写 Python 工具模块,存进数据库。get_tools 的主循环(utils/tools.py:283-370)处理它们:
- 鉴权:非本人拥有且无
read授权就跳过(:238-250)。 - 加载模块:
load_tool_module_by_id把用户代码加载成模块,并按内容哈希缓存(:252-256)。 - 注入 valves:工具的可配置项
Valves/UserValves(:263-269)。 - 逐 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_tools 的 else 分支处理(utils/tools.py:372 起)。
数据流分两个时机:
- 缓存期(装配前):
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:2197)处理:
- 在
TOOL_SERVER_CONNECTIONS里按 id 找到type == 'mcp'的连接,鉴权(:2272-2283)。 - 建
MCPClient(utils/mcp/client.py:59),connect用 streamable-HTTP 传输建会话并initialize(client.py:64-83)。 list_tool_specs(client.py:88)向服务器要工具清单,把每个 MCP tool 的inputSchema直接当作parameters组成 spec(client.py:105)。- 回到 middleware,给每个 spec 包一个调
client.call_tool的 callable,函数名统一加<server_id>_前缀防撞,存进mcp_tools_dict(middleware.py:2757-2779)。
MCPClient.disconnect(client.py:149)那段注释很有教育意义: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:860)
│ 读 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_payload 在 middleware.py:2908 分叉。判据只有一个:metadata['params']['function_calling'] != 'legacy'(native 是默认,显式 legacy 才走老路)。
native:把规格交给模型自己调
原生模式极简——把每个工具的 spec 包成 OpenAI 的 {type, function} 格式,塞进 form_data['tools'],然后就交给模型 provider 了(middleware.py:2908-2913):
# 示意贴近源码 —— 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:1139)。思路很聪明:既然模型不会自己调工具,那就让另一个模型(task 模型)读一段 prompt,替它把该调的工具挑出来。
步骤:
- 拼 prompt:把所有工具 spec 序列化成 JSON,用
tools_function_calling_generation_template(utils/task.py:407,就是把模板里的{{TOOLS}}替换成 specs)填进模板。默认模板(config.py:2381的DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE)明确要求模型只返回 JSON,格式是{"tool_calls": [{"name": ..., "parameters": {...}}]}。 - 喂给 task 模型:用近 4 条对话历史 + 当前 query 组成 user 消息,
stream: False单次请求(:1263-1310)。 - 解析:从返回文本里截出
{...}那段 JSON,json.loads,拿到tool_calls数组(:1321-1326)。 - 执行:对每个 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:2862) | 不走本路径注入 |
换句话说:native 是"给模型工具,让它边想边用";非 native 是"提前替模型把工具用一遍,把结果当资料塞给它"。后者没有本章 04 讲的那种回灌迭代循环。
4. 巧妙之处(可借鉴的技术)
- 签名手术让原生 FC 只看见 该看的参数。
get_async_tool_function_and_apply_extra_params(utils/tools.py:189)不只是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:2759)都用make_tool_function(...)内层函数把function_name/client捕获进闭包——这是 Python 循环里生成函数的经典正确写法,防止所有 callable 都绑到最后一次迭代的变量。 - 非 native 模式:让"选工具"成为一次独立的模型调用。 把工具选择外包给 task 模型 + 严格 JSON 模板(
config.py:2381),用一个通用 LLM 模拟了 function calling 能力,让不支持 FC 的模型也能用工具。 - MCP 断连遵守 SDK 的 task 约束。
MCPClient.disconnect(client.py:149-172)刻意避开 shield/wait_for/CancelScope,直接aclose(),尊重 MCP SDK 的 TaskGroup 同 task 退出要求——一处不得不"反直觉"的正确写法。
5. 边界与局限(诚实)
- 内置工具几乎只服务 native 模式。
get_builtin_tools由middleware.py:2862在use_builtin_tools(含function_calling != 'legacy')为真时才调;legacy 路径需要另一套等价门控(middleware.py:4125附近)才能用上同类能力。 - 非 native 是单次选择,不迭代。
chat_completion_tools_handler选一批、执行、把结果当 source——不会像 native 那样"看到工具结果后再决定下一步调什么"。多步工具编排本质上依赖 native 循环。 - 非 native 把整批 spec 塞进一个 prompt。 工具越多、schema 越大,选工具 prompt 越长,task 模型越容易选错或超上下文;它还依赖模型严格吐 JSON,吐歪了就靠
content.find('{')…rfind('}')兜底截取(middleware.py:1222),不够稳。 - OpenAPI spec 是缓存的。
get_tool_servers_data抓一次缓存进TOOL_SERVERS/Redis;远端服务器改了接口,不刷新缓存就用的是旧 spec。 - 重名靠加前缀,不保证语义清晰。 撞名时机械地加 tool_id/server_id 前缀(
utils/tools.py:365),模型看到的函数名可能变得冗长。
6. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 统一装配入口(②③) | backend/open_webui/utils/tools.py:267 | get_tools |
| 冻结上下文参数 + 签名手术 | backend/open_webui/utils/tools.py:189 | get_async_tool_function_and_apply_extra_params |
| 内置工具装配 + 多重门控 | backend/open_webui/utils/tools.py:520 | get_builtin_tools |
| 内置工具实现 | backend/open_webui/tools/builtin.py:149 | get_current_timestamp 等 |
| 函数→Pydantic 模型 | utils/tools.py:860(backend/open_webui/utils/tools.py) | convert_function_to_pydantic_model |
| 用户工具保存期算 specs | backend/open_webui/utils/tools.py:974 | get_tool_specs |
| 清洗 OpenAI schema | backend/open_webui/utils/tools.py:932 | clean_openai_tool_schema / clean_properties |
展开 OpenAPI $ref | backend/open_webui/utils/tools.py:990 | resolve_schema |
| OpenAPI → 工具 payload | backend/open_webui/utils/tools.py:1035 | convert_openapi_to_tool_payload |
| 拉取 OpenAPI spec(并发/单个) | backend/open_webui/utils/tools.py:1454 / :1236 | get_tool_servers_data / get_tool_server_data |
| 执行 OpenAPI 工具(发 HTTP) | backend/open_webui/utils/tools.py:1553 | execute_tool_server |
| 连接 MCP 服务器 | backend/open_webui/utils/middleware.py:2197 | connect_mcp_server |
| MCP 客户端(连接/列工具/调用/断连) | backend/open_webui/utils/mcp/client.py:59 | MCPClient |
| 四路合并 + native/非 native 分叉 | backend/open_webui/utils/middleware.py:2738-2922 | process_chat_payload(尾段) |
| 非 native 的 prompt 式工具选择器 | backend/open_webui/utils/middleware.py:1139 | chat_completion_tools_handler |
| 工具选择 prompt 模板 | backend/open_webui/config.py:2381 | DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE |
| 模板填充 | backend/open_webui/utils/task.py:407 | tools_function_calling_generation_template |
相邻章节: 工具装配的上游(把"特性"变成能力)见 02 入口装配;工具结果如何回灌模型、原生调用如何迭代见 04 智能体循环;想看用户如何用 pipe/filter 扩展见 05 插件框架。全景与阅读地图见 index。