跳到主要内容
blog-hexo-astro
返回

大模型配置设计架构思考

Table of contents#

Open Table of contents

一、问题不是”调不通”,是三类问题会同时发生#

给合同审查业务接大模型,最初的想法是封一个 ChatClient 就完了。真做下去会发现三种问题会撞在一起:

  1. 厂商异构。OpenAI、Anthropic、Gemini 三家线格式、错误码、用量字段全不一样;国内厂商大多兼容 OpenAI,但”兼容”的程度各不相同。
  2. 稳定性。429、5xx、密钥欠费、连接重置、推理模型首包要等三十秒 —— 每一种的处置方式都不一样。
  3. 成本与可观测。一次审查几万 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 轮换

为什么要分这么细?因为”模型”这个词在不同人嘴里不是同一个东西:

把这四句话压成一层,就必然有一层的人被迫关心另一层的事。 拆开之后,每一层只回答一个问题。

档位不是”模型大小”,是”场景 + 预算”#

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 / TPMdeep 档单独压住(推理模型最费钱)
业务方(× 档位)日配额日天花板,按实际 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 必踩”的:

  1. 批量上限差异极大(OpenAI 2048 条 / 某国内厂商 64 条 / 另一家只有 10 条)→ 必须自己切批;
  2. 响应顺序不保证,必须按 index 归位 —— 单条测试永远是对的,批量才炸;
  3. 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 → 勾模型 → 自动建好厂商/模型/档位/业务方。首次接入从半小时压到三分钟,而且顺带把”档位和候选该怎么配”这个隐性知识固化进了系统。


十一、这套设计的代价#

诚实一点,它也有明显代价:


十二、如果重来一次#

  1. 先做可观测,再做降级。 没有指标和调用日志的降级逻辑,等于蒙着眼睛调参。
  2. 配置变更要有 diff 和回滚。 它不是”防止别人犯错”,它是”防止三个月后的自己犯错”。
  3. 测试优先补在”最贵的路径”上:降级循环、权重分流、限流窗口。这些地方编得过、单测不覆盖、第一次真跑才炸 —— 我踩过的每一个坑都在这个集合里。

结语#

这个模块最后沉淀下来的,不是那 8 张表,而是几条判断:

配置管理的本质,是把人的判断提前固化到系统里;但必须留一件事给运行时:出错的时候,能说清为什么。



上一篇
如何搭建本地开发流水线
下一篇
chunk 有几种切法:把候选集里的 7 种策略逐个拆开