SaaS多租户AI集成:从模型路由到数据隔离的架构实践

4 阅读

多租户AI集成的核心挑战

在SaaS场景下,将大模型能力开放给各租户使用时,远不止“调一个API”那么简单。我们面临的核心问题包括:

  • 模型选择多样性:不同租户可能需要不同的模型(如GPT-4o、Claude-3.5、Qwen-Max),甚至同一租户的不同业务场景也需要不同模型。
  • 资源隔离与公平调度:GPU资源稀缺,如何在多租户间公平分配,避免某个租户的突发流量影响其他租户?
  • 数据安全边界:租户的Prompt模板、微调模型、向量知识库等数据资产必须严格隔离,防止越权访问。
  • 成本归属清晰:每个租户消耗的Token必须精确计量,支持多维度计费。

下面逐一展开每个问题的解决方案。

模型路由层的架构设计

模型共享 vs 独占的架构选择

在多租户AI平台中,模型实例的管理方式决定了资源利用率和隔离性。主要有三种模式:

  • 共享模型实例:所有租户共用同一模型实例,资源利用率高,但隔离性差,可能出现“吵闹邻居”问题。
  • 租户专属模型实例:每个租户独立部署模型,隔离性最好,但成本高,资源利用率低。
  • 混合模式:共享基础模型实例覆盖80%的通用场景,租户专属的微调模型仅服务于高价值客户的核心业务场景。

实际落地中,混合模式是性价比最高的方案。我们通过模型路由层动态决定请求发往哪个实例,并支持降级策略。

模型路由的核心实现

路由层需要解决“哪个请求发给哪个模型”的问题。核心代码如下:

@Service
public class ModelRouterService {
    
    private final LoadingCache<String, TenantModelConfig> configCache;
    private final Map<String, 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);
    }
}

跨模型Provider的统一适配

不同模型厂商的API格式差异很大,通过适配器模式统一接入:

public interface ModelProviderAdapter {
    
    /** 支持的模型列表 */
    List<String> supportedModels();
    
    /** 将统一请求转换为厂商特定格式 */
    Object convertRequest(AIRequest unifiedRequest);
    
    /** 将厂商响应转换为统一格式 */
    AIResponse convertResponse(Object rawResponse);
    
    /** 调用模型 */
    CompletableFuture<AIResponse> 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 CompletableFuture<AIResponse> 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配额与限流

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;
    
    /** 已使用Token(Redis计数) */
    @Transient
    private Long usedTokens;
    
    /** 是否超额后允许继续使用(会产生附加费) */
    private Boolean overageAllowed;
    
    /** 超额倍数上限 */
    private Double overageMultiplier;
}

基于滑动窗口的实时限流

使用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);
        List<Long> 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;
    }
}

租户数据的向量隔离方案

Collection粒度 vs 命名空间隔离

向量数据库(以Milvus为例)提供了两种隔离方式:

隔离方式 原理 优势 劣势 适用场景
Collection粒度 每个租户独立Collection 物理隔离,安全性最高 租户数多时管理复杂 大客户、高安全要求
命名空间(Partition Key) 共享Collection,用partition_key隔离 管理简单,资源利用率高 可能误操作跨租户 中小客户、快速接入
混合方案 高价值客户独立Collection,其他共享 兼顾安全与成本 代码复杂度增加 分层客户策略

向量存储的租户隔离实现

@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, List<Document> documents) {
        TenantConfig config = getTenantConfig(tenantId);
        
        if (config.isVectorIsolationEnabled()) {
            // 专属Collection模式
            String collectionName = DEDICATED_COLLECTION_PREFIX + tenantId;
            ensureCollection(collectionName);
            insertToCollection(collectionName, documents);
        } else {
            // 共享Collection + 分区键隔离
            List<InsertParam.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 List<SearchResult> search(String tenantId, List<Float> 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();
    }
}

租户自定义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能力集成,本质是在资源效率数据隔离之间做平衡。回顾整个架构:

  1. 模型路由:混合模式(共享+专属)是性价比最优解,适配器模式解决多Provider接入的统一性问题。
  2. 配额限流:Redis滑动窗口实现毫秒级精确控制,Lua脚本保证原子性,支持Token+QPM+并发三维限流。
  3. 向量隔离:高价值租户独立Collection(物理隔离),普通租户共享Collection+Partition Key(逻辑隔离),分层策略兼顾安全与成本。
  4. Prompt与模型隔离:存储层面通过路径前缀和索引隔离,查询层面强制追加tenant_id条件,代码层面进行断言校验,形成三层防护。

这套架构在支撑日均千万级AI调用量的同时,成功通过了SOC2安全审计,验证了方案的可行性和安全性。关键在于:不要试图用一种方案覆盖所有租户,分层策略才是多租户架构的第一性原理