feat: 实现基于滑动窗口的实时RPM/TPM统计

- 添加系统级分钟统计,支持1-60分钟可配置时间窗口
- 新增 getRealtimeSystemMetrics 方法计算滑动窗口内的平均值
- 前端显示实时RPM/TPM,标注时间窗口和数据来源
- 修复 EditApiKeyModal 中模型限制和客户端限制复选框状态错误
- 优化性能:使用Pipeline批量操作替代Promise.all
- TPM包含所有token类型:input、output、cache_creation、cache_read
- 添加降级方案:实时数据不可用时返回历史平均值

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-30 14:27:34 +08:00
parent 363d1c3ed3
commit a6ab6b7abe
7 changed files with 237 additions and 103 deletions

View File

@@ -186,6 +186,10 @@ class RedisClient {
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别
// 新增:系统级分钟统计
const minuteTimestamp = Math.floor(now.getTime() / 60000);
const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}`;
// 智能处理输入输出token分配
const finalInputTokens = inputTokens || 0;
const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens);
@@ -197,96 +201,122 @@ class RedisClient {
// 核心token不包括缓存- 用于与历史数据兼容
const coreTokens = finalInputTokens + finalOutputTokens;
await Promise.all([
// 核心token统计保持向后兼容
this.client.hincrby(key, 'totalTokens', coreTokens),
this.client.hincrby(key, 'totalInputTokens', finalInputTokens),
this.client.hincrby(key, 'totalOutputTokens', finalOutputTokens),
// 缓存token统计新增
this.client.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens),
this.client.hincrby(key, 'totalAllTokens', totalTokens), // 包含所有类型的总token
// 请求计数
this.client.hincrby(key, 'totalRequests', 1),
// 每日统计
this.client.hincrby(daily, 'tokens', coreTokens),
this.client.hincrby(daily, 'inputTokens', finalInputTokens),
this.client.hincrby(daily, 'outputTokens', finalOutputTokens),
this.client.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(daily, 'allTokens', totalTokens),
this.client.hincrby(daily, 'requests', 1),
// 每月统计
this.client.hincrby(monthly, 'tokens', coreTokens),
this.client.hincrby(monthly, 'inputTokens', finalInputTokens),
this.client.hincrby(monthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(monthly, 'allTokens', totalTokens),
this.client.hincrby(monthly, 'requests', 1),
// 按模型统计 - 每日
this.client.hincrby(modelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(modelDaily, 'outputTokens', finalOutputTokens),
this.client.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(modelDaily, 'allTokens', totalTokens),
this.client.hincrby(modelDaily, 'requests', 1),
// 按模型统计 - 每月
this.client.hincrby(modelMonthly, 'inputTokens', finalInputTokens),
this.client.hincrby(modelMonthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(modelMonthly, 'allTokens', totalTokens),
this.client.hincrby(modelMonthly, 'requests', 1),
// API Key级别的模型统计 - 每
this.client.hincrby(keyModelDaily, 'inputTokens', finalInputTokens),
this.client.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens),
this.client.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(keyModelDaily, 'allTokens', totalTokens),
this.client.hincrby(keyModelDaily, 'requests', 1),
// API Key级别的模型统计 - 每月
this.client.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens),
this.client.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens),
this.client.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens),
this.client.hincrby(keyModelMonthly, 'requests', 1),
// 小时级别统计
this.client.hincrby(hourly, 'tokens', coreTokens),
this.client.hincrby(hourly, 'inputTokens', finalInputTokens),
this.client.hincrby(hourly, 'outputTokens', finalOutputTokens),
this.client.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(hourly, 'allTokens', totalTokens),
this.client.hincrby(hourly, 'requests', 1),
// 按模型统计 - 每小时
this.client.hincrby(modelHourly, 'inputTokens', finalInputTokens),
this.client.hincrby(modelHourly, 'outputTokens', finalOutputTokens),
this.client.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(modelHourly, 'allTokens', totalTokens),
this.client.hincrby(modelHourly, 'requests', 1),
// API Key级别的模型统计 - 每小时
this.client.hincrby(keyModelHourly, 'inputTokens', finalInputTokens),
this.client.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens),
this.client.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
this.client.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens),
this.client.hincrby(keyModelHourly, 'allTokens', totalTokens),
this.client.hincrby(keyModelHourly, 'requests', 1),
// 设置过期时间
this.client.expire(daily, 86400 * 32), // 32天过期
this.client.expire(monthly, 86400 * 365), // 1年过期
this.client.expire(hourly, 86400 * 7), // 小时统计7天过期
this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期
this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期
this.client.expire(modelHourly, 86400 * 7), // 模型小时统计7天过期
this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期
this.client.expire(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期
this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
]);
// 使用Pipeline优化性能
const pipeline = this.client.pipeline();
// 现有的统计保持不变
// 核心token统计保持向后兼容
pipeline.hincrby(key, 'totalTokens', coreTokens);
pipeline.hincrby(key, 'totalInputTokens', finalInputTokens);
pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens);
// 缓存token统计新增
pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(key, 'totalAllTokens', totalTokens); // 包含所有类型的总token
// 请求计数
pipeline.hincrby(key, 'totalRequests', 1);
// 每日统计
pipeline.hincrby(daily, 'tokens', coreTokens);
pipeline.hincrby(daily, 'inputTokens', finalInputTokens);
pipeline.hincrby(daily, 'outputTokens', finalOutputTokens);
pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(daily, 'allTokens', totalTokens);
pipeline.hincrby(daily, 'requests', 1);
// 每月统计
pipeline.hincrby(monthly, 'tokens', coreTokens);
pipeline.hincrby(monthly, 'inputTokens', finalInputTokens);
pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(monthly, 'allTokens', totalTokens);
pipeline.hincrby(monthly, 'requests', 1);
// 按模型统计 - 每日
pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens);
pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens);
pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(modelDaily, 'allTokens', totalTokens);
pipeline.hincrby(modelDaily, 'requests', 1);
// 模型统计 - 每
pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens);
pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(modelMonthly, 'allTokens', totalTokens);
pipeline.hincrby(modelMonthly, 'requests', 1);
// API Key级别的模型统计 - 每日
pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens);
pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens);
pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens);
pipeline.hincrby(keyModelDaily, 'requests', 1);
// API Key级别的模型统计 - 每月
pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens);
pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens);
pipeline.hincrby(keyModelMonthly, 'requests', 1);
// 小时级别统计
pipeline.hincrby(hourly, 'tokens', coreTokens);
pipeline.hincrby(hourly, 'inputTokens', finalInputTokens);
pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(hourly, 'allTokens', totalTokens);
pipeline.hincrby(hourly, 'requests', 1);
// 按模型统计 - 每小时
pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens);
pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(modelHourly, 'allTokens', totalTokens);
pipeline.hincrby(modelHourly, 'requests', 1);
// API Key级别的模型统计 - 每小时
pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens);
pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens);
pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens);
pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens);
pipeline.hincrby(keyModelHourly, 'requests', 1);
// 新增:系统级分钟统计
pipeline.hincrby(systemMinuteKey, 'requests', 1);
pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens);
pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens);
pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens);
pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens);
pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens);
// 设置过期时间
pipeline.expire(daily, 86400 * 32); // 32天过期
pipeline.expire(monthly, 86400 * 365); // 1年过期
pipeline.expire(hourly, 86400 * 7); // 小时统计7天过期
pipeline.expire(modelDaily, 86400 * 32); // 模型每日统计32天过期
pipeline.expire(modelMonthly, 86400 * 365); // 模型每月统计1年过期
pipeline.expire(modelHourly, 86400 * 7); // 模型小时统计7天过期
pipeline.expire(keyModelDaily, 86400 * 32); // API Key模型每日统计32天过期
pipeline.expire(keyModelMonthly, 86400 * 365); // API Key模型每月统计1年过期
pipeline.expire(keyModelHourly, 86400 * 7); // API Key模型小时统计7天过期
// 系统级分钟统计的过期时间窗口时间的2倍
const config = require('../../config/config');
const metricsWindow = config.system.metricsWindow;
pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2);
// 执行Pipeline
await pipeline.exec();
}
// 📊 记录账户级别的使用统计
@@ -974,6 +1004,76 @@ class RedisClient {
}
}
// 📊 获取实时系统指标(基于滑动窗口)
async getRealtimeSystemMetrics() {
try {
const config = require('../../config/config');
const windowMinutes = config.system.metricsWindow;
const now = new Date();
const currentMinute = Math.floor(now.getTime() / 60000);
// 使用Pipeline批量获取窗口内的所有分钟数据
const pipeline = this.client.pipeline();
for (let i = 0; i < windowMinutes; i++) {
const minuteKey = `system:metrics:minute:${currentMinute - i}`;
pipeline.hgetall(minuteKey);
}
const results = await pipeline.exec();
// 聚合计算
let totalRequests = 0;
let totalTokens = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheCreateTokens = 0;
let totalCacheReadTokens = 0;
results.forEach(([err, data]) => {
if (!err && data) {
totalRequests += parseInt(data.requests || 0);
totalTokens += parseInt(data.totalTokens || 0);
totalInputTokens += parseInt(data.inputTokens || 0);
totalOutputTokens += parseInt(data.outputTokens || 0);
totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0);
totalCacheReadTokens += parseInt(data.cacheReadTokens || 0);
}
});
// 计算平均值(每分钟)
const realtimeRPM = windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0;
const realtimeTPM = windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0;
return {
realtimeRPM,
realtimeTPM,
windowMinutes,
totalRequests,
totalTokens,
totalInputTokens,
totalOutputTokens,
totalCacheCreateTokens,
totalCacheReadTokens
};
} catch (error) {
console.error('Error getting realtime system metrics:', error);
// 如果出错,返回历史平均值作为降级方案
const historicalMetrics = await this.getSystemAverages();
return {
realtimeRPM: historicalMetrics.systemRPM,
realtimeTPM: historicalMetrics.systemTPM,
windowMinutes: 0, // 标识使用了历史数据
totalRequests: 0,
totalTokens: historicalMetrics.totalTokens,
totalInputTokens: historicalMetrics.totalInputTokens,
totalOutputTokens: historicalMetrics.totalOutputTokens,
totalCacheCreateTokens: 0,
totalCacheReadTokens: 0
};
}
}
// 🔗 会话sticky映射管理
async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) {
const key = `sticky_session:${sessionHash}`;

View File

@@ -1261,13 +1261,14 @@ router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, re
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages] = await Promise.all([
const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages, realtimeMetrics] = await Promise.all([
redis.getSystemStats(),
apiKeyService.getAllApiKeys(),
claudeAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages()
redis.getSystemAverages(),
redis.getRealtimeSystemMetrics()
]);
// 计算使用统计统一使用allTokens
@@ -1316,6 +1317,12 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
rpm: systemAverages.systemRPM,
tpm: systemAverages.systemTPM
},
realtimeMetrics: {
rpm: realtimeMetrics.realtimeRPM,
tpm: realtimeMetrics.realtimeTPM,
windowMinutes: realtimeMetrics.windowMinutes,
isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: activeClaudeAccounts > 0,
@@ -1483,7 +1490,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
endTime = new Date(endDate);
// 调试日志
logger.info(`📊 Usage trend hour granularity - received times:`);
logger.info('📊 Usage trend hour granularity - received times:');
logger.info(` startDate (raw): ${startDate}`);
logger.info(` endDate (raw): ${endDate}`);
logger.info(` startTime (parsed): ${startTime.toISOString()}`);

View File

@@ -88,11 +88,11 @@ class ClaudeConsoleRelayService {
logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`);
requestConfig.headers['anthropic-beta'] = options.betaHeader;
} else {
logger.debug(`[DEBUG] No beta header to add`);
logger.debug('[DEBUG] No beta header to add');
}
// 发送请求
logger.debug(`📤 Sending request to Claude Console API with headers:`, JSON.stringify(requestConfig.headers, null, 2));
logger.debug('📤 Sending request to Claude Console API with headers:', JSON.stringify(requestConfig.headers, null, 2));
const response = await axios(requestConfig);
// 移除监听器(请求成功完成)