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

时间:2026-07-03 08:29:41 来源:互联网

Web Agent的核心价值在于突破LLM知识的时间局限。当大语言模型无法给出最新版本信息时,Web Agent通过实时访问网络获取动态数据,从而弥补训练数据的时效性不足。

为什么要有 Web Agent

LLM的知识存在截止日期。询问"LangGraph最新版本是多少",它只能提供训练数据中包含的版本信息。Web Agent解决了这一难题:让智能体真正联网查询,获取实时数据后再进行回答。

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

然而,实现"联网查询"这一功能比想象中更复杂:

  1. 网页本质是HTML,并非纯文本——直接输入上下文会引入大量无用标签
  2. 单个页面可能包含数万Token——远超LLM的处理能力范围
  3. 智能体可能陷入无限循环——从A页面跳到B,B页面再跳到C,无法自行终止
  4. 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(循环)

状态(State)仅包含两个字段:

class WState(TypedDict):
    messages: Annotated[list, add_messages]  # 累积消息
    steps: int                                # 已用步数

steps 字段是Web Agent特有的设计——普通智能体无需显式记录步数,但Web Agent可能在页面间无限跳转,因此必须设置硬性限制。


两个工具

web_search:DuckDuckGo 搜索

@tool
def web_search(query: str) -> str:
    """
    Search the web with DuckDuckGo.
    Returns up to 5 results, each with title, snippet, and URL.
    Use the URLs from results to call fetch_page — never invent 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
            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)
            title   = (block.select_one(".result__title")   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密钥。通过解析 .result CSS类,提取标题、摘要和URL,将结构化文本返回给LLM。

工具描述中包含一条关键指令:Use the URLs from results to call fetch_page — never invent URLs。这是防止URL幻觉的第一道防线——在提示词层面明确告知模型URL的合法来源。

fetch_page:页面抓取 + 清洗

@tool
def fetch_page(url: str) -> str:
    """
    Fetch a web page and return its cleaned text (truncated to token budget).
    Only call 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状态码),后者是连接本身失败(如域名不存在或网络不通)。


三个工程 Guard

Guard 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或更换搜索词。

这是Guard设计的关键原则:错误处理应作为工具的返回值,而非异常。工具调用失败不应中断整个Agent的执行流程,而是让LLM根据错误信息进行自适应调整。

Guard 2:Token Budget 截断

测试PyPI的langgraph页面:

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

原始页面包含4576个Token,截断到800个,节省了82%的上下文空间。

截断功能的实现相对简单:

PAGE_TOKEN_BUDGET = 800   # max tokens of page text sent to LLM per fetchdef count_tokens(text: str) -> int:
    """Rough estimate: ~3 chars per token for English/Chinese mix."""
    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),而非精确的tokenizer。对于截断场景,精度要求不高,执行速度更为重要。

Guard 3:Step Limit

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.

三个Guard均按预期正常工作。

研究部分(Part 1和Part 2)遭遇了DuckDuckGo的限流机制,搜索返回空结果。模型正确报告了失败情况,而非编造答案——这本身也是Guard有效的体现:Agent在搜索失败的情况下没有继续循环,而是明确告知用户无法获取数据。


DuckDuckGo 的局限性

DuckDuckGo的HTML接口虽然无需密钥,但在生产环境中并不可靠:

  1. 频繁请求可能被限流,或返回空结果。
  2. HTML结构可能随时变化,导致CSS选择器失效。
  3. 缺乏速率限制控制,容易触发封锁机制。

生产环境替代方案:

方案特点
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 have at most {MAX_STEPS} total steps. Be efficient.
- Once you have 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

工具设计

  1. HTML清洗:去除script、style、nav、footer标签,只保留正文内容。
  2. 错误分类:针对HTTP错误、连接错误和其他异常,分别返回安全字符串。
  3. 工具描述中明确URL来源规则:never invent URLs

Engineering Guard

  1. Token Budget:页面文本截断到合理上限(800-2000 tokens)。
  2. Step Limit:router优先检查步数,再检查tool_calls。
  3. 两层防护:应用层 steps 配合 LangGraph recursion_limit

State 设计

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

生产化

  1. 搜索工具替换为有API Key的稳定方案(Tavily/SerpAPI)。
  2. User-Agent设置为真实浏览器标识,避免被拒绝。
  3. 请求超时设置:timeout=12(搜索和页面抓取分别配置)。

总结

综上所述,构建可靠Web Agent的关键在于三项核心设计:Guard独立运行机制确保异常不中断流程,Token Budget节省82%上下文空间提升效率,Step Limit作为硬边界防止无限循环。其本质是为LLM配备受控的网页浏览能力,而非开放无限制的网络访问。三条核心结论如下:

  1. Guard是独立的:工具失败不等于Agent失败;将错误作为返回值让LLM自适应,而非中断执行。
  2. Token Budget是必须的:普通网页4576 tokens,截断到800可节省82%上下文空间,在大量页面浏览时影响显著。
  3. Step Limit是硬边界steps >= MAX_STEPS → END 写入router中,不依赖Prompt的"自觉",无论LLM多想继续,步数到达即停止。

参考资料

  1. LangGraph StateGraph 文档
  2. BeautifulSoup HTML 解析文档
  3. 本系列完整 Demo 代码:agent-22-web-agent