让大模型拿到实时搜索结果:SERP API 的一个实现方案
做 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 默认自动路由,也可强制 pc 或 mobile。
返回的 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 的错误码为例:
1001UNAUTHORIZED —— API key 不对,重试也没用1004NOT_FOUND —— 资源不存在,重试也不会出现1029RATE_LIMITED —— 限流,先降并发再重试1500INTERNAL_ERROR —— 平台 bug,可重试但要带 trace id1502UPSTREAM_FAILED —— 上游失败,最适合重试1503SERVICE_UNAVAILABLE —— 服务暂不可用,重试1504UPSTREAM_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 轮对话就爆上下文。我自己的压缩规则:
organic截前 5 条,只留title、link、snippet。- PAA 截前 4 条,每条只留
question和answer。 knowledge_graph取description和关键属性,图片不传。
然后塞进 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_id 去 maps/detail 拉详情。
lat 和 lng 必须一起传,zoom 范围 1-21,不传时默认 14。详情里能拿到电话、官网、营业时间、相册、评分。
注意 feature_id 是 0x...:0x... 这种格式,从 maps/search 返回里的 data_id 和 cid 长得也像 id,但都不能直接当 feature_id 用——这是当时调试时卡了我半小时的地方。
一些收尾的坑
hl/gl要传对。中文用zh-CN / cn,英文用en / us,混了 SERP 完全不一样。page是 1-based,不是 0-based。- 高频调用要自己做缓存。Google 同一个 query 在几分钟内的结果变化不大,能省掉很多重复请求。
request_id一定要打到日志里。status、elapsed_ms、credits_charged一起打,出问题回查时一查一串。- 失败时不一定是 0 credit。
1502 / 1503 / 1504失败时一般是 0,但1029不一定。
整个方案跑下来,模型侧的「实时性」问题就解决了。SerpBase 的完整接口文档在 serpbase.dev/docs。
更多推荐

所有评论(0)