ARTICLE · 1166744
给 AI Agent 写工具这件事,比给人写接口难在哪?
做了一个给 Agent 用的搜索网关,前后改了四个版本。回头看,最大的感受是:调用方从人换成模型以后,很多写后端时的老习惯都不太灵了。
先说起因。
我平时用 ZCode 写代码。它虽然自带联网搜索,但有额度,经常不太够,得自己接搜索 MCP,我接的是 Tavily。有一次它给我的代码怎么都跑不通,一查,用的是一个老版本的 API。翻调用记录才发现,那次搜索工具返回的是空数组。原因是 Tavily 当月额度用完了,接口回了个 432,那个 MCP 把错误吞掉,只交出去一个 []。
模型拿到空数组,结论很自然:网上没这东西,那我凭记忆答吧。
我手里其实还有博查、Brave、Exa 的 key,额度都还在。可一个搜索 MCP 只对接一家,模型不知道还有别的搜索引擎可用。
后来就有了 search-failover:对 Agent 只露一个 search 工具,后面挂 8 家搜索源,一家不行换下一家。代码开源在 GitHub,下面说说做的过程中想明白的几件事。
模型看不到你的日志
给人写接口,出了错打日志,有人会去查。模型不会,它能拿到的只有返回的那段 JSON,还有工具描述里那几行字。
所以我做的第一件事,是让返回值把过程交代清楚。每次搜索都带一条决策链,哪家额度没了、哪家在冷却、最后是谁给的结果,一项一项列着。
第二件事是把“没搜到”和“没搜成”分开。8 家都正常回了空结果,那是真没有;只要有一家是报错或者被跳过,这次搜索就算没跑完,MCP 的 isError 置为 true。模型看到这个标志,就不会把一次失败当成“信息不存在”。
第三件事是教模型怎么读这些东西。插件里带了一份 Skill 文件,说清楚看到什么情况该怎么办:都是空结果,换个说法再搜一次;看到 key 错误,去提醒用户;看到预算耗尽,不要下结论。
工具描述本质上也是写给模型的提示词,这是写人用的接口时不会考虑的事情。
错误码比我想的乱得多
做 failover 得先分清上游出了什么事。接完 8 家,我整理出一张表,挑几个有代表性的:
博查余额不足返回 403,key 错了才是 401。Brave 的 key 无效返回 422,而同一个 429 既可能是秒级限流,也可能是月度额度用完,得解析响应体才知道。Tavily 额度用完返回 432,这个状态码 HTTP 标准里根本没有。DuckDuckGo 限流时返回 202 Accepted。
这些分错了是有真实代价的。把一秒就能恢复的限流当成额度耗尽,这家源会被白白晾 6 小时;反过来,就会每分钟去撞一次墙。
403 | ||
422SUBSCRIPTION_TOKEN_INVALID | ||
429RATE_LIMITED | ||
429QUOTA_LIMITED | ||
4291113 | ||
4291302 | ||
1703 | ||
202 | ||
402 |
这张表来自各家的文档和适配代码,有一点要说清楚:智谱、千帆、Serper 三家可选源的测试,用的是按文档构造的响应,还没用真实 key 校准过。仓库里留了一个 scripts/probe.mts,用来采集上游的真实响应,这一步还欠着。
状态越少越好
说到容灾,很多人的第一反应是上熔断器,三态加滑动窗口。
我最后只给每个源留了三个字段:屏蔽到什么时候、为什么屏蔽、连续失败了几次。到点自动放行,放行后的第一个请求就当试探。
一个 Agent 一分钟也就搜几次,样本太少,滑动窗口算出来的错误率没有意义。为一个用不上的统计维护一套状态机,不划算。
同一家失败了也不重试,直接换下一家。刚超时的服务马上再请求,大概率还是超时,换一家成功率高得多。
Node 单线程,并发照样出 bug
这个坑挺典型的,单独说一下。
Agent 经常一次并发好几个搜索。旧代码先检查额度够不够,然后 await 等限速、await 发请求,拿到响应再计数。
额度只剩一次的时候,三个并发请求都通过了检查,最后发出去三个。
Node 是单线程,可每个 await 都会让出执行权,检查和计数之间隔着 await,就会出竞态。
解法也是单线程带来的:把“检查 + 计数”写成一个没有 await 的同步函数,它天然就是原子的,不用加锁,在发请求前一刻调用就行。
限速等待也犯过同样的错。两个请求算出同样的等待时间,一起睡一起醒,同一毫秒发出去,一起被限流。改成醒来后再查一遍才算修好。
两套时间别搅在一起
这是我觉得最值得说的一个设计问题。
网关里有两套时间。一套是我本地估的请求预算,比如博查每月 1000 次,1 号重置。另一套是上游账户的真实状态,比如博查说余额不足。
旧代码在上游没给恢复时间时,拿本地重置日去推,直接屏蔽到下个月 1 号。复现的结果是 10 月 9 日收到一个余额不足,屏蔽到 11 月 1 日,23 天。
可用户可能十分钟后就充了值。本地那个窗口是我拍脑袋估的,它凭什么替上游决定什么时候恢复?
现在两套时间分开管。上游给了恢复时间就听上游的;没给就等 6 小时,让下一次搜索再试一次。本地预算只管本地计数,不掺和上游的事。
这么改也有代价:真欠费又一直不充值的源,每 6 小时会被试一次。我把这个写进了更新日志。我觉得这类取舍应该写出来,而不是只报喜。
删代码比加代码难
早期我在缓存上加了一套预取策略,想着按次计费的源多拿几条结果存着,下次能直接命中。为了避免缓存半截结果,又加了“取满才缓存”的条件。
后来发现有的源经常凑不满条数,缓存几乎从不命中;还有配置会让第一次请求只取 2 条,用户要 8 条。
这一轮全删了,取数规则只剩一行:用户要几条就取几条,不超过上游上限。
删的时候我没有写“这样更省钱”,因为本地测试证明不了。不同条数分开缓存可能会多请求几次,这个代价也写进了文档。
刻意没做的
不持久化,重启清零;相同查询并发时不合并请求;不把多家结果拼在一起;千帆的真实接口还没拿到凭据验证。
每一项都有成熟做法,只是一个人用、一分钟几次搜索的场景下,多一个组件就多一份维护成本,我觉得现在还不值得。
最后
项目叫 search-failover,TypeScript 写的,源码不到 1800 行,测试 193 个,支持 Codex、Cursor、ZCode、OpenCode、Claude Desktop。不配任何 key 也能用 DuckDuckGo 跑起来。ZCode 里打开插件市场,点 + 粘贴仓库地址就能装。
GitHub:https://github.com/crisweb1994/search-failover
如果你也在给 Agent 写工具,欢迎聊聊你踩过的坑。