Web Agent实战指南:让AI真正浏览网页的核心技术

2026-06-22阅读 0热度 0
其他

为什么需要 Web Agent

大型语言模型的知识存在“截止日期”硬伤——你问它“LangGraph 当前稳定版号”,它只能背诵训练期间抓取的数据,通常是过时信息。Web Agent 的设计目标很直接:让智能体真正联网检索,拿到最新数据再作答,杜绝凭记忆捏造。

Agent 系列(23):Web Agent——让 Agent 真正浏览网页

但“联网检索”这四个字,从原理到落地要踩几个关键坑:

  • 网页本质是 HTML,不是纯文本——把整页源码丢进 LLM 上下文,会拖入大量无关标签和噪声。
  • 普通网页动辄数万 Token——远超大多数 LLM 的上下文窗口容量。
  • Agent 可能陷入无限跳转——从 A 链接到 B,再从 B 链接到 C,不加约束会永无止境。
  • URL 可以被“幻觉”出来——LLM 会自行编造它认为“应该存在”的链接,实际根本不存在。

这四类问题对应四个工程管控点:HTML 净化、Token 开销预算、步数上限、URL 异常兜底。接下来把这些组件组合成一个可运行的 Web Agent。


架构设计

整体采用标准的 LangGraph 双节点图:

复制代码用户问题
    │
    ▼
┌─────────────────────────────────────┐
│         agent_node                  │
│  SystemPrompt + messages → LLM      │
│  bound_llm.invoke(msgs)             │
└────────┬────────────────────────────┘
         │
    有 tool_calls?
         │
    ┌────┴─────┐
   是          否(或 steps >= MAX_STEPS)
    │               │
    ▼               ▼
tools_node         END
web_search /
fetch_page
    │
    └──→ agent_node(循环)

状态只保留两个字段:

复制代码class WState(TypedDict):
    messages: Annotated[list, add_messages]  # 累计的对话消息
    steps: int                                # 已用执行步数

留意其中的 steps 字段——普通对话 Agent 通常不需要显式计步,但 Web Agent 在页面间存在无限跳转风险,必须设置硬性上限。


两个工具

web_search:DuckDuckGo 搜索

复制代码@tool
def web_search(query: str) -> str:
    """
    Execute a web search via DuckDuckGo.
    Returns up to 5 results (title, snippet, URL).
    Always use the returned URLs to call fetch_page — never fabricate URLs.
    """
    try:
        resp = requests.get(
            "https://html.duckduckgo.com/html/",
            params={"q": query},
            headers=HEADERS,
            timeout=12,
        )
        soup = BeautifulSoup(resp.text, "html.parser")
        results = []
        for i, block in enumerate(soup.select(".result"), 1):
            if i > 5:
                break
            title   = (block.select_one(".result__title")   or soup.new_tag("x")).get_text(strip=True)
            snippet = (block.select_one(".result__snippet") or soup.new_tag("x")).get_text(strip=True)
            url_raw = (block.select_one(".result__url")     or soup.new_tag("x")).get_text(strip=True)
            url = f"https://{url_raw}" if url_raw and not url_raw.startswith("http") else url_raw
            results.append(f"{i}. {title}n   {snippet}n   URL: {url}")
        return "nn".join(results) if results else "No results found."
    except Exception as exc:
        return f"Search error: {exc}"

这里使用了 DuckDuckGo 的纯 HTML 接口,优势是不需 API Key。通过解析 .result CSS 类提取标题、摘要、URL,将结构化文本返回给 LLM 处理。

工具描述中嵌入了一条关键约束:Always use the returned URLs to call fetch_page — never fabricate URLs。这是防范 URL 幻觉的第一道屏障——在 Prompt 层直接限定模型:URL 必须来自搜索结果,禁止自行捏造。

fetch_page:页面抓取 + 清洗

复制代码@tool
def fetch_page(url: str) -> str:
    """
    Fetch a webpage and return its cleaned text content (truncated to token budget).
    Only invoke with real URLs obtained from web_search results.
    """
    try:
        resp = requests.get(url, headers=HEADERS, timeout=12)
        resp.raise_for_status()
        full_text = clean_html(resp.text)
        orig_tokens = count_tokens(full_text)
        displayed = truncate_to_budget(full_text)
        shown_tokens = min(orig_tokens, PAGE_TOKEN_BUDGET)
        return (
            f"[URL: {url}]n"
            f"[Size: {orig_tokens} tokens → showing {shown_tokens} tokens "
            f"(budget={PAGE_TOKEN_BUDGET})]nn"
            f"{displayed}"
        )
    except requests.HTTPError as exc:
        return f"HTTP {exc.response.status_code} — could not fetch {url}"
    except requests.ConnectionError:
        return f"Connection error — {url} may not exist or be unreachable"
    except Exception as exc:
        return f"Error fetching {url}: {type(exc).__name__}: {exc}"

该工具分三个环节:

  1. clean_html:利用 BeautifulSoup 剥离 script、style、nav、footer 等噪音,仅保留正文纯文本。
  2. truncate_to_budget:超出 Token 预算的部分直接截断。
  3. 异常分类:HTTP 状态错误、连接错误、其他异常各自返回安全的提示字符串。

注意 requests.HTTPErrorrequests.ConnectionError 的区分——前者是服务器返回了有效响应(4xx/5xx),后者是连接层失败(域名不存在、网络不可达)。


三个工程防护

防护 1:URL 异常兜底

测试一个完全虚构的域名:

复制代码fetch_page(https://totally-made-up-domain-xyz99999.org/docs/n...)
→ Connection error — https://totally-made-up-domain-xyz99999.org/docs/nonexistent may not exist or be unreachable

不会崩溃,不抛出异常,返回一条安全的错误字符串。LLM 收到后,会尝试其他 URL 或调整搜索关键词。

这是防护的核心设计原则:错误是工具的输出,而非异常。工具调用失败不应中断整个 Agent 执行流程,而是让 LLM 根据错误信息自行修正决策。

防护 2:Token 开销预算截断

测试 PyPI 上的 langgraph 页面:

复制代码fetch_page(pypi.org/project/langgraph/)[Size: 4576 tokens → showing 800 tokens (budget=800)]

原始页面 4576 tokens,截断到 800,节省了 82% 的上下文空间。单次浏览效果或许不突出,但 Agent 可能需要浏览多个页面,此时节省量影响巨大。

截断实现很简单:

复制代码PAGE_TOKEN_BUDGET = 800   # 每次 fetch 最多向 LLM 传递的页面 Token 数def count_tokens(text: str) -> int:
    """粗略估算:中英文混合约 3 个字符 ≈ 1 个 Token"""
    return max(1, len(text) // 3)def truncate_to_budget(text: str, budget: int = PAGE_TOKEN_BUDGET) -> str:
    if count_tokens(text) <= budget:
        return text
    cutoff = budget * 3
    return text[:cutoff] + f"nn[... content truncated to ~{budget}-token budget ...]"

count_tokens 采用粗略字符比估算(3 字符 ≈ 1 token),并非精确分词。对截断场景而言,速度比精度更重要。

防护 3:步数上限

复制代码MAX_STEPS = 8def router(state: WState) -> str:
    if state["steps"] >= MAX_STEPS:
        return END
    last = state["messages"][-1]
    if isinstance(last, AIMessage) and last.tool_calls:
        return "tools"
    return END

state["steps"] 在每次 agent_node 执行时自增 1:

复制代码def agent_node(state: WState) -> dict:
    msgs = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
    response = bound_llm.invoke(msgs)
    return {"messages": [response], "steps": state["steps"] + 1}

Router 优先检查步数,再检查 tool_calls。也就是说,即使 LLM 还想继续调用工具,步数耗尽也会强制终止——这是防止无限循环的硬边界。

步数初始化在调用时完成:

复制代码state = graph.invoke(
    {"messages": [HumanMessage(content=query)], "steps": 0},
    config={"recursion_limit": MAX_STEPS * 3},
)

recursion_limit 是 LangGraph 的内置防护,steps 是应用层自定义防护,两者独立工作,互相兜底。


运行结果

复制代码======================================================================
Web Agent Demo
Model: glm-4-flash  |  Token budget/page: 800  |  Max steps: 8
========================================================================= Part 3: Engineering Guards ===──────────────────────────────────────────────────────────────────────
[Guard 1] URL error handling (bad / hallucinated URL)
  fetch_page(...)
  → Connection error —  may not exist or be unreachable──────────────────────────────────────────────────────────────────────
[Guard 2] Token budget enforcement (budget=800 tokens/page)
  fetch_page(pypi.org/project/langgraph/)
  → [Size: 4576 tokens → showing 800 tokens (budget=800)]──────────────────────────────────────────────────────────────────────
[Guard 3] Step limit (MAX_STEPS=8) — agent cannot loop forever
  Graph router returns END when state['steps'] >= 8
  Even if tool_calls remain, execution stops.

三个防护全部按预期生效。有趣的是,研究环节遭遇了 DuckDuckGo 限流,搜索返回了空结果——模型正确报告了失败,而不是编造答案。这恰好也体现了防护的有效性:Agent 在搜索失败时没有陷入循环,而是明确告知用户无法获取数据。


DuckDuckGo 的局限性

DuckDuckGo 的 HTML 接口是无需 Key 的方案,优点是门槛低,缺点是生产环境不可靠:

  • 频繁请求会遭遇限流或返回空结果
  • HTML 结构随时可能变动,CSS 选择器会失效
  • 没有速率限制控制,容易触发封锁

生产环境替代方案:

方案特点
Tavily API专为 LLM Agent 设计,返回结构化结果
SerpAPI多搜索引擎聚合,稳定但需付费
Brave Search API免费额度较大,独立索引
Jina Reader专注网页转文本,效果好

切换时只需替换 web_search 工具的内部实现,Agent 图结构完全不用改动。


完整 Graph 代码

复制代码TOOLS   = [web_search, fetch_page]
TOOL_MAP = {t.name: t for t in TOOLS}
bound_llm = llm.bind_tools(TOOLS)SYSTEM_PROMPT = f"""You are a web research agent. Answer the user's question by browsing the web.Workflow:
1. Call web_search to find relevant pages.
2. Call fetch_page on promising URLs to read content.
3. If you find the answer, give a clear, concise final response.
4. If a page doesn't help, try a different search query.Strict rules:
- Only use URLs from web_search results — never invent or guess URLs.
- If fetch_page returns an error, try a different URL or search query.
- You ha ve at most {MAX_STEPS} total steps. Be efficient.
- Once you ha ve enough information, stop browsing and answer directly."""
class WState(TypedDict):
    messages: Annotated[list, add_messages]
    steps: int
def agent_node(state: WState) -> dict:
    msgs = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
    response = bound_llm.invoke(msgs)
    return {"messages": [response], "steps": state["steps"] + 1}
def tools_node(state: WState) -> dict:
    last = state["messages"][-1]
    results = []
    for tc in last.tool_calls:
        output = TOOL_MAP[tc["name"]].invoke(tc["args"])
        results.append(ToolMessage(content=str(output), tool_call_id=tc["id"]))
    return {"messages": results}
def router(state: WState) -> str:
    if state["steps"] >= MAX_STEPS:
        return END
    last = state["messages"][-1]
    if isinstance(last, AIMessage) and last.tool_calls:
        return "tools"
    return END
def build_graph():
    g = StateGraph(WState)
    g.add_node("agent", agent_node)
    g.add_node("tools", tools_node)
    g.set_entry_point("agent")
    g.add_conditional_edges("agent", router, {"tools": "tools", END: END})
    g.add_edge("tools", "agent")
    return g.compile()

Graph 编译后赋值给模块级变量 graphrun_research 直接调用 graph.invoke()


设计 Checklist

工具设计

  • HTML 净化:去除 script/style/nav/footer,仅保留正文内容
  • 异常分类:HTTP 错误 / 连接错误 / 其他,各自返回安全字符串
  • 工具描述中明确 URL 来源规则:never invent URLs

工程防护

  • Token 预算:页面文本截断到合理上限(800-2000 tokens)
  • 步数上限:router 优先检查步数,再检查 tool_calls
  • 两层保护:应用层 steps + LangGraph recursion_limit

状态设计

  • messages: Annotated[list, add_messages]——必须使用 reducer,否则消息不累积
  • steps: int——Web Agent 特有字段,普通 Agent 可省略

生产化

  • 搜索工具替换为有 API Key 的稳定方案(Tavily/SerpAPI)
  • User-Agent 设置为真实浏览器 UA,避免被拒
  • 请求超时:timeout=12(搜索和页面抓取各自设置)

总结

三条核心结论:

  1. 防护是独立的:工具失败不等于 Agent 失败;错误作为返回值让 LLM 自适应调整,而非中断执行。
  2. Token 预算是必须的:一个普通网页 4576 tokens,截到 800 节省 82% 上下文,在大量页面浏览时效果显著。
  3. 步数上限是硬边界steps >= MAX_STEPS → END 写在 router 中,不依赖 Prompt 的“自觉”,无论 LLM 多想继续,步数到即停。

Web Agent 的本质其实就是:给 LLM 装上可控的眼睛,而非无限的网络访问权限


参考资料

  • LangGraph StateGraph 官方文档
  • BeautifulSoup HTML 解析文档
  • 本系列完整 Demo 代码:agent-22-web-agent
免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策