🏠

Web Fetch Execution and Result Models 模块深度解析

模块概述:为什么需要这个模块?

想象一下,你的智能助手刚刚通过 web_search 工具找到了一组相关的网页链接,但搜索引擎返回的只是几百字的摘要片段。当用户问"这篇技术文档里关于 API 认证的具体步骤是什么?"时,摘要里可能只提到"文档包含认证说明",却没有具体内容。

这就是 web_fetch_execution_and_result_models 模块存在的意义:它是一个深度内容获取器,负责从 web_search 发现的 URL 中抓取完整页面内容,并将其转换为 LLM 可理解的格式。

这个模块的核心挑战不在于"抓取网页"本身(那是 net/http 几行代码的事),而在于:

  1. 安全性:如何防止 Agent 被诱导访问内网资源(SSRF 攻击)?
  2. 兼容性:如何处理 JavaScript 渲染的页面、反爬虫机制、GitHub 特殊 URL 格式?
  3. 可靠性:当一种抓取方式失败时,如何优雅降级?
  4. 可用性:如何将杂乱的 HTML 转换为 LLM 友好的 Markdown 格式?

模块的设计洞察是:网页抓取不是单纯的 I/O 操作,而是一个需要多层防护、多策略回退、多阶段转换的安全敏感流程


架构与数据流

flowchart TB subgraph Input["输入层"] A[WebFetchInput] --> B[WebFetchItem 数组] end subgraph Validation["验证与安全层"] B --> C[validateAndResolve] C --> D[SSRF 安全检查] C --> E[DNS 解析与 IP 绑定] end subgraph Fetch["抓取执行层"] E --> F{fetchHTMLContent} F -->|首选 | G[fetchWithChromedp] F -->|降级 | H[fetchWithHTTP] G --> I[DNS 绑定到 PinnedIP] H --> J[Host 头保留] end subgraph Transform["转换层"] I --> K[convertHTMLToText] J --> K K --> L[goquery HTML 解析] L --> M[Markdown 格式化] end subgraph Summarize["摘要层"] M --> N{processWithLLM} N -->|成功 | O[LLM 摘要] N -->|失败 | P[原始内容预览] end subgraph Output["输出层"] O --> Q[webFetchItemResult] P --> Q Q --> R[ToolResult 聚合] end style Input fill:#e1f5fe style Validation fill:#fff3e0 style Fetch fill:#f3e5f5 style Transform fill:#e8f5e9 style Summarize fill:#fce4ec style Output fill:#fff8e1

数据流详解

整个模块的数据流可以理解为五层管道

第一层:输入解析

  • WebFetchInput 携带 []WebFetchItem 数组进入 Execute 方法
  • 每个 WebFetchItem 包含 URLPrompt 两个字段
  • 模块支持并发处理多个抓取任务(每个 item 一个 goroutine)

第二层:安全验证

  • validateAndResolve 是安全网关,执行三项检查:
    1. URL 格式验证(必须是 http/https)
    2. SSRF 安全检查(调用 utils.IsSSRFSafeURL
    3. DNS 解析并绑定到单一公网 IP(防止 DNS 重绑定攻击)
  • 返回的 validatedParams 包含 PinnedIP,后续所有网络请求都使用这个 IP

第三层:双路径抓取

  • fetchHTMLContent 实现策略模式:先尝试 chromedp(无头浏览器),失败后降级到 http.Client
  • chromedp 路径:通过 host-resolver-rules 强制 Chrome 使用 PinnedIP,防止浏览器重新解析 DNS
  • http 路径:直接连接 PinnedIP,但保留原始 Host 头以支持虚拟主机

第四层:HTML→Markdown 转换

  • convertHTMLToText 使用 goquery 解析 HTML
  • 移除 <script>, <style>, <nav>, <footer>, <header> 等噪声元素
  • processNode 递归遍历 DOM 树,将 HTML 标签映射为 Markdown 语法

第五层:LLM 摘要与结果聚合

  • processWithLLM 调用配置的 chatModel 对内容进行摘要
  • 每个任务的 webFetchItemResult 包含 output(文本)、data(结构化数据)、err(错误)
  • 所有结果聚合为 ToolResult,包含成功标志、聚合输出、结构化数据

核心组件深度解析

WebFetchTool:抓取引擎的主体

设计意图

WebFetchTool 不是一个简单的工具函数,而是一个有状态的抓取引擎。它封装了:

  • *http.Client:预配置的 SSRF 安全 HTTP 客户端
  • chat.Chat:用于内容摘要的 LLM 模型

这种设计体现了依赖注入模式:在 NewWebFetchTool 构造函数中注入外部依赖,使工具可测试、可配置。

关键方法分析

Execute(ctx, args): 并发编排器

func (t *WebFetchTool) Execute(ctx context.Context, args json.RawMessage) (*types.ToolResult, error)

这个方法的核心职责是并发编排,而非实际抓取。它:

  1. 解析 WebFetchInput,验证 items 非空
  2. 为每个 WebFetchItem 启动一个 goroutine
  3. 使用 sync.WaitGroup 等待所有任务完成
  4. 聚合所有 webFetchItemResult 为统一的 ToolResult

设计权衡:为什么选择并发而非串行?

  • 优点:多个 URL 抓取互不阻塞,总耗时 ≈ max(单个任务耗时),而非 sum(单个任务耗时)
  • 风险:可能同时发起大量请求,对目标服务器造成压力
  • 缓解webFetchTimeout = 60s 限制单个任务超时,间接控制并发资源占用

隐式契约:调用者需确保传入的 URL 数量合理(建议 ≤ 10),否则可能触发速率限制。

validateAndResolve(p webFetchParams): 安全网关

func (t *WebFetchTool) validateAndResolve(p webFetchParams) (*validatedParams, error)

这是模块的安全核心。它执行的关键操作:

  1. 基础验证:URL 非空、Prompt 非空、协议为 http/https
  2. SSRF 检查:调用 utils.IsSSRFSafeURL(p.URL),拒绝内网 IP、回环地址等
  3. DNS 解析与绑定
    ips, err := net.DefaultResolver.LookupIP(context.Background(), "ip", hostname)
    var pinnedIP net.IP
    for _, ip := range ips {
        if utils.IsPublicIP(ip) {
            pinnedIP = ip
            break
        }
    }
    

为什么需要 DNS 绑定(DNS Pinning)?

这是一个关键的安全设计。考虑以下攻击场景:

攻击者控制 evil.com
1. 验证时:evil.com → 203.0.113.1(公网 IP,通过检查)
2. 抓取时:evil.com → 192.168.1.1(内网 IP,DNS 重绑定攻击)

通过在验证时解析并绑定 PinnedIP,后续所有请求都直接连接该 IP,绕过 DNS 重新解析。chromedp 通过 host-resolver-rules 标志实现,HTTP 请求通过修改 u.Host 实现。

设计权衡:这种设计牺牲了一定的灵活性(无法利用 CDN 动态调度),但换取了确定性安全。

fetchHTMLContent(ctx, vp): 双策略抓取

func (t *WebFetchTool) fetchHTMLContent(ctx context.Context, vp *validatedParams) (string, string, error)

这个方法实现了策略模式 + 降级模式

html, err := t.fetchWithChromedp(ctx, vp)
if err == nil && strings.TrimSpace(html) != "" {
    return html, "chromedp", nil
}
// 降级到 HTTP
html, httpErr := t.fetchWithHTTP(ctx, vp)

为什么需要两种抓取方式?

场景 Chromedp HTTP
JavaScript 渲染页面 ✅ 执行 JS 后获取 DOM ❌ 只能获取初始 HTML
反爬虫检测 ✅ 真实浏览器指纹 ⚠️ 可能被识别为爬虫
资源消耗 ❌ 高(启动浏览器进程) ✅ 低
速度 ❌ 慢(秒级) ✅ 快(毫秒级)
稳定性 ⚠️ 依赖 Chrome 环境 ✅ 纯 Go 实现

设计洞察:优先使用 chromedp 保证内容完整性,失败后降级到 http 保证可用性。这是一种乐观策略:假设大多数页面需要 JS 渲染,但如果不需要,降级路径也能工作。

convertHTMLToText(html): HTML→Markdown 转换器

func (t *WebFetchTool) convertHTMLToText(html string) string

这个方法的核心是一个递归下降解析器(通过 goquery 实现)。processNode 方法处理各种 HTML 标签:

switch nodeName {
case "h1", "h2", "h3":
    // 转换为 # 标题
case "a":
    // 转换为 [text](href)
case "img":
    // 转换为 ![alt](src)
case "ul", "ol":
    // 转换为 - 或 1. 列表
case "code":
    // 转换为 `code` 或 ```code```
case "table":
    // 转换为 Markdown 表格
// ...
}

设计权衡:为什么不用现成的 HTML→Markdown 库?

可能的原因:

  1. 可控性:自定义实现可以精确控制哪些标签保留、哪些移除
  2. 噪声过滤:主动移除 <script>, <style>, <nav>, <footer>, <header> 等对 LLM 无用的内容
  3. 依赖最小化:避免引入额外的第三方库

潜在问题:自定义解析器可能遗漏某些 HTML 结构(如 <figure>, <figcaption>, <details> 等),导致信息丢失。

processWithLLM(ctx, params, content): 智能摘要

func (t *WebFetchTool) processWithLLM(ctx context.Context, params webFetchParams, content string) (string, error)

这个方法调用配置的 chatModel 对抓取的内容进行摘要:

systemMessage := "你是一名擅长阅读网页内容的智能助手,请根据提供的网页文本回答用户需求,严禁编造未在文本中出现的信息。"
userTemplate := `用户请求:
%s

网页内容:
%s`

设计意图

  1. 减少 token 消耗:原始网页内容可能很长,摘要后只需传递关键信息
  2. 提高回答质量:LLM 直接阅读原始内容,比 Agent 二次处理更准确
  3. 防止幻觉:System Prompt 强调"严禁编造",约束 LLM 行为

设计权衡

  • 优点:摘要更精准,减少后续处理的 token 开销
  • 风险:如果 chatModel 不可用或失败,会降级到返回原始内容(见 buildOutputText
  • 成本:每次抓取都额外调用一次 LLM,增加延迟和成本

隐式契约:调用者需确保传入的 chatModel 已正确初始化,否则 processWithLLM 会返回错误。


WebFetchItem:任务单元

type WebFetchItem struct {
    URL    string `json:"url" jsonschema:"待抓取的网页 URL,需来自 web_search 结果"`
    Prompt string `json:"prompt" jsonschema:"分析该网页内容时使用的提示词"`
}

设计意图

WebFetchItem 是一个值对象(Value Object),不可变、无行为,仅承载数据。它的设计体现了显式契约原则:

  • URL 字段的 jsonschema 注释明确指出"需来自 web_search 结果",这是一个前置条件
  • Prompt 字段告诉 LLM 如何分析内容,实现关注点分离:抓取逻辑不关心分析逻辑

使用模式

input := WebFetchInput{
    Items: []WebFetchItem{
        {URL: "https://example.com/doc", Prompt: "提取 API 认证步骤"},
        {URL: "https://example.com/guide", Prompt: "总结配置方法"},
    },
}

隐式约束

  • URL 必须是绝对路径(相对路径会在 validateAndResolve 中被拒绝)
  • Prompt 不应为空(验证会检查)
  • URL 数量建议 ≤ 10(避免并发资源耗尽)

webFetchItemResult:内部结果封装

type webFetchItemResult struct {
    output string
    data   map[string]interface{}
    err    error
}

设计意图

这是一个内部私有类型(小写开头),不暴露给外部调用者。它的作用是:

  1. 并发安全:每个 goroutine 写入自己的 webFetchItemResult,避免竞争条件
  2. 结果隔离:单个任务失败不影响其他任务
  3. 灵活聚合Execute 方法可以根据需要组合 outputdataerr

数据流

goroutine 1 → webFetchItemResult{output: "...", data: {...}, err: nil}
goroutine 2 → webFetchItemResult{output: "...", data: {...}, err: error}
...
Execute 方法聚合 → ToolResult{Success: false, Output: "聚合文本", Data: {"results": [...]}, Error: "第一个错误"}

设计权衡:为什么不用 chan 传递结果?

  • chan 方案:需要管理 channel 生命周期,代码更复杂
  • 当前方案:预分配 results 数组,goroutine 直接写入对应索引,简单高效

依赖关系分析

模块调用什么(Callees)

依赖 用途 耦合度
utils.SSRFSafeHTTPClient 创建防 SSRF 的 HTTP 客户端 高(安全核心)
utils.IsSSRFSafeURL URL 安全检查 高(安全核心)
utils.IsPublicIP 判断 IP 是否为公网地址 高(安全核心)
chromedp 无头浏览器抓取 中(可降级)
goquery HTML 解析 中(可替换)
chat.Chat LLM 摘要 低(可选功能)

关键依赖:SSRF 安全工具

模块严重依赖 utils 包中的 SSRF 防护函数。如果这些函数被修改或移除,模块的安全性将受到直接影响。这是一种紧耦合,但出于安全考虑是合理的——安全逻辑应该集中管理,而非分散在各个工具中。

可降级依赖:chromedp 和 chatModel

  • chromedp 失败后会降级到 http.Client,属于软依赖
  • chatModelnil 时,processWithLLM 返回错误,但抓取仍成功,属于可选依赖

什么调用模块(Callers)

根据模块树,web_fetch_execution_and_result_models 属于 agent_runtime_and_tools → web_and_mcp_connectivity_tools 子模块。调用者包括:

  1. Agent 引擎internal.agent.engine.AgentEngine):在工具调用阶段执行 WebFetchTool.Execute
  2. 工具注册表internal.agent.tools.registry.ToolRegistry):注册和解析 web_fetch 工具

数据契约

调用者期望的输入:

{
  "items": [
    {"url": "https://...", "prompt": "..."}
  ]
}

调用者期望的输出(types.ToolResult):

type ToolResult struct {
    Success bool
    Output  string  // 人类可读的文本
    Data    map[string]interface{}  // 机器可读的结构化数据
    Error   string
}

隐式契约

  • Output 字段应包含格式化后的结果,供 Agent 显示给用户
  • Data 字段的 display_type 应为 "web_fetch_results",供前端识别渲染方式
  • Successfalse 时,Error 字段应包含错误信息

设计决策与权衡

1. 安全 vs 可用性:SSRF 防护的代价

决策:强制 DNS 绑定 + SSRF 检查

权衡

  • ✅ 防止 Agent 被诱导访问内网资源
  • ❌ 无法访问某些动态 DNS 服务(如某些 CDN)
  • ❌ 增加 DNS 解析开销(验证时解析一次,抓取时不再解析)

为什么这样设计:在安全敏感场景(Agent 可执行任意工具调用),防御性设计优先于便利性。一次 SSRF 漏洞可能导致整个系统被渗透。

2. 性能 vs 可靠性:双路径抓取

决策:优先 chromedp,降级到 http

权衡

  • ✅ 支持 JavaScript 渲染页面
  • ✅ 失败时有降级路径
  • ❌ 正常路径资源消耗高(启动 Chrome 进程)
  • ❌ 延迟增加(chromedp 通常比 http 慢 10 倍以上)

为什么这样设计:Agent 场景下,内容完整性比速度更重要。抓取失败导致无法回答用户问题,比慢几秒更糟糕。

3. 灵活性 vs 控制力:自定义 HTML→Markdown

决策:使用 goquery 手动实现转换,而非第三方库

权衡

  • ✅ 精确控制哪些标签保留/移除
  • ✅ 可针对 LLM 优化输出格式
  • ❌ 代码量大,维护成本高
  • ❌ 可能遗漏某些 HTML 结构

为什么这样设计:LLM 对输入格式敏感,通用库可能保留过多噪声(如导航栏、广告),影响回答质量。

4. 并发 vs 简单性:goroutine -per-task

决策:每个 WebFetchItem 一个 goroutine

权衡

  • ✅ 总耗时最小化
  • ✅ 代码简洁(sync.WaitGroup 模式)
  • ❌ 无并发数限制,可能触发目标服务器速率限制
  • ❌ 错误处理复杂(需聚合多个错误)

为什么这样设计:Agent 场景下,用户期望快速响应。且通常 items 数量较少(≤ 5),并发风险可控。

改进建议:可引入信号量限制最大并发数,如:

sem := make(chan struct{}, 3)  // 最多 3 个并发
for idx := range input.Items {
    sem <- struct{}{}
    go func() {
        defer func() { <-sem }()
        // ... 抓取逻辑
    }()
}

使用指南与示例

基本使用

// 1. 创建工具实例(需注入 chatModel)
chatModel := lkeap.NewLKEAPChat(...)
tool := NewWebFetchTool(chatModel)

// 2. 构造输入
input := WebFetchInput{
    Items: []WebFetchItem{
        {
            URL:    "https://example.com/documentation",
            Prompt: "提取 API 认证的具体步骤",
        },
    },
}

// 3. 执行
args, _ := json.Marshal(input)
result, err := tool.Execute(ctx, args)

// 4. 处理结果
if result.Success {
    fmt.Println(result.Output)  // 人类可读
    fmt.Printf("结构化数据:%+v\n", result.Data)  // 机器可读
}

配置选项

配置项 默认值 说明
webFetchTimeout 60s 单个抓取任务超时
webFetchMaxChars 100000 HTTP 抓取时最大字符数
Temperature (LLM) 0.3 摘要生成的随机性
MaxTokens (LLM) 1024 摘要最大 token 数

与 web_search 的协作模式

1. Agent 调用 web_search → 返回搜索结果(含 URL 和摘要)
2. Agent 判断摘要不足 → 调用 web_fetch 抓取完整内容
3. web_fetch 返回详细内容和摘要 → Agent 综合回答用户问题

最佳实践

  • 仅抓取 web_search 返回的 URL(不要随意抓取未知来源)
  • 为每个 URL 提供具体的 Prompt(如"提取代码示例"而非"总结内容")
  • 检查 result.Data["count"] 确认成功抓取的数量

边界情况与陷阱

1. GitHub URL 特殊处理

func (t *WebFetchTool) normalizeGitHubURL(source string) string {
    if strings.Contains(source, "github.com") && strings.Contains(source, "/blob/") {
        source = strings.Replace(source, "github.com", "raw.githubusercontent.com", 1)
        source = strings.Replace(source, "/blob/", "/", 1)
    }
    return source
}

陷阱:如果直接请求 github.com/.../blob/...,返回的是 HTML 页面而非原始文件内容。模块自动转换为 raw.githubusercontent.com/... 格式。

注意:这个转换在 validateAndResolve 之前执行,所以 SSRF 检查针对的是转换后的 URL。

2. DNS 重绑定攻击防护

攻击场景

1. 攻击者控制 evil.com
2. 验证时:evil.com → 203.0.113.1(公网 IP)
3. 抓取时:evil.com → 192.168.1.1(内网 IP)

防护机制

  • 验证时解析 DNS 并绑定 PinnedIP
  • chromedp 使用 host-resolver-rules 强制 Chrome 使用 PinnedIP
  • HTTP 请求直接连接 PinnedIP,绕过 DNS

限制:如果目标网站使用 CDN 且 IP 频繁变化,可能因 IP 不匹配导致 TLS 握手失败。

3. LLM 摘要失败降级

summary, summaryErr := t.processWithLLM(ctx, params, textContent)
if summaryErr != nil {
    logger.Warnf(ctx, "[Tool][WebFetch] LLM 处理失败 url=%s err=%v", displayURL, summaryErr)
}
// 无论 LLM 是否成功,都会返回原始内容
output := t.buildOutputText(params, textContent, summary, summaryErr)

行为:LLM 失败不会导致整个任务失败,而是降级到返回原始内容。

陷阱:调用者需检查 result.Data["summary"] 是否存在,而非假设一定有摘要。

4. 并发错误聚合

var firstErr error
for idx, res := range results {
    if res.err != nil {
        success = false
        if firstErr == nil {
            firstErr = res.err  // 只保留第一个错误
        }
    }
}

陷阱:只返回第一个错误,后续错误被丢弃。调用者应检查 result.Output 中的详细错误信息,而非仅依赖 result.Error

5. HTML→Markdown 转换限制

不支持的 HTML 结构

  • <figure> / <figcaption>:会被当作普通文本处理
  • <details> / <summary>:折叠内容会丢失
  • <svg>:会被移除(不在处理列表中)
  • Web Components:自定义标签会被递归处理,但可能丢失语义

建议:对于复杂页面,检查 result.Data["content_length"],如果过长可能表示转换效果不佳。


运维与调试

日志关键点

模块在以下位置输出日志:

日志级别 位置 内容
Info Execute 开始 [Tool][WebFetch] Execute started
Info Execute 结束 Completed with success=%v, items=%d
Info executeFetch 开始 Fetching URL: %s
Error 抓取失败 获取页面失败 url=%s err=%v
Warn LLM 失败 LLM 处理失败 url=%s err=%v
Debug Chromedp 开始/结束 Chromedp 抓取开始/成功 url=%s

性能调优建议

  1. 调整超时:如果目标网站响应慢,可增加 webFetchTimeout
  2. 限制并发:在 Agent 引擎层限制同时执行的 web_fetch 任务数
  3. 缓存:对相同 URL 的重复请求,可在上层实现缓存(模块本身无缓存)

常见问题排查

问题 可能原因 排查方法
所有请求失败 SSRF 检查过严 检查 utils.IsSSRFSafeURL 逻辑
Chromedp 失败但 HTTP 成功 缺少 Chrome 依赖 检查服务器是否安装 Chrome
内容为空 HTML 解析失败 检查 convertHTMLToTextbasicTextExtraction 降级路径
LLM 摘要缺失 chatModel 未配置 检查 NewWebFetchTool 是否传入有效 chatModel

相关模块参考


总结

web_fetch_execution_and_result_models 模块是一个安全优先、可靠性驱动的网页抓取引擎。它的核心设计原则是:

  1. 安全不可妥协:SSRF 防护和 DNS 绑定是硬性要求,不可绕过
  2. 降级优于失败:chromedp 失败→HTTP,LLM 失败→原始内容
  3. 并发提升体验:多 URL 并发抓取,减少用户等待时间
  4. 格式优化 LLM:HTML→Markdown 转换针对 LLM 理解优化

理解这个模块的关键是认识到:它不是简单的 HTTP 客户端,而是一个在开放网络环境中安全执行 Agent 指令的可信执行环境

On this page