跳到主要内容

数据截至 (上游 commit 5053c08115bd)

工具与子 agent:定义、并行执行、不听话模型的兜底

30 秒导读: 这一章讲 Onyx 怎么给模型接「手脚」。一个工具要交出哪五样东西才能上场(契约)、同一轮里多个工具怎么并发跑而不撞车(并行)、模型没按格式吐工具调用时怎么把它从纯文本里捞回来(兜底)、以及 Deep Research 这种「工具里面还跑一整个 agent」的套娃是怎么嵌进主流的。

本章属于 Onyx 系列的第三章。上下文怎么装配见 上下文工程,一轮对话的循环骨架见 一次对话的生命周期,搜索工具产出的引用如何编号成文见 检索栈


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

一句话定义: 工具层是「模型想干的事」和「后端真的去干」之间那层胶水。

模型本身只会吐文本。它说「我要搜一下 Q3 财报」,这句话本身什么也不会发生。工具层负责把这句话解析成参数、找到对应的执行器、跑出来、再把结果按模型看得懂的格式塞回对话历史。

这一层难在哪,三件事:

难点具体表现
装配每个用户、每个 persona 能用的工具都不同,还要看后端服务是否活着
并发模型一次会同时要 3 个搜索,得并行跑,还不能让它们的引用编号互相覆盖
格式便宜/自建的模型经常不走标准 tool call 通道,把调用当纯文本吐出来

Onyx 内置的工具清单backend/onyx/tools/built_in_tools.py:35BUILT_IN_TOOL_MAP):

LLM 看到的名字干什么
internal_searchSearchTool查公司自己索引的文档
web_searchWebSearchTool查公网
open_urlOpenURLTool抓取指定 URL 全文
pythonPythonTool在沙箱里跑 Python
generate_imageImageGenerationTool出图
read_fileFileReaderTool按字符偏移读用户上传的文件
add_memoryMemoryTool记住关于用户的事实
coding_agentCodingAgentTool克隆某个 GitHub repo 并用 shell 探索它
knowledge_graphKnowledgeGraphTool知识图谱(当前在重构中被注释掉,见 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 schemabackend/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:15Tool,泛型参数是「override kwargs」的类型)。必须实现的:

成员位置作用
nameinterface.py:38传给 LLM 的函数名(JSON 字段)
description / display_nameinterface.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 返回 Truepython_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 两个信封:ToolCallKickoffToolResponse

工具总线上只跑两种对象。

去程 ToolCallKickoffbackend/onyx/tools/models.py:68):工具名 + 参数字典 + tool_call_id + placement。它还有个 to_msg_str() 把自己序列化成写回历史用的 JSON(models.py:77)。

回程 ToolResponsemodels.py:86):只有两个真正重要的字段,分工非常清楚。

字段给谁看说明
llm_facing_response模型一个字符串,直接包成 tool 消息接进历史
rich_response数据库 / 前端需要落库或渲染的富对象(搜索文档、生成的图、代码产物…)

这个二分是整个工具层的关键约定:模型只吃字符串,UI 吃对象,两条路互不污染rich_response 的联合类型(models.py:89-105)就是全部支持的富对象种类。

placementbackend/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:35BUILT_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_CLASSbuilt_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_toolsbackend/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)

四个值得留意的细节:

  1. 可用性检查是包着 try 的:213-219)。is_available 自己抛异常时不会炸掉整轮,而是当作「不可用」跳过。对 PythonTool 这种要发 HTTP 健康检查的实现很关键。
  2. allowed_tool_ids 是白名单闸门:207)。传了就只装这个列表里的,用于「本轮只许用某几个工具」的场景。
  3. MCP 按服务器缓存:404:450mcp_tool_cache)。同一台 MCP 服务器上的 N 个工具,只拉一次清单,之后按 DB ID 命中缓存。
  4. 两处绕过 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:232run_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_callstool_runner.py:63)按工具名分组,命中 MERGEABLE_TOOL_FIELDS:52)且数量 >1 时,把可合并字段拼成一个列表:

工具合并的字段
internal_searchqueries
web_searchqueries
open_urlurls

合并后沿用第一个调用的 tool_call_idplacement: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_parallelbackend/onyx/utils/threadpool_concurrency.py:278),三个参数决定行为:

参数含义
allow_failuresTrue某个工具炸了,其它继续,它的位置返回 None
max_workersmax_concurrent_toolsNone 时等于任务数,即全并发
timeoutTOOL_EXECUTION_TIMEOUT_SECONDS = 10 分钟(tool_runner.py:53防止单个工具吊死整轮

这个工具函数会跨线程传递 contextvarsthreadpool_concurrency.py:289),这样每个工作线程里都还能拿到租户 ID 去开 DB session——多租户部署下这是硬需求(见 运行时)。

超时语义有个坑,源码注释写得很直白(threadpool_concurrency.py:296-301):超时只是让主线程不再等,后台线程还在跑,还能继续写共享状态。Python 强杀线程太危险,所以选择了这个折中。

4.4 失败隔离

_safe_run_single_tooltool_runner.py:118)是每个线程真正执行的函数。它把 tool.run() 裹在三层 except 里,任何异常都不会向上抛,而是变成一个「失败但格式合法」的 ToolResponse

异常类型给模型的字符串来自额外动作
ToolCallExceptionmodels.py:33异常自带的 llm_facing_message记 span error
ToolExecutionExceptionmodels.py:44str(e)记 span error;emit_error_packet 为真时额外推错误包给前端
其它一切str(e)记 span error

ToolCallException 的双消息设计(models.py:36-41)值得单独说:一条给 tracing 看完整技术细节,另一条是写给模型读的——比如 PythonToolcode 参数时,回给模型的是一句带正确示例的话(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")。

调用场景取值原因
主对话循环Nonechat/llm_loop.py:1124不限制,模型要几个跑几个
Deep Research 的研究 agent 内部1tools/fake_tools/research_agent.py:475Placement 没有「嵌套层里的并行」维度,两个并发子工具的包无法区分(源码注释直说)

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 块

_XmlToolCallContentFilterchat/llm_step.py:89)是个有状态的流式过滤器,两个状态位:待处理缓冲 _pending 和「是否正处在块内」。

难点在于标记会被切碎。流式 chunk 可能在 <function_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_textllm_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。

5.3 第三层:一次性闸门

触发与限流在 chat/llm_loop.py:214_try_fallback_tool_extraction。三个触发条件任一成立就打捞(:177-181):

  1. tool_choiceREQUIRED 但一个调用都没收到
  2. 有 reasoning,却既没答案也没调用
  3. 文本长得像 XML 工具调用(_looks_like_xml_tool_call_payloadllm_step.py:184

打捞源按 answer → raw_answer → reasoning 顺序试(:189-210)——注意 raw_answer 是第一层过滤前的原文,正是 XML 被剥掉后唯一还留着调用的地方。

闸门在调用侧llm_loop.py:1074-1076):只要尝试过一次,fallback_extraction_attempted 就永久置位,本轮不再兜底。注释说明了动机——防止差模型陷入「吐垃圾 → 打捞 → 再吐垃圾」的死循环。

_looks_like_xml_tool_call_payload 还有第二个用途:空答案恢复的排除条件。当模型什么正经答案都没吐时,llm_step.py:1462-1467 会把原始输出当答案兜出去,但如果原始输出是 XML 标记就跳过——否则用户会看到一坨裸标记,而这坨东西马上要被第二层解析成真调用。这个函数刻意要求出现 <parameter>:179-183 的注释),因为零参数调用也是合法调用。

5.4 参数增量流式

这是反方向的事:模型正常吐工具调用时,怎么让前端边收边显示参数。

maybe_emit_argument_deltabackend/onyx/chat/tool_call_args_streaming.py:23)先查这个工具是否声明要增量(_get_tool_class + should_emit_argument_deltas:44-46),然后按 tool-call 下标各维护一个 jsonriver.Parser,喂进参数片段、拿回新解析出的内容:

# backend/onyx/chat/tool_call_args_streaming.py:58-65(节选)
deltas = parser.feed(delta_fragment)
for delta in deltas:
if isinstance(delta, dict):
for key, value in delta.items():
if isinstance(value, str):
argument_deltas[key] = argument_deltas.get(key, "") + value

只处理字符串值:38-39 的注释):数字、布尔、数组、对象都跳过,反正最终 kickoff 包里有完整值。这样代码解释器的 code 参数能逐字符流出来,而复杂结构不会流出半个数组这种没法渲染的东西。

调用点在 llm_step.py:1403,紧跟 _update_tool_call_with_delta:347)之后——顺序有依赖,源码注释特意标了。


6. 代表性工具各挑一个要点

这节不逐个讲工具,每个只挑一个别处学不到的点。

工具值得看的一点位置
MCP在 httpx transport 层做 SSRF 防护,而不是只校验配置的 URLmcp/ssrf.py:38
代码执行沙箱不给密钥,出网走 mitmproxy 反向注入sandbox_proxy/credential_injection.py:88
技能包zip 上传要防路径穿越、软链、解压炸弹三件事skills/bundle.py:392
read_file按字符偏移分页读,一次最多 16000 字符file_reader/file_reader_tool.py:43
add_memory写记忆要带上现存记忆和聊天历史,让模型判断是新增还是更新tool_runner.py:406-421
open_url先在自家索引里按 URL 找,找不到才真去抓open_url/open_url_tool.py:486

6.1 MCP:把 SSRF 检查钉在传输层

问题: MCP SDK 自己会从服务器响应里造新 URL——WWW-Authenticate 头、OAuth 元数据发现、重定向、动态客户端注册。只校验管理员配的 server_url 完全不够,恶意服务器一个 302 就能把后端引到 169.254.169.254 去偷云元数据。

解法: 把校验塞进 httpx transport,SDK 发的每一个请求(包括每一跳重定向)都要过一遍。

# backend/onyx/server/features/mcp/ssrf.py:43-45
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
validate_mcp_outbound_url(str(request.url))
return await super().handle_async_request(request)

mcp_ssrf_httpx_client_factory:80)是 SDK 默认客户端工厂的替身,签名兼容、只换掉 transport。校验强度受管理员的 SSRF 等级设置驱动(validate_mcp_outbound_url:21):即便调到 DISABLED云元数据地址和 link-local 仍然拦:24-26)。

模块顶部的 docstring(ssrf.py:1-8)把这个决策的理由写得很清楚,值得一读。(这个模块已从 tools/tool_implementations/mcp/ 挪进 server/features/mcp/,并新增了配套的 OAuth 挑战 transport:_OAuthChallengeTransport:48)用本地伪造的 401 挑战驱动 OAuth 授权流完成,底层委托给同一个 SSRF guard。)

MCP 工具还有两个细节:

  • 头部优先级三层mcp_tool.py:155-175):请求带来的额外头 → 连接配置里的头 → OAuth token。请求头先过 DENYLISTED_MCP_HEADERS 过滤(:155-167),防 Host 头注入,被拦的会记 warning 供安全监控;三层合并由 merge_mcp_headersserver/features/mcp/models.py:81,后写的赢)完成。
  • schema 补丁_normalize_parameters_schemamcp_tool.py:57):MCP 服务器给零参数工具返回 {"type": "object"} 是合法的,但 Azure OpenAI 会拒收缺 properties 的对象 schema,所以这里补一个空的。

历史坑已修(inferred): 旧版 MCPTool 定义了 llm_name 属性生成 mcp:<server>:<tool> 形式的名字但无人读取。现在这条路径活了:装配时若多个 MCP 工具重名,_disambiguate_mcp_tool_namestool_constructor.py:57)会调 use_disambiguated_name()mcp_tool.py:121),把名字换成构造时预洗好的 mcp_{server}_{tool}sanitize_tool_namemcp_tool.py:103)。从历史重建 assistant 消息时的兜底清洗仍在(llm_step.py:711;函数本体 tools/tool_name.py:9)。自定义工具走的是另一条路,在解析 OpenAPI 时就把 operationId 洗好了(custom/openapi_parsing.py:147)。

6.2 代码执行:沙箱、出网代理与凭据注入

代码解释器由 PythonToolpython/python_tool.py:214)经 CodeInterpreterClientpython/code_interpreter_client.py:202)调外部服务执行。客户端有个 @requires 装饰器做服务端版本门禁:73),新方法在老服务端上会抛 CodeInterpreterVersionError 而不是发出去挨 404。

PythonTool 还缓存已上传文件(python_tool.py:239),键是 (文件名, 内容哈希)——同一轮 agent 里反复引用同一个 CSV 不会重复上传。

真正有意思的是沙箱怎么出网。目录 backend/onyx/sandbox_proxy/ 是一个基于 mitmproxy 的出网代理,核心思路一句话:沙箱里不放任何密钥,需要凭据的请求由代理在中途注入

沙箱容器/Pod
│ 发出 HTTPS 请求(不带任何凭据)

┌────────────────────────────────────────────┐
│ sandbox_proxy (mitmproxy) │
│ │
│ ① 认领:源 IP 是哪个租户的哪个用户? │
│ identity_docker.py / identity_k8s.py │
│ ② 判定:这个 URL 属于哪个已连接的外部应用? │
│ request_evaluator.py │
│ ③ 注入:第一个认领的 resolver 补上 auth 头 │
│ credential_injection.py │
└───────────────────┬────────────────────────┘
▼ 带凭据的请求
上游 API

第 ① 步的身份来源是容器编排层,不是请求内容——Docker 环境订阅 Docker events 维护 IP → 身份表(identity_docker.py:99DockerEventsLookup),K8s 环境用 informer 监听 Pod(identity_k8s.py:84K8sInformerLookup)。沙箱伪造不了自己的源 IP,所以这个身份是可信的。启动时代理会等首次同步完成,超过 60 秒就直接退出而不是带着空表放行(server.py:45-47)。

第 ② 步request_evaluator.py:109ExternalAppRequestEvaluator)按租户已连接的外部应用逐个正则匹配 URL。resolve_app_for_url:37)有两个细节:apps 按 ID 排序,模式重叠时小 ID 赢;某个 app 的正则写坏了只跳过它,不连累其它 app 的解析(:53-58)。

第 ③ 步credential_injection.py:88CredentialInjectionDispatcher)是「首个认领者获胜」:

# backend/onyx/sandbox_proxy/credential_injection.py:124-134(节选)
for resolver in self._resolvers:
try:
if resolver.claims(request, ctx):
return resolver
except Exception:
# One buggy resolver must not deny the others a chance.
logger.exception(...)

四种结局用枚举表达(InjectionOutcome:60):直接放行、认领但不注入、注入成功、拒绝。拿不到凭据时 fail closed——CredentialUnavailableError 直接判 BLOCKED:82-89),不会裸奔发出去。日志纪律也写死了:只记头的名字,绝不记值:111)。

6.3 技能包:一个 zip 要过三关

backend/onyx/skills/ 是「技能包」机制:一个 zip,根目录必须有 SKILL.md,推到沙箱的 /workspace/managed/skillspush.py:53)供 agent 读。内置技能(skills/builtin/ 下的 slack、github、gmail、linear、pptx 等)走同一条渲染推送路径,只是它们的 DB 行由迁移脚本或「管理员连接应用」时创建(built_in.py 顶部 docstring)。

管理员上传的自定义包必须过 normalize_custom_bundlebundle.py:392)三关(同时会把外层包裹目录摊平成规范布局):

关卡拦什么实现
路径../../etc/passwd、绝对路径、反斜杠_validated_bundle_path:265-277
软链归档成 Unix symlink 的条目_validated_bundle_path:278-283),读 zip 元数据的 mode 位
体积单文件上限、解压总量上限边流边累加(_copy_validated_bundle_file:330

软链检查读的是 zip 条目元数据(external_attr 的 mode 位)而不是 Path.is_symlink(),因为此刻什么都还没落盘——检查的全部意义就是拒绝落盘(旧版 _is_symlink 里那段解释这个取舍的 docstring 已随重构内联消失)。体积检查也是分块读、边读边判(:349-355 每 64KB 一块),而不是先解压再看大小,这样解压炸弹撑不爆内存。

自定义包还禁止携带 .template 文件(:481-485:595-599),因为模板渲染是内置技能的特权路径(rendering.py)。

流程编排在 ingest.py:46ingest_skill_bundle:校验 → 解析 SKILL.md 的 frontmatter 拿名字和描述 → 算 sha256 → 存 blob,DB 行由调用方负责,失败时调用方要调 delete_bundle_blobingest.py:123)清理。


7. 子 agent:工具里面跑一整个 agent

这节讲 Deep Research——最复杂的一条路径:一次提问会展开成「澄清 → 规划 → 多个并行研究 agent → 汇总报告」,每个研究 agent 内部又是一整个工具循环。

7.1 四步流水线

主循环是 run_deep_research_llm_loopbackend/onyx/deep_research/dr_loop.py:200)。

用户提问


① 澄清步 ─── 模型选择:调工具(继续)还是直接反问(本轮结束等用户)
│ dr_loop.py:251-307

② 规划步 ─── 产出一份文字研究计划,流式推给前端
│ dr_loop.py:312

③ 编排循环 ── 每轮 tool_choice=REQUIRED,模型只能三选一:
│ ├─ think_tool → 写进历史,不算研究轮,continue
│ ├─ research_agent ×N → 并行跑子 agent(下一小节)
│ └─ generate_report → 跳出循环
│ dr_loop.py:432-782

④ 生成报告 ── generate_final_report,最多 20000 token
dr_loop.py:101

开门槛: 模型的 max_input_tokens 低于 50000 直接拒跑(dr_loop.py:227-230)。理由是这套流程要塞系统提示、研究计划、多份中间报告,小窗口模型必然崩,与其中途炸不如一开始就明说。

循环上限分两档:95:98):普通模型 8 轮,推理模型 4 轮。差的 4 轮正是留给 think_tool 的——推理模型自带思考通道,不需要用工具调用模拟。文件里那段列举「0. 研究 1-3 / 1. 思考 / 2. 研究 4-5 …」的注释(:85-94)就是这个数字的来历。

兜底出口有四个,全部通向 generate_final_report:超时 30 分钟(:83:438)、跑到最后一轮(:438)、模型一个工具都没调(:545)、调了但没有一个是 research_agent(:640)。设计倾向很明确——宁可交一份基于现有材料的报告,也不要空手而归

工具集是硬白名单:只有内部搜索、公网搜索、开 URL 三个(:238-239)。

7.2 think tool:给不会思考的模型装一条思考通道

不带原生 reasoning 的模型,怎么让它「想一想再决定」?Onyx 的答案是给它一个假工具 think_tooldeep_research/dr_mock_tools.py:9),schema 只有一个 reasoning 字符串字段。模型调它,就等于在思考。

麻烦在于:这个「思考」得当成 reasoning 流给前端,不能显示成一次工具调用。create_think_tool_token_processordeep_research/utils.py:117)是个塞进流式管线的转换器,把 think_tool 的参数增量改写成 reasoning 增量。

它要现场剥掉 JSON 外壳。_extract_reasoning_chunkutils.py:62)先找 {"reasoning": " 前缀(带空格和不带空格两种变体都试,:71),然后逐块吐内容——但永远留 3 个字符不吐:94):

# backend/onyx/deep_research/utils.py:107-109
if to_emit and to_emit[-1] == "\\":
remaining = to_emit[-1] + remaining
to_emit = to_emit[:-1]

留白是为了两件事:末尾的 "} 结尾符不能吐出去,以及转义序列不能被切成两半。收尾时 to_emit 若以反斜杠结束,还要再退一格。吐之前过一遍 _unescape_json_string:34)——那个函数用占位符先保护 \\,避免把「转义的反斜杠 + n」错解成换行(:47-48)。

处理完的结果在编排层被 check_special_tool_callsutils.py:202)识别出来,写成一对 assistant/tool 消息接进历史(dr_loop.py:614-637),工具响应固定是一句 "Acknowledged, please continue."(dr_mock_tools.py:112)。这轮不计入研究轮数,直接 continue

7.3 研究 agent:sub_turn_index 怎么把子流嵌进主流

编排器决定要研究几个子题后,run_research_agent_callstools/fake_tools/research_agent.py:666)把它们扔进线程池并行跑,每个是一次 run_research_agent_call:207)。

每个子 agent 内部是一个完整的工具循环:拼系统提示、跑 run_llm_steprun_tool_calls、把结果写回自己的历史,最多 MAX_RESEARCH_CYCLES 轮,超过 RESEARCH_AGENT_FORCE_REPORT_SECONDS 强制收尾出中间报告(:259-273)。

包怎么不打架?Placement 四元组里的前三维各司其职(第四维 model_indexEmitter 盖戳,子 agent 不碰):

坐标子 agent 里的取值含义
turn_index沿用父调用的(:218我属于主流程第几轮
tab_index沿用父调用的(:219我是第几个并行子 agent
sub_turn_index子 agent 自己的循环计数(:356我内部走到第几步
# backend/onyx/tools/fake_tools/research_agent.py:353-357
placement=Placement(
turn_index=turn_index,
tab_index=tab_index,
sub_turn_index=llm_cycle_count + reasoning_cycles,
),

前两个是父给的坐标,第三个是自己数的。前端据此把子 agent 的整串包收进对应 tab 里的嵌套时间线。CodingAgentTool 的内部循环用完全一样的手法(tools/fake_tools/coding_agent.py:351)。

代价写在源码注释里research_agent.py:378-380:456-458):Placement 没有「嵌套层里的并行」这一维,所以子 agent 内部不能并行调工具——max_concurrent_tools=1,而且同一轮里多种类型的工具调用会被砍到只剩第一种(:383-387)。这是数据模型的限制传导成了运行时的限制。

超时不丢结果。 _on_research_agent_timeout:623)在超时时合成一份「超时了」的报告返回,而不是留个空洞(:687-688 传入)。这一点很重要,因为下一步有个硬约束。

每个 tool_use 必须有配对的 tool_result。 编排层回填历史时,对失败的子 agent 会合成一条假的失败响应dr_loop.py:746-768),注释解释得极清楚:Bedrock Converse 这类严格 provider 会拿 400 拒绝下一次请求,报错是 "Expected toolResult blocks at messages.N.content for the following Ids"。合成响应既满足了协议不变量,也让模型知道那路研究失败了。

引用怎么合。 子 agent 用 CitationMode.KEEP_MARKERS 跑(:230-232),中间报告里原样保留 [1] [2] 不动;汇总时 collapse_citations:702)统一重编号并合并映射表。这样各子 agent 之间不需要协调编号。


8. 巧妙之处(能带走的五条)

① 引用号按 100 分段,用稀疏换无锁。 并发工具各占一段互不相犯,最后再统一压缩成连续编号。零协调开销(tool_runner.py:383)。

② 失败被翻译成模型能读的话,而不是抛出去。 ToolCallException 带两条消息,一条给 tracing 一条给模型(models.py:36),模型下一轮真能照着改。整轮不会因为一个工具挂掉而中断(tool_runner.py:118)。

③ 展示流和解析流分家。 展示文本剥掉 XML 标记,原文另存一份给兜底解析用(llm_step.py:1392)。既不脏用户的屏,又不丢解析器的料。

④ SSRF 检查钉在 transport 而不是配置。 因为 MCP SDK 会自己从服务器响应里造 URL,只校验入口等于没校验(ssrf.py:38)。

⑤ 沙箱不持有密钥。 凭据由出网代理按可信身份(容器编排层给的 IP 映射)在中途注入,拿不到就 fail closed(credential_injection.py:103)。沙箱被攻破也偷不到东西。


9. 边界与局限(诚实清单)

限制表现依据
嵌套层不能并行子 agent 内部 max_concurrent_tools=1,且同轮只保留第一种工具类型research_agent.py:456:383
超时不等于停止线程超时后主线程继续,后台线程仍在跑、仍会写共享状态threadpool_concurrency.py:296
兜底只有一次整轮打捞失败就认输,不再重试llm_loop.py:1074
超限调用直接丢max_concurrent_tools 超出部分不排队,静默丢弃tool_runner.py:319
嵌套去重只看相邻JSON 打捞的去重只比对相邻两个对象llm_step.py:474
格式 4 匹配偏激进只靠必填参数命中就认定工具,参数名相似的两个工具可能误判(inferred)llm_step.py:647
Deep Research 门槛硬max_input_tokens < 50000 直接抛 RuntimeErrordr_loop.py:227
知识图谱工具停摆装配分支被整段注释,标注「refactor 后坏了」tool_constructor.py:367
MCP 工具重名仅靠装配期消歧_llm_name 只在重名时被 use_disambiguated_name() 采用,不重名就一直是裸工具名(inferred)tool_constructor.py:57

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

主题文件路径符号名
工具抽象基类backend/onyx/tools/interface.pyTool
调用/响应信封backend/onyx/tools/models.pyToolCallKickoffToolResponseToolCallException
前端定位坐标(四元组)backend/onyx/server/query_and_chat/placement.pyPlacement
内置工具注册表backend/onyx/tools/built_in_tools.pyBUILT_IN_TOOL_MAP_build_tool_name_to_classTOOL_NAME_TO_CLASS
工具名清洗backend/onyx/tools/tool_name.pysanitize_tool_name
按 persona 装配backend/onyx/tools/tool_constructor.pyconstruct_tools_construct_tools_impl
并行执行backend/onyx/tools/tool_runner.pyrun_tool_calls_merge_tool_calls_safe_run_single_toolMERGEABLE_TOOL_FIELDS
线程池backend/onyx/utils/threadpool_concurrency.pyrun_functions_tuples_in_parallel
流式剥离 XMLbackend/onyx/chat/llm_step.py_XmlToolCallContentFilter_find_function_calls_open_marker_matching_open_marker_prefix_len
文本打捞工具调用backend/onyx/chat/llm_step.pyextract_tool_calls_from_response_text_try_match_json_to_tool_extract_xml_tool_calls_from_response_text
打捞去重backend/onyx/chat/llm_step.py_is_nested_arguments_duplicate_extract_nested_arguments_obj
参数清洗backend/onyx/chat/llm_step.py_parse_tool_args_to_dict_try_parse_json_string
兜底闸门backend/onyx/chat/llm_loop.py_try_fallback_tool_extraction
参数增量流式backend/onyx/chat/tool_call_args_streaming.pymaybe_emit_argument_delta_get_tool_class
MCP 工具backend/onyx/tools/tool_implementations/mcp/mcp_tool.pyMCPTool_normalize_parameters_schema
MCP SSRF 防护backend/onyx/server/features/mcp/ssrf.py_SSRFGuardAsyncTransportmcp_ssrf_httpx_client_factorymcp_oauth_challenge_httpx_client_factory
代码执行工具backend/onyx/tools/tool_implementations/python/python_tool.pyPythonTool
代码解释器客户端backend/onyx/tools/tool_implementations/python/code_interpreter_client.pyCodeInterpreterClientrequires
沙箱代理入口backend/onyx/sandbox_proxy/server.py_LOOKUP_INITIAL_SYNC_TIMEOUT_S
凭据注入backend/onyx/sandbox_proxy/credential_injection.pyCredentialInjectionDispatcherInjectionOutcome
请求归属判定backend/onyx/sandbox_proxy/request_evaluator.pyExternalAppRequestEvaluatorresolve_app_for_url
沙箱身份来源backend/onyx/sandbox_proxy/identity_docker.pyidentity_k8s.pyDockerEventsLookupK8sInformerLookup
技能包校验backend/onyx/skills/bundle.pynormalize_custom_bundle_validated_bundle_path_copy_validated_bundle_file
技能包入库backend/onyx/skills/ingest.pyingest_skill_bundle
技能包推送backend/onyx/skills/push.pySKILLS_MOUNT_PATH
内置技能注册backend/onyx/skills/built_in.pyBUILTIN_SKILLS_PATHBuiltInSkillDefinition
Deep Research 主循环backend/onyx/deep_research/dr_loop.pyrun_deep_research_llm_loopgenerate_final_reportMAX_ORCHESTRATOR_CYCLES
think tool 处理backend/onyx/deep_research/utils.pycreate_think_tool_token_processorcheck_special_tool_calls
研究子 agentbackend/onyx/tools/fake_tools/research_agent.pyrun_research_agent_callrun_research_agent_calls_on_research_agent_timeout
编码子 agentbackend/onyx/tools/fake_tools/coding_agent.pyrun_coding_agent_call

继续阅读: 工具调用回填到历史后的循环控制见 一次对话的生命周期;工具响应字符串怎么和系统提示、文件一起装进窗口见 上下文工程;搜索工具内部的查询扩写与引用生成见 检索栈