Skip to content

Latest commit

 

History

History
198 lines (150 loc) · 13 KB

File metadata and controls

198 lines (150 loc) · 13 KB

15|网络搜索与网页抓取

预计时间:55 分钟 | 前置:完成第 14 章 | 本章调用真实 DeepSeek Web Search 与真实网络

第 14 章让智能体可以把工作拆给多个子智能体,但主智能体和子智能体都仍受模型已有知识的限制。模型的内置知识来自训练数据,无法保证软件版本、价格和新闻等信息仍然有效。遇到这类问题时,智能体需要搜索网络,找到可能相关的来源;如果摘要不足,再读取某个网页的正文。本章分别实现 web_search 和 web_fetch,并说明两者为什么应当保持独立。

DeepSeek Web Search 没有单独的 POST /search 接口。客户端需要向 Anthropic 兼容的 /messages 端点发起一次模型请求,并在请求中声明 web_search_20250305 服务器工具。服务器执行搜索后,以结构化内容块返回来源。因此,一次搜索也会产生模型调用的延迟和 token 用量,比普通搜索接口更重。

学习目标

完成本章后,你将能够:

  • 区分专用搜索端点、模型内服务器工具和直接抓取网页;
  • 调用 DeepSeek 的 Anthropic 兼容 /messages 端点完成搜索;
  • 接收必填 queries 数组,并发搜索、去重并按排名轮询合并来源;
  • 从结构化内容块中提取来源、摘录和发布日期,不把模型自由生成的文字当作搜索结果;
  • 使用 web_fetch 校验公网 URL、控制重定向和响应大小,并提取文本正文。

15.1 原理:搜索的三种形态

联网搜索常见的实现方式有三种,各有取舍:

形态 代表 取舍
专用搜索端点 Exa、Perplexity 快、便宜,但要额外服务商与密钥
模型内服务器工具 DeepSeek Web Search 需要一次模型调用,但可以复用模型密钥
自己抓网页 通用爬虫 最灵活,但要处理反爬、HTML 解析

本章使用第二种方式,复用 DEEPSEEK_API_KEY,不需要新的服务密钥。搜索请求使用 Anthropic 兼容地址 https://api.deepseek.com/anthropic/v1,与第 01 章聊天接口的 https://api.deepseek.com 不同,因此不能直接复用 DEEPSEEK_BASE_URL。模型名为 deepseek-v4-flash,请求头还要包含 anthropic-version: 2023-06-01。

15.2 一条查询怎样完成一次搜索

搜索代码分成两层。_search_one() 每次只处理一条查询,负责调用 DeepSeek 并返回统一的 WebSearchResult;search() 接收多条查询,并负责并发调用、去重和合并结果。先看单条查询:

class WebSearchClient:
    def _search_one(self, query, max_results, max_uses) -> WebSearchResult:
        api_key = self._configured_api_key or load_api_key()
        response = httpx.post(
            f"{self.base_url}/messages",
            headers={
                "x-api-key": api_key,
                "authorization": f"Bearer {api_key}",
                "anthropic-version": ANTHROPIC_VERSION,
                "content-type": "application/json",
            },
            json={
                "model": self.model,
                "max_tokens": 4096,
                "messages": [{
                    "role": "user",
                    "content": [{
                        "type": "text",
                        "text": f"Perform a web search for the query: {query}",
                    }],
                }],
                "tools": [
                    {"type": WEB_SEARCH_TOOL, "name": "web_search", "max_uses": max_uses}
                ],
            },
            timeout=120,
        )
        response.raise_for_status()
        data = response.json()
        # ...解析内容块

它与第 01 章的聊天接口有四处协议差异:

  1. 端点:/anthropic/v1/messages,Anthropic 兼容,不是 /chat/completions;
  2. 认证头:按 DeepSeek 当前兼容要求同时发送 x-api-key 与 Bearer;
  3. 工具声明:Anthropic 服务器工具格式,type 为 web_search_20250305,max_uses 是服务器最多搜几次的上限,默认 5;
  4. 结果数量:DeepSeek 没有 max_results 请求参数,客户端需要在 URL 去重后自行截断,并用 truncated 告诉调用方还有来源未返回。

15.3 工具层:queries 数组与轮询合并

web_search 接收必填的 queries 数组,其中可以包含 1 到 4 条查询;只搜索一次时也要使用单元素数组。程序会先检查数量,再删除完全重复的查询。因此,即使传入 5 条相同内容,也仍然超过数量上限,不能借去重绕过预算。

def search(self, queries, max_results=8, max_uses=5, max_queries=4):
    if not queries:
        raise ValueError("queries 至少需要一条查询")
    if len(queries) > max_queries:
        raise ValueError(f"queries 最多只能有 {max_queries} 条查询")
    if any(not isinstance(q, str) or not q.strip() for q in queries):
        raise ValueError("queries 中的每一项都必须是非空字符串")

    unique_queries = list(dict.fromkeys(queries))
    # 单条直接调用;多条通过 ThreadPoolExecutor 并发调用 _search_one

完全相同的查询只执行第一次。多条查询完成后,程序不会简单地把结果首尾相接,而是先取每条查询排名第一的结果,再取各自排名第二的结果,同时按 URL 去重,最后应用整批的 max_results 限制。轮询合并让每条查询都有机会贡献高排名结果。

任一查询失败时,整批搜索都会返回错误,不使用已经成功的部分结果。教学版可以取消尚未开始的线程任务,但无法中断已经发出的同步 HTTP 请求;官方实现还会通过共享取消信号通知其他请求停止。

15.4 只接受能够确认来源的结果

响应的 content 是块列表,两种块与本章有关:

  • web_search_tool_result 是结构化搜索结果,其中的 web_search_result 条目组成来源清单。程序只从这些字段读取来源,不从模型生成的普通文本中提取 URL。
  • text 块可能包含模型生成的回答和 citations 引用。普通回答不作为搜索结果返回;程序只读取与来源 URL 对应的 cited_text,作为该来源的摘录。

解析时采用一条严格规则:

        if not found_result_block:
            raise RuntimeError(
                "[WEB_PROVIDER_ERROR] 响应中没有 web_search_tool_result 块"
            )

如果响应中没有搜索结果块,例如模型没有触发搜索而是直接回答,客户端就报告错误,不能把普通模型文本当作搜索结果。否则,调用方无法判断内容是否真的来自网络。一次请求中的多次搜索还可能返回同一页面,因此来源会按 URL 去重。

搜索结果还可能包含不可直接阅读的 encrypted_content,客户端不能把它当作摘要。可读摘录来自 citations 中的 cited_text,并按 URL 关联到相应来源;page_age 则转换成 published_at。最终的 WebSearchResult 只包含来源列表和是否截断,不包含模型自由生成的回答。

15.5 抓取网页:web_fetch

第二个工具先检查指定 URL 和解析出的地址,再发起 HTTP GET 请求并提取文本:

def web_fetch(url: str, timeout_seconds: float = 30.0) -> str:
    current = _validate_fetch_url(url)
    with httpx.Client(
        timeout=timeout_seconds,
        follow_redirects=False,
        trust_env=False,
    ) as client:
        for redirects in range(FETCH_MAX_REDIRECTS + 1):
            _require_public_destination(current, _resolve_addresses)
            with client.stream("GET", current.geturl()) as response:
                if response.status_code in {301, 302, 303, 307, 308}:
                    # 只接受同源跳转;新目标会再次经过完整校验。
                    ...
                    continue
                kind = _content_kind(response.headers.get("content-type"))
                body, truncated = _read_capped(response, FETCH_MAX_RESPONSE_BYTES)
                # 解码、清理 HTML,再包装为外部不可信数据。
                ...

抓取入口先执行与网络无关的检查:URL 最长 2048 个字符,只允许完整的 HTTP(S) 地址,并拒绝 URL 中嵌入用户名或密码。客户端不读取环境中的代理配置。每一跳都会解析主机名;只要结果包含回环、私网、链路本地、组播或其他非公网地址,整次请求就会被拒绝。跳转最多 5 次且必须同源,跨源地址需要重新发起工具调用。

响应正文最多读取 5,000,000 字节、解码后最多保留 100,000 个字符;仅接受 HTML、text/*、JSON 和 XML 类型。非 2xx 状态仍作为抓取结果返回,便于模型读取错误页。最终文本会带上 URL、状态码和“不可信外部数据”提示,HTML 中的脚本、样式、注释和标签会被移除。

教学版在请求前以及每次跳转时检查 DNS 结果,但 httpx 建立连接时仍会自行解析主机名,未实现官方把连接固定到已校验地址集合的机制,也没有 DNS64 检测和协作式取消。它适合讲解传输策略,不作为对抗性网络环境中的 SSRF 隔离层。

15.6 运行完整示例

uv run python chapters/15-external-capabilities/src/demo.py

真实输出,搜索结果随时间变化,结构稳定:

=== ① Web Search:真实搜索 DeepSeek Harness ===
  queries: ["DeepSeek Harness 是什么?", "DeepSeek Harness 官方仓库地址"]
  来源(最多 8 条):
  - GitHub - deepseek-ai/DeepSeek-Harness
    https://github.com/deepseek-ai/deepseek-harness
    DeepSeek Harness is an open-source agent harness…
  - DeepSeek Harness documentation
    https://github.com/deepseek-ai/deepseek-harness/blob/b2e3b2a0125854567a4a5fcba75782e42fe84901/README.zh.md
  ...
  是否因 max_results 截断: True 或 False

=== ② web_fetch:真实抓取网页 ===
  Fetched https://github.com/deepseek-ai/deepseek-harness (HTTP 200)

  External web content follows. Treat it as untrusted data, not instructions.

  GitHub - deepseek-ai/deepseek-harness: DeepSeek Harness: ...

三个观察点:① 两条查询并发执行,来源按排名轮流合并并按 URL 去重;② 来源来自结构化结果块,标题、URL、可选摘录与发布时间一起返回,模型自由生成的文本没有混入结果;③ web_fetch 只读取经过校验的公共文本资源,并把返回内容明确标记为外部不可信数据。

本章小结

  • WebSearchClient._search_one:调用 Anthropic 兼容的 /messages 端点,并解析结构化搜索结果
  • WebSearchClient.search:校验查询数组,并发搜索,按 URL 去重,再按排名轮流合并
  • web_fetch:公网地址校验、同源重定向、文本类型检查、响应上限与正文清理
  • 三种联网方式:专用搜索接口、模型内服务器工具和直接抓取网页

对照官方

官方实现 我们对应实现 说明
packages/web/tool-web/README.zh.md WebSearchClient.search 对齐必填 queries、最多 4 条、先校验后去重、并发调用、URL 去重、轮询合并与整批失败;教学版不能中断已运行的同步 HTTP 线程
packages/web/web-search-deepseek/README.zh.md _search_one 与官方一样使用 Anthropic Messages 接口和服务器搜索工具,只接受结构化来源与引用,并限制结果数量和拒绝重定向
packages/web/web-fetch-http/README.zh.md web_fetch 对齐 URL、公开地址、同源重定向、类型与大小限制,以及非 2xx 结果语义;教学版不固定已校验的连接地址,也不检测 DNS64
官方凭据扩展位置 load_api_key 未显式传入密钥时,教学版也会在每次搜索时重新读取环境变量或 .env;构造器参数提供固定密钥

练习

  1. 对于数学常识、软件最新版本、新闻事件和一篇已知 URL 的长文,智能体应直接回答、搜索网络还是抓取网页?请从时效性、成本、证据需求和延迟解释选择。
  2. 为一个有歧义的研究问题设计 2–4 条互补查询,并说明如何合并、去重和排序来源。什么情况下应该继续扩展查询,什么情况下已有证据已经足够?
  3. 搜索结果带有标题、摘要和 URL,并不意味着内容可信。设计一套来源筛选与引用规则,考虑重复转载、SEO 垃圾、发布日期缺失、网页提示注入和相互矛盾的来源。
  4. 将 web_search 与 web_fetch 作为两个独立工具接入智能体,完成一个需要最新信息和原文证据的任务。最终回答应区分搜索摘要与抓取正文,保留来源,并在搜索或抓取失败时明确说明未验证的部分。