ARCHITECTURE · FIELD NOTE 002

为什么多模型平台需要
一层真正的抽象?

接入五家模型之后,如果业务代码里出现了五套请求对象、五种流式回调和一排供应商判断,这不是多模型架构,只是把五个接口放进了同一个项目。

我见过最危险的多模型代码,不是写得差,而是最初看起来太合理。

第一个模型进来时,一段判断就够了。第二个模型参数不同,再加一个分支。等到语音、绘画和知识库场景陆续接入,供应商判断会从调用层蔓延到业务层、计费层和异常处理。

if (provider == GPT) {
    return openAiClient.chat(request);
} else if (provider == CLAUDE) {
    return claudeClient.messages(request);
} else if (provider == DEEPSEEK) {
    return deepSeekClient.chat(request);
}

问题不只是分支越来越长。真正的麻烦是业务开始知道每家供应商的细节:谁用 messages,谁的结束原因叫 stop,谁会在流里单独返回用量。此时更换模型,不再是替换一个实现,而是修改一条业务链。

抽象的目的不是消灭差异,而是决定哪些地方有资格看见差异。

01 / CONTRACT

先定义业务需要什么

我通常不会从供应商 SDK 的请求对象开始设计,而是先问上层业务真正需要哪些稳定概念。对话场景里,最基本的是消息、模型标识、采样参数、流式开关和调用上下文。

public record ChatCommand(
    String model,
    List<ChatMessage> messages,
    GenerationOptions options,
    RequestContext context
) {}

这个对象属于业务,不属于任何供应商。它不应该出现 OpenAI、Claude 或某个 SDK 的类型。供应商新增字段时,也不应该立刻污染公共契约。

公共参数放进 GenerationOptions;少量模型特有能力可以进入受控扩展区,或者通过能力声明暴露。这样既保留抽象,也不把模型差异削成最低公分母。

02 / DRIVER

Driver 负责翻译,不负责业务

Driver 的工作是把稳定的业务命令翻译成供应商请求,再把返回结果翻译回来。鉴权头、请求路径、SDK 对象、特殊错误码和协议细节,都应该停在这一层。

public interface ModelDriver {
    boolean supports(String model);
    ChatResult chat(ChatCommand command);
    void stream(ChatCommand command, StreamObserver observer);
}

public final class ClaudeDriver implements ModelDriver {
    // 只在这里理解 Claude 的请求和流式协议
}

业务层通过 Registry 或策略选择 Driver,不再写供应商分支。新增模型时,理想状态是增加实现和配置,而不是改动对话、订单、课程或内容业务。

BUSINESS只理解 ChatCommand 与业务结果
↓
DRIVER CONTRACT选择 · 翻译 · 规范化
↓
PROVIDERS各家 SDK、HTTP 与流式协议

这里有一条很重要的边界:Key 选择、配额状态和重试策略可以由独立组件管理,Driver 只拿到本次可用的调用凭据。否则 Driver 很快会同时承担协议、调度、计费和容错,重新变成另一个大泥球。

03 / STREAM

流式响应要统一成事件

不同供应商的流不是同一种东西。有的发送文本增量,有的把角色、工具调用和结束原因拆成独立事件,还有的在最后一帧才返回用量。

如果把原始流直接透传给前端,前端会被迫成为第二个适配层。我更倾向于在后端定义自己的事件协议:

sealed interface ModelEvent {
    record Started(String requestId) implements ModelEvent {}
    record Delta(String text) implements ModelEvent {}
    record Usage(int input, int output) implements ModelEvent {}
    record Completed(String reason) implements ModelEvent {}
    record Failed(ModelError error) implements ModelEvent {}
}

这样,前端只处理开始、增量、用量、完成和失败。供应商新增一种事件时,先在 Driver 内判断它是否值得进入公共协议,而不是原样向上泄漏。

04 / FAILURE & USAGE

错误和用量也必须被规范化

只统一成功结果,不算完成抽象。生产环境里,业务更需要知道失败是否可重试、是不是额度耗尽、是否触发限流,以及应该切换 Key 还是切换模型。

public record ModelError(
    ErrorType type,
    boolean retryable,
    String providerCode,
    String safeMessage
) {}

原始错误码可以保留给日志和排查,但业务判断应该依赖稳定的 ErrorType。同样,输入 Token、输出 Token、模型和请求归属需要统一记录,才能在不同供应商之间比较成本。

判断标准

业务需要据此做决定的字段,应该进入公共模型;只对某家排障有用的字段,留在 Driver 和日志里。

05 / BOUNDARY

不要把所有模型假装成一样

抽象最容易走向另一个极端:为了统一,只保留所有模型共有的最低能力。这样接口确实干净,但新模型的工具调用、多模态输入或缓存能力也用不起来。

比较实用的做法是把能力分成三层:

稳定核心

消息、流式文本、结束状态、错误和用量,所有对话模型都遵守。

能力声明

工具调用、视觉输入、结构化输出等,由 Driver 明确声明是否支持。

受控扩展

确实无法统一的少数参数,通过命名明确的扩展对象进入实现。

禁止泄漏

供应商 SDK 对象和原始事件不跨过 Driver 边界。

抽象不是追求完全一致,而是让大多数业务走稳定路径,同时给真正的差异留下出口。

06 / CHECKLIST

判断抽象是否真的成立

  • 新增一个同类模型时,是否只增加 Driver 和配置?
  • 业务服务里是否仍然出现供应商名称或 SDK 类型?
  • 前端是否需要根据模型判断流式事件格式?
  • 限流、额度不足和网络错误是否有统一语义?
  • 是否能够按业务、模型和用户统计 Token 与成本?
  • 模型特有能力是否有明确、受控的扩展方式?

如果第一题的答案是“可以”,后面几题也没有泄漏,才算真正建立了多模型边界。否则,平台只是暂时把多家接口放在了一起。

— 老毕,写于杭州