做 LLM 应用绕不开的一个问题:模型的知识停在训练截止日期,问它「昨天发生了什么」它不会承认自己不知道,只会一本正经地编。

更糟的是,模型回答错的语气和回答对的语气完全一样,用户根本分不清。这种「看起来对但其实假」的回答,比「我不知道」危险得多。

要补这块能力,最常见的做法是给模型接一个工具,让它能去网上查。但「网上查」这件事,如果直接丢 HTML 给模型,基本是行不通的——模型要的是结构化字段,不是 div 和 class。HTML 里 70% 的内容是样式、脚本、广告位,模型拿到这种输入基本没法用,就算硬塞进去也只会让回答变得更混乱。

这就需要一个 SERP API:把 Google 搜索结果转成结构化 JSON,再喂给模型。整个链路里 API 这一层的职责是「把脏的 HTML 洗干净,把散的字段整理成结构」,让下游的 LLM 只需要关心「怎么用这些字段」。

下面把整个实现方案拆开讲,从协议选择、字段设计、错误处理到 LLM 侧的上下文压缩,一步步过一遍。

为什么用 API 而不是自爬

我自己一开始是写爬虫的,最早的方案是 Playwright + 简单调度。跑了两周之后翻车:

  • captcha 把 IP 拉黑,改 User-Agent 没用。
  • Google 改版一次,解析器全废,半夜挂掉没人发现。
  • 不同地区拿到的 SERP 不一样,有的数据是「简化版」。

第三个问题最阴——HTTP 200 + 看起来正常的 HTML,但实际上缺了 PAA、缺了 knowledge_graph、甚至缺了第一条 organic。数据混进 JSON 里给模型用,模型还以为这是真的。调了一周才发现是 IP 拿到的 SERP 不完整。

后来转向 SERP API。把抓取、解析、IP 调度这些脏活全交给上游,自己只关心「怎么把结果喂给模型」。这个分工很关键:调用方不应该去管 Google 改版的事,也不应该去管每个 IP 拿到的 SERP 是不是被降级了。这部分工作应该由专门的 API 服务来兜底。

接口长什么样

下面以 SerpBase 的接口为例。Base URL https://api.serpbase.dev,鉴权用 X-API-Key 头,请求体是 JSON。

最常用的搜索接口:

curl -X POST https://api.serpbase.dev/google/search \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $SERPBASE_API_KEY" \
  -d '{"q":"openai realtime api","hl":"en","gl":"us","device":"default"}'

/google/search 一次消耗 1 credit。q 是查询词,hl 是语言,gl 是国家,device 默认自动路由,也可强制 pcmobile

返回的 JSON 里主要带这些模块:

  • organic —— 自然结果
  • top_stories —— 顶部新闻
  • people_also_ask —— 相关问题
  • knowledge_graph —— 知识图谱
  • related_searches —— 相关搜索
  • ai_overview —— Google 给的 AI 摘要(如果有)

所有端点共用一个响应外壳:

{
  "status": 0,
  "request_id": "0f3576b2-6e2e-4f1e-bb0e-8cb0d4a60195",
  "elapsed_ms": 1432,
  "credits_charged": 1,
  "search_type": "search"
}

status: 0 成功。request_id 全链路唯一,调试时直接给支持就行。elapsed_ms 是网关观测到的延迟,可以用来判断是网关慢还是上游慢。credits_charged 是实际扣的 credit,失败时一般是 0。

错误码:调用方最依赖的一层

我第一次接 SERP API 的时候只看 HTTP 状态码,第二天就翻车了——HTTP 200 但 status: 1029(限流),我把空结果当成「搜索没结果」传给了模型。模型收到空上下文之后开始自由发挥,给了一个完全编造的回答。

后来设计成两套状态码:

  • HTTP 状态码:给运维和网关看,决定要不要告警、要不要切流量。
  • 业务状态码 status:给调用方判断成功失败,决定要不要重试。

下面以 SerpBase 的错误码为例:

  • 1001 UNAUTHORIZED —— API key 不对,重试也没用
  • 1004 NOT_FOUND —— 资源不存在,重试也不会出现
  • 1029 RATE_LIMITED —— 限流,先降并发再重试
  • 1500 INTERNAL_ERROR —— 平台 bug,可重试但要带 trace id
  • 1502 UPSTREAM_FAILED —— 上游失败,最适合重试
  • 1503 SERVICE_UNAVAILABLE —— 服务暂不可用,重试
  • 1504 UPSTREAM_TIMEOUT —— 上游超时,重试

HTTP 200 + status: 1029 是合法的。调用方只看 status 字段,错误时附带的 request_id 用来查日志。客户端大致这样:

import os, time, requests

API = "https://api.serpbase.dev"
KEY = os.environ["SERPBASE_API_KEY"]
RETRYABLE = {1029, 1502, 1503, 1504}

def search(q: str, retries: int = 3):
    for i in range(retries):
        r = requests.post(
            f"{API}/google/search",
            headers={"X-API-Key": KEY, "Content-Type": "application/json"},
            json={"q": q, "hl": "en", "gl": "us"},
            timeout=15,
        )
        data = r.json()
        st = data.get("status")
        if st == 0: return data
        if st == 1001: raise RuntimeError(f"unauthorized: {data}")
        if st in RETRYABLE:
            time.sleep(2 ** i); continue
        raise RuntimeError(f"status {st}: {data}")
    raise RuntimeError("retries exhausted")

几个细节:超时 15 秒(Google P50 大概 1.4s,冷启动会到几秒);退避用 2 的幂;retries 卡在 3 以内,再多也救不回来。

喂给 LLM 之前的压缩

拿到 JSON 之后,下一步是塞给模型。但完整 JSON 拼起来其实不小:

  • organic 默认会带 10 条,每条还可能嵌套 sitelinks
  • people_also_ask 会有 4-8 条。
  • knowledge_graph 加上图片、属性、相关实体,能堆几千 token。

贪多全塞,Agent 走 5 轮对话就爆上下文。我自己的压缩规则:

  1. organic 截前 5 条,只留 titlelinksnippet
  2. PAA 截前 4 条,每条只留 questionanswer
  3. knowledge_graphdescription 和关键属性,图片不传。

然后塞进 system prompt:

Use the following search results to answer the user's question.
If the answer is not in the results, say you don't know — do not make things up.

{json.dumps(compressed, ensure_ascii=False, indent=2)}

还有一个容易踩的坑:Google 的 snippet 里经常出现「根据 X 资料整理」「点击查看更多」这种没信息量的句子,模型会原样复述。塞进 prompt 之前先过滤掉空白 snippet、重复标题、太短的描述。

按问题类型选择性注入更省 token。问「什么是 X」就只塞 knowledge_graph,问「X 怎么用」就只塞 organic + people_also_ask,问「X 最新动态」就只塞 top_stories + ai_overview。具体怎么判断,可以在 system prompt 里给模型一个明确的分类指令,让它在调用工具前先输出意图标签,比写一堆 if-else 干净。

压缩之后再加一道:把每条结果的 link 也单独提出来塞进 prompt 末尾的「来源」列表里。模型回答时直接把链接带上,用户能看到「这个结论来自某某网站」,比一个不带链接的干答案可信度高一个量级。这对 Agent 类应用尤其重要——用户没办法判断模型说的是不是真的,但能看到来源就有办法自己核验。

高级模式:MCP

如果想做得更彻底,可以让 Agent 通过 MCP 服务直接调用搜索接口。MCP 是给 AI 客户端用的标准协议,把搜索作为一个工具注册给模型,模型自己决定什么时候调、调什么 query。

下面以 SerpBase 的 MCP Server 为例,Claude / Cursor / Codex 这类客户端配一行 JSON 就能用:

{
  "mcpServers": {
    "serpbase": {
      "command": "python",
      "args": ["-m", "serpbase_mcp"],
      "env": { "SERPBASE_API_KEY": "your_api_key" }
    }
  }
}

我自己在 Claude Code 里接过一次,比手写 tool schema 干净不少。模型自己知道该调几次、调完就停,不再需要提前在代码里写一长串「先调搜索、再判断要不要再调一次」的逻辑。这种「模型自治」的调用方式在多轮对话里特别省心——上一轮调过搜索的 query 下一轮不需要再调,模型自己会判断。

不过 MCP 也有代价:每次会话都要起一个 MCP server 进程,模型对每个工具的调用都要过一遍 schema 校验。如果只是简单的「给 Agent 加一个搜索能力」,手写 tool schema 反而更轻量;如果是多工具、多 step 的复杂工作流,MCP 省的工程量就值回来了。具体怎么选,看自己的场景。

Maps 场景的两段式调用

如果工作流里需要本地搜索,/google/maps/search/google/maps/detail(各 2 credit)组合起来用很顺手:先用 maps/search 拿到一堆 place,再拿 feature_idmaps/detail 拉详情。

latlng 必须一起传,zoom 范围 1-21,不传时默认 14。详情里能拿到电话、官网、营业时间、相册、评分。

注意 feature_id0x...:0x... 这种格式,从 maps/search 返回里的 data_idcid 长得也像 id,但都不能直接当 feature_id 用——这是当时调试时卡了我半小时的地方。

一些收尾的坑

  • hl / gl 要传对。中文用 zh-CN / cn,英文用 en / us,混了 SERP 完全不一样。
  • page 是 1-based,不是 0-based。
  • 高频调用要自己做缓存。Google 同一个 query 在几分钟内的结果变化不大,能省掉很多重复请求。
  • request_id 一定要打到日志里statuselapsed_mscredits_charged 一起打,出问题回查时一查一串。
  • 失败时不一定是 0 credit1502 / 1503 / 1504 失败时一般是 0,但 1029 不一定。

整个方案跑下来,模型侧的「实时性」问题就解决了。SerpBase 的完整接口文档在 serpbase.dev/docs

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐