From 13329b5fa37ad60f4ea0be4068fc76e9f3832c41 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Tue, 23 Jun 2026 23:01:16 +0800 Subject: [PATCH] Track cache-hit tokens and consolidate token stats into one dashboard Capture DeepSeek's prompt_cache_hit_tokens (dropped before by the openai-kotlin Usage parser) via a raw-JSON extractor in ModelService, and persist it plus the group name on each daily record. Collapse the six /jgpt tokens* subcommands into a single /jgpt tokens dashboard showing cache-hit rate, input/output split, daily trend and top users/groups. Groups are shown by name only, never by group id. Co-Authored-By: Claude Opus 4.8 --- src/main/kotlin/JChatGPT.kt | 16 +- src/main/kotlin/ModelService.kt | 31 +++- src/main/kotlin/PluginCommands.kt | 279 +++++++++-------------------- src/main/kotlin/PluginData.kt | 4 + src/main/kotlin/TokenUsageStore.kt | 9 +- 5 files changed, 133 insertions(+), 206 deletions(-) diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 01d6192..dccee40 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -689,7 +689,8 @@ object JChatGPT : KotlinPlugin( do { try { val startedAt = OffsetDateTime.now().toEpochSecond().toInt() - val responseFlow = chatCompletions(history) + var lastCacheUsage: ModelService.CacheUsage? = null + val responseFlow = chatCompletions(history) { lastCacheUsage = it } var responseMessageBuilder: StringBuilder? = null var reasoningContentBuilder: StringBuilder? = null val responseToolCalls = mutableListOf() @@ -789,15 +790,17 @@ object JChatGPT : KotlinPlugin( // 记录token使用量(按日聚合,独立JSON文件) lastTokenUsage?.let { usage -> val now = OffsetDateTime.now().toEpochSecond() - val groupId = if (event is GroupMessageEvent) event.subject.id else null + val group = if (event is GroupMessageEvent) event.group else null TokenUsageStore.record( timestamp = now, userId = event.sender.id, userNickname = event.senderName, - groupId = groupId, + groupId = group?.id, + groupName = group?.name, promptTokens = usage.promptTokens ?: 0, completionTokens = usage.completionTokens ?: 0, - totalTokens = usage.totalTokens ?: 0 + totalTokens = usage.totalTokens ?: 0, + cachedTokens = lastCacheUsage?.hitTokens ?: 0 ) } @@ -1028,7 +1031,8 @@ object JChatGPT : KotlinPlugin( private fun chatCompletions( chatMessages: List, - hasTools: Boolean = true + hasTools: Boolean = true, + onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null ): Flow { val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始") val availableTools = if (hasTools) { @@ -1041,7 +1045,7 @@ object JChatGPT : KotlinPlugin( tools = availableTools, ) logger.info("API Requesting... Model=${PluginConfig.chatModel}") - return llm.chatCompletions(request) + return llm.chatCompletions(request, onCacheUsage) } private fun getNameCard(member: Member): String { diff --git a/src/main/kotlin/ModelService.kt b/src/main/kotlin/ModelService.kt index 1ef8216..dd0ec3e 100644 --- a/src/main/kotlin/ModelService.kt +++ b/src/main/kotlin/ModelService.kt @@ -48,7 +48,28 @@ class ModelService( explicitNulls = false } - fun chatCompletions(request: ChatCompletionRequest): Flow { + /** + * 一次响应的缓存命中用量。DeepSeek 在 usage 顶层返回的非标准字段, + * openai-kotlin 的 Usage 类不含这些字段,必须从原始 JSON 抠出来。 + */ + data class CacheUsage(val hitTokens: Int, val missTokens: Int) + + /** 从原始 data 行(已去掉 "data: " 前缀)解析缓存命中用量;无相关字段返回 null。 */ + private fun extractCacheUsage(rawJson: String): CacheUsage? { + return try { + val usage = json.parseToJsonElement(rawJson).jsonObject["usage"]?.jsonObject ?: return null + val hit = usage["prompt_cache_hit_tokens"]?.jsonPrimitive?.intOrNull + val miss = usage["prompt_cache_miss_tokens"]?.jsonPrimitive?.intOrNull + if (hit == null && miss == null) null else CacheUsage(hit ?: 0, miss ?: 0) + } catch (_: Exception) { + null + } + } + + fun chatCompletions( + request: ChatCompletionRequest, + onCacheUsage: ((CacheUsage) -> Unit)? = null + ): Flow { val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request) .jsonObject.toMutableMap() requestJson["stream"] = JsonPrimitive(true) @@ -91,7 +112,9 @@ class ModelService( } if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) { - emit(json.decodeFromString(firstDataLine.removePrefix("data: "))) + val firstRaw = firstDataLine.removePrefix("data: ") + emit(json.decodeFromString(firstRaw)) + onCacheUsage?.let { cb -> extractCacheUsage(firstRaw)?.let(cb) } val ch = channel!! while (currentCoroutineContext().isActive && !ch.isClosedForRead) { @@ -101,7 +124,9 @@ class ModelService( when { line.startsWith("data: [DONE]") -> break line.startsWith("data: ") -> { - emit(json.decodeFromString(line.removePrefix("data: "))) + val raw = line.removePrefix("data: ") + emit(json.decodeFromString(raw)) + onCacheUsage?.let { cb -> extractCacheUsage(raw)?.let(cb) } } else -> continue } diff --git a/src/main/kotlin/PluginCommands.kt b/src/main/kotlin/PluginCommands.kt index ae25560..0ce091a 100644 --- a/src/main/kotlin/PluginCommands.kt +++ b/src/main/kotlin/PluginCommands.kt @@ -101,217 +101,85 @@ object PluginCommands : CompositeCommand( val cutoff = calculateCutoffDate(days) val today = LocalDate.now().toString() - data class Statistics( - var totalTokens: Long = 0, - var todayTokens: Long = 0, - val userTotals: MutableMap> = mutableMapOf(), - val groupTotals: MutableMap = mutableMapOf(), - val users: MutableSet = mutableSetOf() - ) - - val stats = TokenUsageStore.all.fold(Statistics()) { acc, record -> - if (record.date >= cutoff) { - acc.totalTokens += record.totalTokens - acc.users.add(record.userId) - - val existing = acc.userTotals[record.userId] - if (existing == null) { - acc.userTotals[record.userId] = record.userNickname to record.totalTokens - } else { - acc.userTotals[record.userId] = existing.first to (existing.second + record.totalTokens) - } - - record.groupId?.let { groupId -> - acc.groupTotals[groupId] = acc.groupTotals.getOrDefault(groupId, 0L) + record.totalTokens - } - } - - if (record.date == today) { - acc.todayTokens += record.totalTokens - } - - acc + val windowed = TokenUsageStore.all.filter { it.date >= cutoff } + if (windowed.isEmpty()) { + sendMessage("最近 $days 天无 Token 使用记录") + return } - val topUser = stats.userTotals.entries.maxByOrNull { it.value.second } - val topGroup = stats.groupTotals.entries.maxByOrNull { it.value } - - val response = buildString { - appendLine("📊 Token 使用简报(最近 $days 天)") - appendLine() - appendLine("总计: ${formatNumber(stats.totalTokens)} tokens") - appendLine("今日: ${formatNumber(stats.todayTokens)} tokens") - appendLine("活跃用户: ${stats.users.size} 人") - - topUser?.let { - appendLine() - appendLine("👤 最活跃用户:") - appendLine(" ${it.value.first} - ${formatNumber(it.value.second)} tokens") - } - - topGroup?.let { - appendLine() - appendLine("👥 最活跃群组:") - appendLine(" ${it.key} - ${formatNumber(it.value)} tokens") - } - - appendLine() - appendLine("📋 详细查询:") - appendLine(" /jgpt tokensDaily [days] - 每日统计") - appendLine(" /jgpt tokensUsers [limit] - 用户排名") - appendLine(" /jgpt tokensGroups [limit] - 群组排名") - appendLine(" /jgpt tokensQuery [userId] [days] - 每日逐人记录") - appendLine(" /jgpt tokensUserDaily [days] - 用户日统计") + // 窗口汇总 + var prompt = 0L; var completion = 0L; var total = 0L; var cached = 0L + var calls = 0; var todayTotal = 0L + val users = HashSet() + for (r in windowed) { + prompt += r.promptTokens + completion += r.completionTokens + total += r.totalTokens + cached += r.cachedTokens + calls += r.callCount + users.add(r.userId) + if (r.date == today) todayTotal += r.totalTokens } + val hitRate = if (prompt > 0) cached * 100.0 / prompt else 0.0 - sendMessage(response) - } - - @SubCommand - suspend fun CommandSender.tokensDaily(days: Int = 7) { - validateDays(days) - - val cutoff = calculateCutoffDate(days) - - val dailyStats = TokenUsageStore.all - .filter { it.date >= cutoff } - .groupBy { it.date } - .mapValues { (_, records) -> records.sumOf { it.totalTokens } } + // 每日趋势 + val daily = windowed.groupBy { it.date } + .mapValues { (_, rs) -> rs.sumOf { it.totalTokens } } .toSortedMap() - if (dailyStats.isEmpty()) { - sendMessage("指定时间范围内无使用记录") - return - } - - val response = buildString { - appendLine("最近 $days 天 Token 使用统计:") - appendLine() - dailyStats.forEach { (date, total) -> - appendLine("$date: ${formatNumber(total)} tokens") + // Top 用户 + val topUsers = windowed.groupBy { it.userId } + .map { (_, rs) -> + val name = rs.maxByOrNull { it.date }!!.userNickname + name to rs.sumOf { it.totalTokens } } - } - sendMessage(response) - } - - @SubCommand - suspend fun CommandSender.tokensUsers(limit: Int = 10) { - require(limit > 0) { "limit must be positive: $limit" } - - val userStats = TokenUsageStore.all - .groupBy { it.userId } - .mapValues { (_, records) -> - val latest = records.maxByOrNull { it.date }!! - Pair(latest.userNickname, records.sumOf { it.totalTokens }) - } - .toList() - .sortedByDescending { it.second.second } - .take(limit) - - if (userStats.isEmpty()) { - sendMessage("暂无使用记录") - return - } - - val response = buildString { - appendLine("Token 使用排名 Top $limit:") - appendLine() - userStats.forEach { - appendLine("- ${it.second.first}(${it.first}): ${formatNumber(it.second.second)} tokens") - } - } - sendMessage(response) - } - - @SubCommand - suspend fun CommandSender.tokensGroups(limit: Int = 10) { - require(limit > 0) { "limit must be positive: $limit" } - - val groupStats = TokenUsageStore.all - .filter { it.groupId != null } - .groupBy { it.groupId!! } - .mapValues { (_, records) -> records.sumOf { it.totalTokens } } - .toList() .sortedByDescending { it.second } - .take(limit) + .take(TOP_LIMIT) - if (groupStats.isEmpty()) { - sendMessage("暂无群组使用记录") - return - } - - val response = buildString { - appendLine("群组 Token 使用排名 Top $limit:") - appendLine() - groupStats.forEach { (groupId, total) -> - appendLine("- $groupId: ${formatNumber(total)} tokens") + // Top 群组:只显示群名,绝不暴露群号(避免被误判宣群) + val topGroups = windowed.filter { it.groupId != null } + .groupBy { it.groupId!! } + .map { (gid, rs) -> + val name = rs.firstNotNullOfOrNull { r -> r.groupName?.takeIf { it.isNotBlank() } } + ?: resolveGroupName(gid) + name to rs.sumOf { it.totalTokens } } - } - sendMessage(response) - } - - @SubCommand - suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) { - validateDays(days) - - val cutoff = calculateCutoffDate(days) - - val filtered = TokenUsageStore.all - .filter { it.date >= cutoff } - .filter { userId == null || it.userId == userId } - .sortedWith(compareByDescending { it.date }.thenByDescending { it.totalTokens }) - .take(DEFAULT_QUERY_LIMIT) - - if (filtered.isEmpty()) { - sendMessage("指定时间范围内无使用记录") - return - } + .sortedByDescending { it.second } + .take(TOP_LIMIT) val response = buildString { - appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条,按日聚合):") + appendLine("📊 Token 简报 · 最近 $days 天") appendLine() - filtered.forEach { record -> - val location = if (record.groupId != null) "群${record.groupId}" else "私聊" - appendLine("[${record.date}] $location - ${record.userNickname}") - appendLine(" 调用 ${record.callCount} 次, Tokens: ${formatNumber(record.totalTokens)} " + - "(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})") + appendLine("输入 ${formatCompact(prompt)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(cached)})") + appendLine("输出 ${formatCompact(completion)}") + appendLine("总计 ${formatCompact(total)} | 调用 ${formatNumber(calls)} 次 | 活跃 ${users.size} 人") + appendLine("今日 ${formatCompact(todayTotal)}") + + if (daily.size > 1) { appendLine() + appendLine("📈 每日趋势") + daily.forEach { (date, t) -> + appendLine(" ${date.substring(5)} ${formatCompact(t)}") + } + } + + if (topUsers.isNotEmpty()) { + appendLine() + appendLine("👤 Top 用户") + topUsers.forEachIndexed { i, (name, t) -> + appendLine(" ${i + 1}. $name ${formatCompact(t)}") + } + } + + if (topGroups.isNotEmpty()) { + appendLine() + appendLine("👥 Top 群组") + topGroups.forEachIndexed { i, (name, t) -> + appendLine(" ${i + 1}. $name ${formatCompact(t)}") + } } } - sendMessage(response) - } - - @SubCommand - suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) { - validateDays(days) - - val cutoff = calculateCutoffDate(days) - - val userRecords = TokenUsageStore.all - .filter { it.date >= cutoff && it.userId == userId } - - if (userRecords.isEmpty()) { - sendMessage("用户 $userId 在指定时间范围内无使用记录") - return - } - - val userNickname = userRecords.maxByOrNull { it.date }!!.userNickname - - val userDailyStats = userRecords - .groupBy { it.date } - .mapValues { (_, records) -> records.sumOf { it.totalTokens } } - .toSortedMap() - - val response = buildString { - appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:") - appendLine() - userDailyStats.forEach { (date, total) -> - appendLine("$date: ${formatNumber(total)} tokens") - } - appendLine() - appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens") - } - sendMessage(response) + sendMessage(response.trim()) } // ==================== 辅助函数 ==================== @@ -330,6 +198,25 @@ object PluginCommands : CompositeCommand( return String.format("%,d", number.toLong()) } + /** + * 大数压缩为 K/M,简报用,避免一屏全是逗号长串。 + */ + private fun formatCompact(n: Long): String = when { + n >= 1_000_000 -> "%.2fM".format(n / 1_000_000.0) + n >= 1_000 -> "%.1fK".format(n / 1_000.0) + else -> n.toString() + } + + /** + * 解析群名:记录里没存到群名时(旧数据)才回退到在线 Bot 查询, + * 仍查不到则用占位文案,绝不直接展示群号。 + */ + private fun resolveGroupName(groupId: Long): String { + return net.mamoe.mirai.Bot.instances + .firstNotNullOfOrNull { it.getGroup(groupId)?.name } + ?: "未知群聊" + } + /** * 验证天数参数 */ @@ -339,4 +226,4 @@ object PluginCommands : CompositeCommand( } // 常量定义 -private const val DEFAULT_QUERY_LIMIT = 20 \ No newline at end of file +private const val TOP_LIMIT = 5 \ No newline at end of file diff --git a/src/main/kotlin/PluginData.kt b/src/main/kotlin/PluginData.kt index cc2f2a0..f06150f 100644 --- a/src/main/kotlin/PluginData.kt +++ b/src/main/kotlin/PluginData.kt @@ -56,9 +56,13 @@ data class TokenUsageDailyRecord( val userId: Long, val userNickname: String, val groupId: Long?, + /** 群名称,记录时捕获。展示时优先用它,避免暴露群号(被误判宣群)。私聊为 null。 */ + val groupName: String? = null, val promptTokens: Long = 0, val completionTokens: Long = 0, val totalTokens: Long = 0, + /** 命中缓存的输入 token 数(DeepSeek: prompt_cache_hit_tokens)。缓存命中率 = cachedTokens / promptTokens */ + val cachedTokens: Long = 0, val callCount: Int = 0 ) diff --git a/src/main/kotlin/TokenUsageStore.kt b/src/main/kotlin/TokenUsageStore.kt index 7b740bf..9313bb7 100644 --- a/src/main/kotlin/TokenUsageStore.kt +++ b/src/main/kotlin/TokenUsageStore.kt @@ -52,13 +52,16 @@ object TokenUsageStore { userId: Long, userNickname: String, groupId: Long?, + groupName: String?, promptTokens: Int, completionTokens: Int, - totalTokens: Int + totalTokens: Int, + cachedTokens: Int ) { val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault()) .format(dateFmt) val nickname = sanitizeNickname(userNickname) + val groupNameClean = groupName?.let { sanitizeNickname(it) } val idx = records.indexOfFirst { it.date == date && it.userId == userId && it.groupId == groupId } @@ -66,9 +69,11 @@ object TokenUsageStore { val r = records[idx] records[idx] = r.copy( userNickname = nickname.ifEmpty { r.userNickname }, + groupName = groupNameClean?.ifEmpty { null } ?: r.groupName, promptTokens = r.promptTokens + promptTokens, completionTokens = r.completionTokens + completionTokens, totalTokens = r.totalTokens + totalTokens, + cachedTokens = r.cachedTokens + cachedTokens, callCount = r.callCount + 1 ) } else { @@ -78,9 +83,11 @@ object TokenUsageStore { userId = userId, userNickname = nickname, groupId = groupId, + groupName = groupNameClean?.ifEmpty { null }, promptTokens = promptTokens.toLong(), completionTokens = completionTokens.toLong(), totalTokens = totalTokens.toLong(), + cachedTokens = cachedTokens.toLong(), callCount = 1 ) )