夜雨聆风学习资料网

ARTICLE · 995001

DeepSeek Harness 源码-Web 能力(搜索/抓取)

DeepSeek Harness 源码-Web 能力(搜索/抓取)

DeepSeek Harness 源码解析系列

第 19 讲:Web 能力(搜索/抓取)

基于 DeepSeek Harness 源码 · 2026-09-03

💡 本讲一句话:Web 能力是"能力缝 + provider"范式的又一次完整落地——ctx.web 这条缝只拥有选择、限额与错误语义,搜索/抓取怎么跑全交给 provider。读完你能带走五件事:provider 选择为什么"绝不依赖注册顺序"、web_search 本质是一次模型回合而非 API 调用、跨源重定向为什么被当作"需要重新授权的新请求"拒绝、字节上限与字符上限为什么要分两层,以及HTML→Markdown 转换里那个防 DoS 的嵌套深度守卫。

一、一条缝、三个搜索 provider、一个抓取 provider

packages/web/ 下有六个包,分工和前面讲过的 shell、fs、subagent 完全同构:

🔹 dsh-web(web/):能力缝 Service Definition,注册为 ctx.web——provider 注册表 + 执行期选择 + 结果限额。

🔹 dsh-web-search-deepseek / -exa / -perplexity:三个搜索 provider,各自封装一种后端的 wire format,互相不知道对方存在。

🔹 dsh-web-fetch-http:本地匿名 HTTP(S) 抓取 provider——URL 校验、同源重定向、字节/字符限额、解码,全在这一层。

🔹 dsh-tool-web:模型可见的 web_search / web_fetch 两个工具——schema、参数校验、system prompt 引导、结果渲染,从不碰网络。

先看这条缝对 provider 的契约(packages/web/web/src/types.ts,第 101-119 行):

📄 packages/web/web/src/types.ts(第 101-119 行)

export interface WebSearchProvider { // 搜索后端契约:id + 本地可用性检查 + 一次搜索  readonly id: string // 稳定标识,作注册表键;重复 id 在注册时就被拒绝(WEB_DUPLICATE_PROVIDER)  available(): boolean // 廉价的本地可用性检查(如"有没有 API key"),明确禁止发网络请求  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> // 执行一次搜索,signal 用于协作式取消export interface WebFetchProvider { // 抓取后端契约:与搜索同形,只是请求/结果类型不同  readonly id: string // 稳定标识;在 fetch 能力域内唯一  available(): boolean // 本地可用性检查;匿名公共抓取器恒返回 true(没有凭证可查)  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> // 取一个 URL,signal 用于协作式取消

为什么这样设计:两个接口刻意保持同形(id + available() + 一个带 AbortSignal 的方法),这样缝层的选择逻辑可以写成一份泛型代码同时服务搜索和抓取——后面第二节的 resolveProvider<P> 就是靠这个同形性成立的。available() 被要求"不发网络请求",是因为它会在每次执行时被调用(选择期),如果它自己发请求,取消语义和超时预算就全乱了。而 WebFetchRequest 只有 url 一个字段——超时、格式、提示词这些"上层关切"被刻意排除在请求类型之外:取消走 signal,呈现归 tool-web,缝层保持最小。

二、provider 选择:绝不依赖注册顺序

packages/web/web/src/index.ts(第 172-200 行)是整条缝的核心。选择发生在每次执行时而不是注册时,规则一共五条,每条失败都有独立的机器可路由错误码:

📄 packages/web/web/src/index.ts(第 172-200 行)

function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P { // 搜索/抓取共用的泛型选择器:返回选中的 provider,或抛出对应错误码  const { configuredId, providers } = selection // 解构两个输入:显式配置的 id(可无)+ 该能力域的注册表  if (configuredId !== undefined) { // 分支一:有显式配置——任何不匹配都 fail loud,绝不静默回退到自动选择    const provider = providers.get(configuredId) // 按名字查;undefined 即"没注册过"    if (!provider) throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') // 配了但缺失 → 专属错误码,让运维能直接定位配置问题    if (!provider.available()) throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE') // 注册了但不可用(如缺 key)→ 另一个专属错误码,和"没注册"区分开    return provider // 两项检查都过:就用配置指定的这个  const usable = [...providers.values()].filter(provider => provider.available()) // 分支二:无显式配置——先过滤出"可用"的候选(available() 是纯本地判断)  const [single] = usable // 取第一个元素;undefined 意味着一个可用的都没有  if (single === undefined) throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') // 零可用 → 报错,不猜、不降级  if (usable.length > 1) { // 多个可用 → 歧义    const ids = usable.map(provider => provider.id).join(', ') // 把所有候选 id 拼进错误信息,告诉用户该配哪个    throw new WebError(`multiple usable web providers are registered (${ids}); configure one explicitly`, 'WEB_PROVIDER_AMBIGUOUS') // 强制显式配置——选择结果绝不依赖注册顺序  }  return single // 恰好一个可用 → 自动选中它(单 provider 部署的零配置体验)function capSources(result: WebSearchResult, maxResults: number | undefined): WebSearchResult { // 缝层对搜索结果数做最终执法:provider 可以超量返回,上限由缝保证  if (maxResults === undefined || result.sources.length <= maxResults) return result // 没设上限或本来就在限内 → 原样透传  return { ...result, sources: result.sources.slice(0, maxResults), truncated: true } // 截断到上限并置 truncated 标志,让模型知道"还有更多被丢掉了"

为什么这样设计:"多个可用就报错"是这条缝最有性格的一条规则。直觉做法是按注册顺序取第一个,但那样加载顺序就成了隐式配置——bundle 里插件声明的先后、HMR 重注册的时机都会悄悄改变行为。这里选择 fail loud:要么你显式指定(searchProvider / DSH_WEB_SEARCH_PROVIDER),要么只装一个。注意环境变量走的是同一个字段,注释里特意强调"NOT a hidden priority chain"——不引入第二条优先级链,配置面保持单一。

capSources 则体现了"限额归缝、超量返回不违法"的分工:provider 可以一次拿回 50 条,缝层负责砍到 maxResults(tool-web 默认 8)并打上 truncated。这样每个 provider 都不需要理解"产品上限",而缝层也不需要理解任何后端的返回格式。

选择场景
行为
错误码
配置了 id,已注册且 available()=true
直接用该 provider
—(成功)
配置了 id,但未注册
fail loud,不回退自动选择
WEB_PROVIDER_CONFIGURED_MISSING
配置了 id,已注册但不可用(如缺 key)
fail loud
WEB_PROVIDER_CONFIGURED_UNAVAILABLE
未配置,恰好一个可用
自动选中(零配置路径)
—(成功)
未配置,多个可用
强制显式配置;选择绝不依赖注册顺序
WEB_PROVIDER_AMBIGUOUS
未配置,零个可用
报错,不猜
WEB_PROVIDER_UNAVAILABLE

三、web_search 消费层:渲染即契约

packages/web/tool-web/src/search.ts 拥有模型看到的一切:schema、参数校验(query 非空)、结果数上限的透传,以及把归一化结果渲染成 markdown。看 formatSearchOutput(第 54-75 行)——这段文本就是模型实际读到的东西:

📄 packages/web/tool-web/src/search.ts(第 54-75 行)

export function formatSearchOutput(result: WebSearchResult): string { // 把归一化搜索结果拼成一段模型可见的文本块  const parts: string[] = [] // 收集输出的各个部分,最后用空行连接  if (result.content !== undefined && result.content.length > 0) parts.push(result.content) // provider 生成的答案(Perplexity 有;Exa/DeepSeek 没有)放最前  if (result.sources.length > 0) { // 有来源 → 渲染成 markdown 列表    const lines = result.sources.map((source) => { // 每个 source 一行      const label = sourceLabel(source.url, source.title) // 显示名:优先 title,缺失时退回 hostname(再失败就用原始串)      const meta: string[] = [] // 收集可选元数据(摘要/日期),没有就不拼      if (source.snippet !== undefined && source.snippet.length > 0) meta.push(source.snippet) // 有 snippet 就带上      if (source.publishedAt !== undefined && source.publishedAt.length > 0) meta.push(`(${source.publishedAt})`) // 有日期就带括号附上      const suffix = meta.length > 0 ? ` — ${meta.join(' ')}` : '' // 元数据用破折号连接;全空则无后缀      return `- [${label}](${source.url})${suffix}` // 标准 markdown 链接 + 可选元数据——模型能直接引用    })    parts.push(`Sources:\n${lines.join('\n')}`) // 列表挂在 "Sources:" 标题下  } else if (result.content === undefined || result.content.length === 0) { // 既无来源也无答案 → 显式的空结果信号    parts.push('No results found.') // 明确告诉模型"没搜到",引导它换查询词重试,而不是留白让它瞎猜  if (result.truncated) parts.push(`(Showing the first ${result.sources.length} sources. Refine the query for more.)`) // 截断提示:缝层砍过结果时,教模型收窄查询而不是以为这就是全部  parts.push('Cite the relevant URLs above as markdown links in your answer.') // 常驻指令:强制引用来源链接——搜索工具的价值一半在出处  return parts.join('\n\n') // 各部分之间空一行

为什么这样设计:这段渲染代码里藏着三条产品决策。其一,"No results found." 是显式信号而非空字符串——模型对空白输出的行为不可预测,一句明确的话能把它推向"换词重试"。其二,截断提示直接给出行动建议(Refine the query),把 truncated 这个布尔值翻译成了模型可执行的指令。其三,引用指令是无条件追加的——不管有没有搜到东西都要求引用,因为幻觉引用的代价远高于多一句提示。

📌 system prompt 引导也是条件化的:applyWebSearchTool(第 210-222 行)注册 tool:web_search section 时,会检查同一组合里是否启用了 web_fetch——启用则引导"需要全文时用 web_fetch 跟进",未启用则改为"优先使用返回的 snippet"。提示词从不推荐一个不存在的工具。

四、DeepSeek 搜索 provider:一次模型回合就是一次搜索

三个搜索后端里,默认的是 DeepSeek(packages/web/web-search-deepseek/src/provider.ts)。它和 Exa/Perplexity 有本质区别:搜索不是一次检索 API 调用,而是一次带服务端工具的 Messages 模型回合——每次 web_search 都花一个模型 turn。看请求构造与发送(第 204-241 行):

📄 packages/web/web-search-deepseek/src/provider.ts(第 204-241 行)

const endpoint = `${options.baseURL}/messages` // Anthropic 兼容 Messages 端点(baseURL 默认已含 /anthropic/v1)const body: DeepSeekSearchLlmRequest['body'] = { // 发给 provider 的精确请求体——稍后会原样记入 session log  model: options.model, // 模型名,默认 deepseek-v4-flash  max_tokens: options.maxTokens, // 生成 token 上限,默认 4096  messages: [{ role: 'user', content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }] }], // 唯一一条 user 消息:只说"搜这个查询",不夹带任何上下文  tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: options.maxUses }], // 原生服务端 web_search 工具;默认单次请求最多用 5 次options.recordRequest?.({ endpoint, apiVersion: options.apiVersion, body }) // 派发前把无密钥的精确请求记入 session log——recordRequest 若抛错会阻止派发,模型可见输入逃不出日志throwIfSearchAborted(signal) // 发网络请求前再查一次取消:abort 之后绝不浪费一个模型回合let response: Response // 先声明响应变量,try 块内赋值——fetch 可能抛网络错误try { // fetch 会抛(网络/DNS/abort),统一在 catch 里分类  response = await fetch(endpoint, { // 真正发出 POST;endpoint 已含 /messages    method: 'POST', // 搜索是带 JSON body 的 POST(区别于抓页面的 GET)    redirect: 'error', // 携带凭证的请求拒绝任何重定向——API key 绝不会被自动转发到另一个源    headers: { // 请求头集合:双认证 + 版本 + 内容协商 + 署名      'x-api-key': apiKey, // 官方 DeepSeek 期望 x-api-key……      'authorization': `Bearer ${apiKey}`, // ……Anthropic 兼容代理可能只认 Authorization:两个都发,谁都能解析      'anthropic-version': options.apiVersion, // API 版本头(默认 2023-06-01)      'content-type': 'application/json', // JSON 请求体      'accept': 'application/json', // 期望 JSON 响应      'user-agent': USER_AGENT, // 署名 deepseek-harness/0.0.1,随包版本升级    },    body: JSON.stringify(body), // 序列化请求体    ...signal !== undefined ? { signal } : {}, // 把调用方取消信号透传给 fetch(没有就不传这个字段)  })} catch (error: unknown) {  if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) // abort → 稳定的 WEB_ABORTED 错误码,而不是被当成网络故障  throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) // 其他失败 → provider 错误,cause 链上原始异常

为什么这样设计:三个细节值得放大。第一,redirect: 'error'——这是整个 web 包的统一安全姿态(packages/web/AGENTS.md 明文要求):带凭证的请求一旦跟随重定向,key 就跟着去了另一个源;宁可让重定向直接失败。第二,双认证头是"官方端点 vs Anthropic 兼容代理"两种部署的交集解法,注释里写明了动机。第三,recordRequest 在派发前记录无密钥的精确请求体并允许它抛错阻断——这是"模型可见的辅助输入必须可审计"的落地:搜索查询本身会进入 DeepSeek 的服务端,日志里必须有据可查。

响应侧更见性格。DeepSeek 返回的是 Anthropic 风格的 content blocks,其中 web_search_tool_result 块携带结构化结果项,而 snippet 藏在另一个 text 块的 citations[] 里(按 url 关联)。mapAnthropicResponse(第 145-174 行)负责把这两处拼回一个 source:

📄 packages/web/web-search-deepseek/src/provider.ts(第 145-174 行)

export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResult { // 把 DeepSeek Messages 响应映射成归一化搜索结果  const blocks = response.content ?? [] // content 块数组;缺失时按空处理  const resultBlocks = blocks.filter((block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result') // 挑出原生搜索结果的块(类型谓词收窄)  if (resultBlocks.length === 0) { // 一个搜索结果块都没有 → 这次请求根本没触发原生搜索    throw new WebError('DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search', 'WEB_PROVIDER_ERROR') // fail loud:绝不退回"从散文里刮 URL"的降级路径——结构化结果缺失就是错误  const snippets = citationSnippets(blocks) // 从 text 块的 citations[] 建 url → cited_text 映射(这才是 snippet 的真实来源)  const seen = new Set<string>() // 去重集合:max_uses > 1 时同一条 URL 可能在多次搜索里重复出现  const sources: WebSearchSource[] = [] // 按结果顺序累积归一化 source  for (const block of resultBlocks) { // 遍历每个搜索结果块    for (const item of block.content ?? []) { // ……及其中的 web_search_result 条目      if (item.type !== 'web_search_result' || item.url.length === 0 || seen.has(item.url)) continue // 跳过非结果项、空 URL、以及已见过的重复 URL      seen.add(item.url) // 标记为已见      const snippet = snippets.get(item.url) // 查这条 URL 的引用摘录(可能没有)      sources.push({ url: item.url, ...item.title != null && item.title.length > 0 ? { title: item.title } : {}, ...snippet != null && snippet.length > 0 ? { snippet } : {}, ...item.page_age != null && item.page_age.length > 0 ? { publishedAt: item.page_age } : {} }) // url 必有;title/snippet/publishedAt 有则带上——绝不编造缺失字段  return { sources, truncated: false } // 最终 maxResults 截断归缝层管,所以 provider 永远报"未截断"

为什么这样设计:"没有结果块就抛错"是这条 provider 最硬的决策——模型回合可能返回纯文本(比如 DeepSeek 决定不搜索),此时从散文里刮 URL 看似宽容,实则让搜索结果的质量变成不可测的黑盒。宁可报错让上层重试。truncated 恒为 false 则再次印证第二节的分工:provider 只负责"拿回真实结果",限额执法是缝层的单一职责。

五、web_fetch 消费层:HTML→Markdown 与两级截断预算

packages/web/tool-web/src/fetch.ts(495 行)是 tool-web 里最重的模块,因为它要解决一个 provider 不该管的问题:把任意 HTML 变成模型能读的 markdown,且不能卡死事件循环。先看渲染入口 renderBody(第 224-244 行):

📄 packages/web/tool-web/src/fetch.ts(第 224-244 行)

function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody { // 把解码后的 body 渲染成模型可见的 markdown 文本  const content = body.content.slice(0, maxInputChars) // 先把源文本切到预算内(默认 20 万字符),限制同步转换的工作量  const sourceTruncated = content.length !== body.content.length // 记录这次"转换前截断"是否真的丢了内容  switch (body.kind) { // WebFetchBody 是封闭联合类型——每个分支都必须处理,漏一个就编译失败    case 'html': // HTML:走 turndown 转 markdown,但有两道守卫      if (exceedsConversionDepth(content)) return { text: content, sourceTruncated } // 嵌套深度超过 512 → 跳过转换直接透传原文(防 DoS,见下方分析)      try {        return { text: turndown.turndown(content), sourceTruncated } // 正常路径:HTML → GFM markdown(含表格/删除线插件)      } catch { // turndown 的 DOM 遍历对畸形标记仍可能抛 RangeError——降级兜底        return { text: content, sourceTruncated } // 转换失败 → 原样返回 HTML,截断状态如实带上;"降级的页面"好过"报错的抓取"    case 'text': // 纯文本(json/xml/text/*):原样透传      return { text: content, sourceTruncated } // 无需转换,直接返回;截断状态照带    default:      return assertNever(body, 'unhandled web fetch body kind') // 不可达分支:将来新增 body kind 时,这里会先编译报错逼你处理

🛡️ 那个 512 深度守卫不是拍脑袋:exceedsConversionDepth(第 138-205 行)用一次单遍扫描模拟 HTML 词法栈——跳过注释、跳过 script/style 的 raw-text 体、尊重引号内的 >,只接受"当前元素"的闭合标签。源码注释里给了实测数据:嵌套深度 512 ≈ 0.15s、2,000 ≈ 2s、20,000 ≈ 5s——而转换是同步跑在事件循环上的,期间协作式超时定时器根本来不及触发。真实页面嵌套几十层就封顶了,所以 512"远高于内容、远低于武器化"。恶意构造的未闭合标签会让它高估而非低估深度——宁可误杀透传,不可放行卡死。

输出侧还有第二道预算。整个 web_fetch 返回给模型的文本(header + body + footer)必须整体落在 fetchMaxOutputChars(默认 20 万字符)内,computeFetchOutput(第 310-319 行)负责这件事:

📄 packages/web/tool-web/src/fetch.ts(第 310-319 行)

function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch { // 组装完整的有界输出:header + body + 可选 footer  const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n` // 状态行打头——非 2xx 也是"结果"不是错误,模型必须显式看到状态码  const rendered = renderBody(result.body, maxInputChars) // 转换/截断 body(见上方 renderBody)  const prefix = `${header}${rendered.text}` // header + body 拼起来  const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars // "有效截断"= provider 砍过 OR 转换前切过 OR 整体超预算——三者任一即 true  const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}` // 只有真被截断才追加"换个更具体的 URL 再抓"的提示  if (full.length <= maxOutputChars) return { text: full, truncated } // 整体不超预算 → 直接返回  if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated } // 退化情形:预算比 footer 本身还小——硬切,footer 保不住就算了  return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated } // 正常超预算:从 body 上砍,但给 footer 留位置——"截断提示"本身必须活下来

为什么这样设计:"footer 必须活下来"是个容易被忽略的细节:截断提示是模型下一步行动的依据(换更具体的 URL),如果预算切在 footer 中间,模型就不知道内容被砍过。另外注意 truncated 在这里是有效截断(三种来源取或),而 provider 返回的 WebFetchResult.truncated 只反映网络层——UI 卡片和模型文本共用同一个计算结果(经 WeakMap memo,同一冻结值只转换一次),保证"卡片上显示的截断状态"永远等于"模型实际看到的文本"。

限额参数
默认值
归属层与含义
searchMaxResults
8
tool-web:单次 web_search 返回的来源数上限(对齐 OpenCode 的 Exa 默认)
fetchTimeoutMs / searchTimeoutMs
30,000 ms
tool-web:协作式工具调用预算,挂到 ToolDefinition.timeoutMs 由超时策略执法——是部署策略,不是模型参数
fetchMaxOutputChars
200,000
tool-web:web_fetch 完整输出(header+body+footer)的字符上限,同时约束同步转换的输入量
maxBodyChars
100,000
web-fetch-http:解码后 body 的字符上限(低于 tool 层,留出 header/footer 余量)
maxResponseBytes
5,000,000
web-fetch-http:响应体字节上限——先于解码生效,防大文件吃内存
maxUrlLength / maxRedirects
2,048 / 5
web-fetch-http:URL 长度上限;同源重定向跳数上限(跨源直接拒绝)

为什么分两层:字节上限管"网络与内存",字符上限管"模型上下文"——一个 5MB 的 UTF-8 页面解码后可能远超 20 万字符,两道闸各守一段。而 provider 层的 10 万字符刻意低于 tool 层的 20 万输出预算:body 先被砍到 10 万,加上 header/footer 仍有余量落在 20 万内,正常路径下输出层截断根本不会触发。

六、本地 HTTP fetch provider:传输卫生

packages/web/web-fetch-http/ 把"纯逻辑的 policy"和"带网络的 provider"拆成两个文件。policy.ts(第 24-54 行)是网络无关的一半——URL 校验与同源判定:

📄 packages/web/web-fetch-http/src/policy.ts(第 24-54 行)

export function validateFetchUrl(input: string, maxUrlLength: number): URL { // 任何网络访问之前的传输卫生检查;违规抛 WebError  if (input.length > maxUrlLength) throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL') // 超长 URL 先拒(默认上限 2048)——不解析、不浪费  let url: URL // 声明解析结果,try 块内赋值  try { // new URL 可能抛(畸形输入)——下面 catch 统一包装    url = new URL(input) // 解析;畸形输入在这里抛  } catch (error: unknown) {    throw new WebError(`invalid URL: ${input}`, 'WEB_INVALID_URL', { cause: error }) // 包成类型化 WebError,cause 链上原始解析错误  if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, 'WEB_INVALID_URL') // 只允许 http(s)——file://、gopher:// 之类不在 web fetcher 的职责范围  if (url.username.length > 0 || url.password.length > 0) throw new WebError('credentials in URLs are not allowed', 'WEB_BLOCKED_URL') // user:pass@host 一律拒绝——不给"URL 里夹带凭证"留后门(注意错误码是 BLOCKED 不是 INVALID)  return url // 全部通过;返回解析后的 URL 供后续请求使用export function isSameOrigin(a: URL, b: URL): boolean { // 同源 = scheme + hostname + port 三者全等  return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port // 一个表达式三个字段——专门用来给重定向跟随把关

为什么这样设计:注意 WEB_BLOCKED_URL 和 WEB_INVALID_URL 是两个错误码——"格式不对"和"被策略禁止"对模型/运维的含义不同,前者该修 URL,后者是产品边界。包注释里还有一句诚实的免责声明:SSRF / 私网拦截未实现("do not enable this provider where it can reach sensitive internal targets")——能力缝把"能做什么"和"允许在哪做"分得很清楚。

provider.ts 的 followAndRead(第 56-101 行)是重定向策略的主战场:

📄 packages/web/web-fetch-http/src/provider.ts(第 56-101 行)

private async followAndRead(initialUrl: string, signal: AbortSignal): Promise<WebFetchResult> { // 跟随同源重定向直到拿到非跳转响应(或抛错)  let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) // 初始 URL 先过传输卫生检查,再碰网络  let redirectsFollowed = 0 // 重定向跳数计数器,对应 maxRedirects 预算  for (;;) { // 循环直到出现非重定向响应(错误会直接抛出终止)    const response = await this.requestOnce(currentUrl, signal) // 一次 GET:redirect:'manual' + 共享 abort 信号 + 产品 User-Agent    if (isRedirectStatus(response.status)) { // 301/302/303/307/308 → 处理这一跳      if (redirectsFollowed >= this.limits.maxRedirects) { // 预算耗尽——在解析下一跳之前就拦下        await response.body?.cancel() // 取消(可能还在流的)body,防止 socket 泄漏        throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') // 默认上限 5 跳      const location = response.headers.get('location') // 下一跳地址      if (location === null) { // 重定向状态码却没有 Location → 不是可用资源        await response.body?.cancel() // 同样的 socket 卫生        throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') // 重定向却没有目标地址 → 视为不可用资源,直接报错      const target = resolveRedirect(location, currentUrl) // 相对 Location 按当前 URL 解析成绝对地址      let validatedTarget: URL // 校验通过后的目标 URL,供下一跳使用      try { // 校验可能抛(含跨源拒绝)——catch 里先清理 body 再重抛        validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) // 目标重新过一遍完整卫生检查——重定向不能当"绕过凭证/非 http(s)/超长校验"的后门        if (!isSameOrigin(validatedTarget, currentUrl)) throw new WebError(`cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, 'WEB_REDIRECT_BLOCKED') // 跨源 → 拒绝:每个新 origin 都是一次新的工具调用,需要一次新的权限决策      } catch (error: unknown) {        await response.body?.cancel() // 任何校验失败都先清理 body 再抛出        throw error // 原样重抛(保留 WEB_REDIRECT_BLOCKED / WEB_INVALID_URL 等原始错误码)      await response.body?.cancel() // 丢弃这一跳的 body(重定向响应体没有内容价值)      currentUrl = validatedTarget // 前进到下一 URL      redirectsFollowed++ // 消耗一跳预算      continue // 回到循环顶部发下一个请求    return await this.readBody(response, currentUrl, signal) // 非重定向:分类 content-type → 解码 → 字节限额,返回最终结果

为什么这样设计:"跨源重定向 = 需要重新授权的新请求"是这一节的核心安全模型。浏览器里 example.com 跳到 evil.com 是常态;但在 agent harness 里,模型对 example.com 的抓取授权不能自动延伸到另一个源——所以这里直接拒绝并告诉模型"retry against that URL directly",让下一次工具调用走完整的选择/权限路径。错误信息本身就是给模型的指令。另外每个失败分支都先 response.body?.cancel()——流式 body 不取消就是 socket 泄漏,这个卫生习惯贯穿整个 provider。

最后是字节限额 readCapped(第 155-207 行),它对"声明超限"和"实际超限"采取两种不同策略:

📄 packages/web/web-fetch-http/src/provider.ts(第 155-207 行)

private async readCapped(response: Response, signal: AbortSignal): Promise<{ bytes: Uint8Array; truncatedByBytes: boolean }> { // 按 maxResponseBytes(默认 5MB)读取响应流  const declared = response.headers.get('content-length') // 服务端声明的大小(如果有)  if (declared !== null) { // 有 Content-Length → 先做"声明超限"快速拒绝;没有则直接进流式读取    const length = Number(declared) // 解析成数字    if (Number.isFinite(length) && length > this.limits.maxResponseBytes) { // 还没读一个字节就知道"比上限大"      await response.body?.cancel() // 立即取消——不下载注定要拒绝的东西      throw new WebError(`response exceeds the maximum of ${this.limits.maxResponseBytes} bytes`, 'WEB_FETCH_TOO_LARGE') // 声明超限 → 硬拒绝(错误码明确,模型知道是体积问题)  if (response.body === null) return { bytes: new Uint8Array(0), truncatedByBytes: false } // 防御:无 body 的响应 → 空结果、未截断  const chunks: Uint8Array[] = [] // 收集到的字节块  let total = 0 // 累计字节数  let truncatedByBytes = false // 只有"真的丢了字节"才置位  const reader = response.body.getReader() // body 流读取器  try {    for (;;) { // 读到 EOF 或预算耗尽      const { done, value } = await reader.read() // 读一块(或结束)      if (done) break // EOF → 完整读完      const remaining = this.limits.maxResponseBytes - total // 剩余字节预算      if (value.byteLength > remaining) { // 这一块超出预算        chunks.push(value.subarray(0, remaining)) // 只保留放得下的前缀        total += remaining // 预算恰好耗尽        truncatedByBytes = true // 只有被丢弃的字节才算截断——"恰好等于上限"的 body 不会被误标        break // 停止读取;剩余流在 finally 里放弃      chunks.push(value) // 整块放得下      total += value.byteLength // 记账  } catch (error: unknown) {    throw translateAbortOrNetwork(error, signal) // 读流中途故障 → 按 deadline 信号分类成超时/取消/网络错误(见下方分析)  } finally {    await reader.cancel().catch(() => {}) // 成功读完或超预算跳出后,best-effort 清理被放弃的流  const bytes = new Uint8Array(total) // 按精确总量分配最终缓冲  let offset = 0 // 写入游标  for (const chunk of chunks) { // 按顺序拷贝各块    bytes.set(chunk, offset) // 把这一块拷进最终缓冲的当前位置    offset += chunk.byteLength // 游标前移,指向下一块的写入点  }  return { bytes, truncatedByBytes } // 交回有界字节缓冲 + 是否丢过字节

为什么这样设计:"声明超限拒绝、实际超限截断"是个务实的不对称:Content-Length 可信时,下载一个注定超标的文件纯属浪费带宽;但服务端少报 Content-Length(流式响应常态)时,硬拒绝会让大量可用页面变成错误——所以读的过程中超了就砍掉尾巴,truncated=true 如实上报。"恰好等于上限不算截断"的边界处理(注释里专门解释了)避免了把合法响应误标成残缺。

translateAbortOrNetwork(第 235-240 行)则展示了取消语义的精细度:它不看抛出来的异常值长什么样,而是查 deadline signal 上挂的原因——是我们自己的 WEB_FETCH_TIMEOUT(provider 超时)、还是别的 abort(调用方取消 → WEB_ABORTED)、还是 signal 根本没 abort(纯网络故障 → WEB_PROVIDER_ERROR)。三种失败对模型的含义完全不同,错误码必须分得开。

七、三个搜索 provider 对照

Exa(web-search-exa)和 Perplexity(web-search-perplexity)的 provider 结构与 DeepSeek 同构,差异全在 wire format 和"snippet 从哪来"。对照如下:

维度
DeepSeek(deepseek-official)
Exa(exa)
Perplexity(perplexity)
协议
Anthropic 兼容 Messages API
原生 REST POST /search
OpenAI 兼容 chat/completions
默认端点
api.deepseek.com/anthropic/v1/messages
api.exa.ai/search
api.perplexity.ai/chat/completions
搜索机制
服务端工具 web_search_20250305(max_uses 默认 5)——一次模型回合
Exa keyword/neural/auto 检索,无模型生成
sonar 模型 + search_recency_filter(day/week/month/year)
生成答案 content
无(不返回)
无(不返回)
有:choices[0].message.content → content
snippet 来源
text 块 citations[].cited_text,按 url 关联(首次出现优先)
highlights[] 首个非空;无 snippet 的条目整条丢弃
search_results[].snippet;缺失时退回 URL-only 的 citations[]
maxResults 处理
缝层截断(provider 报 truncated:false)
请求层 numResults 优化 + 缝层兜底
缝层截断(provider 报 truncated:false)
available() 检查
有 key + baseURL 可解析 + maxTokens/maxUses 为正整数
key 非空 + baseURL + highlightsPerResult/numResults 合法
key 非空 + baseURL + maxTokens 为正整数
重定向策略
redirect:'error'(带凭证,拒绝一切跳转)
redirect:'error'
redirect:'error'

两个值得注意的"不编造"决策:Exa 的 mapExaResult(第 56-65 行)对没有 highlight 的条目整条丢弃——注释说得很直白:"inventing one would lie";Perplexity 则允许 URL-only 的来源(citations[] 兜底),因为它的引用本来就可能是裸链接。归一化类型里 title/snippet/publishedAt 全部可选,正是为了容纳这种差异——缝层不强迫 adapter 编造字段。而 Exa 是唯一在请求层应用 maxResults(作为 numResults)的 provider——types.ts 注释明确这是"成本/延迟优化,缝层无论如何都会兜底执法"。

八、总结:两条端到端数据流

web_search:一次模型回合的搜索

① 模型调用 web_search({query}) → tool-web 校验 query 非空(parseSearchArgs)

② ctx.web.search({query, maxResults:8}, signal) → resolveProvider 执行期选择(配置优先,否则唯一可用)

③ DeepSeekSearchProvider:快照 options → resolveApiKey(credentials seam / env)→ recordRequest 记入 session log

④ POST /anthropic/v1/messages:服务端 web_search 工具(max_uses≤5),redirect:'error',双认证头

⑤ mapAnthropicResponse:web_search_tool_result 块 + citations 拼 snippet + url 去重;无结果块 → WEB_PROVIDER_ERROR

⑥ capSources 截断到 8 并置 truncated → formatSearchOutput:markdown 来源列表 + "引用 URL"指令

web_fetch:受控的匿名抓取

① 模型调用 web_fetch({url}) → tool-web 校验 url 非空;超时是部署策略(timeoutMs),不是模型参数

② ctx.web.fetch({url}, signal) → resolveProvider 执行期选择

③ HttpFetchProvider:validateFetchUrl(http(s)/无凭证/≤2048)+ deadline(30s) 统一管请求与读流

④ followAndRead:redirect:'manual';仅同源跳 ≤5 次;跨源 → WEB_REDIRECT_BLOCKED("retry against that URL directly")

⑤ readBody:classifyContentType → decoderForCharset(未知 charset fail loud)→ readCapped 5MB 字节闸 → 解码后 10 万字符闸

⑥ tool-web renderBody:HTML→GFM(嵌套>512 透传原文);computeFetchOutput 整体 ≤20 万字符,footer 必须存活

⑦ 模型收到 "Fetched <url> (HTTP <status>)" + body;非 2xx 是结果不是错误,状态码显式可见

全篇的错误码汇总——每个失败路径都有机器可路由的 WebError.code,工具执行层会把它放进结构化错误元数据:

错误码
触发场景
WEB_INVALID_URL
URL 畸形 / 超长 / 非 http(s) scheme
WEB_BLOCKED_URL
URL 内嵌 user:pass 凭证(策略禁止,非格式错误)
WEB_REDIRECT_BLOCKED
重定向跳数超限,或跨源重定向(需对目标 URL 重新发起工具调用)
WEB_FETCH_TOO_LARGE
声明 Content-Length 超过字节上限(未读即拒)
WEB_UNSUPPORTED_CONTENT_TYPE
二进制 content-type,或声明了 TextDecoder 不认识的 charset(fail loud 好过乱码)
WEB_PROVIDER_ERROR
网络故障 / provider API 非 2xx / 响应体不可解析 / DeepSeek 未返回搜索结果块
WEB_ABORTED
调用方取消(工具超时策略触发)——与 provider 错误严格区分
WEB_FETCH_TIMEOUT
provider 级 deadline(30s)到期——嵌套场景下与外层取消区分开
WEB_DUPLICATE_PROVIDER / WEB_PROVIDER_AMBIGUOUS / WEB_PROVIDER_UNAVAILABLE / WEB_PROVIDER_CONFIGURED_MISSING / WEB_PROVIDER_CONFIGURED_UNAVAILABLE
注册与选择失败(见第二节对照表)——全部 fail loud,无静默降级路径

Web 能力这一讲把"能力缝 + provider + tool consumer"三层范式展示得最完整:缝层用五条选择规则和一套错误码保证行为可预测,provider 层用 redirect:'error'、同源重定向闸和字节/字符双限额保证传输安全,tool-web 层用渲染契约和两级输出预算保证模型体验。下一讲我们进入终端持久会话与 LSP——看 agent 如何维持一个跨 turn 活着的 shell。

📚 系列导航

← 第 18 讲:Subagent 委托

→ 第 20 讲:终端持久会话与 LSP

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料