数据截至 (上游 commit 1b7d2e80db9f)
工具装配:搜索抽象、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_tool | 按 SearchAPI 枚举挑一种搜索后端 | utils.py:531-567 |
tavily_search | Tavily 后端的本地工具:搜索 + 摘要管线 | 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 search | utils.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)
三点值得注意:
ResearchComplete和think_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),两次结果必须一致——因为第二次要用工具名反查出可调用对象来执行(:468 的 tools_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.py 的 researcher_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-68 → tavily_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,含summary和key_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_called | response_metadata.usage.server_tool_use.web_search_requests | 请求数 > 0 | utils.py:607-637 |
openai_websearch_called | additional_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_type用 RFC 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):每个请求都过,拿Bearertoken 找 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 等),开发时不受这套约束。