fix: 为 cacheWarningStateBySource Map 设置上限防止内存泄漏

Map 以 querySource 为 key 存储每个来源的缓存命中率历史状态,
但 querySource 类型为 `any`,长时间会话中可能产生大量唯一值,
Map 持续增长永不清理。

新增 MAX_SOURCE_ENTRIES = 50 上限,新增条目时若达到上限则
逐出最早插入的条目(Map 按插入顺序迭代)。

同时也新增 _resetCacheWarningStateForTest() 用于测试隔离。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
This commit is contained in:
cepvor 2026-05-14 16:05:16 +08:00 committed by James Feng
parent dbfa1b651a
commit c2d02d1991

154
src/utils/cacheWarning.ts Normal file
View File

@ -0,0 +1,154 @@
import { randomUUID } from 'crypto'
import { getInitialSettings } from './settings/settings.js'
import type { Message } from '../types/message.js'
// Usage 类型(从 API 响应中提取)
interface Usage {
input_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
}
export interface CacheHitRateInfo {
hitRate: number
threshold: number
trend: number | null // 正数=上升,负数=下降
shouldWarn: boolean
}
interface CacheWarningState {
lastHitRate: number | null
lastTimestamp: number | null
}
// 模块级状态,每个 querySource 独立跟踪
const cacheWarningStateBySource = new Map<string, CacheWarningState>()
// Limit the number of tracked sources to prevent unbounded Map growth.
// querySource strings are effectively unbounded (typed as `any`), so a
// long-running session that spawns many subagents could leak memory.
// Evict the oldest entry (by insertion order) when the limit is exceeded.
const MAX_SOURCE_ENTRIES = 50
const DEFAULT_CACHE_THRESHOLD = 80
/**
* settings.json
*/
export function getCacheThreshold(): number {
const settings = getInitialSettings()
return settings.cacheThreshold ?? DEFAULT_CACHE_THRESHOLD
}
/**
*
* 0-100null
*/
export function calculateCacheHitRate(
usage: Usage | null | undefined,
): number | null {
if (!usage) return null
const { input_tokens, cache_creation_input_tokens, cache_read_input_tokens } =
usage
// 所有缓存字段为 0 表示无缓存数据
if (cache_read_input_tokens === 0 && cache_creation_input_tokens === 0) {
return null
}
const totalInputTokens =
input_tokens + cache_creation_input_tokens + cache_read_input_tokens
if (totalInputTokens === 0) return null
return (cache_read_input_tokens / totalInputTokens) * 100
}
/**
*
* @param usage API usage
* @param querySource
* @param threshold
* @returns null
*/
export function shouldShowCacheWarning(
usage: Usage | null | undefined,
querySource: string,
threshold: number,
): CacheHitRateInfo | null {
const hitRate = calculateCacheHitRate(usage)
// 无缓存数据
if (hitRate === null) {
return null
}
// 获取或初始化该 querySource 的状态
let state = cacheWarningStateBySource.get(querySource)
if (!state) {
state = { lastHitRate: null, lastTimestamp: null }
// Evict oldest entry when at capacity so the Map stays bounded
if (cacheWarningStateBySource.size >= MAX_SOURCE_ENTRIES) {
const oldestKey = cacheWarningStateBySource.keys().next().value
if (oldestKey !== undefined) {
cacheWarningStateBySource.delete(oldestKey)
}
}
cacheWarningStateBySource.set(querySource, state)
}
// 首次请求不显示警告
if (state.lastHitRate === null) {
state.lastHitRate = hitRate
state.lastTimestamp = Date.now()
return null
}
// 计算趋势
const trend = hitRate - state.lastHitRate
// 更新状态
state.lastHitRate = hitRate
state.lastTimestamp = Date.now()
// 检查是否需要警告
if (hitRate < threshold) {
return { hitRate, threshold, trend, shouldWarn: true }
}
return null
}
/**
*
* @param info
* @returns system REPL transcript
*/
export function createCacheWarningMessage(info: CacheHitRateInfo): Message {
const { hitRate, threshold, trend } = info
let content = `Cache hit rate ${hitRate.toFixed(0)}%, below ${threshold}% threshold`
if (trend !== null && Math.abs(trend) > 0.1) {
const trendIcon = trend > 0 ? '^' : 'v'
const trendPercent = Math.abs(trend).toFixed(0)
content += ` (${trendIcon}${trendPercent}%)`
}
return {
type: 'system',
subtype: 'cache_warning',
level: 'warning' as const,
content,
timestamp: new Date().toISOString(),
uuid: randomUUID(),
isMeta: false,
} as Message
}
/**
* Reset the per-source tracking state only used in tests.
*/
export function _resetCacheWarningStateForTest(): void {
cacheWarningStateBySource.clear()
}