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

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() 中完成。

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

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

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 不等于无限长度。

发现来源与核对内容的分工

两者共享 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
  }))
}
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 和摘要,显示可追溯的来源。

小结

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

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

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

参考资料

相关推荐
独孤九剑打醒他44 分钟前
【原创开源】【概念设计】源 - 栅 - 漏 - 栅 - 源 横向双栅 MOS,低压交流多值逻辑芯片探索
前端·其他·架构·开源·硬件工程
一木 之林1 小时前
提示词工程学习总结:用 Few-shot 把大模型调教成金融文本分类、抽取与匹配的自动化流水线
人工智能·学习·计算机视觉·金融·分类
山顶夕景1 小时前
【Agent】自进化Dream-RSI
大模型·agent·自进化
付威20231 小时前
【pi-rust源码拆解】Agent 源码看不懂?先跟着一条消息走一遍--万字长文
人工智能
老马识码1 小时前
复杂架构的取舍:Agentic RAG、LLM Wiki 与 Multi-Agent
人工智能
Maynor9961 小时前
让 AI 编程助手学会做视频、做 PPT:Agent Skills 入门与安装全指南
人工智能·aigc·ai编程·效率工具·cursor·claude code
C++ 老炮儿的技术栈1 小时前
sizeof操作符
c语言·c++·人工智能·mfc·c
王中阳Go1 小时前
自己摸了 2 个月零 offer,补底子只用了 3 块:Go 后端转 AI 最难的不是技术
后端·agent·ai编程
柯南46681 小时前
【AI工程师精讲】KV Cache 精讲:为什么 AI 越聊越慢,以及长上下文真正的成本在哪
人工智能·ai编程