
给 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。模型可看不懂这个,它会认真分析一个 status1029 的响应,然后基于搜索结果给你一通胡说。第三,agent 会重复调。第一次回答觉得不够,它就再搜一遍同样的词。Agent 循环里这个开销是乘法的,不是加法的。三、解决办法:做一个投影层我最后不是直接用原始响应,而是在 MCP 和模型之间加了一层投影。思路很简单:把宽松的响应压成 LLM 能依赖的最小形状。importjson,urllib.request BASEhttps://api.serpbase.devRETRYABLE{1029,1500,1502,1503,1504}defcall_serp(query,api_key,glus,hlen,page1):bodyjson.dumps({q:query,gl:gl,hl:hl,page:page}).encode()requrllib.request.Request(f{BASE}/google/search,databody,headers{X-API-Key:api_key,Content-Type:application/json},)returnjson.loads(urllib.request.urlopen(req,timeout30).read())defproject(payload:dict,query:str)-dict:# 失败必须变成显式错误,不能让模型去解析ifpayload.get(status)!0:codepayload.get(status)raiseRuntimeError(fstatus{code}request_id{payload.get(request_id)}:{payload.get(error)})organicpayload.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 接了什么搜索能力,打开它的工具返回看一眼:有没有承诺过文档里标可选的字段?失败的时候返回的是异常还是一个看起来正常的空结果?这两个问题有一个答不上来,模型就迟早会编。改起来也就二十来行代码:把响应投影成固定形状,失败显式抛出。