多模型网关的容灾、熔断与智能路由实践

给一个错题拍照,三秒内拿到分步解析、知识点拆解和薄弱环节诊断——这听起来像一个"调一下 OpenAI API 就能做出来"的功能。但当模型供应商开始限流、超时、返回脏数据,当一个班级的学生在同一秒按下拍照按钮,"调一下 API"和"一个可用的产品"之间,隔着一整套工程实践。本文聊一聊我们在一个 AI 教育产品里,是如何把"调 API"这件事做成一个生产级 AI 网关的。

一、问题的起点:单点模型 = 单点故障

最早的版本很朴素:前端拍照 → 后端转发给某家视觉大模型 → 拿到 JSON → 渲染。上线第一周就踩了所有该踩的坑:

  • 限流与超时:高峰期模型方 429、504 是家常便饭,前端只能转圈圈到天荒地老。
  • JSON 不稳定:模型偶尔"任性",把 JSON 包在 markdown 代码块里,或者中途截断,前端解析直接崩。
  • 成本失控:所有题目不管难易都走最贵的 pro 模型,简单题也在烧钱。
  • 迁移成本高:换一家供应商要改一遍代码,迁移期间还得双跑对齐。

这些问题的共性是:把模型当成了一个可信、稳定、廉价的函数调用。现实里它更像一个偶尔会抽风的同事——能力强,但你不能把整个业务押在他一个人身上。

于是我们做了一个决定:在后端和真实模型之间,架一层多模型 AI 网关。它对外暴露统一的接口,对内负责多供应商路由、容灾、熔断、结果修复。本文讲的就是这一层的设计。

二、核心抽象:能力(Capability)与提供方(Provider)

网关要管理的第一件事是"谁能为某个请求服务"。我们把这件事拆成两个概念:

  • 能力(Capability):业务上的一类需求,比如 multimodal(多模态视觉理解)、format-json(把脏文本修成合法 JSON)、erase-handwriting(擦除试卷上的手写笔迹)。
  • 提供方(Provider):某个具体的供应商实现,比如 volcengine、deepseek,它属于某个能力,有自己的优先级。

一个 provider 可以同时归属多个能力,一个能力下挂多个 provider。这样一个二维表就是路由的全部基础:

CapabilityProviders["multimodal"] = [
    {Name: "deepseek-v4-flash",Priority: 1, Role: "flash"},
    {Name: "volcengine",  Priority: 2, Role: "flash"},
    ...
]

启动时,从一份 providers.json 配置里读出所有 provider,用一个工厂函数按 type 字段(openai-compatible / volcengine / textin-erase …)创建对应的服务实例,注册进两张表:一张按 name 索引(直接指定时用),一张按 capability + priority 排序(自动路由时用)。同一批对象,两个视角。

这个抽象的收益立竿见影:新增一家供应商,只需要在配置文件里加一条 JSON,零代码改动

三、容灾的本质:一次"按优先级试一遍"的循环

容灾的实现出乎意料地直白。一次 auto 模式的请求,核心就两步:

第一步,构建一张"待执行函数表"。 遍历该能力下所有 provider,为每个 provider 生成一个闭包(此时不执行,只是把"如何调用它"封装起来):

fnMap[prov.Name] = func() (interface{}, error) {
    return r.callMethod(prov.Service, method, args...)
}

这里有个 Go 闭包的经典坑:循环变量捕获。如果在 range 里直接用循环变量 p,所有闭包会共享最后一个 p。必须用 prov := p 拷一份局部变量,否则最后所有 fn 都会去调用同一个 provider。这种 bug 不会在低并发下暴露,一旦上线就诡异得很。

第二步,按优先级逐个尝试,谁先成功就用谁:

for _, provider := range providers {
    fn := fnMap[provider.Name]
    result, err := fn()
    if err != nil {
        pm.MarkFailure(provider.Name, err) // 记录失败
        continue                           // 继续下一个
    }
    pm.MarkSuccess(provider.Name)          // 重置失败计数
    return result, provider.Name, nil      // 成功,立即返回
}

就这么几行,但每一个细节都值得抠:

  • 短路返回:第一个成功的就 return,后面的 provider 完全不会被调用,省 token、省时间。
  • 失败可区分:我们定义了一种 ContentEmptyError,表示"模型正常响应了,但说这题它不会"——这是语义上的空结果,不是服务故障,不应该触发熔断计数。把"服务挂了"和"题目太难"区分开,是这种系统里很容易忽略的一点。
  • 降级排除:调用方可以传一个 excludeNames 列表,告诉 Execute"这些 provider 我已经试过且失败了,别再试"。这在智能路由的"重试循环"里很有用,避免重复尝试已知会失败的 provider。

四、熔断:连续失败三次,请你去休息

光有"试下一个"还不够。如果一个 provider 已经在持续报错(比如 API Key 过期、额度用尽),每次请求都先去试它、等它超时、再 fallback,是对用户体验和成本的双重浪费。于是引入熔断

  • 每个 provider 维护一个连续失败计数 FailCount
  • 失败一次 +1,成功一次归零。
  • FailCount >= 3 时,标记 Disabled = true后续请求在选择阶段就直接跳过它,不再发起真实调用。
  • 熔断状态会持久化到磁盘data/provider-status.json),服务重启后保持熔断,需要人工确认恢复(POST /api/admin/providers/:name/reset)。

为什么熔断后不自动恢复,而是要人工介入?因为"连续失败三次"通常意味着配置级别的硬故障(Key 失效、套餐到期),自动半开重试只会再次拖慢线上请求。把恢复的决定权交给运维,配合一套管理 API(查看状态、手动禁用、动态调优先级),反而更稳。

熔断触发时还会发一条告警短信,让你在用户投诉之前就知道哪家供应商又出幺蛾子了。

一个性能细节:防抖持久化

熔断状态每次变更都要写文件吗?高并发下,一个 provider 在 100ms 内可能失败十几次,每次都写文件会有磁盘 I/O 尖峰。我们用了一个**防抖(debounce)**机制:状态变更后,2 秒内若再有变更,则取消上一次的写、重新计时。最终一次写文件落下最新状态。

这里还有一个并发安全的坑:定时器在触发前可能被新的定时器替换,触发时要校验"map 里存的还是不是我这个 timer",否则旧 timer 可能在被 Stop 之后仍触发一次写操作(Go 的 Timer.Stop() 不保证已入队的回调不执行)。这种竞态在压测时才会暴露。

五、智能路由:让简单题走便宜模型

容灾解决的是"可用性",智能路由解决的是"成本与延迟的平衡"。

一个观察:很多错题其实是简单题(难度 1-3),用 flash 模型又快又便宜;只有难题、带几何图的题才需要 pro 模型仔细分析。如果一刀切用 pro,成本扛不住;一刀切用 flash,难题解析质量又不够。

于是我们在 ai-analyze(拍题分析)这个核心接口里,插了一个两阶段流水线

拍题图片
   │
   ▼
[阶段一:难度评估] 用一个 flash 模型快速看一眼图片
   │   输出:{ level: 1-10, hasImage, handwriting, subject, question, questionCount }
   │
   ▼
[路由决策] 根据难度和是否有题图,决定阶段二用 pro 还是 flash
   │   level <= 4 且无辅助图  → flash
   │   其他(难题 / 带图)      → pro
   │
   ▼
[阶段二:深度分析] 用选中的模型,配合对应 prompt,输出完整解析

几个值得一提的设计:

  1. 题目计数校验。评估阶段顺便数了图片里有几道题。questionCount > 1 返回 1005,提示用户"一次只拍一道题";== 0 返回 1006。在模型分析之前就把无效请求挡掉,省一次昂贵的 pro 调用。

  2. 手写感知。评估阶段还检测图片里有没有学生的手写过程。如果有,阶段二自动切换到一个"含薄弱环节分析"的专用 prompt,除了给标准答案,还会分析"学生在哪一步思路断了"——这是错题本产品最有价值的部分,不是给答案,而是诊断为什么错。

  3. 预提取回填。评估阶段的 flash 模型其实已经顺手把 subjectquestion 抽出来了。阶段二就不必再重复抽取这两项,prompt 可以更精简(lite 模式),既省 token 又更快。这种"一次评估,多处复用"的思路,是把成本压下来的关键。

  4. 角色匹配 + 降级。路由决策时,先在"角色匹配"(要 pro 就找 pro)的 provider 里选最高优先级的;如果一个匹配的都没有,降级到任意可用 provider。可用性优先于成本优化——找不到便宜的,贵的也得顶上,不能让用户拿不到结果。

六、和脏数据搏斗:LLM JSON 修复流水线

LLM 返回的 JSON 是不可靠的。我们见过的情况包括但不限于:把 JSON 包在 ```````json ````代码块里、字段名拼错、中文引号、中途被 max_tokens 截断、把数学公式写成不合法的转义……前端直接 JSON.parse 必崩。

我们的做法是多层防御

  1. 鲁棒解析。先用一个宽容的解析器:剥代码块、修引号、补尾逗号、尝试提取最大的合法 JSON 片段。这一层能解决 80% 的问题。

  2. LLM 自修复。如果本地解析失败,再调用一个 format-json 能力的模型(DeepSeek 或千问,走同样的容灾),把"原始脏文本 + 上次的错误信息 + 上次的失败输出"喂给它,让它修。最多重试 3 轮,每轮把上一轮的错误反馈回去。这种"用模型修模型的输出"是真实业务里非常实用的兜底。

  3. 空结果降级。对于"知识点薄弱分析"这类接口,热力图部分是纯本地统计(不依赖 LLM),LLM 只负责生成"根因分析/学习路径/策略建议"等增值内容。LLM 挂了的时候,热力图照常返回,其余板块降级为 status: insufficient核心数据永远在,AI 锦上添花——这是面对模型不稳定时的正确姿势。

七、流式输出:SSE 与客户端断开

解题过程如果一口气返回,用户要盯着 loading 看 5-10 秒;流式输出(SSE)让答案一个字一个字地"打"出来,体感快得多。

但流式有个麻烦:没法做故障转移。非流式请求失败了可以换下一个 provider 重试;流式一旦开始往外吐字节,中途失败就只能告诉用户"出错了",没法默默换一家继续(已经发出去的 chunk 收不回来)。

所以我们的流式接口走的是另一套策略:

  • 开始前选一家。优先选 flash(低延迟、首字快),按角色和优先级挑一家可用的 provider。
  • 监听客户端断开。用 context 监听连接关闭,用户关了页面就立刻取消对模型方的请求,不浪费上游额度。
  • 流式数学公式标准化。模型输出的 LaTeX 可能是 \(...\)\[...\],前端要的是 $...$$$...$$。在流式场景下这事更棘手:你不能等到全部输出完再替换(那样就失去流式意义了),但 \(\[ 又是两个字符,可能跨 chunk。我们实现了一个流式标准化器,维护一个状态机缓冲未决的反斜杠,逐 chunk 处理。

八、一些"非功能性"但救命的设计

共享 HTTP 连接池。所有 provider 共用一个 http.Transport(200 连接池)和两个 http.Client(一个非流式带 300s 超时,一个流式不带超时自己控制)。避免每个 provider 各起一套连接,减少句柄和 TLS 握手开销。

并发限制。中间件层用原子计数器限制全局并发请求数(比如 50),超出直接返回 429。防止高峰期模型方还没熔断,自己的服务先被内存撑爆。

HMAC 签名认证。业务接口用 HMAC-SHA256 做请求签名,防止接口被刷。可配置开关,调试期间可以关掉。

优雅关闭。监听 SIGINT/SIGTERM,等待正在处理的请求完成(超时时间为出站超时 + 30s)再退出。AI 请求动辄十几秒,硬杀会丢一堆正在跑的推理。

结构化日志 + 滚动。用 Go 1.21+ 的 slog 做结构化日志,配合 lumberjack 按大小/时间轮转。线上排错靠的是日志里的 trace,不是 println

九、回到前端:把模型能力"渲染"成产品

后端把脏活累活干完了,前端的任务是把那些结构化结果变成学生能看懂的东西。这部分我们用了 Vue 3 + Vite + Element Plus + Tailwind 的组合,技术选型平平无奇,但有几个点值得说:

  • KaTeX 渲染数学。解析步骤里全是公式,KaTeX 比 MathJax 快一个量级,首屏渲染不掉帧。配合后端的"数学公式标准化",前后端约定统一的 LaTeX 分隔符。
  • Mermaid 流程图。后端会基于解题步骤,用一个 format 模型生成 Mermaid 流程图代码,前端直接渲染成可视化流程图。学生看到的是"第一步→第二步→得出答案"的图,而不是一坨文字。
  • ECharts 薄弱星图。知识点薄弱分析返回的热力图数据,前端用 ECharts 画成"薄弱星图"——重灾区的知识点又红又大,一眼就知道该补哪。学习路径的依赖关系也用 Mermaid 有向图渲染,知识点按严重程度(核心重灾区/重点关注/需巩固)着色。

这些都是把 AI 的"结构化输出"翻译成"学生能用的产品形态"。后端再聪明,如果前端只是一坨 JSON 文本,价值就少了一半。

十、写在最后:把"调 API"做成"网关",值吗?

回头看,我们从"直接调模型"演进到"自建 AI 网关",多写了几千行 Go 代码,多了熔断、路由、修复、持久化一堆机制。值吗?

对一个玩具 demo 不值,对一个真实在跑、有付费用户、有高峰流量的产品,值。因为:

  • 可用性。任何一家供应商挂了,用户感知不到——网关自动切到下一家。我们做过统计,某个高峰日里有一家 provider 触发了熔断,但因为容灾,当天的失败率依然接近零。
  • 成本。智能路由让简单题走 flash 模型,整体 token 成本降了相当可观的一个比例。
  • 可演进性。换模型、加模型、调权重,改配置 + 重启即可,不需要发版改代码。在这个模型迭代飞快的年代,这个灵活性是真金白银。

如果你也在做一个依赖外部 AI 模型的产品,强烈建议在后端和模型之间留出这么一层抽象。它不一定一开始就要这么完备,但能力 / 提供方的二维抽象优先级 + 容灾循环JSON 修复兜底这三件事,越早做,后面越省心。

猫头鹰AI错题本(https://www.aicuoti.cn/),大家可以去多试试。这篇就先到这儿。

Logo

一站式 AI 云服务平台

更多推荐