大概两年前,我接手一个AI客服项目的重构,当时系统正处于最尴尬的阶段:一套单体架构撑起了三四个客户,却已经让我每天睡不踏实。业务代码、Prompt模板、向量库、模型调用全塞在一个FastAPI应用里,任何一次改动都可能让另一个客户的对话风格跑偏,甚至有一次只是升级了一下Embedding模型,某个租户的召回质量直接跳水。这篇我不打算讲标准的微服务教科书,而是以这次从单体到SaaS的迁移为主线,把AI应用中和纯业务Web系统完全不同的架构演进路径,一个个掰开聊。内容主要面向正在做AI应用开发、又面临多租户和规模化压力的架构师和研发,也适合想提前规避坑的团队参考。
1. 为什么AI应用比普通Web应用更需要尽早思考SaaS化
先讲个反直觉的判断:很多人觉得SaaS化是业务做大之后才需要考虑的事,但AI应用恰恰相反,它是那种"越早埋下租户边界,后面越省钱"的系统。原因不在于代码结构,而在于AI应用的成本模型和耦合模型都和传统单体完全不一样。
1.1 普通单体与AI单体的核心差异
传统业务单体,比如一套CRM,耦合主要发生在三层:数据库表、服务层代码、前端页面。就算耦合再乱,它的运行成本基本是线性的——一台服务器多少钱,数据库多少钱,流量大了加带宽,瓶颈相对容易定位。
AI应用单体多出来的东西,是普通人最容易忽视的"模型调用层"和"数据召回层":
- 模型调用层:你的业务逻辑和某个模型版本、某个Prompt模板、某组推理参数绑定。今天用的GPT-4o,明天换成新模型,不是改一个配置项就能糊弄过去的,因为输出行为会变,下游解析逻辑也会崩。
- 数据召回层:RAG应用里,向量库中的数据质量直接决定回答质量。当不同客户的数据混在同一个Collection里,隔离和过滤就成了悬在头上的剑。
- 推理成本:每次对话都产生Token消耗和GPU推理延迟,这是普通单体没有的"持续可变成本"。
我见过很多团队,AI应用上线三个月,代码量不超过两万行,但线上问题已经多到没法排查。原因无他:单体的"便利"在AI场景里会放大成"混乱"。
1.2 成本结构不允许你"先跑起来再说"
普通Web单体上线后,用户多了顶多是CPU和内存报警,成本大头在服务器。AI单体不一样,一次对话的Prompt可能有几千Token,一个租户的召回查询可能命中几百条向量。成本增长不是跟着用户数走,而是跟着"调用次数 × Token长度 × 模型单价"走。
更麻烦的是计费粒度。单体模式下,所有调用都从同一个入口发出去,你想知道"租户A这个月花了多少钱""哪个Prompt模板消耗了最多推理资源"时,几乎没有数据可查。我接手那个项目时,云厂商账单出来了,财务拿着账单问我"为什么这个月模型费用翻了三倍",我只能说"可能是有个客户疯了一样不断重试"——这就是没有提前做计量埋点的代价。
1.3 什么样的AI应用值得现在就规划SaaS化
这里给大家一个相对实用的判断标准,避免过度设计:
| 信号 | 说明 | 建议 |
|---|---|---|
| 已经有两家以上独立客户 | 不同客户需要独立配置、独立数据边界 | 至少要引入租户字段 |
| 客户需要按量计费 | 用Token、调用次数、存储量结算 | 必须做调用计量与计费快照 |
| 存在需要分别定制的Prompt/知识库 | 每个租户的专属诉求越来越多 | 需要资源级配置模型 |
| 团队仍然只有一套代码、一个库 | 不是要你立刻拆微服务,而是先建立边界 | 模块化 + 接口抽象先行 |
如果只是内部工具、或者是单客户交付的定制项目,那确实没必要强上SaaS架构。但从第一行代码开始,把tenant_id留好、把模型调用收口成接口,几乎是无成本的保险。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单体阶段的"高墙":当模型、数据、业务耦合在一起
很多人把单体架构的问题简单理解为"代码都堆在一个仓库里,编译慢、部署难"。但对AI应用来说,真正的痛点是三堵更隐蔽的墙:模型版本与业务逻辑耦合、Prompt与业务分支纠缠、向量数据与租户边界模糊。我一个个说。
2.1 第一堵墙:模型版本与业务逻辑的耦合
我在项目里见过最典型的写法:业务代码里面直接写死model="gpt-4o",甚至有人把temperature、max_tokens也散落在各个函数里。当调用点超过20处时,"升级模型版本"就成了一次高危操作。你不知道哪里的输出格式变了,不知道哪个分支会因为模型换了一个措辞导致正则匹配失败。
更隐蔽的是Embedding模型。RAG应用里,文档向量化和查询向量化必须用同一个Embedding模型。如果你在数据导入时用old-embedding,在查询时却升级成了new-embedding,向量空间直接错位,召回结果会变得莫名其妙。单体架构里这种错误极难排查,因为代码层面看起来没有任何报错。
我的建议是,从单体阶段起就建立一个唯一的模型配置入口,哪怕只是一个config.py里的字典,也要让所有调用点只依赖这个入口,而不是散落的字符串常量。
2.2 第二堵墙:Prompt与业务分支纠缠
纯业务系统里,"逻辑分支"是代码层面的if/else,可控。AI应用里多了一个变数:Prompt模板。随着客户增加,你会发现每个客户想要的语气、风格、知识范围都不同。如果这些差异靠代码里的if/else去拼Prompt,项目很快就会失控。
举个例子:一个客服系统,租户A要求回答必须带工单编号,租户B要求先道歉再回答问题,租户C禁止提到竞品。这些东西写在业务代码里,每次修改都要走完整发布流程,而且很容易出现"给A改的东西影响了B"的情况。Prompt的语义耦合比代码耦合更难发现,因为大多数情况下系统不会报错,只是"变得不对劲"。
2.3 第三堵墙:向量数据与租户边界模糊
这是AI单体最危险的一堵墙。很多人都用过Pinecone、Qdrant这类向量数据库,早期一个Collection装所有客户数据非常常见。但问题在于:如果你的检索代码里忘了加租户过滤条件,或者过滤条件写得不对,客户的私密数据就可能被另一个客户检索到。这种事故,已经不是技术问题,而是合规问题。
正确的做法是:即使不拆Collection,也必须在每条向量写入时带上tenant_id元数据字段,并且检索时强制作为必选过滤条件。我在项目中甚至加了一层测试接口,专门用来校验"跨租户检索是否返回空结果"。
2.4 单体的合理上限:什么时候可以不拆
客观一点讲,单体不全是坏处。如果项目还处于验证阶段,租户数量不超过两三个、日均调用量在几百次以内、几乎没有定制化需求,那继续单体完全没问题。我甚至建议这个阶段别把架构搞得太复杂,先跑通业务闭环更重要。
但当出现这些信号时,就该启动演进:模型调用点超过20个、Prompt模板超过10套、需要按租户做流量隔离或配额限制、出现"改一个租户的功能要回归所有租户"的抱怨。满足任意两条,你就已经撞上单体的高墙了。
3. 从模块化单体到可插拔架构:演进的第一步往往不是拆服务
我见过太多团队一上来就拆微服务,最后拆出来一堆"分布式单体",网络调用倒是多了,复杂性一点没少。从实践来看,AI应用的第一步演进应当是把单体内部改造成可插拔结构,让每个模块具备清晰的边界和替换能力。这一步做完,后续怎么拆都有底气。
3.1 接口先行:把"对话"抽象成可替换的协议
首先做的一件事:定义模型调用接口。不要直接让业务代码去依赖具体的OpenAI SDK或者某个国产模型SDK,而是抽象出一个ChatProvider协议。所有业务方只需要面向这个协议编程,模型供应商的变化被限制在一个适配器内部。
python复制from typing import AsyncIterator, Protocol
from dataclasses import dataclass
@dataclass
class ChatMessage:
role: str # "system" / "user" / "assistant"
content: str
@dataclass
class ChatRequest:
messages: list[ChatMessage]
model: str | None = None
temperature: float | None = None
max_tokens: int | None = None
tenant_id: str | None = None # 从一开始就带上租户标识
class ChatProvider(Protocol):
async def chat(self, request: ChatRequest) -> str: ...
async def stream_chat(self, request: ChatRequest) -> AsyncIterator[str]: ...
不要小看这个抽象。它带来的直接收益是:模型升级、多模型路由、灰度切换,都变成了适配器层面的问题,而不需要改动任何业务代码。我在实际项目里,曾经只用三小时就把一个客户从A模型迁到B模型,原因就是业务层只依赖接口。
3.2 数据层的租户字段与隔离准备
第二步,也是最容易忽略的一步:把所有业务表加上tenant_id字段。哪怕现在只有一个租户,也要加。AI应用尤其要关注三个地方:
- 会话表:记录每次对话的消息列表,属于哪个租户。
- 向量数据:每个Embedding片段都要带租户标签,前面已经说过了。
- Prompt模板表:这是一个本身就带租户属性的资源。
表结构设计参考:
sql复制CREATE TABLE conversations (
id BIGINT PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
app_id VARCHAR(64) NOT NULL,
title VARCHAR(255),
model_used VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_conversations_tenant_created
ON conversations (tenant_id, created_at DESC);
设计原则只有一条:所有查询入口强制带tenant_id条件。哪怕初期数据库走全表扫描也无所谓,但代码习惯要养成。我见过太多项目,SaaS化的时候才发现历史表里没有租户字段,补数据补到怀疑人生。
3.3 配置中心:Prompt、模型配置、密钥的收口
第三步是把所有配置类内容从代码中剥离出来。具体来说,这些内容至少要收口到一个数据库表或者配置中心里:
- Prompt模板:支持全局默认 + 租户覆盖两级覆盖。
- 模型配置:每个应用(Agent)用哪个模型、温度、最大Token、系统提示词。
- 模型密钥:不同租户使用不同的模型供应商密钥,或者统一走网关密钥。
我们当时的做法比较轻量,直接用数据库表存配置,加上一层Redis缓存。虽然不如Apollo、Nacos这类配置中心重,但对于早期SaaS化足够用。重点在于:不要让人在代码里改Prompt,不要让人通过直接改环境变量来切模型。所有变更都要走配置接口,这样才有审计和回滚的可能。
4. 拆出第一组独立服务:推理网关、任务队列、数据管道
当模块化单体的边界已经清晰,下一步才是真正的服务拆分。但Split的第一刀切在哪里,直接决定了演进的平稳程度。以我的经验,第一刀不要切业务服务,优先切这三个东西:推理网关、异步任务、数据管道。
4.1 为什么先拆推理网关而不是业务服务
业务服务拆分是"逻辑解耦",而推理网关拆分是"稳定性隔离"。模型调用有三个固有属性:慢、贵、不稳定。慢指的是单次调用可能十几秒甚至几十秒;贵指的是Token成本和GPU成本远高于普通接口;不稳定指的是模型供应商可能限流、超时、返回异常格式。
如果把模型调用直接放在业务服务里,任何一次模型超时都可能拖垮整个接口的响应。拆出独立的推理网关,相当于给所有模型调用加了一个"保险丝"。网关可以统一处理:
- 请求级限流:每秒最多多少个推理请求。
- 重试策略:对于瞬时错误,采用指数退避重试。
- 超时控制:不同模型的超时时间不同。
- 计费埋点:在网关层统一记录Token消耗,形成计费流水。
- 灰度路由:按租户、按比例切换到新模型。
网关不一定要上K8s或者Service Mesh,我当时的实现只是一个独立部署的FastAPI服务,配上了Redis做限流。但它独立出来后,模型侧的任何抖动都不再影响业务主流程,这个收益立竿见影。
4.2 异步任务化:把非流式调用变成可靠队列
AI应用里有大量非实时任务:批量生成摘要、文档解析、Embedding向量化、导出报表。这些任务如果放在请求线程里同步执行,用户等得难受,系统也扛不住并发。更关键的是,一旦进程崩溃,任务就丢了。
我们的做法是引入消息队列,把这些任务异步化。选定方案时,并没有一上来就用重型MQ,而是走了很务实的路线:Redis Stream + worker进程。Redis Stream有消费者组,支持消息持久化,对中小规模场景足够;将来量大了再平滑迁移到Kafka或者RabbitMQ。
一个典型的数据导入任务流程:
- 用户上传文档,业务服务把文档元信息和下载地址写入队列。
- Worker消费消息,下载文档、解析文本、分块切分。
- 调用Embedding接口生成向量,写入向量库。
- 写一条任务状态记录,支持失败重试和人工触发。
关键点是:任务必须支持幂等重试。因为你不能假设网络永远畅通,模型接口也可能偶尔失败。我见过很多初版系统,任务重跑之后文档重复入库,回答质量断崖式下跌,问题就在没有去重。
4.3 数据管道的独立化:RAG流水线的工程化改造
很多团队最初做RAG时,文档导入就是一个一次性脚本:打开CSV、切分、调Embedding、写向量库。这在单体阶段勉强能用,但放到SaaS场景就有很多问题:没有增量更新机制、没有版本回滚、没有每个租户的处理进度视图。
独立化改造集中在三点:
- 文档版本化:同一个知识库文档,重新上传后要生成新版本,旧版本可以回滚。
- 增量同步:只处理变更部分,而不是每次都全量重建向量。
- 质量监控:统计每次导入的文档数、分块数、成功/失败数,至少能知道哪一步断了。
这一步改造完成后,知识库的管理才算真正从"开发脚本"升级成了"产品功能"。我记得我们当时的耗时大头不在代码,而在"理解每个文档切分后的chunk数量和Token量如何评估"。后来加了一个简单的预估环节,导入前先算Token,再决定用哪种切分策略,整个管线的稳定性提升了一大截。
5. SaaS化真正的门槛:多租户、配额与计费模型
前面做的所有工作,本质上都是在为真正的SaaS化打地基。而SaaS化最核心的门槛,也是和单机单体最本质的差异,在于三件事:多租户隔离、资源配额、计费模型。这三件事不解决,你的系统无论拆得多漂亮,都只是"伪SaaS"。
5.1 三种多租户隔离模式在AI场景里的取舍
传统SaaS里讨论隔离模式,通常看的是数据库隔离级别。AI应用还要多考虑两个维度:模型资源隔离和向量数据隔离。我根据自己的实践经验,把三种模式在AI场景下的表现列成一张表:
| 隔离模式 | 数据库 | 向量库 | 模型资源 | 成本 | 适用场景 |
|---|---|---|---|---|---|
| 纯共享 | 共享表 + tenant_id | 共享Collection + 元数据过滤 | 共享模型路由 | 最低 | 低安全敏感场景,早期多租户 |
| 库级隔离 | 独立Schema/库 | 独立Collection | 共享模型路由 | 中 | 中大型客户,数据隔离要求高 |
| 实例级隔离 | 独立实例 | 独立实例 | 独立模型/独立GPU资源 | 最高 | 高合规客户,金融、医疗 |
我的建议是,第一版先做纯共享模式,但数据模型上提前支持库级隔离的扩展字段。实际切换时,只需要在网关层加一个路由规则,把大租户流量导到独立资源池即可,业务代码不用动。
5.2 Token消耗怎么算清楚:一套可复用的计量模型
我见过太多AI项目,上线三个月都不知道每个客户实际产生了多少成本。核心原因是计费埋点没有做在统一的网关层,而是散落在业务代码里。正确的做法是:在推理网关层,把每一次模型调用记录成一条计量流水。
计量流水最少要包含这些字段:
| 字段 | 说明 |
|---|---|
| request_id | 全局唯一请求ID |
| tenant_id | 租户标识 |
| app_id | 应用/项目标识 |
| model | 实际使用的模型名称 |
| prompt_tokens | 输入Token数 |
| completion_tokens | 输出Token数 |
| total_tokens | 合计Token数 |
| cost_estimation | 估算金额,按模型单价计算 |
| created_at | 调用时间 |
Token数的统计一定要从模型返回的usage字段里读取,不要自己猜。这样做的直接收益是:你可以按租户、按应用、按模型维度聚合出成本报表。我发现很多团队觉得"计费"是财务的事,等账单爆炸了才回头做,那时数据已经丢失。
5.3 Prompt、插件、知识库的租户级资源建模
SaaS化的AI应用,本质上卖的不是代码,而是"配置好的智能体"。所以你需要把Prompt、知识库、工具插件这些资源建模成租户级实体。一个推荐的资源层级:
- 平台层(Platform):全局默认模型、全局默认Prompt。
- 租户层(Tenant):租户的专属模型配置、API密钥。
- 应用层(Application):租户下的具体智能体,比如"售前客服机器人"。
- 会话层(Conversation):一次具体对话,绑定应用配置和租户上下文。
每一层都可以覆盖上一层的配置。例如:平台默认用A模型,租户X可以指定用B模型,租户X的"售前机器人"可以再指定用C模型并附带专属Prompt。这个覆盖链设计好了,定制化需求就变成了配置变更,而不是代码变更。
5.4 安全边界:模型输出的数据合规必须前置
这是SaaS化里最容易被低估的环节。很多人觉得安全是"防攻击",但AI应用更常见的风险是数据串线。我列出三个必须做的前置安全策略:
- 召回隔离验证:定期用租户B的查询去检索租户A的知识库,确认结果为空。这个自动化测试要写进CI。
- 系统提示词注入防护:客户的Prompt模板里可能包含试图绕过系统指令的内容。网关层需要做系统提示词和后缀注入的基础检测。
- 输出内容审计:对模型输出做敏感信息过滤,比如手机号、身份证号、内部代号。尤其是面向C端用户的场景,输出审计不是可选项。
安全边界如果等出事故再补,代价往往超出想象。宁可前期多写几个校验中间件,也不能让"数据串线"成为你们产品的标签。
6. 迁移路上最容易被低估的三个隐形工程问题
前面讲的都是宏观架构,最后我想聊三个隐藏在最底层、但实际迁移时必定踩到的工程问题。它们的共同特点是:不解决不会导致系统崩溃,解决不好则会让团队每天都在疲于救火。
6.1 模型灰度发布:SaaS更新的"车祸现场"
单体架构下,换模型就是改代码重新部署,最多自己验证一遍。SaaS化之后,换模型等于"同时给几百个租户做手术"。最典型的翻车现场是:某个新模型在通用测试集上表现很好,但到了某个租户的专业领域里胡说八道,租户发现后直接开骂。
解决方案是建立模型灰度路由规则。我推荐一个从轻到重的灰度路径:
- 内部账号先行:公司内部群、测试账号先切到新模型。
- 白名单租户:选择一两个配合度高的客户,提前沟通后切量。
- 按比例切流:通过网关按请求比例灰度,观察错误率和Token消耗。
- 全量开放:确认稳定后再放开,同时保留一键回滚开关。
模型灰度和Prompt灰度最好同时在网关层实现。如果模型的输出格式变化很大,还可以在业务层做一个解析兼容层,针对新旧两种输出格式做适配。
6.2 流式消息的多租户追踪:从Request ID到Span ID
AI应用大量使用SSE(Server-Sent Events)和WebSocket做流式回复。流式请求的追踪难度比普通HTTP大得多,因为你无法在一条响应里简单地看到全链路。当租户反馈"回答到一半断了"或者"特别慢",你要能快速回答三个问题:哪个租户?哪次请求?卡在哪个环节?
我在实际项目里的做法是,强制所有服务在日志中输出以下字段:
json复制{
"request_id": "req_8f3a...",
"span_id": "span_7c21...",
"tenant_id": "tenant_abc",
"app_id": "app_sales_bot",
"trace_action": "chat_stream_start",
"model": "gpt-4o",
"latency_ms": 1234
}
只要这个链路打通了,排查问题的时间可以从"小时级"降到"分钟级"。很多AI应用,尤其是用了LangChain或多步Agent的,链路特别长,牵涉多次模型调用和工具调用,如果没有完整的追踪,你根本无法解释"为什么这个回答绕了这么一大圈"。这里可以直接引入OpenTelemetry,不一定用商业APM,自己搭一套轻量级SDK封装也行。
6.3 成本爆炸:每个租户的真实成本必须"逐笔落账"
最后一个隐形问题,也是我感受最深的:AI应用的账单是按天跳变的,你不可能靠月末看一次账单来管理成本。尤其是Prompt特别长的用户、自动重试机制、循环调用的Agent,都可能让成本在几个小时内冲破预算。
我给出的建议是建立三层成本防线:
- 请求级限流:每个租户每秒最多调用模型N次。
- 会话级限制:单次对话的最大消息轮数,防止Agent死循环。
- 月度配额:每个租户的Token总额度,超了自动熔断或降级。
这些配额数据要当场计算、当场生效。网关层配一个Redis计数器就够用,不需要引入复杂的计费系统。但"逐笔落账"这个动作必须从第一天开始做,我在这里栽过跟头:一个客户因为一个配置错误的Agent,一晚上循环调用了一万多次模型,第二天账单多出几千美元。如果当时有逐笔落账的流水,至少能在"当晚"就收到告警,而不是"第二天"看见账单才反应过来。
最后再分享一点个人体会:AI应用的架构演进,阻力往往不在技术,而在"觉得现在还能跑"的侥幸心理。单体阶段你会觉得一切都很顺,代码改动快,部署简单;可真到了多个租户、多套模型、多种定制需求叠加的时候,返工成本是呈指数级上升的。我的建议是,哪怕你暂时不做微服务、不上K8s,也一定要先把三件事做好:接口抽象、租户字段、计量流水。这三样东西,就是未来所有架构演进的地基。地基打好了,从单体到SaaS这条路就不会走得那么惊心动魄。
