做 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 URLhttps://api.serpbase.dev,鉴权用X-API-Key头,请求体是 JSON。
最常用的搜索接口:
curl-XPOST 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用来查日志。客户端大致这样:
importos,time,requests API="https://api.serpbase.dev"KEY=os.environ["SERPBASE_API_KEY"]RETRYABLE={1029,1502,1503,1504}defsearch(q:str,retries:int=3):foriinrange(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")ifst==0:returndataifst==1001:raiseRuntimeError(f"unauthorized:{data}")ifstinRETRYABLE:time.sleep(2**i);continueraiseRuntimeError(f"status{st}:{data}")raiseRuntimeError("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。