Spring AI多租户:企业级多租户隔离方案设计实战
2026/4/30大约 6 分钟
Spring AI多租户:企业级多租户隔离方案设计实战
适读人群:有1-5年Java开发经验,想向AI工程师方向转型的开发者 阅读时长:约18分钟 文章价值:① 掌握AI应用多租户隔离的核心设计要点 ② 学会在Spring AI中实现租户级别的模型配置和数据隔离 ③ 获得一套可直接落地的企业级多租户AI框架
去年有个做企业SaaS的朋友找到我,他们在做一个给不同企业客户用的AI助手平台。
"老张,我有个头疼的问题。我有三个客户:A公司要用GPT-4o,B公司说只能用国内模型(数据不能出境),C公司自己部署了私有化模型。同时,A公司和B公司的知识库完全不能混,员工数据也不能互相看到。"
"这是多租户AI架构问题。"我说,"本质上是三层隔离:模型隔离、数据隔离、配置隔离。"
他问能不能一套代码搞定,还是要给每个客户单独部署一套。
"一套代码可以搞定,但设计要从一开始就考虑进去,后期改造代价很大。"
这篇文章就是我给他设计的那套方案。
多租户AI的三层隔离需求
| 隔离层次 | 需要隔离的内容 | 实现方式 |
|---|---|---|
| 模型隔离 | 不同租户用不同LLM | 租户级ChatClient配置 |
| 数据隔离 | 知识库/向量数据互不可见 | 向量库按租户分命名空间 |
| 配置隔离 | Temperature/MaxTokens/SystemPrompt | 租户配置表 |
| 费用隔离 | 各租户独立计量/限额 | Token用量记录+限流 |
| 安全隔离 | 一个租户不能访问另一个的数据 | 强制注入tenantId过滤 |
核心设计:TenantContext + 动态路由
代码实现
第一步:租户上下文(ThreadLocal)
/**
* 租户上下文:通过ThreadLocal在整个请求生命周期内传递tenantId
* 这样Service层不需要到处传tenantId参数
*/
public class TenantContext {
private static final ThreadLocal<TenantInfo> TENANT_THREAD_LOCAL = new InheritableThreadLocal<>();
public static void setTenant(TenantInfo tenantInfo) {
TENANT_THREAD_LOCAL.set(tenantInfo);
}
public static TenantInfo getCurrentTenant() {
TenantInfo tenant = TENANT_THREAD_LOCAL.get();
if (tenant == null) {
throw new TenantContextMissingException("租户上下文未设置,请检查过滤器配置");
}
return tenant;
}
public static String getCurrentTenantId() {
return getCurrentTenant().getTenantId();
}
public static void clear() {
TENANT_THREAD_LOCAL.remove();
}
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TenantInfo {
private String tenantId;
private String tenantName;
private String modelProvider; // OPENAI / TONGYI / LOCAL
private String vectorNamespace; // 向量库命名空间
private Map<String, Object> aiConfig; // temperature/maxTokens等
private Integer monthlyTokenQuota; // 月度token限额,null=无限制
}第二步:租户识别过滤器
@Component
@RequiredArgsConstructor
@Slf4j
@Order(1) // 最先执行
public class TenantIdentificationFilter implements OncePerRequestFilter {
private final TenantConfigService tenantConfigService;
private final JwtTokenParser jwtParser;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
// 1. 从JWT Token中提取tenantId
String token = extractToken(request);
if (token == null) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "未提供认证Token");
return;
}
String tenantId = jwtParser.extractTenantId(token);
// 2. 查询租户配置(带缓存)
TenantInfo tenantInfo = tenantConfigService.getTenantInfo(tenantId);
// 3. 存入TenantContext
TenantContext.setTenant(tenantInfo);
log.debug("租户识别成功,tenantId={}, provider={}",
tenantId, tenantInfo.getModelProvider());
chain.doFilter(request, response);
} finally {
// 重要:请求结束必须清除,防止ThreadLocal泄漏
TenantContext.clear();
}
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}第三步:多租户ChatClient路由器
@Service
@RequiredArgsConstructor
@Slf4j
public class TenantAwareChatRouter {
// 预配置的多个ChatClient,对应不同模型提供商
@Qualifier("openAiChatClient")
private final ChatClient openAiClient;
@Qualifier("tongYiChatClient")
private final ChatClient tongYiClient;
@Qualifier("localLlamaChatClient")
private final ChatClient localLlamaClient;
private final TenantConfigService configService;
private final TokenUsageTracker usageTracker;
/**
* 核心路由方法:根据当前租户自动选择对应模型
*/
public String chat(String userMessage) {
TenantInfo tenant = TenantContext.getCurrentTenant();
// 1. 检查Token配额
checkTokenQuota(tenant);
// 2. 获取租户ChatClient
ChatClient client = resolveClient(tenant.getModelProvider());
// 3. 构建包含租户配置的Prompt
String result = buildTenantPrompt(client, tenant, userMessage)
.call()
.content();
// 4. 记录Token用量
usageTracker.trackUsage(tenant.getTenantId(), estimateTokens(userMessage + result));
return result;
}
private ChatClient resolveClient(String modelProvider) {
return switch (modelProvider) {
case "OPENAI" -> openAiClient;
case "TONGYI" -> tongYiClient;
case "LOCAL" -> localLlamaClient;
default -> throw new UnsupportedModelProviderException("不支持的模型提供商: " + modelProvider);
};
}
private ChatClient.ChatClientRequestSpec buildTenantPrompt(
ChatClient client, TenantInfo tenant, String userMessage) {
// 从租户配置中获取AI参数
String systemPrompt = (String) tenant.getAiConfig()
.getOrDefault("systemPrompt", "你是一个专业的企业AI助手。");
Double temperature = (Double) tenant.getAiConfig().getOrDefault("temperature", 0.7);
Integer maxTokens = (Integer) tenant.getAiConfig().getOrDefault("maxTokens", 2048);
return client.prompt()
.system(systemPrompt)
.user(userMessage)
.options(OpenAiChatOptions.builder()
.temperature(temperature)
.maxTokens(maxTokens)
.build());
}
private void checkTokenQuota(TenantInfo tenant) {
if (tenant.getMonthlyTokenQuota() == null) return; // 无限制
long usedThisMonth = usageTracker.getMonthlyUsage(tenant.getTenantId());
if (usedThisMonth >= tenant.getMonthlyTokenQuota()) {
throw new TokenQuotaExceededException(
"租户" + tenant.getTenantId() + "的月度Token额度已用完");
}
}
}第四步:向量数据隔离
@Service
@RequiredArgsConstructor
@Slf4j
public class TenantVectorStoreService {
private final VectorStore vectorStore;
/**
* 向量检索:强制注入租户命名空间过滤条件
* 租户只能看到自己的数据
*/
public List<Document> similaritySearch(String query) {
String tenantId = TenantContext.getCurrentTenantId();
// 强制过滤:只检索当前租户的文档
SearchRequest searchRequest = SearchRequest.query(query)
.withTopK(5)
.withFilterExpression(
new FilterExpressionBuilder()
.eq("tenantId", tenantId) // 强制租户过滤
.build()
);
List<Document> docs = vectorStore.similaritySearch(searchRequest);
log.debug("租户{}向量检索,query={}, 命中={}条", tenantId, query, docs.size());
return docs;
}
/**
* 文档写入:自动附加租户标签
*/
public void addDocuments(List<Document> documents) {
String tenantId = TenantContext.getCurrentTenantId();
// 强制给每个文档添加tenantId元数据
List<Document> taggedDocuments = documents.stream()
.map(doc -> new Document(
doc.getId(),
doc.getContent(),
addTenantTag(doc.getMetadata(), tenantId)
))
.collect(Collectors.toList());
vectorStore.add(taggedDocuments);
log.info("租户{}新增文档{}条", tenantId, documents.size());
}
/**
* 删除租户所有文档(租户注销时用)
*/
public void deleteAllTenantDocuments() {
String tenantId = TenantContext.getCurrentTenantId();
vectorStore.delete(List.of(tenantId)); // 按tenantId批量删除
log.info("已删除租户{}的所有向量数据", tenantId);
}
private Map<String, Object> addTenantTag(Map<String, Object> metadata, String tenantId) {
Map<String, Object> tagged = new HashMap<>(metadata);
tagged.put("tenantId", tenantId);
return tagged;
}
}第五步:Token用量追踪和限额
@Service
@RequiredArgsConstructor
@Slf4j
public class TokenUsageTracker {
private final StringRedisTemplate redisTemplate;
private final TokenUsageRepository usageRepository;
/**
* 记录Token用量
*/
public void trackUsage(String tenantId, int tokens) {
String monthKey = getMonthKey(tenantId);
// Redis原子累加(高性能)
Long total = redisTemplate.opsForValue().increment(monthKey, tokens);
redisTemplate.expire(monthKey, Duration.ofDays(35)); // 多保留5天,防止月底边界问题
// 异步写入数据库(持久化)
CompletableFuture.runAsync(() ->
usageRepository.save(TokenUsageRecord.builder()
.tenantId(tenantId)
.tokens(tokens)
.recordedAt(LocalDateTime.now())
.build())
);
log.debug("租户{}本月已用{}tokens", tenantId, total);
}
public long getMonthlyUsage(String tenantId) {
String monthKey = getMonthKey(tenantId);
String value = redisTemplate.opsForValue().get(monthKey);
return value != null ? Long.parseLong(value) : 0L;
}
private String getMonthKey(String tenantId) {
YearMonth month = YearMonth.now();
return "token:usage:" + tenantId + ":" + month.getYear() + "-" + month.getMonthValue();
}
}一个真实踩坑:线程池异步调用下的TenantContext丢失
这是实际项目中最容易踩的坑。当你用@Async或CompletableFuture的时候,子线程拿不到父线程的ThreadLocal:
// 错误示例:子线程中TenantContext是空的
@Async
public void asyncProcess() {
String tenantId = TenantContext.getCurrentTenantId(); // 会抛异常!
}
// 正确做法:在提交异步任务时显式传递TenantInfo
@Service
public class AsyncAiService {
private final Executor aiExecutor;
public CompletableFuture<String> processAsync(String prompt) {
// 在父线程中捕获TenantInfo
TenantInfo tenantInfo = TenantContext.getCurrentTenant();
return CompletableFuture.supplyAsync(() -> {
try {
// 在子线程中恢复TenantContext
TenantContext.setTenant(tenantInfo);
return doProcess(prompt);
} finally {
TenantContext.clear(); // 子线程用完必须清理!
}
}, aiExecutor);
}
}多租户的坑,通常不在业务逻辑里,在这些横切关注点上。把这个问题想清楚了,多租户AI系统就稳了。
