前言

如果你在用或写过代理聚合工具,大概率听过 gpt-load——一个轻量级的 LLM API 代理,通过插件式渠道架构支持 OpenAI、Gemini、Anthropic 等多家厂商。最近有个需求:给它接入 Tavily,一个专为 AI Agent 设计的搜索引擎 API。

说来也巧,Tavily 的 API 设计跟 LLM 接口出奇地像——同样是 HTTP POST + Bearer Token 认证,同样是 JSON 格式的请求响应。这种相似性让我们有了一个大胆的想法:能不能把 Tavily 当成一个「特殊渠道」接入 gpt-load,复用现有的流量管理、Key 轮询、鉴权能力?

答案是可以,而且过程很有意思。这篇文章聊聊技术思路。


一、gpt-load 的渠道架构:插拔式设计

先快速看一下 gpt-load 的渠道机制。它的核心是一个 ChannelProxy 接口,每种渠道(OpenAI、Gemini、Anthropic)都在独立的文件中实现这个接口,然后在 init() 里自注册到全局的 channelRegistry

func init() {
    Register("openai", newOpenAIChannel)
}

这种模式的好处很明显——想加新渠道?写一个新文件就够了。不需要改任何现有代码,不需要动路由层,不需要动轮询逻辑。

这里面有个叫 BaseChannel 的嵌入结构体,封装了大部分通用逻辑(请求构造、超时处理、错误解析),新渠道只需要覆写少数几个方法就行。


二、Tavily 渠道适配器:最轻量的接入

既然架构已经铺好了路,Tavily 渠道适配器写起来就没什么悬念。

核心工作就是实现 ChannelProxy 接口的 6 个方法:

方法实现要点
ModifyRequest注入 Bearer tvly-xxx Token,跟 OpenAI 一模一样
IsStreamRequest返回 false——Tavily 不支持 streaming
ExtractModel返回固定值 “tavily-search”(纯标记用)
ValidateKey发一个最小查询测试验证 Key 有效性
ApplyModelRedirect不做 model 重定向,原样返回
TransformModelList返回固定的 model 列表

整个文件大概 60 行代码,其中一半是结构体定义和初始化。上游地址固定为 https://api.tavily.com,在创建 Group 时配置。

有一个小陷阱——failover 状态码。

gpt-load 默认的 failover 区间是 400-403, 405-999,太宽了。Tavily 搜索请求中正常的 400 Bad Request(比如参数不合法)不应该触发 Key 切换。它的专属 failover 码更精确:

  • 401 — Key 无效
  • 429 — 速率限制
  • 432 — 单 Key 额度用尽
  • 433 — 月度限额达到

在创建 Tavily 类型分组时,自动套用这组默认值就行。


三、Key 额度优先策略:从轮询到智能调度

这是整个改造里最有意思的部分。

现状的局限

gpt-load 现有的 Key 选择策略只有 round-robin 轮询——你有 5 个 Key,轮流用,雨露均沾。这在 LLM 场景下没问题,因为每个 Key 额度很大(百万 token 级别),轮询就够了。

但 Tavily 不一样。Tavily 是按搜索次数计费的,一个免费 Key 一个月可能只有 1000 次搜索。更关键的是——Tavily 提供了公开的 /usage API,可以实时查询每个 Key 的剩余额度。

这就给了我们做「智能调度」的条件。

额度优先策略

新策略的逻辑很简单:每次选 Key 的时候,挑剩下的额度最高的那个用

// 伪代码
quotas := GetQuotas(groupID)                // 获取所有 Key 的额度信息
sort.Sort(quotas, ByRemainingDesc)           // 按剩余额度降序排列
topKeys := quotas.WhereRemainingIsMax()      // 取最大额度的那些 Key
chosen := topKeys[rand.Intn(len(topKeys))]   // 同等额度随机选一个

这和「永远用同一个 Key」有什么不同?

区别在于负载均衡:用完一个 Key 后它的剩余额度变低,下次就会被「冷落」,让其他 Key 顶上。所有 Key 的消耗速度基本一致,不会出现一个 Key 跑满、其他 Key 闲置的情况。

四层额度追踪

为了确保额度数据的准确性,我们设计了一套四层互补的追踪机制:

  • ① 实时本地计数(每次代理请求后 +1)—— 保证即时性
  • ② 定时远端同步(每 60 分钟调 /usage API)—— 保证准确性
  • ③ 月度自动重置(每月 1 号清零)—— 对齐 Tavily 计费周期
  • ④ 被动耗尽检测(收到 432/433 时强制标记已用完)—— 兜底异常

这套机制不依赖任何第三方组件,纯数据库操作,多实例共享 DB 天然一致。


四、搜索缓存:同一查询不花两次钱

Tavily 按搜索次数计费,而很多场景下相同 query 短期内搜索结果不会变——比如 Agent 在重试时重新搜索同一个问题。

解决方案是一个简单的 DB 缓存:

  1. Cache Key:从请求体提取 query、search_depth、topic、max_results 等关键字段,排序后取 SHA-256 哈希
  2. 缓存位置:在代理管道的入口处,先查缓存再决定是否真正发请求
  3. 过期时间:默认 12 小时,可配置
  4. 后台清理:每 30 分钟清理过期条目,用 atomic.Bool 防重入

命中缓存时直接返回,不消耗 Key 额度,同时记录 hit_count 用于后续统计。


五、MCP 集成:把搜索能力接入 AI Agent 生态

MCP(Model Context Protocol)是最近 AI 工具链里的一个热门协议,简单说就是让 AI Agent 能调用外部工具的标准化接口。

每个开启了 MCP 的 Tavily 分组拥有独立的端点

POST /mcp/:group_name

我们注册了四个 Tavily API 工具:

工具名用途
tavily-search网页搜索
tavily-extract从 URL 提取内容
tavily-crawl爬取网站
tavily-map获取站点 URL 结构

实现上用了 addProxyTool 模式——每个工具映射到上游的一个 API 路径,通过同一个代理核心管道处理。这意味着 MCP 工具调用自动享受 Key 轮询、缓存、额度追踪、日志记录等所有能力

一个工具的注册代码大概 10 行,四个工具加起来不到 50 行。


六、一些工程细节

  • 并发限速器(Pacer):额度同步时多个 goroutine 同时调 Tavily API 可能被限流,所以加了一个 waitForSlot,控制请求间隔。
  • Key 脱敏:日志中展示 tvly-dev-22gwlB...O42Xtvly-****O42X,保留前缀和末尾 4 位。
  • 请求体选择性记录:只记录 /search 的完整请求/响应体,/extract、/crawl 等大响应只记元数据,超过 32KB 截断。
  • 三层降级策略:当所有 Key 都失败时,优先返回成功的响应,其次返回 429 信息(可能有 rate limit 提示),最后返回 503。

写在最后

这次改造成本不高(核心新代码不到 500 行),收益却很明确——gpt-load 从一个「LLM API 代理」进化成了一个「AI 服务网关」,既能代理大模型,也能代理搜索、爬取等 AI Agent 所需的基础设施。

而且整个过程几乎没有改动现有代码。新增 6 个文件、修改 8 个文件,改得最深的也只是给 KeyProvider.SelectKey 加了一个策略参数——这是好的架构设计该有的样子

如果你也在做类似的网关工具,期待能给你一些启发。