第2320篇:企业AI平台的多租户架构——资源隔离、计量计费与权限管理
2026/4/30大约 6 分钟
第2320篇:企业AI平台的多租户架构——资源隔离、计量计费与权限管理
适读人群:AI平台架构师、SaaS产品技术负责人 | 阅读时长:约20分钟 | 核心价值:系统掌握企业AI平台多租户架构的核心设计,解决资源隔离、公平调度和精确计费三大工程挑战
我们把内部的AI平台开放给集团下属的十几个业务子公司使用时,遇到了一个很经典的问题:某个子公司在月末赶方案,发起了大量LLM并发请求,把整个平台的GPU资源占满了,导致其他子公司的请求超时。
更麻烦的是,当我们去算各个子公司的使用成本时,发现没有精确的数据——所有的调用都混在一起,无法准确追溯。
这两个问题——资源隔离和精确计费——是企业AI平台多租户架构的核心挑战。
多租户AI平台的三层隔离
多租户AI平台需要在三个层面实现隔离:
数据隔离:每个租户的知识库、对话记录、文档数据不能互相访问。
计算隔离:租户A的高并发不能影响租户B的正常使用,需要资源配额和公平调度。
权限隔离:不同租户可以访问的模型、功能、数据量不同,需要细粒度的权限控制。
租户配置模型
/**
* 租户配置:定义租户的资源配额和权限
*/
@Entity
@Table(name = "tenant_configs")
public class TenantConfig {
@Id
private String tenantId;
private String tenantName;
@Embedded
private ResourceQuota resourceQuota; // 资源配额
@Embedded
private ModelPermissions modelPermissions; // 模型权限
@Embedded
private BillingConfig billingConfig; // 计费配置
private TenantStatus status;
public enum TenantStatus {
ACTIVE, SUSPENDED, TRIAL
}
}
/**
* 资源配额:控制租户的资源使用上限
*/
@Embeddable
public class ResourceQuota {
private int maxConcurrentRequests; // 最大并发请求数
private long maxMonthlyTokens; // 月Token上限
private long maxDailyTokens; // 日Token上限(防爆)
private int maxContextWindowTokens; // 单次请求最大上下文
private int maxKnowledgeBaseDocs; // 知识库文档数上限
private long maxVectorStorageGB; // 向量存储上限(GB)
private int requestsPerMinute; // 每分钟请求数(限流)
/**
* 检查请求的token数是否超出当次限制
*/
public boolean isWithinContextLimit(int tokenCount) {
return tokenCount <= maxContextWindowTokens;
}
}
/**
* 模型权限:控制租户可以使用哪些模型
*/
@Embeddable
public class ModelPermissions {
@ElementCollection
private Set<String> allowedModels; // e.g., {"gpt-4o", "gpt-3.5-turbo"}
@ElementCollection
private Set<String> allowedFeatures; // e.g., {"RAG", "AGENT", "FINE_TUNING"}
private boolean canUseStreamingApi;
private boolean canCustomizeSystemPrompt;
public boolean isModelAllowed(String modelName) {
return allowedModels.contains(modelName);
}
}基于租户的资源调度器
@Service
public class TenantAwareRequestScheduler {
private final TenantConfigRepository tenantConfigRepo;
private final TokenBucketRateLimiter rateLimiter;
private final ConcurrentRequestTracker concurrentTracker;
private final MonthlyUsageTracker usageTracker;
/**
* 在处理请求前,检查资源配额是否允许
*/
public ScheduleDecision scheduleRequest(LLMRequest request, String tenantId) {
TenantConfig config = tenantConfigRepo.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
// 1. 检查租户状态
if (config.getStatus() == TenantStatus.SUSPENDED) {
return ScheduleDecision.rejected("租户已被暂停");
}
// 2. 检查模型权限
if (!config.getModelPermissions().isModelAllowed(request.model())) {
return ScheduleDecision.rejected("租户无权使用模型:" + request.model());
}
// 3. 限流检查(每分钟请求数)
if (!rateLimiter.tryAcquire(tenantId, config.getResourceQuota().getRequestsPerMinute())) {
return ScheduleDecision.rateLimited("请求频率超限,请稍后重试");
}
// 4. 并发请求数检查
int currentConcurrent = concurrentTracker.getConcurrentCount(tenantId);
if (currentConcurrent >= config.getResourceQuota().getMaxConcurrentRequests()) {
return ScheduleDecision.queued("并发请求数达到上限,已加入队列");
}
// 5. 月/日用量检查
TokenUsageSummary usage = usageTracker.getUsage(tenantId);
if (usage.monthlyTokens() >= config.getResourceQuota().getMaxMonthlyTokens()) {
return ScheduleDecision.rejected("月Token用量已达上限");
}
if (usage.dailyTokens() >= config.getResourceQuota().getMaxDailyTokens()) {
return ScheduleDecision.rejected("日Token用量已达上限");
}
// 6. 上下文大小检查
int estimatedTokens = estimateTokens(request);
if (estimatedTokens > config.getResourceQuota().getMaxContextWindowTokens()) {
return ScheduleDecision.rejected("请求上下文超过限制");
}
// 通过所有检查,允许执行
concurrentTracker.increment(tenantId);
return ScheduleDecision.allowed();
}
/**
* 请求完成后,释放并发计数并记录用量
*/
public void onRequestCompleted(String tenantId, TokenUsage actualUsage) {
concurrentTracker.decrement(tenantId);
usageTracker.record(tenantId, actualUsage);
}
}精确计量计费系统
/**
* Token使用记录(计费的最小单元)
*/
@Entity
@Table(name = "token_usage_records",
indexes = {
@Index(name = "idx_tenant_time", columnList = "tenant_id, created_at"),
@Index(name = "idx_billing_period", columnList = "tenant_id, billing_period")
}
)
public class TokenUsageRecord {
@Id
@GeneratedValue
private Long id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
@Column(name = "request_id", nullable = false)
private String requestId;
@Column(name = "model_name", nullable = false)
private String modelName;
@Column(name = "feature_type")
private String featureType; // "CHAT", "RAG", "AGENT", "EMBEDDING"
@Column(name = "prompt_tokens")
private int promptTokens;
@Column(name = "completion_tokens")
private int completionTokens;
@Column(name = "total_tokens")
private int totalTokens;
@Column(name = "cost_in_cents") // 以分为单位,避免浮点精度问题
private long costInCents;
@Column(name = "billing_period") // 格式:2024-01(月度计费周期)
private String billingPeriod;
@Column(name = "created_at")
private Instant createdAt;
@Column(name = "user_id") // 租户内的具体用户(支持子用户计费)
private String userId;
}
/**
* 计费服务:计算成本和生成账单
*/
@Service
public class BillingService {
private final TokenUsageRecordRepository usageRepo;
private final ModelPricingConfig pricingConfig;
/**
* 计算一次LLM调用的成本
*/
public long calculateCostInCents(String modelName, int promptTokens, int completionTokens) {
ModelPricing pricing = pricingConfig.getPricing(modelName);
// 按千token计费,精确到分(避免浮点数问题用long运算)
long promptCost = (long) promptTokens * pricing.promptCostPerThousandTokenInCents() / 1000;
long completionCost = (long) completionTokens * pricing.completionCostPerThousandTokenInCents() / 1000;
return promptCost + completionCost;
}
/**
* 生成月度账单
*/
public MonthlyBill generateMonthlyBill(String tenantId, String billingPeriod) {
List<TokenUsageRecord> records = usageRepo.findByTenantIdAndBillingPeriod(
tenantId, billingPeriod
);
// 按模型和功能类型分组汇总
Map<String, BillingLineItem> lineItems = records.stream()
.collect(Collectors.groupingBy(
r -> r.getModelName() + "_" + r.getFeatureType(),
Collectors.collectingAndThen(
Collectors.toList(),
items -> new BillingLineItem(
items.get(0).getModelName(),
items.get(0).getFeatureType(),
items.stream().mapToInt(TokenUsageRecord::getTotalTokens).sum(),
items.stream().mapToLong(TokenUsageRecord::getCostInCents).sum(),
items.size()
)
)
));
long totalCostInCents = lineItems.values().stream()
.mapToLong(BillingLineItem::costInCents)
.sum();
return new MonthlyBill(
tenantId, billingPeriod,
new ArrayList<>(lineItems.values()),
totalCostInCents,
generateBillBreakdownReport(lineItems)
);
}
/**
* 租户用量预警:当月已使用超过80%时发出预警
*/
@Scheduled(cron = "0 0 * * * *") // 每小时检查
public void checkUsageAlerts() {
List<TenantConfig> activeTenants = tenantConfigRepo.findByStatus(TenantStatus.ACTIVE);
for (TenantConfig tenant : activeTenants) {
TokenUsageSummary summary = usageTracker.getUsage(tenant.getTenantId());
long monthlyLimit = tenant.getResourceQuota().getMaxMonthlyTokens();
double usagePercent = (double) summary.monthlyTokens() / monthlyLimit;
if (usagePercent >= 0.9) {
alertService.sendUsageAlert(tenant.getTenantId(), usagePercent,
"月度Token用量已达" + (int)(usagePercent * 100) + "%");
}
}
}
}数据隔离:向量库的多租户设计
@Service
public class TenantIsolatedVectorStore {
private final VectorStoreFactory vectorStoreFactory;
/**
* 获取租户专属的向量索引命名空间
* 在Pinecone中使用namespace,在Weaviate中使用class前缀
*/
private String getTenantNamespace(String tenantId) {
// 格式:tenant_{tenantId}_knowledge
return "tenant_" + tenantId.replace("-", "_") + "_knowledge";
}
public List<ScoredDocument> search(String tenantId, String query, int topK) {
String namespace = getTenantNamespace(tenantId);
// 确保搜索限制在租户的命名空间内
return vectorStoreFactory.getStore()
.search(query, topK, SearchFilter.namespace(namespace));
}
public void upsert(String tenantId, VectorDocument document) {
String namespace = getTenantNamespace(tenantId);
// 所有文档强制带上租户命名空间,防止跨租户污染
document.setNamespace(namespace);
document.getMetadata().put("tenant_id", tenantId); // 双重保障
vectorStoreFactory.getStore().upsert(document);
}
}管理控制台:租户使用监控
@RestController
@RequestMapping("/admin/tenants")
@RequiresRole("PLATFORM_ADMIN")
public class TenantManagementController {
private final BillingService billingService;
private final TenantConfigRepository tenantConfigRepo;
private final UsageTracker usageTracker;
@GetMapping("/{tenantId}/usage-dashboard")
public TenantUsageDashboard getDashboard(@PathVariable String tenantId) {
TenantConfig config = tenantConfigRepo.findById(tenantId)
.orElseThrow(() -> new TenantNotFoundException(tenantId));
String currentPeriod = getCurrentBillingPeriod();
MonthlyBill currentBill = billingService.generateMonthlyBill(tenantId, currentPeriod);
TokenUsageSummary summary = usageTracker.getUsage(tenantId);
return TenantUsageDashboard.builder()
.tenantId(tenantId)
.tenantName(config.getTenantName())
.currentPeriodCost(currentBill.totalCostInCents())
.monthlyTokenUsage(summary.monthlyTokens())
.monthlyTokenLimit(config.getResourceQuota().getMaxMonthlyTokens())
.usagePercent(calcUsagePercent(summary, config))
.topModels(currentBill.getTopModelsByUsage(3))
.dailyUsageTrend(usageTracker.getDailyTrend(tenantId, 30))
.build();
}
@PostMapping("/{tenantId}/quota")
public ResponseEntity<Void> updateQuota(@PathVariable String tenantId,
@RequestBody UpdateQuotaRequest request) {
tenantConfigRepo.updateQuota(tenantId, request.toResourceQuota());
log.info("管理员更新租户{}配额: {}", tenantId, request);
return ResponseEntity.ok().build();
}
}我们把这套多租户架构推出来之后,每个子公司都能看到自己的用量和费用,月底再也不会有人说"我用了多少?怎么这么贵?"这类争议了。配额控制也解决了资源抢占问题——一个子公司的高峰请求会被队列化,不再影响其他子公司的正常使用。
