前言
如果你在用或写过代理聚合工具,大概率听过 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 缓存:
- Cache Key:从请求体提取 query、search_depth、topic、max_results 等关键字段,排序后取 SHA-256 哈希
- 缓存位置:在代理管道的入口处,先查缓存再决定是否真正发请求
- 过期时间:默认 12 小时,可配置
- 后台清理:每 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...O42X→tvly-****O42X,保留前缀和末尾 4 位。 - 请求体选择性记录:只记录 /search 的完整请求/响应体,/extract、/crawl 等大响应只记元数据,超过 32KB 截断。
- 三层降级策略:当所有 Key 都失败时,优先返回成功的响应,其次返回 429 信息(可能有 rate limit 提示),最后返回 503。
写在最后
这次改造成本不高(核心新代码不到 500 行),收益却很明确——gpt-load 从一个「LLM API 代理」进化成了一个「AI 服务网关」,既能代理大模型,也能代理搜索、爬取等 AI Agent 所需的基础设施。
而且整个过程几乎没有改动现有代码。新增 6 个文件、修改 8 个文件,改得最深的也只是给 KeyProvider.SelectKey 加了一个策略参数——这是好的架构设计该有的样子。
如果你也在做类似的网关工具,期待能给你一些启发。