1. 为什么要把14个免费通道塞进一个入口
1.1 免费模型的真实困境:不是不够用,是太散
我大概从去年下半年开始密集折腾各类免费模型通道。最开始的想法很简单——既然有这么多平台愿意提供免费额度,那就挨个注册、挨个用呗。结果不到两周,我的浏览器书签栏就炸了:七八个控制台页面、五六个API Key文档、一堆不同格式的调用示例。更麻烦的是,每个通道的免费额度刷新时间不一样,有的是每天重置,有的是每月重置,有的按分钟限流。我经常写着写着代码,突然某个通道返回429,然后就得手动切到另一个通道,改配置、重启服务、重新跑。
这种体验说白了就是:免费资源确实多,但管理成本高到离谱。你本来是想省点钱,结果搭进去的时间比省下的钱还值钱。
后来我就想,能不能做一个本地网关,把所有免费通道统一收口,对外只暴露一个入口,内部按任务类型自动路由?这就是WorkBuddy这套方案的起点。核心思路不复杂:用一份models.json描述所有通道的能力和限制,用一个本地服务做请求分发,按任务特征选择最合适的通道。
1.2 什么叫“按任务自动路由”
这里得先把“自动路由”说清楚。很多人一听路由,以为是简单的轮询——第一个通道用完切第二个,第二个用完切第三个。这种叫负载均衡,不叫智能路由。
我做的路由逻辑分三层:
第一层是任务类型匹配。比如代码补全类任务,优先走响应快、上下文窗口大的通道;长文写作类任务,优先走输出质量高、限流宽松的通道;翻译类任务,优先走多语言支持好的通道。这些偏好不是拍脑袋定的,是我在实际使用中逐个通道测试后记录下来的。
第二层是额度状态感知。每个通道的剩余额度、限流窗口、当前并发数都记录在本地状态文件里。路由时会跳过已经打满的通道,避免无效请求。
第三层是失败降级。如果首选通道返回错误或超时,自动降级到备选通道,整个过程对上层调用方透明。
这三层加起来,才叫“按任务自动路由”。下面我拆开讲具体怎么落地。
1.3 这套方案适合谁
如果你符合以下任意一条,这套方案大概率能帮到你:
- 手头有多个免费模型通道的API Key,但懒得每次手动切换
- 在本地跑一些自动化任务(比如批量翻译、代码审查、文档生成),需要稳定的模型调用
- 想统一管理所有通道的额度消耗,避免某个通道被悄悄打满
- 对数据隐私有要求,不希望请求经过第三方中转服务
如果你只是偶尔用用网页版对话,那这套方案对你来说太重了,没必要。
2. 整体架构设计与核心选型思路
2.1 为什么选本地网关而不是云端中转
市面上有一些云端的中转服务,你把Key传上去,它帮你做路由。我不选这条路的原因有三个:
第一是隐私。我的请求内容里经常包含未公开的代码片段和内部文档,走第三方中转等于把内容暴露给一个我不完全信任的中间人。本地网关所有请求都在自己机器上完成路由,内容不出本机。
第二是可控性。云端中转的路由逻辑是黑盒,它怎么选通道、什么时候降级、额度怎么算,我完全不知道。本地网关所有逻辑都是我自己写的,出问题能排查,想改规则随时改。
第三是成本。云端中转本身也要消耗资源,要么收费要么有限制。本地网关跑在自己的机器上,除了电费没有额外开销。
当然,本地网关也有代价:你得自己维护状态文件、自己处理并发、自己写降级逻辑。但这些工作是一次性的,写完就不用管了。
2.2 models.json的设计哲学:一份配置描述所有通道
整个方案的核心是一份models.json文件。这份文件的结构设计我改了大概四版,前三版都因为扩展性不够推倒重来了。最终定下来的结构长这样:
{ "channels": [ { "id": "channel_a", "name": "通道A", "endpoint": "https://api.example-a.com/v1/chat/completions", "api_key_env": "CHANNEL_A_KEY", "models": ["model-x", "model-y"], "capabilities": ["code", "chat"], "context_window": 128000, "rate_limit": { "requests_per_minute": 20, "requests_per_day": 500 }, "priority": 1, "enabled": true } ], "routing_rules": [ { "task_type": "code", "preferred_channels": ["channel_a", "channel_c"], "fallback_channels": ["channel_b"] } ] }这个结构里几个关键字段的设计意图:
capabilities字段用来标记通道擅长的任务类型。我一开始想用数值打分,后来发现太复杂,不如直接打标签来得直观。一个通道可以同时有多个标签,路由时按标签匹配。
rate_limit字段记录通道的限流规则。这里要注意,不同通道的限流维度不一样,有的按分钟,有的按天,有的按并发数。我统一用三个维度来描述:每分钟请求数、每天请求数、最大并发数。缺失的维度留空表示不限制。
priority字段是手动指定的优先级。数值越小优先级越高。这个字段的存在是因为自动匹配有时候不够精准,需要人工干预。比如某个通道虽然响应慢,但输出质量明显更好,我就把它的priority调高,让它在非紧急任务中优先被选中。
api_key_env字段存的是环境变量名,不是Key本身。这样models.json可以安全地提交到版本控制,Key通过环境变量注入。这个设计是我踩过坑之后改的——之前直接把Key写在配置文件里,结果有一次不小心把配置文件同步到了公开仓库,虽然及时发现删掉了,但那次教训让我彻底改了方案。
2.3 路由引擎的工作流程
路由引擎收到一个请求后,按以下步骤处理:
解析任务类型。从请求的metadata里读取task_type字段。如果没传,根据请求内容做简单推断——比如包含大量代码块的判为code,包含长文本的判为writing。
筛选候选通道。从models.json里找出所有capabilities包含该任务类型、且enabled为true的通道。
检查额度状态。读取本地状态文件,过滤掉当前额度已打满或处于限流窗口内的通道。
按优先级排序。对剩余通道按priority排序,priority相同的按最近成功率排序。
发起请求。向首选通道发送请求,同时启动超时计时器。
失败降级。如果首选通道返回错误或超时,标记该通道为临时不可用,切换到下一个候选通道重试。最多重试3次。
更新状态。请求成功后,更新该通道的额度消耗记录和成功率统计。
这个流程看起来步骤多,但实际执行时间在毫秒级,对上层调用方几乎无感。
2.4 状态文件的设计与持久化
状态文件我用的SQLite,不是JSON。原因很简单:JSON文件在并发写入时容易损坏,而SQLite有事务保证。状态文件里主要存三张表:
channel_status表记录每个通道的当前状态:剩余额度、限流窗口起始时间、当前并发数、最近一次成功时间、最近一次失败时间、连续失败次数。
request_log表记录每次请求的详细信息:时间戳、任务类型、选中的通道、响应时间、是否成功、错误码。这张表主要用来做统计分析和问题排查。
routing_stats表记录路由决策的统计信息:每个通道被选中的次数、成功率、平均响应时间。这些数据用来动态调整priority——成功率持续偏低的通道会被自动降权。
状态文件默认放在项目目录下的data/文件夹里。如果你想把状态文件放到其他位置,改一下配置文件里的db_path字段就行。
3. 核心细节解析与实操要点
3.1 通道接入的标准化流程
每接入一个新通道,我按以下标准化流程操作。这套流程是我接了十几个通道之后总结出来的,能避免大部分低级错误。
第一步:确认API兼容性。优先选择兼容OpenAI接口规范的通道,这样路由引擎的请求构造逻辑不用改。如果通道用的是自定义协议,需要额外写一个适配器。适配器的工作就是把统一请求格式转换成通道特有的格式,再把响应转换回来。
第二步:测试基础连通性。用curl发一个最简单的请求,确认Key有效、网络可达、返回格式符合预期。这一步千万别跳过,我至少有三次因为Key复制时多了个空格,排查了半天。
curl -X POST https://api.example-a.com/v1/chat/completions \ -H "Authorization: Bearer $CHANNEL_A_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"model-x","messages":[{"role":"user","content":"ping"}]}'第三步:探测限流规则。官方文档写的限流规则有时候和实际不符。我的做法是发一批请求,观察什么时候开始返回429,从而推断出真实的限流阈值。探测时注意控制频率,别把账号搞封了。
第四步:记录能力标签。用同一组测试用例跑一遍所有通道,对比输出质量、响应速度、上下文保持能力,然后给每个通道打上合适的能力标签。测试用例我固定用三组:一组代码生成、一组长文摘要、一组多轮对话。
第五步:写入models.json。把通道信息按格式写入配置文件,设置初始priority为中间值,后续根据实际表现调整。
3.2 任务类型推断的实用规则
路由引擎需要知道当前请求属于什么任务类型。最可靠的方式是调用方显式传入task_type,但很多现有工具不支持传这个字段。所以我加了一层自动推断。
推断规则不复杂,按以下优先级依次判断:
- 请求中包含
```代码块标记,或system prompt里出现“代码”“函数”“debug”等词,判为code - 请求的max_tokens设置大于2000,或user消息长度超过1500字符,判为writing
- 请求中包含“翻译”“translate”等词,判为translation
- 请求中包含“总结”“摘要”“summarize”等词,判为summarization
- 以上都不匹配,判为chat(通用对话)
这套规则覆盖了我日常90%以上的场景。剩下10%的边界情况,我会在调用时显式传task_type来覆盖。
注意:自动推断只是兜底方案,不要过度依赖。如果你在跑批量任务,最好在调用层就把task_type传清楚,这样路由准确率会高很多。
3.3 额度消耗的精确追踪
免费通道最怕的就是额度悄悄用完了还不知道。我的追踪方案分两个层面:
请求前预检。每次路由前,先查状态文件里该通道的当日已用请求数。如果已用数加上本次请求会超过限额,直接跳过该通道。
请求后更新。请求成功后,把该通道的当日已用数加一,同时更新最近请求时间戳。如果返回429,把该通道标记为限流状态,记录限流解除时间。
这里有个细节:不同通道的额度重置时间不一样。有的按自然日重置(每天0点),有的按滚动24小时重置。我在models.json里加了一个reset_policy字段来区分:
daily:每天0点重置rolling_24h:从第一次请求开始算24小时后重置monthly:每月1号重置
对于rolling_24h类型的通道,状态文件里需要记录窗口起始时间,判断是否过期时用当前时间减去窗口起始时间,超过24小时就重置计数。
3.4 失败降级的触发条件与处理策略
降级不是随便触发的,得区分不同情况:
| 错误类型 | 触发条件 | 处理策略 |
|---|---|---|
| 超时 | 请求超过30秒未返回 | 标记通道临时不可用,降级到下一通道 |
| 429限流 | 返回429状态码 | 标记通道限流,记录解除时间,降级 |
| 401鉴权失败 | 返回401状态码 | 标记通道配置错误,禁用该通道,告警 |
| 500服务端错误 | 返回5xx状态码 | 重试一次,仍失败则降级 |
| 网络错误 | 连接超时或DNS失败 | 重试一次,仍失败则降级 |
降级时的重试次数我设为最多3次。如果3个通道都失败,返回统一错误给调用方,同时记录详细日志供排查。
实操心得:降级策略里最容易忽略的是“恢复检测”。一个通道被标记为不可用后,不能永远不再尝试。我的做法是每隔5分钟对不可用通道发一个轻量级探测请求(比如只发一个token的请求),如果成功就恢复其可用状态。这个探测请求也计入额度消耗,但消耗极小。
3.5 配置文件的热加载机制
改完models.json后,我不希望每次都要重启服务。所以路由引擎加了一个文件监听器,检测到models.json变更后自动重新加载配置。重新加载时不影响正在处理的请求——新请求用新配置,旧请求继续用旧配置直到完成。
热加载的实现要注意文件锁的问题。如果配置文件正在被写入,监听器可能读到不完整的内容。我的处理方式是:检测到变更后等500毫秒再读取,如果解析失败就再等500毫秒重试,最多重试3次。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖安装
这套方案对运行环境要求不高,一台能跑Python的机器就行。我平时跑在一台闲置的迷你主机上,配置是4核8G,同时处理十几个并发请求毫无压力。
依赖清单如下:
pip install fastapi uvicorn httpx aiosqlite watchdog- fastapi:提供HTTP接口,接收上层调用
- uvicorn:ASGI服务器,跑FastAPI
- httpx:异步HTTP客户端,用来向各通道发请求
- aiosqlite:异步SQLite驱动,操作状态文件
- watchdog:监听配置文件变更
如果你打算用Docker跑,基础镜像选python:3.11-slim就行,体积小、启动快。
4.2 目录结构规划
我习惯把项目结构规划清楚,后面维护省心:
workbuddy-gateway/ ├── config/ │ ├── models.json # 通道配置 │ └── settings.json # 服务配置 ├── data/ │ └── state.db # 状态数据库 ├── src/ │ ├── main.py # 入口 │ ├── router.py # 路由引擎 │ ├── channel.py # 通道适配器 │ ├── state.py # 状态管理 │ └── config_loader.py # 配置加载 ├── logs/ │ └── gateway.log # 运行日志 └── requirements.txt这个结构里,config和data分开是关键。config目录可以提交到版本控制,data目录加到.gitignore里。这样配置和状态互不干扰。
4.3 路由引擎核心代码实现
路由引擎的核心逻辑我简化成一个函数,方便你理解:
async def route_request(request, task_type=None): # 1. 推断任务类型 if task_type is None: task_type = infer_task_type(request) # 2. 获取候选通道 candidates = get_channels_by_capability(task_type) # 3. 过滤不可用通道 available = [] for ch in candidates: if not ch.enabled: continue if await is_rate_limited(ch.id): continue if await is_temporarily_unavailable(ch.id): continue available.append(ch) # 4. 按优先级排序 available.sort(key=lambda c: (c.priority, -get_success_rate(c.id))) # 5. 依次尝试 last_error = None for ch in available[:3]: try: response = await send_to_channel(ch, request) await record_success(ch.id) return response except RateLimitError: await mark_rate_limited(ch.id) last_error = "rate_limited" except TimeoutError: await mark_unavailable(ch.id) last_error = "timeout" except Exception as e: await record_failure(ch.id) last_error = str(e) raise AllChannelsFailed(last_error)这段代码里几个关键点:
候选通道数量限制。我只取排序后的前3个通道尝试,不是遍历所有通道。原因是如果前3个都失败,后面的大概率也会失败,继续尝试只是浪费时间。
成功率动态排序。priority相同的通道,按最近成功率排序。成功率数据从routing_stats表里读取,统计最近100次请求的成功率。
异常分类处理。不同类型的异常对应不同的标记策略。限流和超时要区分开,因为限流需要等窗口过期,超时只需要短暂冷却。
4.4 通道适配器的编写要点
每个通道的请求格式可能略有差异,适配器的职责就是抹平这些差异。以两个典型通道为例:
通道A兼容OpenAI格式,适配器几乎不用做什么:
async def send_to_channel_a(channel, request): headers = { "Authorization": f"Bearer {os.environ[channel.api_key_env]}", "Content-Type": "application/json" } async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( channel.endpoint, headers=headers, json=request ) resp.raise_for_status() return resp.json()通道B的请求格式不同,需要转换:
async def send_to_channel_b(channel, request): # 格式转换 converted = { "model": request["model"], "prompt": convert_messages_to_prompt(request["messages"]), "max_tokens": request.get("max_tokens", 1024) } headers = { "X-API-Key": os.environ[channel.api_key_env], "Content-Type": "application/json" } async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( channel.endpoint, headers=headers, json=converted ) resp.raise_for_status() # 响应格式转换 return convert_response_to_openai_format(resp.json())适配器的编写原则是:对外统一,对内灵活。不管通道用什么格式,适配器出口的请求和响应格式必须一致。
4.5 服务启动与验证
启动服务:
uvicorn src.main:app --host 0.0.0.0 --port 8080验证服务是否正常:
curl -X POST http://localhost:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "auto", "messages": [{"role": "user", "content": "写一个快速排序"}], "metadata": {"task_type": "code"} }'注意model字段传的是auto,表示让路由引擎自动选择通道。如果你想指定某个通道,传该通道支持的模型名即可。
验证路由是否生效,看日志输出:
[INFO] task_type=code, candidates=3, selected=channel_a, latency=1.2s如果日志里显示selected的通道符合你的预期,说明路由逻辑工作正常。
4.6 批量任务的并发控制
跑批量任务时,并发控制很重要。并发太高容易触发限流,并发太低效率上不去。我的经验值是:并发数设为所有通道每分钟限额总和的十分之一。
举个例子:假设你有5个通道,每个通道每分钟限额20次请求,总和是100次。那并发数设为10比较合适。这样即使某个通道被限流,其他通道也能分担压力。
并发控制的实现用asyncio.Semaphore:
semaphore = asyncio.Semaphore(10) async def process_task(task): async with semaphore: return await route_request(task)实操心得:批量任务跑之前,先用小批量(比如10条)试跑一遍,观察各通道的限流情况。如果发现某个通道频繁触发429,要么降低并发数,要么在models.json里调低该通道的priority,让路由引擎少选它。
5. 常见问题与排查技巧实录
5.1 通道返回429但额度明明没用完
这是最常见的问题。原因通常有三种:
第一种是并发限流。很多通道除了每日限额,还有每分钟限额和并发限额。你可能每日额度还剩很多,但短时间内发了太多请求,触发了分钟级限流。排查方法是看429返回头里的Retry-After字段,它会告诉你多少秒后可以重试。
第二种是共享额度。有些通道的免费额度是账号级别的,如果你在多个地方用了同一个账号,额度会被共享消耗。排查方法是登录通道的控制台,看实际消耗记录。
第三种是IP限流。少数通道会按IP做限流,同一IP下的所有请求共享限额。如果你在同一个网络下跑了多个服务,可能会互相影响。排查方法是换一个网络环境测试。
对应的解决策略:
| 原因 | 解决策略 |
|---|---|
| 并发限流 | 降低并发数,或在路由层加请求间隔 |
| 共享额度 | 确认账号使用情况,避免多服务共用同一账号 |
| IP限流 | 分散部署,或联系通道方确认限流策略 |
5.2 路由选中的通道响应特别慢
路由引擎默认按priority和成功率排序,没有考虑响应时间。如果某个通道虽然成功率高但响应特别慢,会影响整体体验。
解决方案是在排序时加入响应时间权重。我改成了综合评分:
def score(channel): success_rate = get_success_rate(channel.id) avg_latency = get_avg_latency(channel.id) # 成功率权重0.6,响应时间权重0.4 return success_rate * 0.6 - (avg_latency / 10) * 0.4响应时间从request_log表里统计最近50次请求的平均值。这个评分函数可以根据你的实际偏好调整权重。
5.3 配置文件改了但没生效
热加载机制偶尔会失效,原因通常是文件监听器没有正确触发。排查步骤:
- 确认watchdog进程还在运行:
ps aux | grep watchdog - 检查日志里有没有
config reloaded的输出 - 手动触发一次重载:
curl -X POST http://localhost:8080/admin/reload
如果手动重载能生效但自动监听不生效,大概率是文件系统的inotify限制。Linux下可以通过sysctl fs.inotify.max_user_watches查看当前限制,如果太小就调大。
5.4 状态数据库越来越大
request_log表如果不清理,会一直增长。我的做法是加一个定时清理任务,每天凌晨删除30天前的日志:
async def cleanup_old_logs(): cutoff = datetime.now() - timedelta(days=30) await db.execute( "DELETE FROM request_log WHERE timestamp < ?", (cutoff.isoformat(),) ) await db.commit()如果你需要保留更长时间的日志做分析,可以先把日志导出到文件再删除数据库记录。
5.5 某个通道突然全部请求失败
这种情况通常是通道方的服务出了问题,或者你的Key被禁用了。排查顺序:
- 用curl直接测试该通道,确认是通道问题还是网关问题
- 检查Key是否过期或被禁用
- 检查通道方的状态页面(如果有的话)
- 如果确认是通道方问题,在models.json里把该通道的enabled改为false,等恢复后再改回来
注意:不要因为一次失败就永久禁用通道。我遇到过好几次通道临时故障,几分钟后就恢复了。所以我的做法是标记为临时不可用,由恢复检测机制自动恢复,而不是手动禁用。
5.6 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 429频繁出现 | 并发过高/共享额度/IP限流 | 查看Retry-After头 | 降低并发/确认账号/换网络 |
| 响应慢 | 通道本身慢/网络延迟 | 看request_log的latency | 调整评分权重/换通道 |
| 配置不生效 | 监听器失效/文件锁 | 手动重载测试 | 调大inotify限制 |
| 数据库膨胀 | 日志未清理 | 查看表行数 | 加定时清理任务 |
| 通道全部失败 | 通道故障/Key失效 | curl直接测试 | 临时禁用/检查Key |
| 路由选错通道 | 能力标签不准 | 看routing_stats | 调整capabilities/priority |
6. 进阶优化与扩展思路
6.1 基于历史数据的动态优先级调整
手动设置priority有个问题:通道的实际表现会随时间变化,手动调整跟不上。我后来加了一个动态调整机制:
每周统计一次各通道的成功率和平均响应时间,根据统计结果自动微调priority。调整幅度控制在±1以内,避免剧烈波动。调整后的priority写入一个单独的override文件,不直接改models.json,这样手动配置和自动调整互不覆盖。
6.2 多机部署的额度共享
如果你在多台机器上跑网关,额度状态需要共享,否则会出现两台机器同时消耗同一个通道额度的情况。解决方案是把状态数据库换成PostgreSQL或MySQL,所有网关实例连同一个数据库。
切换数据库只需要改state.py里的连接配置,SQL语句基本不用动。我用的是SQLAlchemy做ORM,换数据库只需要改连接字符串。
6.3 请求内容的缓存复用
有些请求是重复的,比如同一段代码反复让模型解释。加一层缓存能省不少额度。缓存的key用请求内容的哈希值,value存模型返回结果。缓存有效期设为24小时,过期自动清理。
缓存命中时直接返回结果,不消耗任何通道额度。我的实测数据是:在批量处理场景下,缓存命中率大约15%到20%,相当于省了五分之一的额度。
6.4 监控告警的接入
跑在后台的服务最怕悄无声息地挂了。我加了一个简单的监控接口:
curl http://localhost:8080/admin/health返回各通道的当前状态、今日消耗、成功率。如果某个通道连续失败超过5次,或者所有通道都不可用,通过webhook发告警通知。
告警渠道我用的是企业微信机器人,配置简单,消息触达及时。如果你不想用企业微信,换成邮件或短信通知也行,改一下告警模块的实现即可。
6.5 后续可以扩展的方向
这套方案目前满足了我的日常需求,但还有几个方向可以继续优化:
智能任务分类。现在的任务类型推断基于关键词匹配,准确率大概85%。如果接入一个轻量级分类模型,准确率能提到95%以上。不过这会增加延迟,需要权衡。
通道质量实时评分。现在的成功率统计是滑动窗口,反应有延迟。如果改成指数加权移动平均,能更快反映通道的实时质量变化。
请求优先级队列。紧急请求插队处理,普通请求排队等待。这在批量任务和交互式任务混跑的场景下很有用。
多模态支持。现在只处理文本请求,如果通道支持图片输入,路由引擎需要增加对多模态请求的处理逻辑。
这套方案我从最初的想法到稳定运行,前后迭代了大概两个月。中间踩过的坑主要集中在限流处理和状态一致性上,这两个问题解决之后,整体就非常稳定了。现在我的日常开发工作流里,所有模型调用都走这个网关,基本不用再操心通道切换的事。