Chatbot里的联网能力很容易被说成"模型会搜索"。从实现看,它是一条有明确边界的数据通道。从搜索框到 Agent:Chatbot 联网搜索的技术演进 指出 web_search 负责找到候选来源,web_fetch 负责读取已知 URL。两者把不同 Provider 的输出收敛成同一种结果,再由模型决定怎样引用和回答。
本文讨论应用侧工具路线。模型服务商原生提供的 web search 或 grounding 由服务商执行,结果处理边界不同,不在本文范围内。
两个工具各自解决什么
一个负责找路,一个负责读内容
web_search(query) 面对的是一个主题或问题。它的输出是多个候选来源,每项通常带标题、URL 和 Provider 给出的相关片段。
web_fetch(urls) 面对的是一个或多个已知 URL。它读取页面内容,尽量得到可读正文。模型常见的工作方式是先搜索,在候选来源里挑少数页面继续读取。用户直接给出链接时,模型也可以跳过搜索。
这里的 Provider 指提供搜索、网页阅读或抓取 API 的服务。候选来源是搜索阶段发现的页面,正文则是模型可进一步核对的原始证据。
候选链接和网页证据需要分开处理
搜索结果的短片段适合回答"哪里可能有答案",却经常缺少前提、时间范围和例外。把所有候选页都抓下来又会增加等待时间和上下文成本。上下文指模型在生成当前回答时一并读取的文本,越长并不必然越有用。
让模型按需衔接两次调用
这两个动作没有自动串联。搜索完成后是否抓取正文、抓哪几个 URL,仍由模型在工具循环中判断。这样可以避免每次搜索都下载所有候选页面。
web_fetch 的正文提取
从 URL 到可读正文
web_fetch 的目标是把网页交给模型阅读。内置 fetch Provider 走本地主进程。它先通过受保护的远程抓取函数下载文本,再将 HTML 交给可读内容服务。HTML 是浏览器渲染网页时使用的标记文本,里面通常混有导航、广告、脚本和正文。
原始 HTML 为什么不能直接交给模型
原始 HTML 很少适合直接放进模型上下文。页面上的菜单、推荐、评论和脚本会挤占 token,也会干扰模型判断。token 是模型处理文本时使用的计量单位,输入越多,延迟和成本通常越高。
可读内容服务把解析工作放到 worker thread,并以最多三个并发任务运行。worker thread 是运行在主进程之外的后台线程。单次解析默认十秒超时,这样 JSDOM 建树、正文识别和 HTML 转换不会拖慢聊天界面。
在后台线程完成提取
worker 的核心顺序很短。
- 用 JSDOM 将 HTML 变为 DOM。
- 调用
new Readability(document).parse()识别文章。 - 读取文章标题。
- 将
article.content交给 Turndown,得到 Markdown。 - 以
{ title, url, content }形式返回。
实现代码
核心抓取与结果封装在 fetchWebSearchContent() 中完成。
ts
const html = await fetchRemoteText(url, {
headers: buildHeaders(httpOptions.headers),
signal: httpOptions.signal ?? undefined,
maxRedirects: 5
})
const article = await readableContentService.extractReadableMarkdown(html, {
signal: httpOptions.signal ?? undefined
})
return {
title: article.title || url,
url,
content: article.content,
sourceInput: url
}
worker 中的正文识别与格式转换如下。
ts
const dom = new JSDOM(input.source, { url: SAFE_JSDOM_URL })
const article = new Readability(dom.window.document).parse()
title = article?.title || ''
content = article?.textContent || ''
if (article && input.format === 'markdown') {
content = new TurndownService().turndown(article.content || '').trim()
}
解析失败时,当前实现不会退回到整个 HTML。调用方会将这次 URL fetch 视为失败,让服务层保留其他 URL 的成功结果或尝试配置的 fallback Provider。fallback Provider 指主服务失败后按顺序尝试的备用服务。
为什么正文提取选 Readability
Reader View 的文章识别能力
Readability 是 Firefox Reader View 使用的独立库。它以 DOM 为输入,parse() 会返回文章标题、处理后的文章 HTML 与纯文本等字段。DOM 是 HTML 解析后的树状结构,程序可以据此识别文章主体与页面杂项。
社区里常见的正文抽取路线大致有四类。
| 路线 | 常见实现 | 适用处 | 代价 |
|---|---|---|---|
| DOM 选择器 | 网站专属 CSS/XPath 规则 | 页面结构稳定、目标站点较少 | 覆盖站点一多,规则维护很快膨胀 |
| 通用文章启发式 | Mozilla Readability、Postlight Parser 一类方案 | 新闻、博客、文档等典型文章页 | 对应用壳、付费墙和强交互页面没有保证 |
| 多算法抽取器 | Trafilatura 一类方案 | 批量抓取,需要正文、元数据和更丰富策略 | 通常偏向 Python 抓取流水线,运行时与依赖更重 |
| 浏览器渲染后抽取 | Playwright、Puppeteer 或远程 Reader 服务 | 依赖 JavaScript 渲染、登录或交互的页面 | 延迟、资源占用和反爬处理成本更高 |
选择通用启发式的工程取舍
当前运行时已经使用 JSDOM,二者接口正好衔接。选择 Readability 是一项适合通用文章页的工程取舍。
- 不需要维护站点级选择器,适合桌面应用读取开放网页的通用路径。
- 输入与输出都在本地内存完成,适合放进可取消、限并发的 worker。
- 返回的文章 HTML 仍保留结构,后续可以转成 Markdown。
- 依赖关系直接。代码只需要 JSDOM、Readability 和一个 HTML 到 Markdown 转换器。
将 DOM 交给文章解析器
将下载到的 HTML 构造成 JSDOM document,交给 Readability.parse()。若返回文章对象,取其标题与文章 HTML,随后交给 Markdown 转换层。
Readability 面向文章式内容,不能替代浏览器渲染,不能绕过访问控制,也不能把不存在于响应 HTML 中的内容变出来。若产品目标变成大规模爬取、结构化字段提取或 JavaScript 应用抓取,Trafilatura、浏览器自动化或外部 Reader/Scrape Provider 会是更合适的层。
为什么文章 HTML 再转为 Turndown
将网页结构改写为 Markdown
Turndown 是一个把 HTML 转为 Markdown 的 JavaScript 库。Markdown 用少量符号表示标题、列表、链接和代码,模型通常比原始 HTML 更容易阅读它。
Readability 能给出纯文本 textContent,当前实现保留 article.content,再用 Turndown 转换。
留住模型阅读时需要的结构
这个选择保住了标题、段落、列表、链接、强调和代码块等阅读结构。模型接收 Markdown 时能区分章节、引用和代码,也更容易在回答中复述限定条件。
| 方案 | 优点 | 在当前链路中的限制 |
|---|---|---|
直接使用 textContent |
最简单,文本最短 | 文章结构丢失,列表和代码很难恢复 |
| 自写 DOM 遍历与拼接规则 | 可以完全按产品格式控制 | 需要持续处理表格、嵌套列表、代码、链接与边缘 HTML |
| rehype 和 remark 转换链 | 适合已有 Unified 生态、复杂 AST 改写 | 对这条只需单页转换的路径,组件与配置更多 |
| Turndown | 直接接收 HTML 或 DOM,输出 Markdown,允许扩展规则 | 输出质量取决于输入 HTML 和默认规则,复杂表格仍要单独评估 |
用默认规则完成第一步转换
将 Readability 返回的 article.content 直接传给 TurndownService().turndown(),并对输出执行 trim()。Turndown 的规则模型保留了继续演进的空间。需要改变链接、图片、表格或代码的表示时,可以增加或覆盖转换规则,而不必替换正文识别器。当前实现使用默认 TurndownService,没有在 web_fetch 路径额外配置 GFM 扩展,因此文档中的表格等复杂元素应视为尽力转换。
黑名单过滤在何处发生
在结果进入上下文前筛掉来源
黑名单是一组来源规则,用来排除不希望进入回答证据的 URL。无论结果来自 web_search 还是 web_fetch,统一结果进入服务层后都会先经过域名黑名单。过滤发生在正文 token 截断之前。
统一来源策略能避免什么问题
搜索 Provider 和网页阅读 Provider 都可能返回不适合产品场景的来源,例如不受信任站点、内容农场或业务明确排除的域名。把规则放在统一服务层,能让不同 Provider 遵循同一来源策略,也能避免为随后会删除的结果分配上下文预算。
规则如何匹配 URL
黑名单接受两种规则。
- Chromium 风格的匹配模式,例如
https://example.com/*、*://*.example.com/*和<all_urls>。 - 以
/包裹的正则表达式,例如/example\.com\/sponsored/。
每个结果会把 URL 解析为 scheme、host、path 和 query。规则命中时,该结果会从结果集删除。无效规则只会记录 warning,不会阻断整次搜索。某条结果的 URL 无法解析时也会保留该结果,避免把 Provider 返回的异常数据误判为已过滤。
过滤函数的核心逻辑如下。正则先在 origin + path + query 上测试,未命中时再测试结构化匹配规则。
ts
results: response.results.filter((result) => {
try {
const url = new URL(result.url)
const regexTarget = `${url.origin}${url.pathname}${url.search}`
if (regexPatterns.some((regex) => regex.test(regexTarget))) {
return false
}
return !matchPatterns.some((pattern) => matchesPattern(pattern, url))
} catch {
return true
}
})
这层过滤解决的是来源策略,不是网络安全策略。URL 在实际抓取前还要经过远程 URL 安全校验,防止私网地址、危险协议和 DNS 重绑定成为主进程请求。
可选 token 截断
给每条结果分配上下文预算
结果通过黑名单后,可以按压缩设置裁剪正文。默认模式为 cutoff,默认总预算是 2000 token。这里的截断是保留正文开头的一部分,不是让模型重新概括文章。
长网页为什么会挤掉其他证据
网页正文可能很长。一条长文章就能耗尽本轮上下文,使其他来源和用户问题没有足够空间。为结果设置总预算可以控制成本和延迟,也让多个来源有机会被模型看到。
按结果数平均截取正文
算法按结果数量平均分配预算。
text
每条正文预算 = max(1, floor(总 token 预算 / 过滤后结果数))
服务使用 tokenx.sliceByTokens(content, 0, 每条正文预算) 保留每个 content 的开头。发生截断时,结果末尾追加 ...。title、url 和内部的 sourceInput 不参与该裁剪。
对应实现没有调用模型,它只对字符串进行 token 范围切片。
ts
const perResultLimit = Math.max(1, Math.floor(config.cutoffLimit / results.length))
return results.map((result) => {
const sliced = sliceByTokens(result.content, 0, perResultLimit)
return {
...result,
content: sliced.length < result.content.length ? `${sliced}...` : sliced
}
})
这套算法优先保证每条来源都有机会进入上下文。它也有明确取舍。
- 它不按相关性重新分配 token。
- 空正文同样参与均分,会稀释其他结果的预算。
- 多个 query 或多个 URL 合并后,结果数增加,每条正文得到的预算会下降。
- 当结果数大于总预算时,每条至少保留一个 token,总量不再是严格上限。
- 追加的省略号也不计入切片预算。
如果调用方将压缩模式设为 none,服务保留 Provider 的完整 content。模型上下文仍可能在后续工具输出持久化或渲染投影层被限制,因此 none 不等于无限长度。
web_search 和 web_fetch 怎样协作
发现来源与核对内容的分工
两者共享 Provider 选择、失败隔离、黑名单与压缩规则,差异在输入和内容来源。
| 工具 | 输入 | content 的典型来源 |
常见下一步 |
|---|---|---|---|
web_search |
独立查询词 | 搜索摘要、相关片段或 Provider 返回的短文本 | 比较来源,或选择 URL 再 fetch |
web_fetch |
已知 HTTP(S) URL | 本地提取的 Markdown,或 Jina、Firecrawl 等 Reader/Scrape Provider 的正文 | 根据页面细节回答并引用 |
先广后深能控制成本和噪声
搜索和阅读分开,模型可以先用低成本的结果片段建立信息面,再把网络与 token 预算留给少数需要核实的页面。用户直接提供 URL 时,模型也无需先搜索一次。
模型在工具循环中决定下一步
应用侧工具的协作由模型完成。工具说明要求模型在只有主题时先用 web_search,已有明确 URL 或搜索片段不足时再用 web_fetch。这给模型留下了两个重要选择。
第一,搜索摘要足以支持回答时,不额外抓取页面。第二,只有最有价值的少数页面进入正文提取,避免把大量网页内容塞进当前回合。
WebSearchService 对多个输入使用 Promise.allSettled()。一批 query 或 URL 中某项失败时,成功项照常返回。失败项会按能力尝试 fallback Provider。关键词搜索的默认 fallback 是 Exa MCP,URL 抓取的默认 fallback 顺序是内置 fetch,再到 Jina。
工具入口将搜索和抓取分别交给同一个服务,再由服务根据能力选择 Provider。
ts
export async function searchWeb(query: string, signal?: AbortSignal) {
const response = await application
.get('WebSearchService')
.searchKeywords({ keywords: [query] }, { signal })
return mapResponse(response)
}
export async function fetchWeb(urls: string[], signal?: AbortSignal) {
const response = await application
.get('WebSearchService')
.fetchUrls({ urls }, { signal })
return mapResponse(response)
}
服务不会让已经成功的输入重新执行。它仅找出失败位置,将这些输入交给备用 Provider,并把恢复后的结果放回原位置。
ts
const failedIndexes = mergedResults.flatMap((result, index) =>
result.status === 'rejected' ? [index] : []
)
const fallbackContext = {
...context,
inputs: fallbackCandidates.map(({ input }) => input),
provider: fallbackProvider,
providerDriver: createWebSearchProvider(fallbackProvider, this.apiKeyRotationState)
}
const fallbackResults = await this.executeCapability(fallbackContext, httpOptions)
最终结果怎样映射
先把异构 Provider 输出收敛
Provider 层先统一成内部 WebSearchResult。
ts
type WebSearchResult = {
title: string
content: string
url: string
sourceInput: string
}
sourceInput 用于追溯这条结果来自哪个 query 或 URL。它不会直接作为模型工具输出的一部分。
统一结构让工具和界面保持简单
搜索和抓取服务的响应格式彼此不同。有的返回摘要,有的返回网页正文,还有的使用服务商专属字段。先统一内部结果形状,工具层、引用系统和 UI 就不必理解每一家 Provider 的协议。
引用 ID 让模型的某句事实能回到具体来源。用户看到引用后可以打开 URL、检查标题和片段,判断证据是否支持回答。
给每项结果附上可追溯的引用 ID
工具边界调用 mapResponse() 时,为每条结果生成当前调用唯一的引用 ID,最终输出变为:
ts
type WebSearchOutputItem = {
id: string
title: string
url: string
content: string
}
映射代码只暴露模型与引用 UI 所需的四个字段。
ts
function mapResponse(response: WebSearchResponse): WebSearchOutput {
const prefix = newCitePrefix()
return response.results.map((result, index) => ({
id: citeId(prefix, index),
title: result.title,
url: result.url,
content: result.content
}))
}
ID 在每次 lookup 时带有新的随机前缀,避免同一轮多次搜索发生冲突。模型会收到每项的 id,并被要求将 [cite:id] 放在对应事实后。渲染层据此找到标题、URL 和摘要,显示可追溯的来源。
小结
联网工具的工作包含发现候选来源、读取页面证据、执行来源策略、控制上下文预算和建立引用关联。下图是完整架构:

这条链路仍有边界。网页正文是外部不可信输入,Readability 不能解决动态渲染和付费墙,前缀截断也不等于语义摘要。将这些边界留在实现和产品提示里,能帮助模型和用户正确看待一次工具调用的证据强度。下面几点需要特别注意:
- web_search 与 web_fetch 不会自动串联,只有主题时先搜索;已有 URL 或片段不够再抓取
- 摘要已经够用时,可以不调用 web_fetch
- 内置 fetch 在主进程下载 HTML,JSDOM、Readability、Turndown 跑在 worker
- 搜索摘要和抓取正文都先过域名黑名单,再按 token 截断,token 值可以根据 LLM 的能力自适应调整