跳到主要内容

工具装配:搜索抽象、Tavily 摘要管线、MCP 集成与鉴权

30 秒导读: 前几章讲了谁在指挥(supervisor)、谁在干活(researcher ReAct 循环)。这一章只回答一个问题:researcher 手里那套"手脚"(工具)是从哪来的、长什么样。 答案是一个叫 get_all_tools 的装配总线——它在 researcher 每一步现场把三类工具拼成一份清单:固定的收尾/反思工具、按配置挑的一种搜索后端、按白名单过滤的 MCP 工具。其中 Tavily 搜索背后藏着最精华的一段:并行搜索 → 去重 → 并行摘要 → 截断 → 超时兜底。

本章聚焦 utils.py。它上游的编排见 01-orchestration-graph,这些工具怎么被 researcher 循环调用见 02-supervisor-researcher-loop,工具产出的笔记怎么被压缩见 03-compression-token-limits


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

一个 research agent(研究智能体)本质上是"会说话的大脑 + 一副手脚"。大脑是 LLM,负责决定"下一步搜什么、够不够了";手脚就是工具(tools)——真正去联网搜索、去调外部服务、去反思的那些函数。

这一章讲的就是手脚是怎么被组装出来、递到 researcher 手上的

难点从来不在"调用一个搜索 API",而在:

  • 同一套代码要支持四种不同的搜索后端(Anthropic 原生、OpenAI 原生、Tavily、无),它们的调用形态还完全不同;
  • 网页原文动辄几万字,直接塞给模型会爆 token,得先压缩;
  • 用户还能挂自己的 MCP 工具(比如查内部数据库),这就牵扯鉴权——怎么拿到 token、怎么在 token 过期时给用户一句人话。

utils.py 把这些全收拢了。researcher 那边只要一句 tools = await get_all_tools(config),就拿到一份配好的、能直接 bind_tools 给模型的清单。

一句话类比:get_all_tools 想成机器人上工前的"工具腰带装配站"——固定塞两件基础工具,再按今天的活儿挑一把搜索钻头,最后把用户自带的专用扳手(MCP)也别上,别之前还检查会不会和已有工具重名。


2. 顶层全景(装配总线怎么转)

researcher 在 02 章的 ReAct 循环里,每一步都会喊一次 get_all_tools(deep_researcher.py:384 生成响应前、:467 执行工具前各喊一次)。这条总线内部分三段拼装:

get_all_tools(config) utils.py:569

┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
① 固定基础工具 ② 按 search_api ③ MCP 工具
ResearchComplete 挑一种搜索后端 load_mcp_tools
+ think_tool get_search_tool (按白名单过滤
(utils.py:579) (utils.py:531) + 防重名 + 鉴权)
│ (utils.py:449)
┌──────┼──────┬───────┐
▼ ▼ ▼ ▼
Anthropic OpenAI Tavily None
(dict声明)(dict) (本地工具)(空)


拼进 existing_tool_names 集合 ──防重名──► 传给 ③


一份完整 tools 清单 ──► researcher.bind_tools()

怎么读这张图: 从上往下是装配顺序,①②③ 依次往清单里追加;②③ 之间有一条暗线——② 拼好后会算出"已占用的工具名集合",交给 ③ 用来防止 MCP 工具撞名。

各部件一句话职责:

部件干什么位置
get_all_tools装配总线:把三类工具拼成一份清单utils.py:569-597
get_search_toolSearchAPI 枚举挑一种搜索后端utils.py:531-567
tavily_searchTavily 后端的本地工具:搜索 + 摘要管线utils.py:43-136
think_tool反思工具:让模型在搜索间"停下来想"utils.py:219-244
load_mcp_tools连 MCP 服务器、按白名单过滤、包鉴权utils.py:449-524
wrap_mcp_authenticate_tool把 MCP 的鉴权错误翻译成人话utils.py:385-447
anthropic/openai_websearch_called检测本轮是否用了原生 web searchutils.py:607-658

3. 装配总线 get_all_tools

3.1 它要解决的小问题

researcher 不能拿一份写死的工具清单——因为搜索后端、MCP 挂载都是运行时配置决定的。所以工具清单必须每次现场算

3.2 三步装配

get_all_tools(utils.py:569)只有二十来行,逻辑干净:

# utils.py:579-595(节选)
tools = [tool(ResearchComplete), think_tool] # ① 固定两件

search_api = SearchAPI(get_config_value(configurable.search_api))
search_tools = await get_search_tool(search_api) # ② 按配置挑搜索
tools.extend(search_tools)

existing_tool_names = { # 算出已占用的名字
tool.name if hasattr(tool, "name") else tool.get("name", "web_search")
for tool in tools
}
mcp_tools = await load_mcp_tools(config, existing_tool_names) # ③ MCP,带防重名
tools.extend(mcp_tools)

三点值得注意:

  • ResearchCompletethink_tool 永远在场(:579)。前者是 researcher 声明"我查完了"的信号(见 02 章的退出判定),后者是强制反思点(§3.4)。无论选哪个搜索后端,这两件都塞进去。
  • existing_tool_names 的构造要兼容两种工具形态(:588-591):Tavily/MCP 工具是有 .name 属性的 BaseTool 对象;而 Anthropic/OpenAI 原生搜索是纯 dict 声明(§4),只能用 .get("name") 取,取不到就兜底为 "web_search"
  • 这个集合只服务于 ③ 的防重名——用户自挂的 MCP 工具如果起了个跟内建工具一样的名字,会被跳过(§7.2)。

一段暗示的巧妙: tool.name if hasattr(tool, "name") else tool.get("name", ...) 这个模式在本文件出现两次——get_all_tools(:589)和 researcher 侧构造 tools_by_name(deep_researcher.py:469)。它是"工具清单里混着对象和 dict 两种形态"这一设计的直接代价:凡是要按名字索引工具的地方,都得写这个二选一。

3.3 装配总线跑两次的原因

researcher 节点生成模型响应前调一次(deep_researcher.py:384),researcher_tools 节点执行工具前又调一次(:467),两次结果必须一致——因为第二次要用工具名反查出可调用对象来执行(:468tools_by_name)。装配是幂等且无状态的(纯粹读 config),所以调两次安全。

3.4 think_tool——什么都不做的工具,却是关键

think_tool(utils.py:219-244)的实现只有一行:

# utils.py:244
return f"Reflection recorded: {reflection}"

不查任何东西,只是把模型写的反思原样回显。设计意图全在它超长的 docstring 里(:222-243):它是一个刻意的暂停点,逼模型在每次搜索后回答四个问题——"我找到了什么关键信息 / 还缺什么 / 证据够不够 / 该继续搜还是该收尾"。

为什么这有用?ReAct 循环里模型很容易"无脑连搜",搜一堆冗余结果。给它一个"反思工具",相当于把"停下来想一步"变成一个可被模型主动调用的显式动作。researcher 侧对 think_tool 的调用也是当普通工具执行、把回显塞回对话(deep_researcher.pyresearcher_tools),模型下一轮就能看到自己刚写的反思。supervisor 那层也复用了同一个 think_tool(deep_researcher.py:202)。


4. 四种搜索后端抽象 get_search_tool

4.1 它要解决的小问题

四种搜索后端的调用形态根本不同:两家的原生 web search 是"告诉模型 API 有这个能力"的声明,由模型厂商服务端自己去搜;Tavily 则是一个真正在本地跑的工具函数。get_search_tool(utils.py:531)要把这种差异抹平成"都返回一个 tools 列表"。

4.2 四种后端对照

SearchAPI返回什么形态谁去执行搜索源码
ANTHROPIC{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}dict 声明Anthropic 服务端utils.py:540-546
OPENAI{"type": "web_search_preview"}dict 声明OpenAI 服务端utils.py:548-550
TAVILY[tavily_search](打上 metadata)本地 BaseTool本地 tavily_search 函数utils.py:552-560
NONE[]utils.py:562-564

关键差异:

  • 原生搜索是"声明",不是"函数"。 Anthropic/OpenAI 分支返回的是纯 dict,bind_tools 时会被当成一个厂商服务端工具的声明传给 API;真正的搜索发生在模型厂商那边,本地代码看不到执行过程。这也是为什么需要 §6 的"检测函数"来事后判断"这轮到底搜没搜"。
  • Tavily 是"真工具"。 它给 tavily_search 打上 metadata = {..., "type": "search", "name": "web_search"}(:555-559),然后返回对象本身。注意它把工具统一命名为 web_search——和原生分支保持一致,这样上层 prompt 与判定逻辑不用区分后端。
  • SearchAPI 枚举只有这四个值(configuration.py:11-17),默认是 TAVILY(configuration.py:79)。选哪个后端还有个前提:研究模型本身得支持该原生搜索(configuration.py:84 的描述特意提醒)。

5. Tavily 管线——本章最精华的一段

tavily_search(utils.py:43-136)是唯一在本地完整跑的搜索后端,也是设计含量最高的一支。它要解决的核心矛盾:网页原文太长,直接喂模型会爆 token,但又不能丢信息。 解法是一条七步管线,把"搜到的原始网页"压成"结构化摘要"再回给 researcher。

5.1 管线全貌

queries (多条查询)

│ ① 并行搜索 tavily_search_async utils.py:138 (asyncio.gather)

多份原始结果(含 raw_content 全文)

│ ② 按 URL 去重 utils.py:71-76 (dict 以 url 为键)

unique_results {url: result}

│ ③ 每条截断到 max_content_length 再并行摘要
│ summarize_webpage → summarization_model.with_structured_output(Summary)
│ utils.py:100-110 (again asyncio.gather)

summaries[] (结构化 Summary,或超时/失败时回退原文)

│ ④ 摘要成功用摘要,失败用 Tavily 自带的短 content utils.py:113-123

格式化成 "SOURCE i / URL / SUMMARY" 文本块 utils.py:129-136


一段字符串,回灌 researcher 对话

怎么读: 两处 asyncio.gather 是性能关键——多条查询并行搜(①),多个网页并行摘要(③)。中间的去重(②)保证同一个 URL 不会被摘要两次。

5.2 逐步拆解

① 并行搜索(utils.py:62-68tavily_search_async :138-173)。多条查询用 asyncio.gather 一次打出去(:161-172),每条带 include_raw_content=True 抓全文。

② 按 URL 去重(utils.py:71-76)。用一个 dict 以 url 为键,重复 URL 只保留第一次出现的,顺带记下是哪条 query 命中的。这一步避免了后面对同一网页重复调摘要模型——省钱又省时

③ 并行结构化摘要(utils.py:86-110)。摘要模型是单独配的(configuration.py:121 默认 openai:gpt-4.1-mini,比研究模型便宜),关键几点:

  • .with_structured_output(Summary) 强制输出结构化的 Summary(state.py:24-28,含 summarykey_excerpts 两个字段),再挂 .with_retry(:91-93)。
  • 每条原文先截断到 max_content_length(默认 50000 字符,configuration.py:141)才送进摘要(:104),这是第一道 token 防线。
  • 没有 raw_content 的结果直接跳过(用一个 noop() 占位,:96-98/101),不浪费摘要调用。
  • 所有摘要任务再一次 asyncio.gather 并行跑(:110)。

④ 兜底与格式化(utils.py:113-136)。摘要成功就用摘要,summary is None(表示没原文)就退回 Tavily 自带的短 content(:116)。最后拼成带 --- SOURCE i --- / URL / SUMMARY 分隔的文本(:129-134);一条结果都没有时返回一句提示让模型换查询(:126-127)。

5.3 超时兜底——不让一个慢网页拖垮整轮

summarize_webpage(utils.py:175-213)里,摘要调用被 asyncio.wait_for(..., timeout=60.0) 包住(:193-196)。这是本管线最实用的容错:

  • 超时(TimeoutError)→ 记一条 warning,返回原始网页内容(:206-209),不抛异常;
  • 其它任何异常 → 同样降级返回原文(:210-213)。

结果就是:摘要是"尽力而为"的增强,失败绝不阻断研究。 单个网页摘要挂了,researcher 顶多多读点原文,循环照常往下走。这与 03 章"研究不因单点失败而崩"的容错哲学一脉相承。


6. 原生 web search 的事后检测

原生搜索(Anthropic/OpenAI)由厂商服务端执行,本地拿不到"工具调用"事件,只能事后从响应里翻证据。researcher 的退出判定要靠它:一轮里如果既没有普通 tool_calls、又没触发原生搜索,就该收尾(deep_researcher.py:457-464)。

两个检测函数各自钻响应的不同角落:

函数钻哪判据源码
anthropic_websearch_calledresponse_metadata.usage.server_tool_use.web_search_requests请求数 > 0utils.py:607-637
openai_websearch_calledadditional_kwargs.tool_outputs 里找 type == "web_search_call"存在即真utils.py:639-658

两者都对结构缺失做了防御(层层 if not ...: return False,或 except (AttributeError, TypeError)),响应结构不符预期时安全返回 False,不会炸掉循环。


7. MCP 集成——给 researcher 挂用户自己的工具

7.1 它要解决的小问题

MCP(Model Context Protocol,模型上下文协议)让用户把自己的外部工具(查内部数据库、调私有 API 等)挂给 agent。难点有三:只暴露用户白名单里的工具、连不上时不能崩、以及鉴权——很多 MCP 服务器要 token,token 还会过期。

load_mcp_tools(utils.py:449-524)把这三件都办了。

7.2 加载流程

load_mcp_tools(config, existing_tool_names) utils.py:449

│ ① 需要鉴权? → fetch_tokens 换 token utils.py:465-468

│ ② 校验配置:url + tools 白名单 + token 齐全 utils.py:471-479
│ 任一缺失 → 返回 []

│ ③ 连 MCP 服务器 MultiServerMCPClient utils.py:499-504
│ 连不上 → try/except 吞掉,返回 []

对每个远端工具:
│ 撞已有工具名? → warn 跳过 utils.py:510-514
│ 不在白名单 tools 里? → 跳过 utils.py:517-518
│ 否则 → wrap_mcp_authenticate_tool 包一层 utils.py:521

configured_tools[]

两道过滤叠加(utils.py:508-522):先防撞名(用 §3.2 传进来的 existing_tool_names),再按白名单(configurable.mcp_config.tools)只留用户显式点名的工具。整个连接段用 try/except 兜底(:499-504),MCP 服务器挂了就返回空列表,研究照常进行——研究模型至少还有搜索工具。

MCPConfig(configuration.py:19-36)三个字段:url(服务器地址)、tools(白名单)、auth_required(是否要鉴权)。

7.3 OAuth token 交换与缓存

auth_required 为真,fetch_tokens(utils.py:352-383)负责拿 token,链路是 Supabase token → 换 MCP token:

fetch_tokens utils.py:352

│ ① 先看缓存里有没有没过期的 get_tokens utils.py:293
│ 有 → 直接用
▼(没有)
│ ② 从 config 取 Supabase 用户 token(x-supabase-access-token)
│ + mcp_config.url utils.py:367-374

│ ③ OAuth token-exchange 换 MCP token get_mcp_access_token utils.py:250
│ POST {mcp_url}/oauth/token
│ grant_type = token-exchange utils.py:265-271

│ ④ 存缓存 set_tokens utils.py:331

返回 MCP token

三个函数分工:

  • get_mcp_access_token(utils.py:250-291):真正发 OAuth 请求。grant_typeRFC 8693 token-exchange(:268),拿用户的 Supabase token 去 MCP 服务器的 /oauth/token 端点(:275)换一个 MCP 专用 token。失败返回 None
  • get_tokens(utils.py:293-329):从 LangGraph 的 store(user_id, "tokens") 读缓存,并校验过期——用 created_at + expires_in 算过期时刻(:319-322),过期就顺手删掉再返回 None(:324-327)。user_id 取自 config.metadata.owner,而这个 owner 正是 §8 的 auth 层写进去的。
  • set_tokens(utils.py:331-350):换到新 token 后写回 store。

这套缓存 + 过期自愈的设计,让大多数请求命中缓存(get_tokens 直接返回),只有首次或过期才走一次昂贵的 OAuth 交换。

7.4 鉴权错误翻成人话 wrap_mcp_authenticate_tool

即便 token 齐全,MCP 工具调用时仍可能撞上"需要用户交互"的鉴权错误(比如要用户去某个 URL 授权)。wrap_mcp_authenticate_tool(utils.py:385-447)把每个 MCP 工具的协程包一层,专门处理这种情况:

  • 它先递归在异常链里找 McpError(:399-409,连 Python 3.11 的 ExceptionGroup 都顺着 .exceptions 挖);
  • 找到后检查错误码,-32003 是"interaction-required"(:428);
  • 命中就从 error_data 里抽出人话文案、必要时拼上授权 URL(:429-438),包成一个 ToolException 抛出(:440)——这样用户看到的是"请去 X 授权"而不是一个裸的协议错误码;
  • 其它 MCP 错误原样重抛(:443),不该它管的不乱管。

8. 部署边界:auth 挂载

MCP 鉴权链里的 user_id(config.metadata.owner)不是凭空来的,它由 LangGraph 部署层的 auth 中间件写入。langgraph.json:11-13./src/security/auth.py:auth 挂成全局鉴权:

  • get_current_user(auth.py:22-69):每个请求都过,拿 Bearer token 找 Supabase 验身份,返回 {"identity": user.id}
  • on_thread_create(auth.py:72-91):建 thread 时把 metadata["owner"] = ctx.user.identity 写进去——这就是 §7.3 里 get_tokens 读的那个 owner
  • 读操作过滤器(auth.py:94-111 等):thread / assistant 的读删改查都返回 {"owner": ctx.user.identity} 过滤器,保证用户只看得到自己的数据。
  • authorize_store(auth.py:149-156):断言 store 命名空间的第一段必须是当前用户身份——这道防线正好守住了 token 缓存(§7.3 用 (user_id, "tokens") 做 key),防止跨用户读别人的 token。

StudioUser(本地 LangGraph Studio 调试用户)在各处被特判放行(auth.py:85 等),开发时不受这套约束。


9. 巧妙之处(可带走的技术)

  • 两处 asyncio.gather 叠加做双层并行(utils.py:161-172 搜索层、:100-110 摘要层):多查询并行搜、多网页并行摘,把 I/O 密集的搜索管线压到最短墙钟时间。
  • 摘要是"尽力增强"、失败不阻断(utils.py:193-213 的 60s 超时 + 双重 except 降级返回原文):单个慢/坏网页绝不拖垮整轮研究。
  • URL 去重前置于摘要(utils.py:71-76):在最贵的一步(调摘要模型)之前先去重,省 token 又省钱。
  • 原生搜索"声明化 + 事后检测"(utils.py:540-550 返回 dict 声明,:607-658 事后从响应翻证据):用同一套工具装配框架容纳"本地执行"和"厂商服务端执行"两种完全不同的搜索模型。
  • token 缓存带过期自愈(utils.py:319-327):过期即删,读时透明重新交换,业务代码无感。
  • 鉴权错误人话化(utils.py:428-440 专治 -32003):把裸协议错误码翻成"去这个 URL 授权",递归穿透 ExceptionGroup

10. 边界与局限

  • MCP 只支持单服务器。 load_mcp_tools 里硬编码了 "server_1"(utils.py:490),代码自带 TODO 注明"等多 MCP 服务器支持合并后再改"(:496)。
  • OAuth 交换写死 Supabase。 token 交换假定用户 token 是 Supabase 的(fetch_tokensx-supabase-access-token,utils.py:367),换别的身份提供商要改 auth 层。
  • 原生搜索的检测依赖厂商响应格式。 anthropic/openai_websearch_called(utils.py:607-658)钻的是特定 usage / additional_kwargs 结构,厂商改格式就会静默失效(好在返回 False 不炸)。
  • 摘要模型质量决定信息保真度。 全文先截断到 max_content_length(utils.py:104)再摘要,超长网页尾部内容会被直接丢弃。

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

主题文件路径符号名
工具装配总线src/open_deep_research/utils.py:569get_all_tools
四种搜索后端抽象src/open_deep_research/utils.py:531get_search_tool
搜索后端枚举src/open_deep_research/configuration.py:11SearchAPI
Tavily 搜索 + 摘要管线src/open_deep_research/utils.py:43tavily_search
并行搜索src/open_deep_research/utils.py:138tavily_search_async
单网页摘要 + 60s 超时兜底src/open_deep_research/utils.py:175summarize_webpage
结构化摘要数据模型src/open_deep_research/state.py:24Summary
反思工具src/open_deep_research/utils.py:219think_tool
收尾信号工具src/open_deep_research/state.py:21ResearchComplete
MCP 工具加载 + 白名单过滤src/open_deep_research/utils.py:449load_mcp_tools
MCP 鉴权错误包装src/open_deep_research/utils.py:385wrap_mcp_authenticate_tool
OAuth token 交换src/open_deep_research/utils.py:250get_mcp_access_token
token 缓存读 + 过期校验src/open_deep_research/utils.py:293get_tokens
token 拉取/刷新编排src/open_deep_research/utils.py:352fetch_tokens
MCP 配置模型src/open_deep_research/configuration.py:19MCPConfig
原生搜索检测(Anthropic)src/open_deep_research/utils.py:607anthropic_websearch_called
原生搜索检测(OpenAI)src/open_deep_research/utils.py:639openai_websearch_called
部署鉴权 + owner 写入src/security/auth.py:22get_current_user / on_thread_create
auth 挂载点langgraph.json:11auth.path