去年下半年开始做一个 AI Agent 项目,目标是「能根据用户的提问去网上查最新信息,再基于查到的内容给出可引用的回答」。听起来是个典型的 RAG 增强场景,但真做起来发现,光靠向量数据库远远不够------用户问「上周发生了什么」「最新版本号是多少」「这个 API 现在怎么收费」这类问题时,模型要么瞎编、要么答错。
最后整个工程做下来,能稳定跑的那版大致是这么个流程:选 SERP API → 集成工具 → 上下文压缩 → 错误处理 → 缓存 → 高级模式。下面把每一步的关键决策过一下。
第一步:先想清楚要不要自爬
项目一开始我考虑过两个方向:
- 写个爬虫直接抓 Google 搜索结果页,丢给模型。
- 用 SERP API,让它返回结构化 JSON。
第一个方向我之前在 SEO 项目里做过,但拿到 Agent 项目里就发现不灵:
- 同一个 SERP 在不同地区 IP 拿到的内容不一样,有时候是「简化版」。
- Google 改版一次,解析器全废。
- captcha 弹出来之后,Agent 会拿到一个空上下文,然后开始自由发挥。
第三个问题最阴:HTTP 200 + 看起来正常的 HTML,但实际上缺了 PAA、缺了 knowledge_graph、甚至缺了第一条 organic。数据混进 JSON 里给模型用,模型还以为这是真的。调了一周才发现是 IP 拿到的 SERP 不完整。
最后定下来用 SERP API。理由很简单:抓取、解析、IP 调度这些脏活不应该由 Agent 项目方来管,应该让专门的 API 服务兜底。下面以 SerpBase 的接口为例,Base URL https://api.serpbase.dev,鉴权用 X-API-Key 头,POST + JSON 风格:
bash
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。返回里主要带 organic、top_stories、people_also_ask、knowledge_graph、related_searches,Google 给出 AI Overview 时还会有 ai_overview 字段。
SerpBase 一共 6 个端点(/google/search、/google/images、/google/news、/google/videos、/google/maps/search、/google/maps/detail),覆盖了 Agent 场景下绝大部分 Google 数据的调用需求。舆情监控走 /google/news,本地商家查询走 /google/maps/*,日常问答走 /google/search。
第二步:把搜索注册成工具
Agent 调搜索最干净的方式是把它注册成一个 tool。OpenAI 风格的 tool schema:
python
tools = [{
"type": "function",
"function": {
"name": "google_search",
"description": "Search Google for real-time information. Use this when the user asks about recent events, current prices, latest versions, or anything that requires up-to-date info.",
"parameters": {
"type": "object",
"properties": {
"q": {"type": "string", "description": "Search query"},
"hl": {"type": "string", "default": "en"},
"gl": {"type": "string", "default": "us"}
},
"required": ["q"]
}
}
}]
description 一定要写清楚「什么时候用」。我一开始写得太抽象(「搜索信息」),模型调用得很随意,有些不需要搜索的问题也调了一次。后来改成「when the user asks about recent events, current prices, latest versions, or anything that requires up-to-date info」之后,调用率明显合理了。
第三步:响应外壳要统一
调用方拿到 JSON 之后,先看外壳。下面以 SerpBase 的响应外壳为例:
json
{
"status": 0,
"request_id": "0f3576b2-6e2e-4f1e-bb0e-8cb0d4a60195",
"elapsed_ms": 1432,
"credits_charged": 1,
"search_type": "search"
}
外壳统一之后,调用方写一个通用的响应处理函数,多个端点共用。status: 0 是成功;request_id 是排查问题的唯一线索;elapsed_ms 用来判断是网关慢还是上游慢。
只看 HTTP 状态码是不够的。我早期翻车就是因为 HTTP 200 但 status: 1029(限流),把空结果当成「搜索没结果」传给了模型。
第四步:错误码 + 重试
SerpBase 的业务错误码分了 7 个:
1001UNAUTHORIZED ------ API key 不对,重试也没用1004NOT_FOUND ------ 资源不存在1029RATE_LIMITED ------ 限流,先降并发再重试1500INTERNAL_ERROR ------ 平台 bug,可重试但要带 trace id1502UPSTREAM_FAILED ------ 上游失败,最适合重试1503SERVICE_UNAVAILABLE ------ 服务暂不可用,重试1504UPSTREAM_TIMEOUT ------ 上游超时,重试
调用方实现大致这样:
python
import time, requests
API = "https://api.serpbase.dev"
KEY = "your_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")
退避用 2 的幂 + 抖动,retries 卡在 3 以内。Agent 调搜索时还要设一个「快速失败」上限------超过 5 秒还没结果就放弃,让模型自己承认「不知道」,不要让整个对话被拖住。
另一个细节是日志。request_id 一定要打到结构化日志里。Agent 出问题回查的时候,调用方拿不到完整链路,只能看到「模型在某一轮说错了」------这时候搜索调用留下的 request_id 是定位上游的唯一线索。生产里把 status、elapsed_ms、credits_charged、q 一起打,出问题一查一串,比看裸 stderr 强很多。
第五步:上下文压缩
完整 JSON 直接塞 prompt 是大忌。我一开始贪多,organic 传 20 条,PAA 传 10 条,结果 Agent 走 5 轮对话就报「上下文超限」。
压缩规则定下三条:
organic截前 5 条,只留title、link、snippet。- PAA 截前 4 条,每条只留
question和answer。 knowledge_graph取description和关键属性,图片不传。
更省 token 的做法是按问题类型选择性注入 。问「什么是 X」只塞 knowledge_graph,问「X 怎么用」只塞 organic + people_also_ask,问「X 最新动态」只塞 top_stories + ai_overview。具体怎么判断,可以在 tool 的 description 里强调一下,让模型自己分类。
还有一个容易踩的坑 :Google 的 snippet 里经常出现「根据 X 资料整理」「点击查看更多」这种没信息量的句子,模型会原样复述。塞进 prompt 之前先过滤掉空白 snippet、重复标题、太短的描述。
压缩完之后,把每条结果的 link 单独提出来塞进 prompt 末尾的「来源」列表,模型回答时直接带上链接。Agent 类应用这一点特别重要------用户能直接看到「这个结论来自某某网站」,比一个干答案可信度高一个量级。这对 RAG 类应用是质变------用户没办法判断模型说的是不是真的,但能看到来源就有办法自己核验。
第六步:缓存
高频 Agent 调用不缓存 credit 烧得很快。Google 同一个 query 在几分钟内的结果变化不大,TTL 5-10 分钟的内存缓存就够:
python
import time
_cache = {}
def search_cached(q, hl="en", gl="us", ttl=300):
key = f"{q}|{hl}|{gl}"
now = time.time()
if key in _cache and now - _cache[key]["t"] < ttl:
return _cache[key]["data"]
data = search(q)
_cache[key] = {"t": now, "data": data}
return data
缓存 key 一定要带上 hl / gl,不同地区 SERP 完全不一样,混了反而更糟。q 拼 key 之前最好做一次归一化(去空格、转小写),不然 OpenAI 和 openai 会被当成两个 key,命中率腰斩。
第七步:高级模式 MCP
工具多、调用顺序需要模型决定时,用 MCP 比手写 tool schema 干净。SerpBase 开源了一个 MCP Server,Claude / Cursor / Codex 这类客户端配一行 JSON 就能用:
json
{
"mcpServers": {
"serpbase": {
"command": "python",
"args": ["-m", "serpbase_mcp"],
"env": { "SERPBASE_API_KEY": "your_api_key" }
}
}
}
我自己在 Claude Code 里接过一次,模型自己知道该调几次、调完就停,不需要提前在代码里写一长串「先调搜索、再判断要不要再调一次」的逻辑。这种「模型自治」的调用方式在多轮对话里特别省心------上一轮调过搜索的 query 下一轮不需要再调,模型自己会判断。
不过 MCP 也有代价:每次会话都要起一个 MCP server 进程,模型对每个工具的调用都要过一遍 schema 校验。如果只是「给 Agent 加一个搜索能力」,手写 tool schema 反而更轻量;如果是多工具、多 step 的复杂工作流,MCP 省的工程量就值回来了。
一些收尾的坑
hl/gl要传对 。中文用zh-CN / cn,英文用en / us,混了 SERP 完全不一样。page是 1-based,不是 0-based。request_id一定要打到日志里 。status、elapsed_ms、credits_charged一起打。- 失败时不一定是 0 credit 。
1502 / 1503 / 1504失败时一般是 0,但1029不一定。 - Agent 不要把搜索当聊天伴侣。在 system prompt 里加一句「如果当前上下文已经足够回答,就不要调用搜索」,能省下 30-50% 的搜索调用。
- 不要假设「Google 永远会返回完整 SERP」。地区降级、设备降级、登录态降级都会让 Google 返回缺数据的结果。Agent 端要做一层二次过滤,识别出「该地区不可用」「点击查看更多」这种 Google 自己塞的占位文本,不然模型会原样复述。
整个项目跑下来,Agent 的「瞎编率」几乎降到了零------遇到不知道的问题,模型直接说「我去搜一下」再回答,用户看到的是带链接、带来源的答案,体验完全不一样。
更具体的数据是:上线前 100 次对话里有 23 次模型瞎编(基于用户反馈的人工核查),上线后这个数字降到了 2-3 次。剩下少数几次瞎编基本都是「Google 自己也搜不到答案」或者「用户问的是观点类问题」,属于合理范围。
SerpBase 的完整接口文档在 serpbase.dev/docs。