第1779篇:多租户AI平台的成本分摊——按部门/项目追踪和控制AI支出
第1779篇:多租户AI平台的成本分摊——按部门/项目追踪和控制AI支出
给一家中等规模公司做AI平台架构评审时,我问了一个问题:你们每个业务部门用AI各花了多少钱?
负责AI基础设施的工程师沉默了几秒,然后说:"好像没有按部门统计过……"
这个情况很普遍。公司搭了统一的AI平台,所有部门共用,月底账单一来,就知道总共花了多少,但说不清哪个部门用了多少、哪个项目的AI ROI最高。
没有分摊体系,就没有成本意识,部门随意使用AI,成本自然失控。
这篇文章,我来聊多租户AI平台的成本分摊设计,从技术架构到报表,把整个体系讲清楚。
为什么成本分摊这么重要
一个有意思的心理现象:当资源是"公共的"时,个体倾向于过度消耗;当资源是"自己掏钱"时,会自然控制用量。
不做分摊的后果:
- 某个部门狂用AI做实验,但成本均摊给了所有部门,不公平
- 没人有动力优化AI用量,因为省下来也不是自己的
- 无法评估哪些AI项目值得投入,哪些应该停掉
- 年底做预算,不知道明年该给各部门分多少AI预算
做了分摊:
- 每个部门清楚自己用了多少,自然产生节约意识
- 可以按ROI决定AI资源分配
- 成本异常时能快速定位到哪个部门/项目
- 支撑对业务部门的内部收费(AI平台变成内部"云服务")
多租户成本分摊的分层架构
我建议用三层租户结构,覆盖大部分企业场景:
这样的层级结构支持:
- 公司层面看总体AI支出
- 部门层面看自己的支出,进行内部管控
- 项目层面精确追踪单个AI项目的成本
- 用户层面追踪个人使用情况(如果需要)
租户标识与API Key管理
多租户分摊的基础是:每次AI调用都能关联到具体的租户层级。
最直接的方法是按租户分配独立的API Key,通过Key来归因成本。
@Entity
@Table(name = "tenant_api_key")
@Data
public class TenantApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String keyId; // 内部Key ID
private String keyValue; // 实际API Key(加密存储)
private String tenantType; // COMPANY / DEPARTMENT / PROJECT
private String tenantId; // 对应的租户ID
private String tenantName; // 显示名称
private String parentTenantId; // 父级租户ID
private String model; // 限制使用的模型(null表示不限)
private Integer monthlyBudgetCny; // 月预算(元)
private String status; // ACTIVE / SUSPENDED
private LocalDateTime createdAt;
private LocalDateTime expiredAt;
}调用鉴权与租户识别
@Component
@Slf4j
public class TenantAuthInterceptor implements HandlerInterceptor {
@Autowired
private TenantApiKeyRepository keyRepository;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String apiKey = extractApiKey(request);
if (apiKey == null) {
response.setStatus(401);
return false;
}
TenantApiKey tenantKey = keyRepository.findByKeyValue(apiKey);
if (tenantKey == null || "SUSPENDED".equals(tenantKey.getStatus())) {
response.setStatus(401);
return false;
}
// 检查Key是否过期
if (tenantKey.getExpiredAt() != null &&
LocalDateTime.now().isAfter(tenantKey.getExpiredAt())) {
response.setStatus(401);
writeError(response, "API Key已过期");
return false;
}
// 将租户信息存入请求上下文
TenantContext context = TenantContext.builder()
.tenantId(tenantKey.getTenantId())
.tenantName(tenantKey.getTenantName())
.tenantType(tenantKey.getTenantType())
.parentTenantId(tenantKey.getParentTenantId())
.allowedModel(tenantKey.getModel())
.monthlyBudgetCny(tenantKey.getMonthlyBudgetCny())
.build();
TenantContextHolder.set(context);
return true;
}
private String extractApiKey(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return request.getHeader("X-API-Key");
}
}成本分摊的聚合计算
每次调用的成本都记录了租户ID,接下来是如何聚合计算各层级的分摊成本。
@Service
public class CostAllocationService {
@Autowired
private AIUsageRepository usageRepository;
/**
* 生成部门维度的月度成本分摊报告
*/
public List<DepartmentCostReport> generateDepartmentReport(int year, int month) {
// 查询当月所有部门的用量
List<DepartmentUsageAggregation> aggregations =
usageRepository.aggregateByDepartment(year, month);
return aggregations.stream().map(agg -> {
// 计算与上月的对比
BigDecimal lastMonthCost = getLastMonthCost(agg.getDepartmentId(), year, month);
BigDecimal momChange = calculateMomChange(agg.getTotalCostCny(), lastMonthCost);
// 计算占公司总成本的比例
BigDecimal companyTotalCost = getCompanyTotalCost(year, month);
BigDecimal sharePercent = companyTotalCost.compareTo(BigDecimal.ZERO) > 0
? agg.getTotalCostCny().divide(companyTotalCost, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal(100))
: BigDecimal.ZERO;
return DepartmentCostReport.builder()
.departmentId(agg.getDepartmentId())
.departmentName(agg.getDepartmentName())
.totalCostCny(agg.getTotalCostCny())
.totalTokens(agg.getTotalTokens())
.callCount(agg.getCallCount())
.activeUserCount(agg.getActiveUserCount())
.costPerCallCny(agg.getCallCount() > 0
? agg.getTotalCostCny().divide(new BigDecimal(agg.getCallCount()), 4, RoundingMode.HALF_UP)
: BigDecimal.ZERO)
.momChangePercent(momChange)
.shareOfCompanyPercent(sharePercent)
.projectBreakdown(getProjectBreakdown(agg.getDepartmentId(), year, month))
.build();
}).collect(Collectors.toList());
}
/**
* 获取项目维度的成本分解
*/
private List<ProjectCostSummary> getProjectBreakdown(
String departmentId, int year, int month) {
return usageRepository.aggregateByProject(departmentId, year, month)
.stream()
.map(p -> ProjectCostSummary.builder()
.projectId(p.getProjectId())
.projectName(p.getProjectName())
.costCny(p.getTotalCostCny())
.callCount(p.getCallCount())
.topFeatures(getTopFeatures(p.getProjectId(), year, month, 3))
.build()
).collect(Collectors.toList());
}
/**
* 计算环比变化
*/
private BigDecimal calculateMomChange(BigDecimal current, BigDecimal lastMonth) {
if (lastMonth == null || lastMonth.compareTo(BigDecimal.ZERO) == 0) {
return null; // 无法计算
}
return current.subtract(lastMonth)
.divide(lastMonth, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal(100));
}
}预算管控:给各部门设置上限
分摊是反映成本,管控是控制成本。两者都需要。
@Service
@Slf4j
public class TenantBudgetControlService {
@Autowired
private TenantApiKeyRepository apiKeyRepository;
@Autowired
private AIUsageRepository usageRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 每次AI调用前的预算检查
*/
public BudgetCheckResult checkBudget(TenantContext context, int estimatedTokens) {
// 1. 检查项目级预算
if (context.getMonthlyBudgetCny() != null) {
BigDecimal monthlySpend = getMonthlySpend(context.getTenantId());
BigDecimal estimated = estimateCost(context.getAllowedModel(), estimatedTokens);
if (monthlySpend.add(estimated).compareTo(new BigDecimal(context.getMonthlyBudgetCny())) > 0) {
return BudgetCheckResult.denied(
String.format("项目[%s]月度预算已达上限: 已用%.2f元/上限%d元",
context.getTenantName(), monthlySpend, context.getMonthlyBudgetCny())
);
}
}
// 2. 检查部门级预算(向上追溯)
if (context.getParentTenantId() != null) {
BudgetConfig deptBudget = getBudgetConfig(context.getParentTenantId());
if (deptBudget != null && deptBudget.getMonthlyBudgetCny() != null) {
BigDecimal deptSpend = getDeptMonthlySpend(context.getParentTenantId());
BigDecimal estimated = estimateCost(context.getAllowedModel(), estimatedTokens);
if (deptSpend.add(estimated).compareTo(deptBudget.getMonthlyBudgetCny()) > 0) {
return BudgetCheckResult.denied(
String.format("部门月度AI预算已达上限,请联系部门负责人")
);
}
}
}
return BudgetCheckResult.allowed();
}
/**
* 获取部门级月度消耗(汇总所有子项目)
* 使用Redis缓存,避免每次都查数据库
*/
private BigDecimal getDeptMonthlySpend(String deptId) {
String cacheKey = "budget:dept:" + deptId + ":" +
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return new BigDecimal(cached);
}
// 缓存未命中,从数据库查
BigDecimal spend = usageRepository.sumByDepartment(
deptId,
LocalDate.now().getYear(),
LocalDate.now().getMonthValue()
);
// 缓存30分钟(实时性要求不高,但要避免频繁查库)
redisTemplate.opsForValue().set(cacheKey, spend.toPlainString(), Duration.ofMinutes(30));
return spend;
}
/**
* 设置/更新部门预算
*/
@Transactional
public void setDepartmentBudget(String deptId, BigDecimal monthlyBudget, String operatorId) {
BudgetConfig config = budgetConfigRepository.findByTenantId(deptId);
if (config == null) {
config = new BudgetConfig();
config.setTenantId(deptId);
}
config.setMonthlyBudgetCny(monthlyBudget);
config.setUpdatedBy(operatorId);
config.setUpdatedAt(LocalDateTime.now());
budgetConfigRepository.save(config);
log.info("更新部门预算: deptId={}, budget={}元, operator={}",
deptId, monthlyBudget, operatorId);
// 清除相关缓存
invalidateBudgetCache(deptId);
}
}成本告警:及时发现异常
@Service
@Scheduled(fixedRate = 3600000) // 每小时检查一次
public class CostAlertService {
/**
* 检查各租户的预算使用情况,发送告警
*/
public void checkAndAlert() {
List<BudgetConfig> allConfigs = budgetConfigRepository.findAll();
for (BudgetConfig config : allConfigs) {
if (config.getMonthlyBudgetCny() == null) continue;
BigDecimal monthlySpend = getMonthlySpend(config.getTenantId());
BigDecimal budget = config.getMonthlyBudgetCny();
BigDecimal usagePercent = monthlySpend.divide(budget, 4, RoundingMode.HALF_UP)
.multiply(new BigDecimal(100));
// 80%时告警
if (usagePercent.compareTo(new BigDecimal("80")) >= 0
&& usagePercent.compareTo(new BigDecimal("100")) < 0) {
sendAlert(config, usagePercent, "WARNING");
}
// 100%时暂停并告警
if (usagePercent.compareTo(new BigDecimal("100")) >= 0) {
sendAlert(config, usagePercent, "EXCEEDED");
// 暂停该租户的AI调用
suspendTenant(config.getTenantId(), "月度预算已用尽");
}
}
}
/**
* 检测异常用量(短时间内突增)
*/
public void detectAnomalousSurge() {
// 获取今日各小时用量
LocalDate today = LocalDate.now();
List<HourlyUsage> todayUsage = usageRepository.getHourlyUsage(today);
// 与过去7天同时段的平均值对比
for (HourlyUsage hourly : todayUsage) {
double historicalAvg = getHistoricalHourlyAvg(hourly.getTenantId(), hourly.getHour());
if (historicalAvg > 0 && hourly.getTotalCostCny().doubleValue() > historicalAvg * 3) {
log.warn("检测到AI用量异常突增: tenant={}, hour={}, current={}元, avg={}元",
hourly.getTenantId(), hourly.getHour(),
hourly.getTotalCostCny(), historicalAvg);
sendAnomalyAlert(hourly, historicalAvg);
}
}
}
}成本分摊报表系统
数据有了,需要一个报表让各部门负责人能看懂。
@RestController
@RequestMapping("/api/cost-allocation")
public class CostAllocationReportController {
/**
* 公司总览:各部门成本占比
*/
@GetMapping("/company-overview")
@PreAuthorize("hasRole('FINANCE_ADMIN') or hasRole('CTO')")
public ApiResponse<CompanyOverviewVO> getCompanyOverview(
@RequestParam int year, @RequestParam int month) {
List<DepartmentCostReport> deptReports =
allocationService.generateDepartmentReport(year, month);
BigDecimal totalCost = deptReports.stream()
.map(DepartmentCostReport::getTotalCostCny)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 找出成本最高的功能
List<TopFeatureVO> topFeatures = allocationService.getTopFeaturesByCompany(year, month, 5);
// 趋势数据(最近6个月)
List<MonthlyCostTrend> trends = allocationService.getMonthlyTrend(6);
return ApiResponse.success(CompanyOverviewVO.builder()
.year(year)
.month(month)
.totalCostCny(totalCost)
.departmentBreakdown(deptReports)
.topFeatures(topFeatures)
.monthlyTrend(trends)
.build());
}
/**
* 部门视角:本部门的成本明细
*/
@GetMapping("/department/{deptId}")
@PreAuthorize("@deptPermission.canView(authentication, #deptId)")
public ApiResponse<DepartmentDetailVO> getDepartmentDetail(
@PathVariable String deptId,
@RequestParam int year, @RequestParam int month) {
DepartmentCostReport report = allocationService.getDepartmentReport(deptId, year, month);
// 项目排名
List<ProjectCostSummary> projectRanking = report.getProjectBreakdown().stream()
.sorted(Comparator.comparing(ProjectCostSummary::getCostCny).reversed())
.collect(Collectors.toList());
// 每日趋势
List<DailyCostVO> dailyTrend = allocationService.getDailyTrend(deptId, year, month);
// 模型使用分布
Map<String, BigDecimal> modelDistribution =
allocationService.getModelDistribution(deptId, year, month);
return ApiResponse.success(DepartmentDetailVO.builder()
.report(report)
.projectRanking(projectRanking)
.dailyTrend(dailyTrend)
.modelDistribution(modelDistribution)
.budgetUtilization(calculateBudgetUtilization(deptId, report.getTotalCostCny()))
.build());
}
/**
* 导出Excel报表(供财务核算用)
*/
@GetMapping("/export/{deptId}")
@PreAuthorize("@deptPermission.canExport(authentication, #deptId)")
public ResponseEntity<byte[]> exportReport(
@PathVariable String deptId,
@RequestParam int year, @RequestParam int month) {
byte[] excelBytes = allocationService.exportDepartmentReport(deptId, year, month);
String filename = String.format("AI成本分摊_%s_%d%02d.xlsx", deptId, year, month);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(excelBytes);
}
}内部转账:让AI平台成为内部"云服务"
比较成熟的公司,会把AI平台运营成内部的云服务:AI平台团队每月向使用方部门"收费",费用用于覆盖GPU、人力等成本。
@Service
public class InternalChargebackService {
/**
* 月末生成内部账单
* 向各业务部门结算AI平台使用费
*/
@Scheduled(cron = "0 0 1 1 * ?") // 每月1号凌晨1点执行
public void generateMonthlyChargeback() {
YearMonth lastMonth = YearMonth.now().minusMonths(1);
List<DepartmentCostReport> reports = allocationService
.generateDepartmentReport(lastMonth.getYear(), lastMonth.getMonthValue());
for (DepartmentCostReport report : reports) {
if (report.getTotalCostCny().compareTo(BigDecimal.ZERO) == 0) continue;
// 计算内部收费金额
// 通常会在原始成本上加一个平台服务费(如10%-20%)用于覆盖运维成本
BigDecimal platformFeeRate = new BigDecimal("0.15");
BigDecimal chargeAmount = report.getTotalCostCny()
.multiply(BigDecimal.ONE.add(platformFeeRate))
.setScale(2, RoundingMode.HALF_UP);
// 生成内部账单
InternalInvoice invoice = InternalInvoice.builder()
.invoiceNo(generateInvoiceNo(report.getDepartmentId(), lastMonth))
.fromDept("AI平台中心")
.toDept(report.getDepartmentId())
.toDeptName(report.getDepartmentName())
.rawCostCny(report.getTotalCostCny())
.platformFeePercent(platformFeeRate.multiply(new BigDecimal(100)))
.chargeAmountCny(chargeAmount)
.period(lastMonth.toString())
.generatedAt(LocalDateTime.now())
.build();
invoiceRepository.save(invoice);
// 通知财务和部门负责人
notificationService.sendInternalInvoiceNotice(invoice);
log.info("生成内部账单: dept={}, amount={}元",
report.getDepartmentName(), chargeAmount);
}
}
}落地经验
经验一:先做可见性,再做管控。不要一上来就限制各部门的用量,先做2-3个月的观察期,让大家看到自己在用多少、占公司多少比例。这个可见性本身就会让大家产生节约意识,不需要强制限制。
经验二:预算要和业务结果挂钩。纯粹的成本控制会让业务部门觉得AI平台是"管他们的",引起抵触。更好的方式是把AI预算和业务指标一起看:部门花了多少AI成本,带来了多少业务价值,ROI怎么样。
经验三:给各部门一个自服务的看板。不要让部门负责人每次都找AI平台团队查数据,做一个简单的自服务看板,让他们自己能看到实时用量、月度趋势、预算剩余。减少摩擦,数据才能被真正用起来。
经验四:成本分摊口径要提前对齐。什么算AI成本?仅是API费用,还是包含工程师维护成本?这个口径要在开始分摊之前和财务、业务方都对齐,否则后续会有很多争议。
