搜索 API 额度用完返回空数组,Agent 就开始一本正经地胡说

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 里都有。

仓库:github.com/crisweb1994...

要是你也被哪家搜索 API 的奇怪错误码坑过,欢迎开 issue,最好带上脱敏后的响应体,我能直接把它变成一条契约测试。

相关推荐
乘风gg1 小时前
Spec Kit vs OpenSpec vs Superpowers:三大 AI Coding 框架深度对比
前端·ai编程·claude
w2sfot1 小时前
AI时代,JS加密还有效吗?
开发语言·前端·javascript
风骏时光牛马1 小时前
AI服务线上接口异常导致响应中断
前端
haluhalu.2 小时前
Agent = LLM + 上下文 + 工具(含观察 / 动作空间、ReAct 循环)
前端·javascript·人工智能·python·react.js·机器学习·个人开发
子兮曰7 小时前
把 649 种文件格式塞进一个网页里:一个「文件不出浏览器」的预览器
前端·javascript·后端
paopaokaka_luck10 小时前
基于springboot+vue的智慧家政管理系统(AI问答、 WebSocket实时沟通、ECharts运营分析、订单规则与服务追踪)
java·javascript·spring boot·数据分析·echarts·mybatis
sm_9267870510 小时前
RFID 标签打印的技术实现要点与二次开发实践
java·大数据·前端·c++·编辑器
IMPYLH10 小时前
HTML 的 <video> 元素
前端·html
濮水大叔11 小时前
Cabloy 双引擎架构详解:Vona 与 Zova 是如何协作的
typescript·node.js·全栈