1. 为什么我要把14个免费模型通道塞进一个入口
手里攒了一堆免费模型通道,这件事本身就挺让人头疼的。我最初的情况是:这个平台送一点额度,那个平台有每日免费调用量,还有一个是社区维护的公益接口,再加上几个自己申请到的测试key,零零散散加起来有十四个能用的通道。每次写代码要调用模型,得先想清楚这次用哪个、key放在哪、额度还剩多少、这个通道今天是不是又限流了。切换成本高到离谱,有时候调试一个prompt,光是在不同通道之间来回试就花掉半小时。
后来我干脆花了一个周末,把这十四个通道全部收敛到一个本地入口,对外只暴露一个地址、一套鉴权,内部按任务类型自动路由。做完之后的效果是:我所有的脚本、工具、甚至一些日常的小自动化,都只需要认准这一个入口,剩下的选通道、切key、处理限流、失败重试,全部在网关层消化掉。这篇文章就是把这套东西的完整思路、配置细节、踩过的坑,原原本本讲清楚。
WorkBuddy在这套方案里扮演的是“工作台”的角色,它本身不是一个模型,而是一个把各种能力编排起来的壳。我把它理解成一个调度中心:你告诉它要干什么,它去决定用哪个模型、走哪条通道。而models.json就是这套调度逻辑的配置文件,所有通道的定义、优先级、路由规则都写在这里面。自动路由是核心机制,也是整套方案里最值得花时间打磨的部分。
这套东西适合谁?如果你手头有多个免费模型来源,又不想每次手动切换;如果你在写一些需要稳定调用模型的小工具,但预算有限只能用免费额度;如果你对网关这个概念有基本认知,知道它是流量入口和转发层——那这篇内容基本可以照着抄。不需要你懂多深的网络原理,但需要你愿意动手改配置文件、看日志、做测试。
我先把整体架构用一句话说清楚:一个本地服务作为统一入口,读取 models.json 里的通道定义,根据请求里的任务标签和当前各通道的健康状态,决定这次请求发给谁,失败就换下一个,全程对调用方透明。下面从设计思路开始拆。
2. 整体设计与路由思路拆解
2.1 为什么是“网关”而不是“脚本里写死”
最开始我试过最笨的办法:在每个脚本里写一个通道列表,用随机或者轮询的方式选一个。这个方案跑了不到三天就崩了。原因很现实:免费通道的状态是动态的。有的通道上午还活着,下午就返回429;有的通道对某些模型支持好,对另一些模型直接报错;还有的通道响应特别慢,但质量高,适合做需要仔细想的任务。
把这些逻辑散落在各个脚本里,意味着每加一个通道、每改一次规则,都要去改所有脚本。这违背了最基本的工程直觉:变化的东西要收敛到一处。网关的价值就在这里——它是唯一知道所有通道状态的地方,也是唯一需要处理路由决策的地方。调用方只管发请求,不关心背后是谁在干活。
另一个考虑是鉴权。十四个通道意味着十四套key,散落在各处本身就是安全隐患。收敛到网关之后,key只存在于网关的配置文件里,调用方只需要一个统一的token。哪怕这个token泄露了,我也可以在网关层直接吊销,不用去每个平台重新生成key。
2.2 路由决策的三个维度
自动路由不是简单地“随便挑一个”,我最终定下来的决策依据是三个维度,按优先级从高到低:
第一个维度是任务类型。这是最硬性的约束。有些通道只支持对话补全,有些支持函数调用,有些对长上下文支持好。如果请求里明确标注了“这是一个需要长上下文的任务”,那路由就必须排除掉那些上下文窗口小的通道。这个维度是过滤性的,不满足直接淘汰。
第二个维度是通道健康度。每个通道我维护一个健康分数,初始都是满分。每次调用成功加分,失败扣分,连续失败到阈值就临时拉黑一段时间。这个分数是动态的,每次请求都会重新计算可用通道列表。
第三个维度是成本与配额。免费通道也有额度限制,有的按天,有的按月。我会给每个通道设置一个配额上限,接近上限时降低它的优先级,用完则暂时移除。这样能保证不会因为某个通道被薅爆而影响整体可用性。
这三个维度组合起来,路由逻辑就清晰了:先按任务类型过滤,再按健康度排序,最后按配额情况微调优先级,选出一个最合适的通道。如果选中的通道调用失败,自动降级到下一个,直到成功或者全部失败。
2.3 models.json 的结构设计
配置文件的设计直接决定了这套东西好不好维护。我见过有人把配置写成一大坨嵌套很深的JSON,改一个参数要数半天括号。我的原则是:扁平、可读、每个通道一段独立配置。
顶层是一个对象,里面有一个channels数组,每个元素是一个通道的完整定义。每个通道包含这些字段:唯一标识、类型、接入地址、鉴权信息、支持的模型列表、能力标签、配额设置、健康度初始值。路由规则单独放在routing字段里,和通道定义分开,这样改规则不用动通道。
我特意没有用太复杂的继承或者引用机制。免费通道的数量是有限的,十几个而已,重复写一些字段完全可以接受,换来的是配置文件一眼能看懂。维护成本低比“优雅”重要得多。
3. 核心细节解析与实操要点
3.1 通道定义的字段逐个说清楚
每个通道的定义看起来简单,但有几个字段如果理解不到位,后面路由会出各种奇怪的问题。我拿一个典型的通道配置来逐字段解释。
id是通道的唯一标识,我习惯用“平台名-用途”的格式,比如alpha-chat、beta-long。这个id会出现在日志里,所以起名要有意义,别用channel1这种,出问题的时候根本不知道是谁。
type标识通道的协议类型。虽然大部分免费通道都兼容同一套接口规范,但细节上还是有差异,比如有的对请求体的某些字段敏感,有的返回格式略有不同。我在网关层做了适配,每个type对应一个适配器,把差异消化掉。
endpoint是接入地址。这里有个坑:有些免费通道的地址会变,或者有多个备用地址。我的做法是允许endpoint是一个数组,路由时按顺序尝试。这个设计后来救了我好几次,某个地址突然不通的时候,自动切到备用地址,调用方完全无感。
models是这个通道支持的模型列表。这个字段直接参与任务类型过滤。如果请求指定的模型不在某个通道的列表里,这个通道直接被排除。所以这个列表要维护准确,不能偷懒写个通配。
capabilities是能力标签,比如long-context、function-call、vision。这些标签是路由过滤的依据。我一开始没重视这个字段,结果有一次一个需要函数调用的任务被路由到了一个不支持函数调用的通道,返回了一堆莫名其妙的错误。后来我把能力标签作为硬性过滤条件,这类问题就再没出现过。
quota定义配额,包含limit(总量)、used(已用)、period(周期,day或month)。网关每次调用成功后会更新used,接近limit时降低优先级。这个字段需要持久化,我用了最简单的本地文件存储,每次更新写回,虽然不够高效但足够可靠。
health是健康度配置,包含score(当前分数)、threshold(拉黑阈值)、cooldown(冷却时间)。健康度的更新逻辑后面单独讲。
3.2 路由规则的写法与优先级
路由规则我放在routing字段里,是一个规则数组,按顺序匹配,命中第一条就停止。每条规则包含match和target两部分。match描述什么条件下触发,target描述选哪些通道。
match支持的条件有:任务类型、指定模型、能力要求、优先级标签。比如一条规则可以写成“当任务类型是 long-task 且需要 long-context 能力时,优先选择带 high-quality 标签的通道”。target则是一个通道id的列表,按优先级排列。
这里有个设计决策值得说:我为什么用“规则数组+顺序匹配”而不是“打分排序”。打分排序看起来更智能,但调试起来很痛苦——你很难解释为什么这次请求走了A通道而不是B通道。规则数组的好处是决策路径完全透明,日志里直接打印命中了哪条规则,一目了然。对于十几个通道的规模,规则数组完全够用,不需要过度设计。
规则的顺序很重要。我把最具体的规则放在前面,最通用的兜底规则放在最后。兜底规则通常就是“所有通道按健康度排序”,保证任何请求都有通道可用。
3.3 健康度与配额的联动机制
健康度和配额这两个维度如果各自独立工作,会出现一种尴尬情况:一个通道健康度很高但配额快用完了,路由还是优先选它,结果调用失败,然后降级。虽然最终能成功,但浪费了一次调用。
我的做法是让两者联动。计算通道优先级时,用一个综合分数:健康度分数乘以配额剩余比例。配额充足时,这个乘数接近1,不影响健康度的排序;配额紧张时,乘数变小,自然降低优先级。这样不需要额外的规则,配额的影响就平滑地融入了路由决策。
健康度的更新我用了简单的加减分机制。成功一次加1分,失败一次扣5分,分数上限100,低于20分触发拉黑,冷却时间30分钟。冷却结束后分数重置为50,给通道一个恢复的机会。这些数字不是拍脑袋定的,是我观察了一段时间的调用日志后调的。失败扣分比成功加分重,是因为免费通道的失败往往意味着它暂时不可用,需要更快地把它排除出去。
注意:健康度分数一定要持久化。我一开始放在内存里,网关重启后所有通道都恢复满分,结果重启后连续踩了好几个已经挂掉的通道。后来改成每次更新都写文件,重启后状态还在。
4. 实操过程与核心环节实现
4.1 环境准备与依赖选择
整套东西我用的技术栈很朴素:Python加上几个基础库。选Python不是因为性能,而是因为改起来快。免费通道的接入方式经常变,用Python可以随时改适配器,不用编译不用打包。对于个人使用的网关,每秒几个请求的量级,Python完全够用。
依赖方面,我只需要一个HTTP客户端和一个Web框架。HTTP客户端用的是标准库之外的轻量选择,Web框架也是极简的那种。我刻意避免引入重型框架,因为这东西的核心逻辑就是“收请求、做决策、转发、返回”,不需要ORM、不需要模板引擎、不需要复杂的中间件。
目录结构是这样的:根目录下放models.json配置文件,gateway.py是主程序,adapters/目录下每个type一个适配器文件,logs/放日志,state/放健康度和配额的持久化文件。这个结构简单到任何人拿到都能在五分钟内看懂。
4.2 请求处理流程的完整拆解
一个请求进来,网关的处理流程分六步,我按顺序讲。
第一步是解析请求。调用方发来的请求里,除了标准的模型调用参数,还带了一个自定义的task_hint字段,用来标注任务类型。这个字段是可选的,没有的话走默认路由。解析完请求后,网关提取出模型名、任务类型、能力要求这些路由需要的信息。
第二步是过滤通道。遍历所有通道,排除掉不满足硬性条件的:模型不支持、能力不匹配、处于拉黑状态、配额已用完。这一步之后剩下的通道就是候选集。
第三步是应用路由规则。按顺序匹配routing里的规则,找到第一条命中的,从它的target列表里选出候选通道。如果所有规则都没命中,用兜底规则,即候选集按综合分数排序。
第四步是选择通道。从候选列表里选综合分数最高的。如果分数相同,选最近使用次数少的,做个简单的负载均衡。
第五步是转发请求。用对应type的适配器,把请求转换成目标通道需要的格式,带上该通道的鉴权信息,发出去。这里要设置合理的超时时间,免费通道有时候会卡住,超时时间太长会拖垮整个网关。我设的是15秒,超过就当作失败处理。
第六步是处理响应和更新状态。成功的话,更新健康度加分、配额已用加一,把响应转换回标准格式返回给调用方。失败的话,更新健康度扣分,然后从候选列表里移除这个通道,回到第四步重新选择,直到成功或者候选列表为空。如果全部失败,返回一个明确的错误,告诉调用方所有通道都不可用。
这个流程里,第五步和第六步的循环是自动降级的关键。调用方完全感知不到背后换了通道,它只看到最终的成功响应或者全部失败的错误。
4.3 适配器层的实现要点
适配器层是整套方案里最“脏”的部分,因为每个免费通道的接口细节都不一样。有的要求鉴权放在header里,有的放在query参数里;有的返回的JSON结构多一层包装,有的直接就是标准格式;有的对请求里的某些字段特别敏感,多传一个就报错。
我的做法是给每个type写一个适配器类,实现两个方法:prepare_request和parse_response。前者把标准请求转换成目标通道的格式,后者把目标通道的响应转换回标准格式。适配器里可以写各种if-else来处理细节差异,因为这部分代码是隔离的,脏一点没关系,不影响主流程。
写适配器的时候有个经验:先把一个通道调通,再抽象。我一开始想设计一个通用的适配器基类,结果发现每个通道的差异太大,抽象出来的东西反而更难维护。后来改成先针对单个通道写死,跑通之后再提取公共部分。这样写出来的适配器虽然有一些重复代码,但每个都独立可测,改一个不会影响另一个。
4.4 配置文件的加载与热更新
models.json在网关启动时加载。但我经常需要调整路由规则或者临时禁用某个通道,如果每次都要重启网关就太麻烦了。所以我加了一个简单的热更新机制:网关监听配置文件的修改时间,发现变化就重新加载。
热更新有个坑要注意:重新加载时不能直接替换正在使用的配置对象,否则正在处理的请求可能会读到一半新一半旧的配置。我的做法是加载到一个新的配置对象,加载成功后再原子性地替换引用。Python里可以用一个简单的锁来保证这一点。
配置加载失败的处理也很重要。如果新的配置文件有语法错误,不能直接让网关崩溃。我的做法是捕获加载异常,保留旧配置继续运行,同时打一条错误日志。这样即使我改错了配置,网关也不会挂掉,只是新配置不生效而已。
5. 常见问题与排查技巧实录
5.1 通道全部失败怎么办
这是最让人紧张的情况:所有通道都返回失败,调用方拿到一个错误。遇到这种情况,我按这个顺序排查。
先看日志里每个通道的失败原因。如果都是超时,那可能是网络问题,检查一下本机的网络连接。如果都是鉴权失败,那可能是某个key过期了,需要去对应平台重新生成。如果失败原因各不相同,那可能是请求本身有问题,比如模型名写错了、参数格式不对。
我遇到过一次所有通道都失败,排查了半天发现是请求里的task_hint字段值写错了,导致路由规则全部不匹配,兜底规则又因为某个bug没有生效。这个教训让我在兜底规则里加了一条日志,每次兜底触发都打印出来,方便发现这类问题。
还有一种情况是某个通道的失败被误判为全部失败。比如通道A失败了,降级到通道B,但通道B的响应解析出了bug,被当成失败,继续降级到C,最后全部失败。实际上通道B是成功的,只是解析错了。这类问题要靠单元测试来防,每个适配器的parse_response都要有测试用例。
5.2 路由结果不符合预期怎么查
路由不符合预期,通常是规则写错了或者通道的能力标签配错了。我的排查方法是:在网关里加一个调试接口,传入一个模拟请求,返回完整的路由决策过程——哪些通道被过滤了、为什么被过滤、命中了哪条规则、最终选了谁。这个接口在调试路由问题时极其有用。
有一次我发现一个长上下文任务被路由到了一个上下文窗口很小的通道,查了半天发现是那个通道的capabilities里误加了long-context标签。这种配置错误靠看日志很难发现,因为路由逻辑本身是对的,只是输入数据错了。调试接口能直接告诉你“这个通道因为带有long-context标签而被选中”,问题就一目了然了。
5.3 配额统计不准的问题
配额统计不准通常有两个原因:一是并发请求导致的计数丢失,二是持久化时机不对。并发问题可以用锁解决,每次更新配额时加锁,保证计数准确。持久化时机我改过一次:一开始是每次更新都写文件,后来发现请求量大时IO成为瓶颈,改成批量写,攒够一定次数或者每隔几秒写一次。但这样又带来了新问题:网关崩溃时可能丢失最后几次的计数。权衡之后我改回了每次写,因为免费通道的请求量不大,IO压力可以接受,准确性更重要。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 所有通道失败 | 网络问题、key过期、请求格式错误 | 看日志里各通道的失败原因 | 针对性修复,检查兜底规则 |
| 路由结果不符预期 | 规则顺序错误、能力标签配错 | 用调试接口查看决策过程 | 修正规则或标签 |
| 配额统计不准 | 并发计数丢失、持久化时机 | 检查锁的使用和写文件频率 | 加锁、改回每次写 |
| 通道健康度不恢复 | 冷却时间未到、分数重置逻辑错误 | 检查state文件里的分数和时间戳 | 修正冷却逻辑 |
| 热更新不生效 | 文件监听失败、加载异常被吞 | 看日志里有没有加载成功的记录 | 检查文件权限和JSON语法 |
| 响应解析错误 | 适配器parse_response有bug | 对比原始响应和解析结果 | 修适配器,加测试用例 |
提示:这张表里的问题我基本都遇到过,其中“路由结果不符预期”和“配额统计不准”是最耗时的。建议在搭建初期就把调试接口和日志做好,后面排查问题会省很多时间。
5.5 几个让我印象深刻的坑
第一个坑是时区问题。配额是按天重置的,我一开始用本地时间判断是否跨天,结果有次服务器时区变了,配额重置逻辑乱了,导致某个通道的配额被重复计算。后来统一用UTC时间,问题解决。
第二个坑是响应体过大。有个免费通道返回的响应里带了很多调试信息,响应体特别大,网关转发时内存占用飙升。后来在适配器里加了裁剪逻辑,只保留需要的字段。
第三个坑是重试风暴。某个通道失败后,降级到下一个,下一个也失败,又降级,短时间内对多个通道发起大量请求。虽然每个通道只试一次,但整体请求量还是很大。后来加了降级之间的短暂延迟,避免瞬间打爆所有通道。
6. 后续可以继续打磨的方向
这套东西跑了一段时间,基本满足了我的需求。如果继续打磨,我会从这几个方向入手。
第一个是更细粒度的健康度。现在的健康度是通道级别的,但同一个通道对不同模型的表现可能不一样。可以细化到“通道+模型”级别的健康度,这样路由更精准。
第二个是请求内容的感知路由。现在路由主要靠调用方传的task_hint,如果调用方不传,就只能走默认规则。可以加一个轻量的内容分析,根据请求里的文本长度、是否包含代码、是否是多轮对话,自动推断任务类型。这样调用方不用改代码就能享受智能路由。
第三个是更完善的监控。现在只有日志,没有可视化的监控面板。可以加一个简单的状态页面,展示各通道的健康度、配额使用情况、最近的调用成功率。这样一眼就能看出哪个通道有问题。
第四个是配置的版本管理。models.json改来改去,有时候想回滚到之前的版本。可以加一个简单的版本快照机制,每次修改前自动备份,需要时一键回滚。
我在实际使用中最大的体会是:这套东西的价值不在于技术多复杂,而在于把分散的、易变的、需要人工判断的事情,收敛成了一个自动化的、可观测的、可配置的入口。省下来的切换时间和排查时间,远超搭建它花的那一个周末。如果你手头也有多个免费通道在来回切换,强烈建议花点时间做类似的收敛,哪怕一开始只支持三四个通道,后面慢慢加,收益是持续的。