云计算百科
云计算领域专业知识百科平台

SaaS多租户的AI能力集成:从模型路由到数据隔离的完整架构复盘

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 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);
    }
    }

    2.3 跨模型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配额与限流

    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;

    /** 已使用Token(Redis计数) */
    @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);
    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;
    }
    }

    四、租户数据的向量隔离方案

    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, 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();
    }
    }

    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脚本保证原子性,支持Token+QPM+并发三维限流。
  • 向量隔离:高价值租户独立Collection(物理隔离),普通租户共享Collection+Partition Key(逻辑隔离),分层策略兼顾安全与成本。
  • Prompt与模型隔离:存储层面通过路径前缀和索引隔离,查询层面强制追加tenant_id条件,代码层面进行断言校验,形成三层防护。
  • 这套架构在支撑日均千万级AI调用量的同时,成功通过了SOC2安全审计,验证了方案的可行性和安全性。关键在于:不要试图用一种方案覆盖所有租户,分层策略才是多租户架构的第一性原理。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » SaaS多租户的AI能力集成:从模型路由到数据隔离的完整架构复盘
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!