Codex、Cursor 都自带联网搜索,ZCode 自带的联网搜索有额度,想让 Agent 查大量的资料得自己接一个搜索 MCP。我一开始接的是 Tavily。
上个月我让 ZCode 里的 Agent 帮我查一个库的新版用法,它很快给了答案,代码写得有模有样。跑起来报错,一看用的是两年前的 API。
去翻工具调用记录,搜索工具返回的是 []。Tavily 当月的免费额度用完了,接口回了 432,那个 MCP 把错误吞掉,只返回了一个空数组。模型拿到空数组,以为网上没这东西,只好用训练数据凑。
手上其实还有博查、Brave、Exa 的 key,额度都没动过。可每个搜索 MCP 只对接一家,模型不知道还有别的路可走。
所以我写了 search-failover。对 Agent 来说它只是一个 search 工具,后面挂了 8 家搜索源:默认是博查 → Tavily → Brave → Exa → DuckDuckGo,智谱、百度千帆、Serper 要在配置里手动打开。前一家挂了就换下一家。
ZCode 里打开插件市场,点 +,粘贴仓库地址 https://github.com/crisweb1994/search-failover 就能装。不配 key 也能跑,会走 DuckDuckGo。
听起来就是个 for 循环,我一开始也这么以为。写下来源码有 1776 行,测试 193 个,大部分工夫都花在循环里面那些细节上。下面挑几个印象最深的坑讲。
返回值要写给模型看
给人用的接口,出了错打一行日志就行,总会有人去看。给 Agent 用的工具不一样,模型看不到 stderr,它能拿到的只有你返回的 JSON 和工具描述里那几句话。
所以每次返回都带着一份完整的决策链:
json
{
"results": [ ... ],
"meta": {
"provider_used": "brave",
"fallback_chain": [
{ "provider": "bocha", "outcome": "quota_exhausted", "detail": "http_403 余额不足" },
{ "provider": "tavily", "outcome": "skipped:rate_limited", "detail": "42s" }
],
"cache_hit": false
}
}
比这更要紧的是 MCP 的 isError 字段。结果为空时我会看链路:如果每一家都是正常返回了空结果,那就是真没搜到,isError 为 false;只要有一家报错、被跳过,或者压根没有能用的源,这次搜索就算没完成,isError 为 true。
ts
const isError = results.length === 0 && (meta.fallback_chain.length === 0
|| meta.fallback_chain.some(step => step.outcome !== 'no_results'));
开头那个问题,根子就在这里:"没搜到"和"没搜成"被混成了一个空数组。
插件里还带了一份 Skill,告诉模型怎么读这条链:全是 no_results 就换个说法再搜一次;看到 auth_failure 就去提醒用户某家的 key 配错了;看到 skipped:budget_exhausted 就别下"查不到"的结论。
有些源不支持某个参数,比如博查不支持 include_domains,我会照常搜,同时在 note 里写一句"bocha: include_domains 不支持,已忽略"。不然模型会以为结果都来自指定站点,把第三方博客当成官方文档引用。
8 家 API,额度不够返回都不一样
failover 的前提是你得分得清上游到底怎么了。我原以为状态码够用,接完一圈发现想多了:
| 源 | 实际返回 | 含义 |
|---|---|---|
| 博查 | 403 |
余额不足,401 才是 key 错 |
| Tavily | 432 / 433 |
额度用完,这俩状态码 HTTP 标准里都没有 |
| Brave | 422 + SUBSCRIPTION_TOKEN_INVALID |
key 无效 |
| Brave | 429 + body RATE_LIMITED |
秒级限流 |
| Brave | 429 + body QUOTA_LIMITED |
月度额度用完 |
| 智谱 | 429 + code 1113 |
欠费 |
| 智谱 | code 1703 |
没搜到,按空结果处理 |
| DuckDuckGo | 202 + 异常页 |
被限流 |
| Exa / Serper | 402 |
credits 用完 |
Brave 的同一个 429 有两种意思,得解析 body 才分得开。DuckDuckGo 限流的时候回你一个 202 Accepted。
分错的代价很具体。秒级限流当成额度耗尽,这家会被白白屏蔽 6 小时;额度耗尽当成限流,每分钟去撞一次墙,每次多等一个超时;key 错了当成网络抖动,就会一直冷却、重试、冷却下去。
所以每个 adapter 都自己写 classify,把上游五花八门的信号收成 8 类:rate_limited、quota_exhausted、auth_failure、timeout、network、server_error、request_error、no_results。
这张表来自各家的文档和适配代码。有一点要说清楚:智谱、千帆、Serper 这三家可选源的契约测试,用的是按文档构造的响应,还没用真实 key 校准过,千帆的真实接口也还在等凭据验证。仓库里有个 scripts/probe.mts,专门用来采集真实响应、校准这些适配,这一步还欠着。
搜 "coding challenge",DDG 被封 6 小时
DuckDuckGo 没有官方 API,我抓的是它的 HTML 页面。被风控时它会返回一个验证页,最早的判断很粗暴:页面里出现 captcha、challenge 就当被封了,屏蔽 6 小时。
review 时发现了一个问题:要是用户搜的就是 "coding challenge" 呢?
正常结果页的标题和摘要里全是 challenge,直接被判成封禁。对一个 key 都没配的人来说,DDG 是唯一的源,这一下就是 6 小时没法搜索。
现在改成两个条件同时满足才算:一条结果都没解析出来,并且页面有验证页的特征。解析也换成了 htmlparser2,按结果块逐条取,顺手过滤掉广告。页面结构对不上就报 invalid_response,不再当作空结果糊弄过去。这个场景现在是一条固定的回归测试。
没写熔断器
说到容灾,很多人会想到 Hystrix 那套 closed、open、half-open 加滑动窗口。我没写。每个源只记三个字段:
ts
interface PHealth {
blockedUntilMs: number; // 0 = 正常;Infinity = key 无效
reason: string;
failStreak: number;
}
出错就算一个解封时间,到点自动放行,放行后的第一个请求就是试探。所谓 half-open,在这里就是"时间到了,让下一个请求过去"。
超时和网络错误从 30 秒起,服务端错误从 60 秒起,连续失败翻倍,封顶 1 小时。限流优先听上游的 Retry-After。key 无效直接设成 Infinity,key 错了等多久都没用。
滑动窗口在这儿用不上,一个 Agent 一分钟搜几次,样本太少,算出来的错误率没意义。
同一家失败了也不重试,直接换下一家。刚超时的源马上再打,多半还是超时,还白白烧掉预算。
单个源超时 10 秒,整次搜索预算 30 秒。预算用完就停,链路里记一条 skipped:budget_exhausted,不算那家源的错,它只是排在后面没轮上。用户在宿主里点了取消,MCP 的取消信号会一路传到 fetch 上,当前请求立刻中断,也不再往下切换。
单线程的 Node,并发照样超发
Agent 经常一口气并发三四个 search。0.3 版本的额度控制大概长这样:
ts
if (used(name) >= limit) skip(); // 检查
await pace(...); // 等限速
const raws = await adapter.search(...); // 发请求
tick(name); // 拿到响应再计数
limit 是 1000,已经用了 999,三个请求同时进来,都看到 999 < 1000,都放行,最后发出去三个。
Node 是单线程没错,可每个 await 都会把执行权让出去。检查和计数之间只要隔了一个 await,就有竞态。
反过来,单线程也给了一个便宜的解法:不含 await 的同步函数天然是原子的,用不着锁。
ts
tryStart(name: string, cfg: ProviderCfg, now = Date.now()): boolean {
const c = this.counter(name, cfg, now);
if (cfg.quota.type !== 'unbounded' && cfg.quota.limit !== undefined && c.used >= cfg.quota.limit) return false;
c.used++;
return true;
}
发请求前一刻调用,批准就记一笔。上游报错也算,请求已经打过去了;批准后被取消也算,请求可能已经发出去了;缓存命中不算;还在排队等限速时取消的也不算。
限速等待犯过一模一样的错。旧代码是算出要等多久、睡一觉、醒来就发。Brave 本地限 1 QPS,两个并发请求读到同一个上次请求时间,睡一样久,同一毫秒醒来一起发,一起吃 429。现在醒来后再查一遍:
ts
for (;;) {
signal?.throwIfAborted();
const now = Date.now();
if (now >= deadline) return false;
const wait = paceWaitMs(this.lastPaceMs.get(name), interval, now);
if (wait <= 0) {
this.lastPaceMs.set(name, now);
return true;
}
if (now + wait >= deadline) return false;
await sleep(wait, undefined, { signal });
}
旧版 paceWaitMs 里还有个 if (!lastMs) return 0。时间戳为 0 时 !0 是 true,限速直接被跳过。线上基本碰不到,测试里假时钟从 0 开始跑就撞上了。现在改成 lastMs === undefined。
代码旁边我留了句注释:这里只保证间隔,不保证先来先服务。要公平就得上队列,暂时没这个需求。
一个 403,博查被屏蔽了 23 天
这是最近一轮重构修的,我觉得最有意思。
网关里其实有两套时间。一套是本地请求预算,比如我给博查配了每月 1000 次、1 号重置,这是我自己估的保守值。另一套是上游账户的真实状态,比如博查回了 403,余额不足。
旧代码把这两套搅在一起了:收到额度耗尽、上游又没说什么时候恢复,就拿本地的 reset_day 去推,屏蔽到下个月 1 号。
本地复现了一下,10 月 9 日收到一个 403,一路屏蔽到 11 月 1 日。
403 只说明余额不够。用户可能十分钟后就充了钱,网关还要再晾它 22 天。本地计数窗口根本不知道上游账户什么时候恢复,却替它做了决定。
现在拆开了:
ts
case 'quota_exhausted': {
const resetAt = err.resetAtMs;
h.blockedUntilMs = resetAt !== undefined && Number.isFinite(resetAt) && resetAt > now
? resetAt
: now + cfg.quota_retry_s * 1000; // 默认 6 小时
break;
}
上游给了靠谱的未来时间就听上游的。Brave 的 X-RateLimit-Reset: 1, 183945,第二个数就是月度窗口还剩多少秒。没给,或者给了个过去的时间,就等 6 小时后让下一次搜索再试。没有后台探测,也不读 reset_day。
两道闸各管各的。健康冷却结束了但本地预算用光,照样跳过;本地窗口重置了但上游恢复时间没到,照样屏蔽。两边都放行才发请求。
还有个小地方:额度耗尽的冷却不受全局 1 小时上限约束,不然默认的 6 小时会被悄悄截成 1 小时。
代价也写进了 CHANGELOG:如果某家真欠费、一直不充,它每 6 小时会被试一次。本地预算和 opt-in 会兜住这部分开销。
删掉一个自作聪明的缓存策略
早期我在缓存上做了一个我认为很有意思的地方。按请求计费的源多拿几条不额外花钱,那就每次多取一些存起来,叫 cache_fill。按条数计费的 Exa 就按需取,叫 as_requested,而且为了不缓存半截结果,只有取满 store_size 条才写缓存。
后来复现出来,Exa 请求 8 条,连着两次都没进缓存,因为上游返回的条数凑不满。还有用户把 store_size 设成 2,再请求 8 条,第一次真的只向上游要了 2 条。
为了省钱做的优化,最后让缓存基本不命中,有时还少给结果。
这一轮全删了,取数只剩一条规则:
ts
const fetchCount = Math.min(req.maxResults, p.adapter.maxCount);
请求条数加进缓存 key,合法的非空结果都缓存。同一个查询要 3 条和要 8 条分开存,可能会多请求几次,这笔账我认。到底是省了还是多花了,看查询分布和各家计费,本地测试证明不了,所以文档里没写"更省钱"。
旧字段 fetch_policy 和 store_size 写了不会报错,但会被忽略,路由、缓存、状态模块里一行旧分支都没留。
还有几条规矩
智谱每次 1 分钱,千帆每月免费 1500 次、超了按量算,Serper 是一次性 credits。这三家光配 key 不进链,得在配置里写 "enabled": true。不少人 shell 里 export 了一堆 key 给别的工具用,网关要是见 key 就用,免费源一抖,流量全落到付费源上,到月底看账单才知道。
stdout 里只能有 MCP 协议帧,日志全走 stderr,往 stdout 打一行日志,宿主那边 JSON-RPC 就解析挂了。
响应体最多读 512KB。DDG 的验证页里塞满了 JS,不设上限就一直往内存里读。
源的名单只有一处来源,默认配置、工具描述里的能力说明、测试里的一致性断言都从 REGISTRY 生成,加一家源只改一处。
没做的
计数、冷却、缓存都在进程内存里,重启清零,多个宿主之间不共享。本地计数是个保守估计,不是你的账户余额。
没做 single-flight,两个一模一样的查询同时进来,会各打一次上游。
不合并结果,第一家有结果就返回。它是个容灾网关,不是元搜索引擎。
千帆的真实接口我还没拿到凭据验证,README 里也是这么写的。
这几样都有现成做法,可每加一样就多一个组件、多一份状态。一个人用、一分钟几次搜索,我觉得还犯不上。
如何接入
ZCode 最省事:插件市场 → 发现 → +,粘贴 https://github.com/crisweb1994/search-failover,装好后在插件配置面板里可以填配置文件路径(不填就用默认值)。
也可以手写配置,用户级在 ~/.zcode/cli/config.json:
json
{
"mcp": {
"servers": {
"search-failover": {
"type": "stdio",
"command": "npx",
"args": ["-y", "search-failover"],
"env": { "BOCHA_API_KEY": "...", "TAVILY_API_KEY": "..." },
"timeoutMs": 60000
}
}
}
}
ZCode 的配置校验很严:command 只能是字符串,不认识的字段会让整个 server 被静默丢掉,${...} 也不会展开,key 要写字面值。这几个坑我都踩过。
有几家配几家,没 key 的自动跳过。搜索表现奇怪的时候,让 Agent 调一下 status,每家源的屏蔽原因、剩余冷却时间、用量都在里面。Codex、Cursor 自带搜索,但如果你想换成自己的 key 或者要中文搜索源,也能接,OpenCode、Claude Desktop 的写法 README 里都有。
要是你也被哪家搜索 API 的奇怪错误码坑过,欢迎开 issue,最好带上脱敏后的响应体,我能直接把它变成一条契约测试。