Table of contents#
Open Table of contents
一、问题不是”调不通”,是三类问题会同时发生#
给合同审查业务接大模型,最初的想法是封一个 ChatClient 就完了。真做下去会发现三种问题会撞在一起:
- 厂商异构。OpenAI、Anthropic、Gemini 三家线格式、错误码、用量字段全不一样;国内厂商大多兼容 OpenAI,但”兼容”的程度各不相同。
- 稳定性。429、5xx、密钥欠费、连接重置、推理模型首包要等三十秒 —— 每一种的处置方式都不一样。
- 成本与可观测。一次审查几万 token,谁花的、走了哪个模型、为什么慢、为什么降级,全靠猜。
单看任何一条,都能用一个 if-else 解决。三条叠在一起,业务代码里就会长出这种东西:
if (provider.equals("anthropic")) { /* 换格式 */ }
if (e.status == 429) { /* 立刻重试?还是换厂商? */ }
log.info("花了 {} token", usage); // 然后没人看
所以真正的需求不是”封装调用”,是把”选一个模型”这件事从代码里挪到配置里。
目标可以写成一句话:
业务方只说”要快 / 要稳 / 要深”,至于用哪个厂商的哪个模型、挂了换谁、每分钟能烧多少,都不该由业务方操心,也不该需要发版。
二、先定边界:砍掉一半需求的判据#
设计初期最容易犯的错是”能力清单越写越长”。我给自己定了一条判据:
这条能力是在防止别人犯错,还是在让你自己做得更好?
一个人开发、一个内部平台,前者做了也是空转。按这条砍掉的东西:多语言 SDK、WebSocket 长连接、配置审批流、灾备多活、厂商账单对账、独立内容审核服务、A/B 实验平台、多环境隔离。
也明确划出”不是这个模块的事”:
| 不做 | 归属 |
|---|---|
| Prompt 模板与编排 | 独立模块(本模块只接收 messages) |
| RAG 检索编排 | 知识库模块(本模块只提供 embedding) |
| 多租户 | 用业务方(app)做隔离,不引入 tenant_id |
| 金额账单 / 对账 | 未来接财务系统,本模块只记 token |
半年后回看,这条边界比任何架构图都有用 —— 它挡住了至少三次”顺手加个功能”的冲动。
三、核心抽象:五层,业务只看见第一层#
业务模块 "要快 / 要深",只认一个门面:chat / chatStream / embed
│
档位 Tier fast / standard / deep ← 语义:场景与预算
│
逻辑模型 logic_code qwen3-max ← 语义:一个"型号"
│
物理部署 qwen3-max @ 阿里云
qwen3-max-2025 @ 火山引擎 ← 语义:某厂商的一次部署
│
厂商 + 密钥池 base_url / protocol / 多把 key 轮换
为什么要分这么细?因为”模型”这个词在不同人嘴里不是同一个东西:
- 业务说”这次审查要深一点”;
- 架构说”用主力模型”;
- 运维说”qwen3-max 在阿里云那个部署挂了”;
- 账单说”某个厂商某把 key 这个月花了多少”。
把这四句话压成一层,就必然有一层的人被迫关心另一层的事。 拆开之后,每一层只回答一个问题。
档位不是”模型大小”,是”场景 + 预算”#
fast 小模型 分类、抽取、闲聊 15s
standard 主力模型 默认 60s
deep 推理模型 复杂审查、多步推理 180s
关键点:档位是业务语言,不是技术分类。业务不需要知道 deep 档后面挂的是哪家的推理模型,只需要知道”选 deep,慢一点、贵一点、想得更久一点”。
所以 thinking=true 我直接写死走 deep 档,不做成”业务方可以自己配深度思考档位”。原因很简单:那个配置可以被清空,清空之后就出现”以为在 deep、实际跑在 standard”的认知偏差。能配错的开关,比没有开关更危险。
逻辑模型是整套设计里最值钱的一层#
如果档位直接挂物理模型 id,那么”换厂商”就等于”改档位配置”;而挂逻辑型号之后:
logic_code = qwen3-max
├─ qwen3-max @ 阿里云 主
└─ qwen3-max-2025 @ 火山引擎 备
候选链里只写一次 qwen3-max,运行时展开成两个候选,主挂了切备,档位和业务方一行都不用改。
这个决定看起来只是数据建模,实际决定的是”加一个容灾备份要不要发版”。也正因为它,后面做灰度、做多厂商分流才有地方落脚。
四、两层权重:三种”比例”不能混成一个字段#
这类系统最常见的建模错误,是把”先试谁”和”按多少比例分流”塞进一个字段。我拆成三个:
| 层 | 字段 | 回答的问题 |
|---|---|---|
| 档位内候选 | priority | 先试哪一组?1 最先 |
| 档位内候选 | weight | 同 priority 内多个候选的灰度比例 |
| 物理部署 | ai_model.weight | 同一逻辑型号的两个厂商部署,谁先试、怎么分流(总和 100) |
举个真实用法:standard 档想从 A 模型灰度到 B 模型,就把两个候选放同一 priority,weight 配 90/10;灰度完成,把 B 提到 priority 1。而”某个逻辑型号有两个厂商部署”是另一件事,由部署自己的 weight 决定 —— 两者混在一起,就会出现”我想调灰度,结果把容灾顺序改了”。
weight <= 0 我定义成”该部署临时摘掉但保留配置”,不是删除。灰度回滚只需要改数字。
五、一次请求的旅程:顺序就是设计#
1. 幂等查缓存 命中直接返回,不消耗配额、不打厂商
2. 幂等占位 同一个 requestId 并发进来只放一个,其余等结果
3. 路由解析 业务方 → 路由组 → 档位 → 候选链(已过滤不可用)
4. 限流 RPM / TPM / 日配额,按业务方与档位分别算
5. 执行 候选 × key 双层循环,失败分类 → 换 key → 换候选
6. 记账 调用日志 + 指标 + token 累加
7. 写结果 释放锁
顺序不是随便排的:
- 幂等在最前面:合同审查一次调用很贵,业务方网络抖动重试一次,就是真金白银调两次。
- 限流在路由之后:因为它要按”业务方 × 档位”算,得先知道走的是哪个档。
- 限流必须在调厂商之前:先花钱再扣额度,限额就失去意义了。
第 5 步是核心,chat 和 embedding 共用同一份降级逻辑:
for (candidate : candidates) {
if (剩余预算不足 || 熔断器 OPEN) continue;
for (key : 该候选厂商的可用 key) {
try { return 调用(candidate, key); }
catch (e) {
类型 = 分类(e);
熔断器.记录(candidate, 类型); // 只有该计的类型才计
if (!类型.可重试) throw e; // 参数错、审核拒绝:直接抛
}
}
}
throw new NoAvailableModelException(候选清单, 每个候选被跳过的原因);
一句话概括这段代码的设计意图:key 挂了换 key,key 换完还没成再换候选;而不是一有问题就降级。
六、容错:不是所有失败都值得重试#
这是我认为整套设计里最关键的一张表:
| 错误类型 | 换 key | 换候选 | 计入熔断 |
|---|---|---|---|
| 429 限流 | ✅ | ✅ | ❌ 不计入 |
| 5xx / 超时 / 连接重置 | ✅ | ✅ | ✅ |
| 401 / 403 密钥失效 | ✅ | 换完仍失败才降级 | 仅 key 级 |
| 400 参数错 | ❌ | ❌ | ❌ |
| 内容审核拒绝 | ❌ | ❌ | ❌ |
两条判断值得展开:
① 429 不能计入熔断。 厂商限流的时候,本地熔断器再把它熔掉,等于雪上加霜:本来等两秒就能过,现在要等一分钟。这类”外部压力”和”我们自己的故障”必须分开计。
② 内容审核不能当技术错误。 如果审核拒绝返回一个通用的”服务异常”,用户会重试,越重试越贵,而问题永远不会好。它必须是独立错误码,让业务侧给出不同提示。
流式的边界:首包之后不能降级#
流式比同步难,因为响应头一旦发给前端,就没有回头路了。所以做了”首包探测”:
建立连接、开始读流 → 但不向业务回调任何数据
→ 首包到了:立刻回调,切成直通模式
→ 首包超时:abort 连接,记失败,换下一个候选
原则只有一句:只缓冲首包,绝不缓冲第二包之后。 缓冲整个流,流式就退化成”伪流式”,低延迟这个唯一的优势也就没了。
超时必须分三层,而且要真的生效#
1. 业务总预算 deadlineMs(默认 120s)
2. 单次调用 min(档位超时, 剩余预算) ← 档位优先,没配才回落厂商
3. HTTP 层 按第 2 层派生客户端
这三层只要有一层没接上,配置就是假的。我踩过的最典型的一个坑:界面把 fast 档配成 15 秒,实际照样能跑满 120 秒 —— 因为 HTTP 客户端超时是硬编码的,第 2 层的值只进了日志。“能配”和”生效”之间,隔着一整条链路。
七、成本:三层限流,加一个 key 池#
限流拆成两个维度、三种算法:
| 维度 | 语义 |
|---|---|
| 业务方 RPM | 总量保护 |
| 业务方 TPM | 每分钟 token,防单次大 prompt 打穿 |
| 业务方 × 档位 RPM / TPM | deep 档单独压住(推理模型最费钱) |
| 业务方(× 档位)日配额 | 日天花板,按实际 token 累加 |
为什么 TPM 不能用一个现成的限流器?因为常见的令牌桶/滑动窗口实现一次只能”扣 1 个”,而这里要扣的是 token 数(一次请求可能 5000)。所以 RPM 用现成限流器,TPM 自己用有序集合做时间窗口:清理窗口外 → 累加当前窗口 → 判断 → 写入本次预估。
另一个容易被忽略的点:限流要”快速失败”,不排队。 内部网关排队只会把超时压力往后堆,最后所有请求一起超时,比直接拒绝更糟。
密钥池则比想象中简单也容易出错:一个厂商 N 把 key,按”最久未使用”轮询,401/403 立刻隔离该 key,其余继续。这里有个只有真跑才会暴露的细节 —— 新加的 key”最后使用时间”是空的,而数据库默认把空值排在最后,于是新 key 永远轮不到,表现是”加了备用 key 流量却没分流”。这种 bug 单测和 code review 都很难发现,只有把”加第二把 key”当验收动作才会看到。
八、适配层:差异只允许存在一处#
协议适配的诱惑是”给每个厂商写一个 Client”。我见过的开源实现里,一个厂商一个类,类之间实质差异只有”一个字符串 + 批量上限”,结果是接一家新厂商要发版。
所以分成两层:
| 层 | 放什么 |
|---|---|
| 模板方法基类 | 切批、参数归一化、响应组装、按 index 排序、维度校验 |
| 具体实现 | 只放”请求长什么样、响应怎么解析” |
chat 有 3 个实现(OpenAI 兼容 / Anthropic / Gemini),覆盖了主流厂商;Embedding 更彻底 —— 它不按厂商分,按线格式分,因为除了 Anthropic 没有 embedding 接口,其余基本都收敛到 OpenAI 那一套形状。
顺手记三条 embedding 的硬约束,都是”照抄 SDK 必踩”的:
- 批量上限差异极大(OpenAI 2048 条 / 某国内厂商 64 条 / 另一家只有 10 条)→ 必须自己切批;
- 响应顺序不保证,必须按
index归位 —— 单条测试永远是对的,批量才炸; - query 和 document 不对称,检索侧要加指令前缀,不加召回直接塌。
九、可解释性:把”没有可用模型”变成一句能读懂的话#
这可能是整个模块最不性感、但最省时间的设计。
线上最常见的报错是 NoAvailableModel。如果只有这五个字,你得依次去查:档位配了候选吗?候选挂的逻辑型号有部署吗?部署被禁用了吗?厂商有 key 吗?key 全熔断了吗?—— 五个地方,五分钟起步。
所以我做了三件事:
① 路由阶段收集”跳过原因”
每个候选被过滤时都记一条:qwen3-max:厂商禁用 / deepseek-chat:不支持深度思考 / xxx:熔断中。抛错时把整份清单带出来。页面上的”测试”按钮会直接告诉你”哪个候选、为什么被跳过”。
② 发布前 dry-run
保存前静态校验 8 条:档位超时 ≤ 0、档位下无候选、候选没有可用物理模型、逻辑型号权重和 ≠ 100、引用的厂商不存在、协议没实现、embedding 没设维度、同档 embedding 维度不一致。
③ 被拒绝的调用也落库
这条是上线后补的教训。限流拒绝发生在”选中候选之前”,而日志表的模型字段是 NOT NULL —— 于是被限流的请求在调用日志里一行都没有,只能去 docker logs 里 grep WARN。后来把模型字段放开为可空,并定了一个查询口径:
-- 被我方拦下的调用
select * from ai_call_log where status = 1 and model_code is null;
背后的原则我总结成一句话:宁可不聪明,也不要静默。
同一个原则还体现在一个反直觉的取舍上:业务明确指定了模型时,如果它不可用,我选择直接报错,而不是”悄悄回退到档位里的其他模型”。因为悄悄换模型会造成”我以为在用 A,实际跑的是 B”——账单和效果都对不上。OpenAI 官方接口的行为也是直接报错,这里保持一致。
十、配置后台:按人的心智组织,不是按表#
后台这块我改了三次,每次都是同一个问题:这张表是不是一个”人需要理解的独立事物”?
| 之前 | 之后 | 判断 |
|---|---|---|
| 独立的”档位管理”菜单 | 档位收进路由组,一行一档 | 档位是组的三个固定槽位,不是可自由创建的对象 |
| 路由配置藏在业务方页里 | 路由组独立成页,排在业务方上面 | 一个组可被多个业务方共享,应该”先配组、再选组” |
| 独立的”密钥管理”菜单 | 密钥收进厂商页的展开子表 | 密钥是厂商的属性,不是独立资产 |
最后形成的主线只有一句:一键接入 → 厂商 → 模型 → 路由组 → 业务方 → 调用日志。
界面上的表达也顺着这个概念走:一个路由组一行,三档横排,每档下面是”按顺序试哪些模型”的标签列表。之前是弹窗里三层结构 + 每档一个保存按钮,用户看不出自己到底在配什么;改完之后,“三档是不是同一个模型”这个最关键的信息一眼就能看到。
“一键接入”则是纯体验投入:选厂商模板(预填 base_url 和推荐模型)→ 填 key → 勾模型 → 自动建好厂商/模型/档位/业务方。首次接入从半小时压到三分钟,而且顺带把”档位和候选该怎么配”这个隐性知识固化进了系统。
十一、这套设计的代价#
诚实一点,它也有明显代价:
- 每次调用要查 5 到 10 次库,没做缓存。前提是日均两万次(0.23 QPS);到了十万级就必须加缓存或改结构。
- 全部逻辑外键,没有一条 REFERENCES。换来的是软删友好、批量写不锁、未来能分区;代价是脏数据全靠 Service 保证 —— 每一条删除路径都要手写引用校验,漏一处就是”配置页看起来正常、下次调用直接没有可用模型”。
- 抽象层深。Selector → Executor → Client 三层,新人排障要读代码。我用”跳过原因”和明确的错误码来缓解,但不打算减层。
- 配置没有版本化和回滚。改坏一个路由组,只能靠记忆恢复。当初判断”一个人开发不需要”,现在觉得这是唯一后悔的一条。
十二、如果重来一次#
- 先做可观测,再做降级。 没有指标和调用日志的降级逻辑,等于蒙着眼睛调参。
- 配置变更要有 diff 和回滚。 它不是”防止别人犯错”,它是”防止三个月后的自己犯错”。
- 测试优先补在”最贵的路径”上:降级循环、权重分流、限流窗口。这些地方编得过、单测不覆盖、第一次真跑才炸 —— 我踩过的每一个坑都在这个集合里。
结语#
这个模块最后沉淀下来的,不是那 8 张表,而是几条判断:
- 抽象的价值在于”换一层不用动另一层”,而不是层多好看;
- 错误要分类处理,不是所有失败都值得重试,也不是所有失败都该熔断;
- 能配的,就一定要真的生效,否则它比不能配更糟;
- 宁可不聪明,也不要静默 —— 网关的价值不在第一次调通,在第一百次故障时你知不知道发生了什么。
配置管理的本质,是把人的判断提前固化到系统里;但必须留一件事给运行时:出错的时候,能说清为什么。