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——很多时效问题根本起不了步。
缺口到底是什么?
没有这对工具时,常见「假上网」有三种:
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, … })
→ 格式化成给模型看的文本
当前支持:
| 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)→ WebSearchConfigError → isError 文案
- 用户 Ctrl+C → WebSearch 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,都变成带 isError 的 tool_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。
你可以从这里带走什么?
仓库与相关文档
- GitHub:https://github.com/jimchou-h/react-agent-mini
- 相关前作:Bash · Interrupt · MCP 概念
- 源码:WebSearchTool.ts · resolveAdapter.ts · WebFetchTool.ts · fetchUrl.ts
- 术语:src/tools/CONTEXT.md
- OpenSpec:v7-web-search · v7-web-fetch
欢迎 Star、Issue 和 PR。
本文基于 react-agent-mini 变更 v7-web-search 与 v7-web-fetch 撰写:用一对只读联网工具补上「搜索 → 读页」闭环。



