给 Agent 接搜索能力:用 MCP 把搜索结果变成模型能用的工具
现在给 Agent 接搜索有两种常见做法:一种是让它读网页,一种是调 SERP API。第一种灵活但慢且贵,第二种快但要求你想清楚一件事——模型拿到的到底是什么形状的数据。
我把 SerpBase 的 MCP server 接进了本地 Agent 流程,踩了几个坑之后总结出三条经验。这篇讲怎么接、以及接完之后你必须处理的"数据投影"问题。
一、MCP 接入本身:配置比想象中简单
SerpBase 的 MCP server 是开源项目(repo:github.com/serpbase-dev/serpbase-mcp),配置就是标准的 MCP servers 声明:
{"mcpServers":{"serpbase":{"command":"python","args":["-m","serpbase_mcp"],"env":{"SERPBASE_API_KEY":"your_api_key"}}}}官方文档说它适配 Claude、Codex、Cursor、Cline/Roo、Continue 这些支持 MCP 的客户端。我是在 Claude Code 里配的,重启客户端后工具列表里就能看到搜索工具。
这里有个坑要先说:文档明确提醒,不同部署的工具目录不一样,别假定工具叫什么名字。我第一次配完就去调google_search,结果报 tool not found——实际暴露的名字要看客户端里列出来的工具清单。所以配完第一件事是打开工具列表看一眼,别照抄教程里的名字。
另外官方还有个 skill 版本(serpbase-skill),是给读本地指令的 agent 用的(shell 兜底)。如果你的 agent 不吃 MCP,走 skill 那条路。
二、真正的坑:模型不能用"原始 API 响应"
接上之后你会发现,Agent 调一次搜索拿回来的是完整的 SERP 响应,里面有机结果、精选摘要、相关问题、知识图谱,还有一大堆这次查询根本没出现的模块。直接把这坨 JSON 丢给模型,会有三个问题:
第一,可选字段会让模型产生幻觉。文档里 organic 的rank/title/link是必填,但snippet、date这些是可选的——某次查询就是没有。模型看到"应该有 snippet"却拿到空,经常会自己编一段摘要上去。
第二,失败响应长得像成功响应。SERP API 的失败是 HTTP 200 + body 里非 0 的 status。模型可看不懂这个,它会认真分析一个 status=1029 的响应,然后"基于搜索结果"给你一通胡说。
第三,agent 会重复调。第一次回答觉得不够,它就再搜一遍同样的词。Agent 循环里这个开销是乘法的,不是加法的。
三、解决办法:做一个"投影层"
我最后不是直接用原始响应,而是在 MCP 和模型之间加了一层投影。思路很简单:把宽松的响应压成 LLM 能依赖的最小形状。
importjson,urllib.request BASE="https://api.serpbase.dev"RETRYABLE={1029,1500,1502,1503,1504}defcall_serp(query,api_key,gl="us",hl="en",page=1):body=json.dumps({"q":query,"gl":gl,"hl":hl,"page":page}).encode()req=urllib.request.Request(f"{BASE}/google/search",data=body,headers={"X-API-Key":api_key,"Content-Type":"application/json"},)returnjson.loads(urllib.request.urlopen(req,timeout=30).read())defproject(payload:dict,query:str)->dict:# 失败必须变成显式错误,不能让模型去解析ifpayload.get("status")!=0:code=payload.get("status")raiseRuntimeError(f"status={code}request_id={payload.get('request_id')}:{payload.get('error')}")organic=payload.get("organic")or[]results=[{"rank":r.get("rank"),"title":r.get("title"),"link":r.get("link"),"snippet":r.get("snippet"),# 拿不到就是 null,不是"应该有个摘要""date":r.get("date"),}forrinorganicifr.get("link")# 没有链接的条目对引用没意义]return{"query":query,"count":len(results),"results":results,"related_searches":payload.get("related_searches")or[],}三条规则:
- 键永远给全,值可以是 null。缺 featured_snippet 就显式写 null,别省掉这个键。模型处理"明确为 null"比处理"这个键时有时无"稳得多。
- 只保证文档标必填的字段。organic 的 rank/title/link 一定在,snippet/date 不保证,工具描述里就写清楚"snippet 可能为 null"。
- 非 0 status 直接抛异常,别包装成"成功返回了一个错误对象"。同时把
request_id带进错误信息——这是文档里说的用于排查的稳定标识,出问题时能定位到具体是哪次调用。
四、成本控制:盯信封,不盯请求数
响应信封里有两个字段在 Agent 场景下特别有用:credits_charged和elapsed_ms。
credits_charged说明这次实际扣了多少。Agent 会重复调用,所以别用"调了几次"估算成本,直接累加这个字段。我在 Agent 跑完后打印一次总额,比自己数请求数准。
elapsed_ms是限流的早期信号。延迟开始爬升,通常比开始报 1029 要早。我用它做降速触发:最近 10 次平均耗时超过历史均值两倍,就自动把节奏放慢,而不是等报错。
顺带一句,这套接口按请求计费、标准 credits 不过期,对 Agent 这种突发性调用形态比较友好——不会因为某天没调就浪费月费。具体计费口径以 SerpBase 的端点文档 为准。
五、现在就能做的
如果你已经给 Agent 接了什么搜索能力,打开它的工具返回看一眼:有没有承诺过文档里标"可选"的字段?失败的时候返回的是异常还是一个"看起来正常的空结果"?这两个问题有一个答不上来,模型就迟早会编。
改起来也就二十来行代码:把响应投影成固定形状,失败显式抛出。