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,不再写供应商分支。新增模型时,理想状态是增加实现和配置,而不是改动对话、订单、课程或内容业务。
这里有一条很重要的边界: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 与成本?
- 模型特有能力是否有明确、受控的扩展方式?
如果第一题的答案是“可以”,后面几题也没有泄漏,才算真正建立了多模型边界。否则,平台只是暂时把多家接口放在了一起。
— 老毕,写于杭州