让大模型拿到实时搜索结果: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 URLhttps://api.serpbase.dev鉴权用X-API-Key头请求体是 JSON。最常用的搜索接口curl-XPOST https://api.serpbase.dev/google/search\-HContent-Type: application/json\-HX-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 APIhttps://api.serpbase.devKEYos.environ[SERPBASE_API_KEY]RETRYABLE{1029,1502,1503,1504}defsearch(q:str,retries:int3):foriinrange(retries):rrequests.post(f{API}/google/search,headers{X-API-Key:KEY,Content-Type:application/json},json{q:q,hl:en,gl:us},timeout15,)datar.json()stdata.get(status)ifst0:returndataifst1001:raiseRuntimeError(funauthorized:{data})ifstinRETRYABLE:time.sleep(2**i);continueraiseRuntimeError(fstatus{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 promptUse the following search results to answer the users question. If the answer is not in the results, say you dont know — do not make things up. {json.dumps(compressed, ensure_asciiFalse, indent2)}还有一个容易踩的坑Google 的snippet里经常出现「根据 X 资料整理」「点击查看更多」这种没信息量的句子模型会原样复述。塞进 prompt 之前先过滤掉空白 snippet、重复标题、太短的描述。按问题类型选择性注入更省 token。问「什么是 X」就只塞knowledge_graph问「X 怎么用」就只塞organicpeople_also_ask问「X 最新动态」就只塞top_storiesai_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。

相关新闻