数据截至 (上游 commit 5053c08115bd)
工具与子 agent:定义、并行执行、不听话模型的兜底
30 秒导读: 这一章讲 Onyx 怎么给模型接「手脚」。一个工具要交出哪五样东西才能上场(契约)、同一轮里多个工具怎么并发跑而不撞车(并行)、模型没按格式吐工具调用时怎么把它从纯文本里捞回来(兜底)、以及 Deep Research 这种「工具里面还跑一整个 agent」的套娃是怎么嵌进主流的。
本章属于 Onyx 系列的第三章。上下文怎么装配见 上下文工程,一轮对话的循环骨架见 一次对话的生命周期,搜索工具产出的引用如何编号成文见 检索栈。
1. 这是什么(零基础也能懂)
一句话定义: 工具层是「模型想干的事」和「后端真的去干」之间那层胶水。
模型本身只会吐文本。它说「我要搜一下 Q3 财报」,这句话本身什么也不会发生。工具层负责把这句话解析成参数、找到对应的执行器、跑出来、再把结果按模型看得懂的格式塞回对话历史。
这一层难在哪,三件事:
| 难点 | 具体表现 |
|---|---|
| 装配 | 每个用户、每个 persona 能用的工具都不同,还要看后端服务是否活着 |
| 并发 | 模型一次会同时要 3 个搜索,得并行跑,还不能让它们的引用编号互相覆盖 |
| 格式 | 便宜/自建的模型经常不走标准 tool call 通道,把调用当纯文本吐出来 |
Onyx 内置的工具清单(backend/onyx/tools/built_in_tools.py:35 的 BUILT_IN_TOOL_MAP):
| LLM 看到的名字 | 类 | 干什么 |
|---|---|---|
internal_search | SearchTool | 查公司自己索引的文档 |
web_search | WebSearchTool | 查公网 |
open_url | OpenURLTool | 抓取指定 URL 全文 |
python | PythonTool | 在沙箱里跑 Python |
generate_image | ImageGenerationTool | 出图 |
read_file | FileReaderTool | 按字符偏移读用户上传的文件 |
add_memory | MemoryTool | 记住关于用户的事实 |
coding_agent | CodingAgentTool | 克隆某个 GitHub repo 并用 shell 探索它 |
knowledge_graph | KnowledgeGraphTool | 知识图谱(当前在重构中被注释掉,见 tool_constructor.py:367) |
除内置工具外还有两条外部通道:自定义工具(管理员贴一份 OpenAPI schema)和 MCP 工具(接一台 MCP 服务器,它自报有哪些工具)。
一句话直觉: 把 Tool 想成插座标准。模型是插头,插座标准规定「你得能自我介绍、能被启动、能跑、跑完能交出一份给模型看的字符串」——满足这四条,内置工具、OpenAPI 工具、MCP 工具就能插在同一条总线上。
2. 顶层全景(一次工具调用怎么走完)
怎么读这张图: 从上到下是一轮对话里工具的完整生命周期;左边是「谁在做」,右边是关键文件。
┌─────────────────────────────────────────────────────────────┐
│ ① 装配:这轮给模型哪几把刀 │
│ persona 的工具行 + 可用性检查 + 用户开关 │
│ tools/tool_constructor.py │
└───────────────────────────┬─────────────────────────────────┘
│ list[Tool]
▼
┌─────────────────────────────────────────────────────────────┐
│ ② 描述:每把刀写成 JSON 函数签名交给模型 │
│ tool.tool_definition() tools/interface.py │
└───────────────────────────┬─────────────────────────────────┘
│ tool_definitions
▼
┌─────────────────────────────────────────────────────────────┐
│ ③ 收单:模型流式吐 tool call,这里边收边拼 │
│ 走标准通道 ──────────────┐ │
│ 没走标准通道 → 从纯文本里挖 │ chat/llm_step.py │
└───────────────────────────┬─┴───────────────────────────────┘
│ list[ToolCallKickoff]
▼
┌─────────────────────────────────────────────────────────────┐
│ ④ 执行:合并重复 → 分配引用号段 → 线程池并发 → 失败隔离 │
│ tools/tool_runner.py │
└───────────────────────────┬─────────────────────────────────┘
│ list[ToolResponse]
▼
回填进对话历史,进入下一轮(见 01-chat-turn-loop.md)
四段的职责一句话:
| 段 | 干什么 | 主文件 |
|---|---|---|
| 装配 | 把 DB 里的 persona 工具行变成活的 Tool 实例 | backend/onyx/tools/tool_constructor.py |
| 描述 | 每个工具自报 JSON schema | backend/onyx/tools/interface.py |
| 收单 | 把流式 delta 拼成完整调用,格式不对时兜底 | backend/onyx/chat/llm_step.py |
| 执行 | 合并、编号、并发、隔离 | backend/onyx/tools/tool_runner.py |
3. 工具契约:一个工具要交出什么
这节讲「插座标准」本身:Tool 抽象基类要求实现哪些东西,以及两个在总线上跑的信 封数据结构。
3.1 Tool ABC 的五件必答题
抽象基类定义在 backend/onyx/tools/interface.py:15(Tool,泛型参数是「override kwargs」的类型)。必须实现的:
| 成员 | 位置 | 作用 |
|---|---|---|
name | interface.py:38 | 传给 LLM 的函数名(JSON 字段) |
description / display_name | interface.py:44 / :50 | 一个给模型看,一个给人看 |
tool_definition() | interface.py:66 | 完整 JSON schema,直接进 LLM 请求 |
emit_start() | interface.py:73 | 往前端推「我开始跑了」的包 |
run() | interface.py:85 | 真正执行,返回 ToolResponse |
另外两个有默认实现、按需覆盖的钩子:
is_available(db_session)(interface.py:54,默认True)——动态可用性。PythonTool在这里 ping 代码解释器服务的健康检查(python_tool.py:259),服务挂了就当这个工具不存在;FileReaderTool则用它做特性开关(file_reader_tool.py:76,只在DISABLE_VECTOR_DB时开放)。should_emit_argument_deltas()(interface.py:96,默认False)——是否把参数的流式增量推给前端。全仓目前只有PythonTool返回True(python_tool.py:593),因为代码解释器要让用户边生成边看到代码。
run() 的签名有个值得注意的设计:参数分两路进来。
# 示意,非源码
def run(self, placement, override_kwargs, **llm_kwargs) -> ToolResponse:
queries = llm_kwargs["queries"] # 模型给的
start_num = override_kwargs.starting_citation_num # 后端给的
llm_kwargs 是模型填的参数;override_kwargs 是后端塞的、模型不该知道也填不好的东西——比如原始用户提问、消息历史、这次该从几号开始编引用(interface.py:88 的注释直接写明了这个意图)。
3.2 两个信封:ToolCallKickoff 和 ToolResponse
工具总线上只跑两种对象。
去程 ToolCallKickoff(backend/onyx/tools/models.py:68):工具名 + 参数字典 + tool_call_id + placement。它还有个 to_msg_str() 把自己序列化成写回历史用的 JSON(models.py:77)。
回程 ToolResponse(models.py:86):只有两个真正重要的字段,分工非常清楚。
| 字段 | 给谁看 | 说明 |
|---|---|---|
llm_facing_response | 模型 | 一个字符串,直接包成 tool 消息接进历史 |
rich_response | 数据库 / 前端 | 需要落库或渲染的富对象(搜索文档、生成的图、代码产物…) |
这个二分是整个工具层的关键约定:模型只吃字符串,UI 吃对象,两条路互不污染。rich_response 的联合类型(models.py:89-105)就是全部支持的富对象种类。
placement(backend/onyx/server/query_and_chat/placement.py:4)是前端路由包的四元组坐标。完整字段表见 01 章 §6.2;本章只用到其中三维:
turn_index— 第几轮工具循环tab_index— 同一轮里第几个并行工具(前端渲染成 tab)sub_turn_index— 嵌套层。顶层包是None,工具内部再调工具时是整数
第四维 model_index 与工具层无关:它由 Emitter 在投递包时统一盖戳,只服务于多模型对比(backend/onyx/chat/emitter.py:8)。
sub_turn_index 是后面讲子 agent 的伏笔。
3.3 注册表:从类名到 LLM 名字
built_in_tools.py:35 的 BUILT_IN_TOOL_MAP 用类名做键(DB 里存的 in_code_tool_id 就是类名字符串)。但流式收单时拿到的是 LLM 名字,两者对不上,于是有第二张表:
# backend/onyx/tools/built_in_tools.py:64 _build_tool_name_to_class
name_attr = cls.__dict__.get("name")
if isinstance(name_attr, property) and name_attr.fget is not None:
tool_name = name_attr.fget(cls) # 在类对象上直接调 property 的 getter
这段有点绕但很实用:name 在各工具里是 @property,正常要有实例才能读。这里直接从类字典里取出 property 对象、拿 fget 在类上调一次——因为所有内置工具的 name 都只是 return self.NAME,读类常量不需要实例。产物是模块级常量 TOOL_NAME_TO_CLASS(built_in_tools.py:80),流式层用它做 O(1) 反查(chat/tool_call_args_streaming.py:20)。
同文件还有两张按名字分组的小清单:
STOPPING_TOOLS_NAMES(:48)— 跑完就该停下不再循环的工具,目前只有出图。CITEABLE_TOOLS_NAMES(:49)— 会产生引用编号的三个:内部搜索、公网搜索、开 URL。
3.4 装配:construct_tools 按 persona 拼当轮工具集
入口 construct_tools(backend/onyx/tools/tool_constructor.py:143)只做一件事——确保有 DB session,然后转给 _construct_tools_impl(:149)。返回值是 dict[int, list[Tool]],键是 DB 工具 ID(一个 OpenAPI schema 可以展开成多个 Tool,所以值是 list)。
主循环遍历 persona.tools(:205),每行按三条岔路分流:
persona.tools 里的一行
│
├─ 有 in_code_tool_id ──→ 查内置类 → is_available? → 按类名逐个 new
│ (tool_constructor.py:210-340)
│
├─ 有 openapi_schema ──→ 解析 OpenAPI ,一个 endpoint 一个 Tool
│ (tool_constructor.py:343)
│
└─ 有 mcp_server_id ───→ 拉服务器的工具清单,缓存后取本行那个
(tool_constructor.py:403)
四个值得留意的细节:
- 可用性检查是包着 try 的(
:213-219)。is_available自己抛异常时不会炸掉整轮,而是当作「不可用」跳过。对PythonTool这种要发 HTTP 健康检查的实现很关键。 allowed_tool_ids是白名单闸门(:207)。传了就只装这个列表里的,用于「本轮只许用某几个工具」的场景。- MCP 按服务器缓存(
:404与:450的mcp_tool_cache)。同一台 MCP 服务器上的 N 个工具,只拉一次清单,之后按 DB ID 命中缓存。 - 两处绕过 persona 的强制注入:
search_usage_forcing_setting == ENABLED而 persona 又没挂搜索工具时,强行补一个(:478)。- 用户开了
enable_memory_tool时,MemoryTool无视 persona 关联也无视allowed_tool_ids直接注入(:495,注释里明说了 "bypassing")。
一个小瑕疵:
:509-511构造了局部变量tools但 从未使用,函数返回的是tool_dict。是重构残留(inferred)。
4. 并行执行:run_tool_calls 的四道工序
这节讲同一轮里多个工具怎么同时跑。入口是 backend/onyx/tools/tool_runner.py:232 的 run_tool_calls,四道工序按顺序过。
tool_calls (模型给的原始列表)
│
① ├─ _merge_tool_calls:同名的 search/web_search/open_url 合成一个
│
② ├─ 丢掉不认识的工具名 → 按 max_concurrent_tools 截断
│
③ ├─ 逐个 emit_start + 装 override_kwargs
│ 其中带引用的工 具,起始引用号每个 +100
│
④ └─ 线程池并发跑 _safe_run_single_tool(失败不传染)
│
└→ 把新产生的引用映射合并回 citation_mapping
4.1 合并重复调用
模型经常一口气发三个 internal_search,每个一条 query。三次独立执行意味着三次查询扩写、三批文档、三段引用——纯浪费。
_merge_tool_calls(tool_runner.py:63)按工具名分组,命中 MERGEABLE_TOOL_FIELDS(:52)且数量 >1 时,把可合并字段拼成一个列表:
| 工具 | 合并的字段 |
|---|---|
internal_search | queries |
web_search | queries |
open_url | urls |
合并后沿用第一个调用的 tool_call_id 和 placement(:100-104),因为合并结果在 UI 上就是一个 tab。合并逻辑还容错单值:如果模型把 queries 写成了字符串而非数组,会被 str(values) 收进列表(:91-93)。
4.2 引用号分段:每个调用相隔 100
这是全章最值得抄走的一招。
问题: 三个搜索工具并发跑,各自要给找到的文档编引用号。它们互相看不见,谁都从 1 开始编,回来一合并就全撞了。
解法: 派号段。每分配一个会产生引用的工具,起始号就往后推 100(tool_runner.py:383、:384、:392):
# backend/onyx/tools/tool_runner.py:367-377(节选)
override_kwargs = SearchToolOverrideKwargs(
starting_citation_num=starting_citation_num, ...
)
# Estimate: reserve 100 citation slots per search tool
starting_citation_num += 100
于是并发的三个工具分别在 [N, N+100)、[N+100, N+200)、[N+200, N+300) 里自由编号,天然不冲突。起点 next_citation_num 由调用方给(:316),避开已经被项目文件占掉的号段。
代价: 号段稀疏,最终答案里会出现 [1] [103] [207] 这种跳号。真正连续化在渲染前另做(见 检索栈;deep research 里对应 collapse_citations,用在 tools/fake_tools/research_agent.py:722)。这是拿「号码好看」换「无锁并发」,很划算。
4.3 线程池并发
执行本身交给 run_functions_tuples_in_parallel(backend/onyx/utils/threadpool_concurrency.py:278),三个参数决定行为:
| 参数 | 值 | 含义 |
|---|---|---|
allow_failures | True | 某个工具炸了,其它继续,它的位置返回 None |
max_workers | max_concurrent_tools | None 时等于任务数,即全并发 |
timeout | TOOL_EXECUTION_TIMEOUT_SECONDS = 10 分钟(tool_runner.py:53) | 防止单个工具吊死整轮 |
这个工具函数会跨线程传递 contextvars(threadpool_concurrency.py:289),这样每个工作线程里都还能拿到租户 ID 去开 DB session——多租户部署下这是硬需求(见 运行时)。
超时语义有个坑,源码注释写得很直白(threadpool_concurrency.py:296-301):超时只是让主线程不再等,后台线程还在跑,还能继续写共享状态。Python 强杀线程太危险,所以选择了这个折中。
4.4 失败隔离
_safe_run_single_tool(tool_runner.py:118)是每个线程真正执行的函数。它把 tool.run() 裹在三层 except 里,任何异常都不会向上抛,而是变成一个「失败但格式合法」的 ToolResponse:
| 异常类型 | 给模型的字符串来自 | 额外动作 |
|---|---|---|
ToolCallException(models.py:33) | 异常自带的 llm_facing_message | 记 span error |
ToolExecutionException(models.py:44) | str(e) | 记 span error;emit_error_packet 为真时额外推错误包给前端 |
| 其它一切 | str(e) | 记 span error |
ToolCallException 的双消息设计(models.py:36-41)值得单独说:一条给 tracing 看完整技术细节,另一条是写给模型读的——比如 PythonTool 缺 code 参数时,回给模型的是一句带正确示例的话(python_tool.py:369-373)。模型下一轮能照着改。
无论成败都做两件收尾(tool_runner.py:219-228):推一个 SectionEnd 包让前端关掉这个 tab,以及把 tool_call 挂回响应对象上供下游对账。
4.5 并发上限在不同场景的取值
max_concurrent_tools 同时是并发度和总量闸门——超过上限的调用直接丢弃、不排队(tool_runner.py:313-319,注释写明 "drop tool calls beyond the cap")。
| 调用场景 | 取值 | 原因 |
|---|---|---|
| 主对话循环 | None(chat/llm_loop.py:1124) | 不限制,模型要几个跑几个 |
| Deep Research 的研究 agent 内部 | 1(tools/fake_tools/research_agent.py:475) | Placement 没有「嵌套层里的并行」维度,两个并发子工具的包无法区分(源码注释直说) |
5. 模型不听话时的兜底
这节讲整章工程含量最高的部分:模型没走标准 tool call 通道时,怎么把调用从纯文本里捞回来。
自建模型、量化模型、某些代理层不支持 function calling,模型只好把调用写成正文。Onyx 的应对分三层,从「实时防污染」到「事后打捞」再到「一次性闸门」。
模型流式吐 token
│
第一层 ┌─────────────────────────────────────────┐
│ 边流边剥:<function_calls>…</function_calls>│ llm_step.py
│ 整块从展示文本里挖掉,原文另存一份 │ _XmlToolCallContentFilter
└───────────────────┬─────────────────────┘
▼
第二层 ┌─────────────────────────────────────────┐
│ 流结束、没收到任何标准 tool call │ llm_step.py
│ → 从 answer / raw_answer / reasoning 里挖 │ extract_tool_calls_
│ 先试 JSON(4 种格式),再试 XML │ from_response_text
└───────────────────┬─────────────────────┘
▼
第三层 ┌─────────────────────────────────────────┐
│ 整轮只允许兜底一次,挖不到就认输 │ llm_loop.py
└─────────────────────────────────────────┘
5.1 第一层:流式剥离 XML 块
_XmlToolCallContentFilter(chat/llm_step.py:89)是个有状态的流式过滤器,两个状态位:待处理缓冲 _pending 和「是否正处在块内」。
难点在于标记会被切碎。流式 chunk 可能在 <functi 和 on_calls> 之间断开,逐 chunk 做子串匹配一定漏。解法是 _matching_open_marker_prefix_len(:143):吐字之前,先算出缓冲区末尾有多长的一截可能是开标记的前缀,把这一截留在缓冲里不吐。
# backend/onyx/chat/llm_step.py:149-151
for candidate_len in range(max_len, 0, -1):
if text_lower.endswith(marker_lower[:candidate_len]):
return candidate_len
配套的 _find_function_calls_open_marker(:160)还要求标记后面跟的是 > 或空白字符(_is_valid_function_calls_open_follower,:156),免得把正文里的 <function_callsomething> 误判成标记。
流结束时 flush()(:131)做两件事:块内没闭合就整段丢弃;不在块内就把残留吐出来。调用点在 llm_step.py:1393(逐 chunk 过滤)和 :1369(收尾 flush)。
关键设计:过滤只作用于展示文本,原始输出另存。
# backend/onyx/chat/llm_step.py:1350-1353
accumulated_raw_answer += delta.content # 原文,留给兜底解析
filtered_content = xml_tool_call_content_filter.process(delta.content)
if filtered_content:
yield from _emit_content_chunk(filtered_content) # 展示,已剥干净
用户看不到 XML 垃圾,解析器还能拿到完整原文。
5.2 第二层:从文本里挖工具调用
extract_tool_calls_from_response_text(llm_step.py:425)是打捞主函数。它先把工具定义整理成 名字 → schema 的表,然后先 JSON 后 XML。
JSON 路线:用 find_all_json_objects 扫出文本里所有 JSON 对象,逐个丢给 _try_match_json_to_tool(:596)。这个函数按四种格式依次尝试:
| 格式 | 长什么样 | 代码位置 |
|---|---|---|
| 1. 直接调用 | {"name": "internal_search", "arguments": {...}} | :616 |
| 2. 函数包裹 | {"function": {"name": "...", "arguments": {...}}} | :623 |
| 3. 工具名当键 | {"internal_search": {...}} | :632 |
| 4. 参数裸奔 | {"queries": [...]}——靠 schema 反推是哪个工具 | :639 |
格式 4 最激进:只要 JSON 里包含某工具的全部必填参数、且至少命中一个已声明 属性,就认定是它,并把无关键过滤掉(:648-654)。
去重问题: find_all_json_objects 会把外层调用对象和它内嵌的 arguments 对象都返回,两者都能匹配上同一个工具,结果是同一次调用被抽出两遍。_is_nested_arguments_duplicate(:659)配合 _extract_nested_arguments_obj(:669)解决——后者按同样三种格式取出「上一个对象的 arguments 子对象」,若与当前对象完全相等,判定为嵌套伪影跳过(:466-476)。注意它只比对相邻两个对象,是精准而非全局去重。
XML 路线(_extract_xml_tool_calls_from_response_text,:513)只在 JSON 一无所获时才跑(:484)。正则拆 <invoke name="..."> 块(:69)和内层 <parameter name="..." string="...">(:73),值的解析看 string 属性:标为 true 就当字符串,否则试 json.loads(_parse_xml_parameter_value,:565)。
参数还要再洗一遍。 _parse_tool_args_to_dict(:217)处理三种畸形:参数整体是 JSON 字符串、参数是被二次编码的字符串字面量、以及单个值是 JSON 字符串({"queries": '["a","b"]'} → 真数组,见 _try_parse_json_string,:191)。所有字符串都过一遍 sanitize_string 去掉 NULL 字节和代理对——不然写不进 Postgres。