SaaS多租户的AI能力集成从模型路由到数据隔离的完整架构复盘在多租户SaaS平台上集成大模型能力远不止调一个API那么简单。模型如何按租户路由Token配额如何精确控制向量数据如何做到租户隔离本文基于过去半年在一家B2B SaaS平台的实战复盘整个AI集成架构的设计决策与技术细节。一、多租户AI集成的核心挑战在SaaS场景下将大模型能力开放给各租户使用时面临以下核心问题模型选择多样性不同租户可能需要不同的模型GPT-4o、Claude-3.5、Qwen-Max等甚至同一租户的不同场景也需要不同模型。资源隔离与公平调度GPU资源是稀缺的如何在多租户间公平分配避免某个租户的突发流量影响其他租户。数据安全边界租户的Prompt模板、微调模型、向量知识库等数据资产必须严格隔离。成本归属清晰每个租户消耗的Token必须精确计量支持多维度计费。下面逐一展开每个问题的解决方案。二、模型路由层的架构设计2.1 模型共享 vs 独占的架构选择在多租户AI平台中模型实例的管理方式决定了资源利用率和隔离性。主要有三种模式实际落地中混合模式是性价比最高的方案。共享基础模型实例覆盖80%的通用场景租户专属的微调模型仅服务于高价值客户的核心业务场景。2.2 模型路由的核心实现路由层需要解决哪个请求发给哪个模型的问题核心代码如下Service public class ModelRouterService { private final LoadingCacheString, TenantModelConfig configCache; private final MapString, ModelInstancePool modelPools; public ModelRouterService() { this.configCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(this::loadTenantConfig); this.modelPools new ConcurrentHashMap(); } /** * 根据租户和场景路由到目标模型实例 */ public ModelInstance route(String tenantId, String scene, AIRequest request) { // 1. 获取租户的模型配置 TenantModelConfig config configCache.get(tenantId); // 2. 根据场景选择模型 ModelPolicy policy config.getPolicy(scene); String modelName policy.getModelName(); // 3. 检查是否为租户专属模型 if (policy.isDedicated()) { ModelInstance dedicated modelPools.get(tenantId : modelName); if (dedicated ! null dedicated.isHealthy()) { return dedicated; } // 专属模型不可用时降级到共享模型 log.warn(Dedicated model unavailable for tenant{}, falling back to shared, tenantId); } // 4. 从共享池获取实例 ModelInstancePool pool modelPools.get(modelName); if (pool null) { throw new ModelNotFoundException(Model not found: modelName); } return pool.acquire(tenantId, config.getPriority()); } private TenantModelConfig loadTenantConfig(String tenantId) { return tenantConfigRepository.findByTenantId(tenantId); } }2.3 跨模型Provider的统一适配不同模型厂商的API格式差异很大通过适配器模式统一接入public interface ModelProviderAdapter { /** 支持的模型列表 */ ListString supportedModels(); /** 将统一请求转换为厂商特定格式 */ Object convertRequest(AIRequest unifiedRequest); /** 将厂商响应转换为统一格式 */ AIResponse convertResponse(Object rawResponse); /** 调用模型 */ CompletableFutureAIResponse invoke(AIRequest request, int timeoutMs); } // OpenAI适配器 Component public class OpenAIAdapter implements ModelProviderAdapter { private final OpenAIClient client; Override public Object convertRequest(AIRequest unifiedRequest) { return ChatCompletionRequest.builder() .model(unifiedRequest.getModel()) .messages(convertMessages(unifiedRequest.getMessages())) .temperature(unifiedRequest.getTemperature()) .maxTokens(unifiedRequest.getMaxTokens()) .build(); } Override public CompletableFutureAIResponse invoke(AIRequest request, int timeoutMs) { ChatCompletionRequest openAIReq (ChatCompletionRequest) convertRequest(request); return client.createChatCompletion(openAIReq) .orTimeout(timeoutMs, TimeUnit.MILLISECONDS) .thenApply(this::convertResponse) .exceptionally(this::handleError); } }三、租户级别的Token配额与限流3.1 Token配额模型设计配额系统需要支持多种维度的控制Data Document(collection tenant_quota) public class TenantQuota { Id private String id; private String tenantId; /** 周期类型DAILY/WEEKLY/MONTHLY */ private QuotaPeriod period; /** Token上限 */ private Long tokenLimit; /** QPM限制每分钟请求数 */ private Integer qpmLimit; /** 并发请求数限制 */ private Integer concurrencyLimit; /** 已使用TokenRedis计数 */ Transient private Long usedTokens; /** 是否超额后允许继续使用会产生附加费 */ private Boolean overageAllowed; /** 超额倍数上限 */ private Double overageMultiplier; }3.2 基于滑动窗口的实时限流使用Redis的Sorted Set实现滑动窗口限流精确到毫秒级Component public class TokenRateLimiter { private final StringRedisTemplate redis; private static final String QUOTA_KEY_PREFIX ai:quota:; private static final String RATE_KEY_PREFIX ai:rate:; /** * 检查并扣减Token配额 * return true表示允许false表示已达上限 */ public boolean tryAcquire(String tenantId, int tokens) { String quotaKey QUOTA_KEY_PREFIX tenantId : today(); String rateKey RATE_KEY_PREFIX tenantId; // Lua脚本保证原子性 String script local quota_key KEYS[1] local rate_key KEYS[2] local tokens tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local qpm tonumber(ARGV[3]) local now tonumber(ARGV[4]) local window now - 60000 -- 1分钟窗口 -- 1. 检查日配额 local used redis.call(GET, quota_key) if used and tonumber(used) tokens limit then return {0, quota_exceeded, used} end -- 2. 清理过期记录并检查QPM redis.call(ZREMRANGEBYSCORE, rate_key, 0, window) local qpm_count redis.call(ZCARD, rate_key) if qpm_count qpm then return {0, qpm_exceeded, qpm_count} end -- 3. 扣减配额并记录请求 redis.call(INCRBY, quota_key, tokens) redis.call(EXPIRE, quota_key, 86400) redis.call(ZADD, rate_key, now, now .. : .. tokens) return {1, ok, redis.call(GET, quota_key)} ; TenantQuota quota getQuota(tenantId); ListLong result redis.execute( new DefaultRedisScript(script, List.class), List.of(quotaKey, rateKey), String.valueOf(tokens), String.valueOf(quota.getTokenLimit()), String.valueOf(quota.getQpmLimit()), String.valueOf(System.currentTimeMillis()) ); return result.get(0) 1L; } }四、租户数据的向量隔离方案4.1 Collection粒度 vs 命名空间隔离向量数据库以Milvus为例提供了两种隔离方式隔离方式原理优势劣势适用场景Collection粒度每个租户独立Collection物理隔离安全性最高租户数多时管理复杂大客户、高安全要求命名空间Partition Key共享Collection用partition_key隔离管理简单资源利用率高可能误操作跨租户中小客户、快速接入混合方案高价值客户独立Collection其他共享兼顾安全与成本代码复杂度增加分层客户策略4.2 向量存储的租户隔离实现Service public class TenantVectorStoreService { private final MilvusServiceClient milvusClient; private static final String SHARED_COLLECTION tenant_knowledge_base; private static final String DEDICATED_COLLECTION_PREFIX kb_dedicated_; /** * 插入向量数据自动路由到正确的存储空间 */ public void insert(String tenantId, ListDocument documents) { TenantConfig config getTenantConfig(tenantId); if (config.isVectorIsolationEnabled()) { // 专属Collection模式 String collectionName DEDICATED_COLLECTION_PREFIX tenantId; ensureCollection(collectionName); insertToCollection(collectionName, documents); } else { // 共享Collection 分区键隔离 ListInsertParam.Field fields buildFields(documents); // 关键将tenantId作为partition key写入 fields.add(new InsertParam.Field(tenant_id, documents.stream().map(d - tenantId).collect(Collectors.toList()))); milvusClient.insert(InsertParam.newBuilder() .withCollectionName(SHARED_COLLECTION) .withFields(fields) .build()); } } /** * 搜索时自动添加租户过滤防止数据泄露 */ public ListSearchResult search(String tenantId, ListFloat embedding, int topK) { TenantConfig config getTenantConfig(tenantId); String collectionName config.isVectorIsolationEnabled() ? DEDICATED_COLLECTION_PREFIX tenantId : SHARED_COLLECTION; SearchParam searchParam SearchParam.newBuilder() .withCollectionName(collectionName) .withVectorFieldName(embedding) .withVectors(List.of(embedding)) .withTopK(topK) .withExpr(tenant_id \ tenantId \) // 强制租户过滤 .withParams({\nprobe\: 16}) .build(); return milvusClient.search(searchParam) .getData().getResults(); } }4.3 租户自定义Prompt与微调隔离对于允许租户自定义Prompt模板和微调模型的场景需要做到Service public class TenantPromptService { private final MongoTemplate mongo; private final MinioClient minio; /** * 租户级别的Prompt模板管理 * 每个租户的Prompt存储在独立MongoDB Collection或带tenant_id索引的文档中 */ public PromptTemplate getTemplate(String tenantId, String templateName) { Query query new Query(Criteria .where(tenantId).is(tenantId) .and(name).is(templateName) .and(status).is(ACTIVE)); // 租户ID强制作为查询条件防止越权访问 return mongo.findOne(query, PromptTemplate.class, prompt_templates); } /** * 微调模型文件的物理隔离 * 存储路径/models/{tenantId}/{modelName}/checkpoint-{step}/ */ public String getModelStoragePath(String tenantId, String modelName) { return String.format(models/%s/%s/, tenantId, modelName); } /** * 加载微调模型时的租户隔离校验 */ public FineTunedModel loadModel(String tenantId, String modelId) { FineTunedModel model modelRepository.findById(modelId); // 关键断言模型必须属于当前租户 if (!model.getTenantId().equals(tenantId)) { throw new AccessDeniedException( Model modelId does not belong to tenant tenantId); } return model; } }五、总结多租户SaaS的AI能力集成本质是在资源效率与数据隔离之间做平衡。回顾整个架构模型路由混合模式共享专属是性价比最优解适配器模式解决多Provider接入的统一性问题。配额限流Redis滑动窗口实现毫秒级精确控制Lua脚本保证原子性支持TokenQPM并发三维限流。向量隔离高价值租户独立Collection物理隔离普通租户共享CollectionPartition Key逻辑隔离分层策略兼顾安全与成本。Prompt与模型隔离存储层面通过路径前缀和索引隔离查询层面强制追加tenant_id条件代码层面进行断言校验形成三层防护。这套架构在支撑日均千万级AI调用量的同时成功通过了SOC2安全审计验证了方案的可行性和安全性。关键在于不要试图用一种方案覆盖所有租户分层策略才是多租户架构的第一性原理。