SaaS多租户AI集成:从模型路由到数据隔离的架构实践
多租户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能力集成,本质是在资源效率与数据隔离之间做平衡。回顾整个架构:
- 模型路由:混合模式(共享+专属)是性价比最优解,适配器模式解决多Provider接入的统一性问题。
- 配额限流:Redis滑动窗口实现毫秒级精确控制,Lua脚本保证原子性,支持Token+QPM+并发三维限流。
- 向量隔离:高价值租户独立Collection(物理隔离),普通租户共享Collection+Partition Key(逻辑隔离),分层策略兼顾安全与成本。
- Prompt与模型隔离:存储层面通过路径前缀和索引隔离,查询层面强制追加tenant_id条件,代码层面进行断言校验,形成三层防护。
这套架构在支撑日均千万级AI调用量的同时,成功通过了SOC2安全审计,验证了方案的可行性和安全性。关键在于:不要试图用一种方案覆盖所有租户,分层策略才是多租户架构的第一性原理。