把网页变成可引用知识——Chatbot 联网工具

0 阅读12分钟

Chatbot里的联网能力很容易被说成“模型会搜索”。从实现看,它是一条有明确边界的数据通道。从搜索框到 Agent:Chatbot 联网搜索的技术演进 指出 web_search 负责找到候选来源,web_fetch 负责读取已知 URL。两者把不同 Provider 的输出收敛成同一种结果,再由模型决定怎样引用和回答。

本文讨论应用侧工具路线。模型服务商原生提供的 web search 或 grounding 由服务商执行,结果处理边界不同,不在本文范围内。

两个工具各自解决什么

一个负责找路,一个负责读内容

web_search(query) 面对的是一个主题或问题。它的输出是多个候选来源,每项通常带标题、URL 和 Provider 给出的相关片段。

web_fetch(urls) 面对的是一个或多个已知 URL。它读取页面内容,尽量得到可读正文。模型常见的工作方式是先搜索,在候选来源里挑少数页面继续读取。用户直接给出链接时,模型也可以跳过搜索。

这里的 Provider 指提供搜索、网页阅读或抓取 API 的服务。候选来源是搜索阶段发现的页面,正文则是模型可进一步核对的原始证据。

候选链接和网页证据需要分开处理

搜索结果的短片段适合回答“哪里可能有答案”,却经常缺少前提、时间范围和例外。把所有候选页都抓下来又会增加等待时间和上下文成本。上下文指模型在生成当前回答时一并读取的文本,越长并不必然越有用。

让模型按需衔接两次调用

这两个动作没有自动串联。搜索完成后是否抓取正文、抓哪几个 URL,仍由模型在工具循环中判断。这样可以避免每次搜索都下载所有候选页面。

flowchart LR
  U[用户问题或链接] --> M[模型]
  M -->|主题未知或需要实时信息| S[web_search]
  S --> R[候选标题 URL 和片段]
  R --> M
  M -->|需要核对页面细节| F[web_fetch]
  U -->|已知 URL| F
  F --> C[可读正文]
  C --> M
  M --> A[带引用的回答]

web_fetch 的正文提取

从 URL 到可读正文

web_fetch 的目标是把网页交给模型阅读。内置 fetch Provider 走本地主进程。它先通过受保护的远程抓取函数下载文本,再将 HTML 交给可读内容服务。HTML 是浏览器渲染网页时使用的标记文本,里面通常混有导航、广告、脚本和正文。

flowchart LR
  A[web_fetch URL] --> B[FetchProvider.fetchUrls]
  B --> C[fetchWebSearchContent]
  C --> D[fetchRemoteText]
  D --> E[HTML]
  E --> F[ReadableContentService]
  F --> G[worker thread]
  G --> H[JSDOM]
  H --> I[Mozilla Readability]
  I --> J[文章 HTML]
  J --> K[Turndown]
  K --> L[Markdown 正文]

原始 HTML 为什么不能直接交给模型

原始 HTML 很少适合直接放进模型上下文。页面上的菜单、推荐、评论和脚本会挤占 token,也会干扰模型判断。token 是模型处理文本时使用的计量单位,输入越多,延迟和成本通常越高。

可读内容服务把解析工作放到 worker thread,并以最多三个并发任务运行。worker thread 是运行在主进程之外的后台线程。单次解析默认十秒超时,这样 JSDOM 建树、正文识别和 HTML 转换不会拖慢聊天界面。

在后台线程完成提取

worker 的核心顺序很短。

  1. 用 JSDOM 将 HTML 变为 DOM。
  2. 调用 new Readability(document).parse() 识别文章。
  3. 读取文章标题。
  4. 将 article.content 交给 Turndown,得到 Markdown。
  5. 以 { title, url, content } 形式返回。

实现代码

核心抓取与结果封装在 fetchWebSearchContent() 中完成。

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 中的正文识别与格式转换如下。

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 截断之前。

flowchart LR
  A[Provider 标准化结果] --> B[合并成功请求]
  B --> C[域名黑名单]
  C --> D{压缩模式}
  D -->|cutoff| E[按 token 截断 content]
  D -->|none| F[保留 content]
  E --> G[统一结果]
  F --> G

统一来源策略能避免什么问题

搜索 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 上测试,未命中时再测试结构化匹配规则。

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。这里的截断是保留正文开头的一部分,不是让模型重新概括文章。

长网页为什么会挤掉其他证据

网页正文可能很长。一条长文章就能耗尽本轮上下文,使其他来源和用户问题没有足够空间。为结果设置总预算可以控制成本和延迟,也让多个来源有机会被模型看到。

按结果数平均截取正文

算法按结果数量平均分配预算。

每条正文预算 = max(1, floor(总 token 预算 / 过滤后结果数))

服务使用 tokenx.sliceByTokens(content, 0, 每条正文预算) 保留每个 content 的开头。发生截断时,结果末尾追加 ...。title、url 和内部的 sourceInput 不参与该裁剪。

对应实现没有调用模型,它只对字符串进行 token 范围切片。

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。

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,并把恢复后的结果放回原位置。

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。

type WebSearchResult = {
  title: string
  content: string
  url: string
  sourceInput: string
}

sourceInput 用于追溯这条结果来自哪个 query 或 URL。它不会直接作为模型工具输出的一部分。

统一结构让工具和界面保持简单

搜索和抓取服务的响应格式彼此不同。有的返回摘要,有的返回网页正文,还有的使用服务商专属字段。先统一内部结果形状,工具层、引用系统和 UI 就不必理解每一家 Provider 的协议。

引用 ID 让模型的某句事实能回到具体来源。用户看到引用后可以打开 URL、检查标题和片段,判断证据是否支持回答。

给每项结果附上可追溯的引用 ID

工具边界调用 mapResponse() 时,为每条结果生成当前调用唯一的引用 ID,最终输出变为:

type WebSearchOutputItem = {
  id: string
  title: string
  url: string
  content: string
}

映射代码只暴露模型与引用 UI 所需的四个字段。

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
  }))
}
flowchart LR
  A[Provider 原始响应] --> B[Provider adapter]
  B --> C[WebSearchResult]
  C --> D[黑名单与 token 截断]
  D --> E[WebSearchResponse]
  E --> F[mapResponse]
  F --> G[id title url content]
  G --> H[模型在回答中写 cite id]
  H --> I[UI 解析为可跳转引用]

ID 在每次 lookup 时带有新的随机前缀,避免同一轮多次搜索发生冲突。模型会收到每项的 id,并被要求将 [cite:id] 放在对应事实后。渲染层据此找到标题、URL 和摘要,显示可追溯的来源。

小结

联网工具的工作包含发现候选来源、读取页面证据、执行来源策略、控制上下文预算和建立引用关联。下图是完整架构:

web-fetch&web-search.png

这条链路仍有边界。网页正文是外部不可信输入,Readability 不能解决动态渲染和付费墙,前缀截断也不等于语义摘要。将这些边界留在实现和产品提示里,能帮助模型和用户正确看待一次工具调用的证据强度。下面几点需要特别注意:

  • web_search 与 web_fetch 不会自动串联,只有主题时先搜索;已有 URL 或片段不够再抓取
  • 摘要已经够用时,可以不调用 web_fetch
  • 内置 fetch 在主进程下载 HTML,JSDOM、Readability、Turndown 跑在 worker
  • 搜索摘要和抓取正文都先过域名黑名单,再按 token 截断,token 值可以根据 LLM 的能力自适应调整

参考资料