SaaS多租户的AI能力集成:从模型路由到数据隔离的完整架构复盘
在多租户SaaS平台上集成大模型能力,远不止"调一个API"那么简单。模型如何按租户路由?Token配额如何精确控制?向量数据如何做到租户隔离?本文基于过去半年在一家B2B SaaS平台的实战,复盘整个AI集成架构的设计决策与技术细节。
一、多租户AI集成的核心挑战
在SaaS场景下将大模型能力开放给各租户使用时,面临以下核心问题:
下面逐一展开每个问题的解决方案。
二、模型路由层的架构设计
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能力集成,本质是在资源效率与数据隔离之间做平衡。回顾整个架构:
这套架构在支撑日均千万级AI调用量的同时,成功通过了SOC2安全审计,验证了方案的可行性和安全性。关键在于:不要试图用一种方案覆盖所有租户,分层策略才是多租户架构的第一性原理。
网硕互联帮助中心

评论前必须登录!
注册