Agent 终于能上网了:WebSearch + WebFetch

系列回顾:主循环 · 代码库工具 · REPL · 项目上下文 · Skills · 权限 + Write · MCP 概念 · MCP 实现 · Context Budget · Bash · compact 2.0 · autocompact · Hooks · Memory

到目前为止,react-agent-mini 能读本地仓库、改文件、跑 Bash、挂 MCP、开子代理,也能半路 Ctrl+C。
但还有一个很常见的缺口:问时效问题只能靠模型瞎猜,或者让 Bash 去 curl——既难测,又不安全。
这篇讲 v7-web-search + v7-web-fetch:一对只读联网工具,把「搜一下 → 打开看」补进工具表。


先说结论:上网是两步,不是一个大锤子

人查资料通常是:

先搜索 → 扫标题/摘要 → 点开几页细读

Agent 也一样。所以仓库没有做成「一个超级 Web 工具」,而是两把专用钥匙:

工具 干什么 像什么
WebSearch 给关键词,拿回 title / url / snippet 列表 搜索引擎结果页
WebFetch 给具体 URL,拿回可读正文(HTML 去标签) 打开链接读内容

合在一起才闭环:

WebSearch("某库最新 breaking change")
  → 挑一个靠谱 URL
  → WebFetch(url)
  → 用正文回答 / 再决定要不要改代码

只 Search 没有 Fetch:模型只能靠 snippet 猜。
只 Fetch 没有 Search:模型得自己知道 URL——很多时效问题根本起不了步。


缺口到底是什么?

没有这对工具时,常见「假上网」有三种:

  1. 模型凭训练记忆答——过期就错
  2. 让 Bash 调 curl / wget——权限更重、输出难控、还容易碰到内网
  3. 指望 MCP 临时挂一个搜索 server——能用,但不是默认能力

Claude Code 把联网检索做成内置 WebSearch,并配有 WebFetch。mini 用的是 DeepSeek 等兼容 Chat Completions,没有 Anthropic 那种服务端 search,所以走的是:

客户端工具 + 外部 Search API + 本地 HTTP 拉页

目标很克制:可中断、可测试、缺 Key 时失败得清楚,而不是静默装死。


WebSearch:先把「找得到」做稳

入参很短

必填只有 query。可选:

字段 作用
allowed_domains 只保留这些域名的结果
blocked_domains 排除这些域名
num_results 返回条数(默认约 8)

工具本身是只读、可并发安全的——不改磁盘,也不需要像 Write/Bash 那样弹 y/N

Adapter:换搜索后端,不换工具形状

真正发请求的不是 WebSearchTool 硬编码某一家,而是 adapter

WebSearchTool.call
  → resolveWebSearchAdapter()
  → adapter.search(query, { signal, … })
  → 格式化成给模型看的文本

当前支持:

Adapter 主要环境变量
Brave(默认) BRAVE_API_KEY(或 BRAVE_SEARCH_API_KEY
Tavily TAVILY_API_KEY

选择顺序在 resolveWebSearchAdapterKey 里写得很直白:

export function resolveWebSearchAdapterKey(
  env: Record<string, string | undefined> = process.env,
): WebSearchAdapterKey {
  const explicit = env.WEB_SEARCH_ADAPTER?.trim().toLowerCase()
  if (explicit === 'brave' || explicit === 'tavily') {
    return explicit
  }
  if (readTavilyApiKey(env) && !readBraveApiKey(env)) {
    return 'tavily'
  }
  return 'brave'
}

可以记成:

显式 WEB_SEARCH_ADAPTER
  → 否则:只有 Tavily Key 就用 Tavily
  → 否则默认 Brave

测试可以 setWebSearchAdapterForTests(mock),不必每次打真网——这和主循环可注入 callModel 是同一味道。

缺 Key / 失败:进 tool_result,不炸进程

  async call(args, context: ToolUseContext) {
    const adapter = resolveWebSearchAdapter()
    try {
      const hits = await adapter.search(args.query, {
        signal: context.abortController?.signal,
        numResults: args.num_results,
        allowedDomains: args.allowed_domains,
        blockedDomains: args.blocked_domains,
      })
      return { data: formatHits(args.query, hits) }
    } catch (err) {
      if (err instanceof WebSearchConfigError) {
        return { data: err.message, isError: true }
      }
      if (isAbortError(err) || context.abortController?.signal.aborted) {
        return { data: 'WebSearch aborted', isError: true }
      }
      const msg = err instanceof Error ? err.message : String(err)
      return { data: `WebSearch failed: ${msg}`, isError: true }
    }
  },

要点:

  • 配置错误(没 Key)→ WebSearchConfigErrorisError 文案
  • 用户 Ctrl+CWebSearch aborted
  • 其它网络/API 失败 → 短错误,主循环继续

模型看到失败可以改策略(换问法、提醒配置 Key),而不是整条会话崩掉。

结果长什么样?

给模型的是可读列表,不是原始 JSON 甩脸上:

Web search results for "…":

1. 标题
   https://…
   摘要……

title / url / 可选 snippet——够用来挑链接,也够在 snippet 已经足够时少打一次 Fetch。


WebFetch:再把「打得开」做安全

Search 给的是候选 URL;真正核对文档、changelog、issue 正文,要靠 Fetch。

入参更短:只要 url

const webFetchInputSchema = z.object({
  url: z.string().min(1).describe('The URL to fetch'),
})

流程:

assertSafeFetchUrl
  → GET(带超时 + 可与 turn abort 合并)
  → 按 content-type 取文本
  → HTML 去标签压空白
  → 超大则截断并标注 [truncated]

默认大约 30s 超时 / 512KB 上限——防止一页把上下文撑爆。

为什么必须有 SSRF 护栏?

模型(或被污染的提示)可能让你去拉:

  • file:///etc/passwd
  • http://127.0.0.1:6379/…
  • 云厂商 metadata 地址
  • 家里的 192.168.x.x

Agent 若「有网就能 GET」,等于把内网扫端口能力交给了不可信输入。所以 Fetch 在发请求前先拦:

export function assertSafeFetchUrl(raw: string): URL {
  // …
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new WebFetchUrlError(
      `Only http(s) URLs are allowed (got ${url.protocol})`,
    )
  }
  // localhost / 私网 / 链路本地 / 部分 metadata 主机名 → Blocked host
  return url
}

这是基础护栏,不是完美沙箱:DNS rebinding、跳板代理之类都刻意不在本刀范围内。但对「默认别把内网暴露给模型」已经够用。

失败同样 fail-soft

非法 URL、被拦主机、超时、abort,都变成带 isErrortool_result,不把异常抛穿主循环。


两者怎么配合?一张图就够

用户:这个依赖上周有 breaking change 吗?
        │
        ▼
   WebSearch(query)
        │  title / url / snippet
        ▼
   模型挑选 URL(或发现 snippet 已够)
        │
        ▼
   WebFetch(url)   ← 可选第二步
        │  可读正文(可能 truncated)
        ▼
   回答 / 再调本地 Read·Edit·Bash

和本地工具的分工也很清楚:

能力 工具
仓库里已有的文件 Read / Grep / Glob
外网「有没有、在哪」 WebSearch
外网「这一页写了什么」 WebFetch
验证改动 Bash

MCP Resource 仍然是「挂已声明的材料」;Web* 是「临时去公网查」。两者不互替。


和 interrupt / 权限怎么接?

上一篇 interrupt cascade 之后,联网工具必须接 AbortSignal,否则用户按了 Ctrl+C,搜索请求还在飞。

两边都做了:

  • Search:adapter.search(..., { signal })
  • Fetch:AbortSignal.any([用户 signal, 超时 signal])

权限侧:二者 isReadOnly() === true,REPL 不会为「读网页」弹写确认;仍可走 Hooks(若你 matcher 配了 WebSearch / WebFetch / *)。

所以:

急停拉索(interrupt)→ 能掐断搜索/拉页
门卫(canUseTool)→ 默认放行只读联网
项目规约(Hooks)→ 仍可额外拦或记日志

30 秒感受一下

配置任一搜索 Key(PowerShell 例):

$env:TAVILY_API_KEY = "tvly-..."
# 或
$env:BRAVE_API_KEY = "..."

然后:

bun run dev

自然语言即可,例如:

用 WebSearch 查 react 19 的 release notes 链接,再 WebFetch 打开其中一篇,摘要三点。

缺 Key 时不应崩进程,而应在工具结果里看到可读的配置错误提示。


刻意没做什么?

没做 意味着什么
Anthropic 服务端 search 走客户端 adapter,不绑一家模型厂商
多 provider 配置 UI / /web-tools 面板 env 选 adapter 即可
无头浏览器 / JS 渲染页 只拿静态 HTML/文本;SPA 可能残缺
PDF / 二进制正文解析 非文本会失败或短错误
完整代理绕过式 SSRF 攻防 基础拒绝私网与危险 scheme
把 Search 默认绑死 Tavily Extract Fetch 独立 HTTP 直取

这一刀验证的是最小闭环:

内置 WebSearch(Brave/Tavily)
  + 内置 WebFetch(护栏 + 截断 + abort)
  → 搜得到、打得开、停得住、失败不炸

系列拼图

补的是哪一段
代码库工具 本地读改
Bash 本地跑
MCP 外挂能力协议
Interrupt 半路拉闸
本篇 公网查与读

Harness 又多一根柱子:循环、工具、会话、上下文、技能、权限、MCP、预算、hooks、memory、subagent、interrupt、web


你可以从这里带走什么?

  1. 上网拆成 Search + Fetch——找链接和读正文是两件事。
  2. Search 用 adapter——换 Brave/Tavily 不改工具契约;缺 Key 要清晰失败。
  3. Fetch 先护栏再请求——只允许 http(s),默认拒绝 localhost / 私网。
  4. 超时、截断、abort 都要有——否则上下文和会话都会被拖死。
  5. 失败进 tool_result——联网是效应器,不是让主循环崩溃的理由。

仓库与相关文档

欢迎 Star、Issue 和 PR。


本文基于 react-agent-mini 变更 v7-web-searchv7-web-fetch 撰写:用一对只读联网工具补上「搜索 → 读页」闭环。

更多推荐