diff --git a/internal/dao/internal/bil_usage_daily.go b/internal/dao/internal/bil_usage_daily.go index 3b2ceb0..41210b8 100644 --- a/internal/dao/internal/bil_usage_daily.go +++ b/internal/dao/internal/bil_usage_daily.go @@ -21,42 +21,48 @@ type BilUsageDailyDao struct { // BilUsageDailyColumns defines and stores column names for the table bil_usage_daily. type BilUsageDailyColumns struct { - Id string // - StatDate string // 统计日期(按 created_at 的日期分桶) - TenantId string // 租户ID - ProjectId string // 项目ID(0=无项目/个人Key) - ModelName string // 模型名称 - ChannelId string // 渠道ID(0=无渠道) - Status string // 请求状态:success/error/timeout/cancelled - RequestCount string // 请求数(COUNT(*)) - InputTokens string // 输入Token合计 - OutputTokens string // 输出Token合计 - TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs string // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt string // - UpdatedAt string // + Id string // + StatDate string // 统计日期(按 created_at 的日期分桶) + TenantId string // 租户ID + ProjectId string // 项目ID(0=无项目/个人Key) + ModelName string // 模型名称 + ChannelId string // 渠道ID(0=无渠道) + Status string // 请求状态:success/error/timeout/cancelled + RequestCount string // 请求数(COUNT(*)) + InputTokens string // 输入Token合计 + OutputTokens string // 输出Token合计 + TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs string // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt string // + UpdatedAt string // + CacheCreationTokens string // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens string // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount string // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } // bilUsageDailyColumns holds the columns for the table bil_usage_daily. var bilUsageDailyColumns = BilUsageDailyColumns{ - Id: "id", - StatDate: "stat_date", - TenantId: "tenant_id", - ProjectId: "project_id", - ModelName: "model_name", - ChannelId: "channel_id", - Status: "status", - RequestCount: "request_count", - InputTokens: "input_tokens", - OutputTokens: "output_tokens", - TotalCost: "total_cost", - AccountCost: "account_cost", - SumLatencyMs: "sum_latency_ms", - SumFirstTokenMs: "sum_first_token_ms", - CreatedAt: "created_at", - UpdatedAt: "updated_at", + Id: "id", + StatDate: "stat_date", + TenantId: "tenant_id", + ProjectId: "project_id", + ModelName: "model_name", + ChannelId: "channel_id", + Status: "status", + RequestCount: "request_count", + InputTokens: "input_tokens", + OutputTokens: "output_tokens", + TotalCost: "total_cost", + AccountCost: "account_cost", + SumLatencyMs: "sum_latency_ms", + SumFirstTokenMs: "sum_first_token_ms", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + CacheCreationTokens: "cache_creation_tokens", + CacheReadTokens: "cache_read_tokens", + CacheHitRequestCount: "cache_hit_request_count", } // NewBilUsageDailyDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_tenant_models.go b/internal/dao/internal/mdl_tenant_models.go index 4f603b1..885b674 100644 --- a/internal/dao/internal/mdl_tenant_models.go +++ b/internal/dao/internal/mdl_tenant_models.go @@ -27,7 +27,7 @@ type MdlTenantModelsColumns struct { Enabled string // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice string // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice string // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier string // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier string // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt string // 创建时间 UpdatedAt string // 更新时间 BillingMode string // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/internal/logic/common/model_perf_metrics.go b/internal/logic/common/model_perf_metrics.go index 4d453eb..0568bab 100644 --- a/internal/logic/common/model_perf_metrics.go +++ b/internal/logic/common/model_perf_metrics.go @@ -23,6 +23,8 @@ import ( // SUM(latency_ms) 对 NULL 跳过、但 SUM/COUNT 用全部请求数作分母的语义一致) // - 平均首 Token = Σttft / Σttft_n(首 Token 单独计数,避免非流式/无首Token请求计入分母拉低均值) // - 成本以整数 micro-USD 累加(对齐 Redis 钱包 micro 规范,杜绝 HINCRBYFLOAT 漂移) +// - 缓存指标与日聚合对齐:cache 命中请求数(chit)按「该次请求 cache_read>0」计 1, +// 仅写入缓存(creation>0、read=0)不计命中 // // 可靠性为 best-effort:Redis 不可用时计数丢弃、查询返回空,不影响请求主链路,页面退化为历史日表。 const ( @@ -41,6 +43,9 @@ type ModelPerfRecord struct { InputTokens int OutputTokens int CostUSD float64 + // 缓存 token(命中判定由本模块从 CacheReadTokens>0 推导,调用方无需单独传标志) + CacheCreationTokens int + CacheReadTokens int } // ModelPerfCounter 当日某模型的累计计数(Redis 小时桶聚合结果)。 @@ -54,6 +59,10 @@ type ModelPerfCounter struct { Tin int64 Tout int64 CostMicro int64 + // 缓存累计:creation/read token 与 命中缓存的请求数(与 bil_usage_daily 同口径) + CacheCreation int64 + CacheRead int64 + CacheHitReq int64 } func modelPerfModelsKey(date string) string { @@ -84,7 +93,8 @@ func microFromUSD(usd float64) int64 { // modelPerfIncrLua 一次 RTT 把样本原子累加到「当日模型集合 + 小时桶」并续期。 // KEYS[1] = perf_models:{date};KEYS[2] = perf_model:{hex}:{hour} // ARGV[1] = 模型名;ARGV[2] = TTL 秒;ARGV[3] = ok(1/0);ARGV[4] = lat ms; -// ARGV[5] = ttft ms;ARGV[6] = input tokens;ARGV[7] = output tokens;ARGV[8] = cost micro-USD。 +// ARGV[5] = ttft ms;ARGV[6] = input tokens;ARGV[7] = output tokens;ARGV[8] = cost micro-USD; +// ARGV[9] = cache creation tokens;ARGV[10] = cache read tokens(>0 时 chit 同步 +1)。 const modelPerfIncrLua = ` redis.call("SADD", KEYS[1], ARGV[1]) redis.call("EXPIRE", KEYS[1], ARGV[2]) @@ -98,14 +108,20 @@ end if tonumber(ARGV[6]) > 0 then redis.call("HINCRBY", KEYS[2], "tin", tonumber(ARGV[6])) end if tonumber(ARGV[7]) > 0 then redis.call("HINCRBY", KEYS[2], "tout", tonumber(ARGV[7])) end if tonumber(ARGV[8]) > 0 then redis.call("HINCRBY", KEYS[2], "cost", tonumber(ARGV[8])) end +if tonumber(ARGV[9]) > 0 then redis.call("HINCRBY", KEYS[2], "ccr", tonumber(ARGV[9])) end +if tonumber(ARGV[10]) > 0 then + redis.call("HINCRBY", KEYS[2], "crd", tonumber(ARGV[10])) + redis.call("HINCRBY", KEYS[2], "chit", 1) +end redis.call("EXPIRE", KEYS[2], ARGV[2]) return 1 ` // modelPerfReadLua 累加某模型当日所有小时桶计数。KEYS = 当日各小时桶 key。 -// 返回扁平数组 [req, ok, lat, ttft, ttft_n, tin, tout, cost]。 +// 返回扁平数组 [req, ok, lat, ttft, ttft_n, tin, tout, cost, ccr, crd, chit]。 +// 旧版本桶缺失 ccr/crd/chit 字段时 tonumber(nil) or 0 兜底为 0,平滑上线。 const modelPerfReadLua = ` -local req,ok,lat,ttft,ttftn,tin,tout,cost = 0,0,0,0,0,0,0,0 +local req,ok,lat,ttft,ttftn,tin,tout,cost,ccr,crd,chit = 0,0,0,0,0,0,0,0,0,0,0 for i = 1, #KEYS do local h = redis.call("HGETALL", KEYS[i]) for j = 1, #h, 2 do @@ -118,10 +134,13 @@ for i = 1, #KEYS do elseif f == "ttft_n" then ttftn = ttftn + v elseif f == "tin" then tin = tin + v elseif f == "tout" then tout = tout + v - elseif f == "cost" then cost = cost + v end + elseif f == "cost" then cost = cost + v + elseif f == "ccr" then ccr = ccr + v + elseif f == "crd" then crd = crd + v + elseif f == "chit" then chit = chit + v end end end -return {req, ok, lat, ttft, ttftn, tin, tout, cost} +return {req, ok, lat, ttft, ttftn, tin, tout, cost, ccr, crd, chit} ` // RecordModelPerfMetrics 将一次请求的模型性能采样累加到 Redis 当日小时桶。 @@ -153,6 +172,7 @@ func RecordModelPerfMetrics(ctx context.Context, r ModelPerfRecord) { boolToInt(r.Success), int64(math.Round(r.LatencyMs)), int64(r.FirstTokenMs), int64(r.InputTokens), int64(r.OutputTokens), microFromUSD(r.CostUSD), + int64(r.CacheCreationTokens), int64(r.CacheReadTokens), } if _, err := g.Redis().Do(bgCtx, "EVAL", args...); err != nil { // 指标是 best-effort,Redis 抖动只记 debug,不刷告警 @@ -203,14 +223,17 @@ func GetModelPerfMetricsToday(ctx context.Context, date string) (map[string]Mode } vals := res.Vars() out[model] = ModelPerfCounter{ - Req: vals[0].Int64(), - Ok: vals[1].Int64(), - Lat: vals[2].Int64(), - Ttft: vals[3].Int64(), - TtftN: vals[4].Int64(), - Tin: vals[5].Int64(), - Tout: vals[6].Int64(), - CostMicro: vals[7].Int64(), + Req: vals[0].Int64(), + Ok: vals[1].Int64(), + Lat: vals[2].Int64(), + Ttft: vals[3].Int64(), + TtftN: vals[4].Int64(), + Tin: vals[5].Int64(), + Tout: vals[6].Int64(), + CostMicro: vals[7].Int64(), + CacheCreation: vals[8].Int64(), + CacheRead: vals[9].Int64(), + CacheHitReq: vals[10].Int64(), } } return out, nil diff --git a/internal/logic/monitor/api_metrics.go b/internal/logic/monitor/api_metrics.go index 2015a43..9433ea1 100644 --- a/internal/logic/monitor/api_metrics.go +++ b/internal/logic/monitor/api_metrics.go @@ -121,6 +121,10 @@ type modelPerfRow struct { TotalCost float64 `json:"total_cost"` SumLatencyMs int64 `json:"sum_latency_ms"` SumFirstTokenMs int64 `json:"sum_first_token_ms"` + // 缓存聚合列(000016 迁移新增,口径与 bil_usage_logs 明细一致) + CacheCreationTokens int64 `json:"cache_creation_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheHitRequestCount int64 `json:"cache_hit_request_count"` } // gradeSuccessRate 按成功率返回分级。阈值:≥99 excellent、≥95 good、≥90 warning、<90 critical。 @@ -146,6 +150,10 @@ type perfModelAgg struct { sumLatencyMs int64 sumFirstTokenMs int64 totalCost float64 + // 缓存累计(历史日表 + 当日热桶同口径累加) + cacheCreationTokens int64 + cacheReadTokens int64 + cacheHitRequests int64 } // GetModelPerformance 从 bil_usage_daily 按模型聚合历史性能指标,并合并当日 Redis 实时热桶 @@ -164,7 +172,10 @@ func GetModelPerformance(ctx context.Context, startDate, endDate string) (any, e COALESCE(SUM(output_tokens), 0) AS output_tokens, COALESCE(SUM(total_cost), 0) AS total_cost, COALESCE(SUM(sum_latency_ms), 0) AS sum_latency_ms, - COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms + COALESCE(SUM(sum_first_token_ms), 0) AS sum_first_token_ms, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_hit_request_count), 0) AS cache_hit_request_count FROM bil_usage_daily WHERE stat_date >= $1 AND stat_date <= $2 AND stat_date < $3 AND model_name <> 'unknown' GROUP BY model_name @@ -189,7 +200,7 @@ func GetModelPerformance(ctx context.Context, startDate, endDate string) (any, e } // buildModelPerformanceList 将历史日表聚合行与当日 Redis 热桶合并为统一的模型性能列表, -// 统一重算派生指标(成功率/均延迟/均首Token/TPS/分级)并按 request_count 降序输出。 +// 统一重算派生指标(成功率/均延迟/均首Token/TPS/分级/缓存命中率)并按模型名称升序输出。 func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]common.ModelPerfCounter) []map[string]any { agg := make(map[string]*perfModelAgg) ensure := func(model string) *perfModelAgg { @@ -212,6 +223,9 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo a.sumLatencyMs += r.SumLatencyMs a.sumFirstTokenMs += r.SumFirstTokenMs a.totalCost += r.TotalCost + a.cacheCreationTokens += r.CacheCreationTokens + a.cacheReadTokens += r.CacheReadTokens + a.cacheHitRequests += r.CacheHitRequestCount } for model, c := range todayCounts { if c.Req <= 0 || model == "" || model == "unknown" { @@ -225,6 +239,9 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo a.sumLatencyMs += c.Lat a.sumFirstTokenMs += c.Ttft a.totalCost += billing.FromMicro(c.CostMicro).InexactFloat64() + a.cacheCreationTokens += c.CacheCreation + a.cacheReadTokens += c.CacheRead + a.cacheHitRequests += c.CacheHitReq } list := make([]map[string]any, 0, len(agg)) @@ -238,19 +255,34 @@ func buildModelPerformanceList(rows []modelPerfRow, todayCounts map[string]commo if a.sumLatencyMs > 0 { tps = float64(a.outputTokens) / (float64(a.sumLatencyMs) / 1000) } + // 缓存命中率(Token 级)= 命中读取 / 归一化总输入(input + creation + read)。 + // Claude 口径(input 不含缓存)精确;OpenAI 原生口径(input 已含 cached)分母偏大, + // 命中率为保守值,趋势仍可比。请求级命中率分母为 request_count,不受该口径影响。 + var cacheHitRate, cacheHitReqRate float64 + if totalInput := a.inputTokens + a.cacheCreationTokens + a.cacheReadTokens; totalInput > 0 { + cacheHitRate = float64(a.cacheReadTokens) * 100 / float64(totalInput) + } + if a.requestCount > 0 { + cacheHitReqRate = float64(a.cacheHitRequests) * 100 / float64(a.requestCount) + } list = append(list, map[string]any{ - "model_name": model, - "request_count": a.requestCount, - "success_count": a.successCount, - "success_rate": math.Round(successRate*100) / 100, - "grade": gradeSuccessRate(successRate), - "input_tokens": a.inputTokens, - "output_tokens": a.outputTokens, - "total_tokens": a.inputTokens + a.outputTokens, - "total_cost": a.totalCost, - "avg_latency_ms": math.Round(avgLatency*100) / 100, - "avg_first_token_ms": math.Round(avgTTFT*100) / 100, - "tps": math.Round(tps*100) / 100, + "model_name": model, + "request_count": a.requestCount, + "success_count": a.successCount, + "success_rate": math.Round(successRate*100) / 100, + "grade": gradeSuccessRate(successRate), + "input_tokens": a.inputTokens, + "output_tokens": a.outputTokens, + "total_tokens": a.inputTokens + a.outputTokens, + "total_cost": a.totalCost, + "avg_latency_ms": math.Round(avgLatency*100) / 100, + "avg_first_token_ms": math.Round(avgTTFT*100) / 100, + "tps": math.Round(tps*100) / 100, + "cache_creation_tokens": a.cacheCreationTokens, + "cache_read_tokens": a.cacheReadTokens, + "cache_hit_rate": math.Round(cacheHitRate*100) / 100, + "cache_hit_request_count": a.cacheHitRequests, + "cache_hit_request_rate": math.Round(cacheHitReqRate*100) / 100, }) } // 默认按模型名称升序排列,便于运营按名称定位具体模型;用户可在前端表格点任意列切换排序。 diff --git a/internal/logic/monitor/api_metrics_test.go b/internal/logic/monitor/api_metrics_test.go index e84079f..aa4414a 100644 --- a/internal/logic/monitor/api_metrics_test.go +++ b/internal/logic/monitor/api_metrics_test.go @@ -37,8 +37,10 @@ func assertPerfField(t *testing.T, list []map[string]any, model, field string, w func TestBuildModelPerformanceList_HistoryOnly(t *testing.T) { rows := []modelPerfRow{ - {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000}, - {ModelName: "claude-3-5-sonnet", RequestCount: 50, SuccessCount: 1, InputTokens: 100, OutputTokens: 50, TotalCost: 0.1, SumLatencyMs: 30000, SumFirstTokenMs: 0}, + {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000, + CacheReadTokens: 800, CacheHitRequestCount: 80}, + {ModelName: "claude-3-5-sonnet", RequestCount: 50, SuccessCount: 1, InputTokens: 100, OutputTokens: 50, TotalCost: 0.1, SumLatencyMs: 30000, SumFirstTokenMs: 0, + CacheCreationTokens: 200, CacheReadTokens: 700, CacheHitRequestCount: 40}, } list := buildModelPerformanceList(rows, nil) @@ -54,20 +56,32 @@ func TestBuildModelPerformanceList_HistoryOnly(t *testing.T) { assertPerfField(t, list, "gpt-4o", "total_tokens", 0, 3000) assertPerfField(t, list, "gpt-4o", "total_cost", 1.5, 0) + // 缓存:无写入,命中率 = 800/(1000+0+800) = 44.44%;请求级 = 80/100 = 80% + assertPerfField(t, list, "gpt-4o", "cache_read_tokens", 0, 800) + assertPerfField(t, list, "gpt-4o", "cache_creation_tokens", 0, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_rate", 44.44, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_count", 0, 80) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_rate", 80.0, 0) + // claude 成功率 2%(1/50)→ critical assertPerfField(t, list, "claude-3-5-sonnet", "success_rate", 2.0, 0) assertGrade(t, list, "claude-3-5-sonnet", "critical") assertPerfField(t, list, "claude-3-5-sonnet", "avg_latency_ms", 600.0, 0) assertPerfField(t, list, "claude-3-5-sonnet", "tps", 1.67, 0) // 50/(30000/1000)=1.6666 + + // 缓存:命中率 = 700/(100+200+700) = 70%;请求级 = 40/50 = 80% + assertPerfField(t, list, "claude-3-5-sonnet", "cache_hit_rate", 70.0, 0) + assertPerfField(t, list, "claude-3-5-sonnet", "cache_hit_request_rate", 80.0, 0) } func TestBuildModelPerformanceList_MergeToday(t *testing.T) { rows := []modelPerfRow{ - {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000}, + {ModelName: "gpt-4o", RequestCount: 100, SuccessCount: 90, InputTokens: 1000, OutputTokens: 2000, TotalCost: 1.5, SumLatencyMs: 50000, SumFirstTokenMs: 8000, + CacheCreationTokens: 100, CacheReadTokens: 800, CacheHitRequestCount: 70}, } today := map[string]common.ModelPerfCounter{ - "gpt-4o": {Req: 10, Ok: 9, Lat: 6000, Ttft: 500, TtftN: 10, Tin: 50, Tout: 150, CostMicro: 500000}, // 500000 micro = 0.5 USD - "new-model-day": {Req: 1, Ok: 0, Lat: 8000, Ttft: 0, TtftN: 0, Tin: 100, Tout: 0, CostMicro: 12345}, + "gpt-4o": {Req: 10, Ok: 9, Lat: 6000, Ttft: 500, TtftN: 10, Tin: 50, Tout: 150, CostMicro: 500000, CacheCreation: 20, CacheRead: 180, CacheHitReq: 8}, // 500000 micro = 0.5 USD + "new-model-day": {Req: 1, Ok: 0, Lat: 8000, Ttft: 0, TtftN: 0, Tin: 100, Tout: 0, CostMicro: 12345, CacheRead: 60, CacheHitReq: 1}, } list := buildModelPerformanceList(rows, today) @@ -79,6 +93,14 @@ func TestBuildModelPerformanceList_MergeToday(t *testing.T) { assertPerfField(t, list, "gpt-4o", "total_tokens", 0, 3200) // (1000+50)+(2000+150) assertPerfField(t, list, "gpt-4o", "total_cost", 2.0, 0) + // 缓存合并:read 800+180=980、creation 100+20=120、命中请求 70+8=78; + // 命中率 = 980/((1000+50)+120+980) = 980/2150 = 45.58%;请求级 = 78/110 = 70.91% + assertPerfField(t, list, "gpt-4o", "cache_read_tokens", 0, 980) + assertPerfField(t, list, "gpt-4o", "cache_creation_tokens", 0, 120) + assertPerfField(t, list, "gpt-4o", "cache_hit_rate", 45.58, 0) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_count", 0, 78) + assertPerfField(t, list, "gpt-4o", "cache_hit_request_rate", 70.91, 0) + // 仅当日模型 assertPerfField(t, list, "new-model-day", "request_count", 0, 1) assertPerfField(t, list, "new-model-day", "total_cost", 0.012345, 0) @@ -86,10 +108,35 @@ func TestBuildModelPerformanceList_MergeToday(t *testing.T) { assertPerfField(t, list, "new-model-day", "avg_latency_ms", 8000.0, 0) assertPerfField(t, list, "new-model-day", "tps", 0.0, 0) // 无输出 token + // 仅当日缓存:命中率 = 60/(100+0+60) = 37.5%;请求级 = 1/1 = 100% + assertPerfField(t, list, "new-model-day", "cache_hit_rate", 37.5, 0) + assertPerfField(t, list, "new-model-day", "cache_hit_request_rate", 100.0, 0) + // 按模型名称升序:gpt-4o('g')在 new-model-day('n')前 _ = assertOrder(t, list, "gpt-4o", "new-model-day") } +// 缓存边界:仅写入未命中(creation>0、read=0)不算命中请求;无缓存活动时分母为 0 不 panic、比率为 0。 +func TestBuildModelPerformanceList_CacheEdgeCases(t *testing.T) { + rows := []modelPerfRow{ + // 首次写入缓存,尚未命中:creation 500 / read 0 / 命中请求 0 + {ModelName: "write-only", RequestCount: 10, SuccessCount: 10, InputTokens: 500, OutputTokens: 100, + CacheCreationTokens: 500}, + // 完全无缓存活动(embedding 类模型) + {ModelName: "text-embedding-3", RequestCount: 20, SuccessCount: 20, InputTokens: 4000, OutputTokens: 0}, + } + list := buildModelPerformanceList(rows, nil) + + assertPerfField(t, list, "write-only", "cache_creation_tokens", 0, 500) + assertPerfField(t, list, "write-only", "cache_read_tokens", 0, 0) + assertPerfField(t, list, "write-only", "cache_hit_rate", 0.0, 0) // 0/(500+500+0) + assertPerfField(t, list, "write-only", "cache_hit_request_count", 0, 0) // 仅写入不计命中 + assertPerfField(t, list, "write-only", "cache_hit_request_rate", 0.0, 0) // 0/10 + + assertPerfField(t, list, "text-embedding-3", "cache_hit_rate", 0.0, 0) + assertPerfField(t, list, "text-embedding-3", "cache_hit_request_rate", 0.0, 0) +} + func TestBuildModelPerformanceList_FilterUnknownAndEmpty(t *testing.T) { rows := []modelPerfRow{ {ModelName: "unknown", RequestCount: 999, SuccessCount: 0}, diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 27f9696..a7f7e0c 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -343,6 +343,9 @@ func (p *DataProviderImpl) RecordUsage(ctx context.Context, record *common.Usage InputTokens: record.PromptTokens, OutputTokens: record.CompletionTokens, CostUSD: record.TotalCost, + // 缓存 token 透传给热桶(命中请求判定由热桶模块从 read>0 推导) + CacheCreationTokens: record.CacheCreationTokens, + CacheReadTokens: record.CacheReadTokens, }) } diff --git a/internal/logic/task/usage_aggregation.go b/internal/logic/task/usage_aggregation.go index 8c4e108..b792951 100644 --- a/internal/logic/task/usage_aggregation.go +++ b/internal/logic/task/usage_aggregation.go @@ -13,7 +13,9 @@ const aggregateUsageSQL = ` INSERT INTO bil_usage_daily (stat_date, tenant_id, project_id, model_name, channel_id, status, request_count, input_tokens, output_tokens, total_cost, account_cost, - sum_latency_ms, sum_first_token_ms, updated_at) + sum_latency_ms, sum_first_token_ms, + cache_creation_tokens, cache_read_tokens, cache_hit_request_count, + updated_at) SELECT DATE(created_at) AS stat_date, tenant_id AS tenant_id, @@ -28,6 +30,9 @@ SELECT COALESCE(SUM(account_cost), 0) AS account_cost, COALESCE(SUM(latency_ms), 0) AS sum_latency_ms, COALESCE(SUM(first_token_ms), 0) AS sum_first_token_ms, + COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(CASE WHEN cache_read_tokens > 0 THEN 1 ELSE 0 END), 0) AS cache_hit_request_count, now() FROM bil_usage_logs WHERE created_at >= $1 AND created_at < $2 @@ -39,14 +44,17 @@ GROUP BY COALESCE(channel_id, 0), COALESCE(NULLIF(status, ''), 'unknown') ON CONFLICT (stat_date, tenant_id, project_id, model_name, channel_id, status) DO UPDATE SET - request_count = EXCLUDED.request_count, - input_tokens = EXCLUDED.input_tokens, - output_tokens = EXCLUDED.output_tokens, - total_cost = EXCLUDED.total_cost, - account_cost = EXCLUDED.account_cost, - sum_latency_ms = EXCLUDED.sum_latency_ms, + request_count = EXCLUDED.request_count, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + total_cost = EXCLUDED.total_cost, + account_cost = EXCLUDED.account_cost, + sum_latency_ms = EXCLUDED.sum_latency_ms, sum_first_token_ms = EXCLUDED.sum_first_token_ms, - updated_at = now() + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + cache_hit_request_count = EXCLUDED.cache_hit_request_count, + updated_at = now() ` // AggregateUsageRange 将 bil_usage_logs 在 [startDate, endDate)(半开区间)的用量 diff --git a/internal/model/do/bil_usage_daily.go b/internal/model/do/bil_usage_daily.go index 4a5efc8..09720bd 100644 --- a/internal/model/do/bil_usage_daily.go +++ b/internal/model/do/bil_usage_daily.go @@ -11,21 +11,24 @@ import ( // BilUsageDaily is the golang structure of table bil_usage_daily for DAO operations like Where/Data. type BilUsageDaily struct { - g.Meta `orm:"table:bil_usage_daily, do:true"` - Id any // - StatDate *gtime.Time // 统计日期(按 created_at 的日期分桶) - TenantId any // 租户ID - ProjectId any // 项目ID(0=无项目/个人Key) - ModelName any // 模型名称 - ChannelId any // 渠道ID(0=无渠道) - Status any // 请求状态:success/error/timeout/cancelled - RequestCount any // 请求数(COUNT(*)) - InputTokens any // 输入Token合计 - OutputTokens any // 输出Token合计 - TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs any // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // + g.Meta `orm:"table:bil_usage_daily, do:true"` + Id any // + StatDate *gtime.Time // 统计日期(按 created_at 的日期分桶) + TenantId any // 租户ID + ProjectId any // 项目ID(0=无项目/个人Key) + ModelName any // 模型名称 + ChannelId any // 渠道ID(0=无渠道) + Status any // 请求状态:success/error/timeout/cancelled + RequestCount any // 请求数(COUNT(*)) + InputTokens any // 输入Token合计 + OutputTokens any // 输出Token合计 + TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs any // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // + CacheCreationTokens any // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens any // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount any // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/do/mdl_tenant_models.go b/internal/model/do/mdl_tenant_models.go index c849def..e510c84 100644 --- a/internal/model/do/mdl_tenant_models.go +++ b/internal/model/do/mdl_tenant_models.go @@ -19,7 +19,7 @@ type MdlTenantModels struct { Enabled any // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice *decimal.Decimal // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice *decimal.Decimal // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier any // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt *gtime.Time // 创建时间 UpdatedAt *gtime.Time // 更新时间 BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/internal/model/entity/bil_usage_daily.go b/internal/model/entity/bil_usage_daily.go index 96a3937..77d3330 100644 --- a/internal/model/entity/bil_usage_daily.go +++ b/internal/model/entity/bil_usage_daily.go @@ -11,20 +11,23 @@ import ( // BilUsageDaily is the golang structure for table bil_usage_daily. type BilUsageDaily struct { - Id int64 `json:"id" orm:"id" description:""` // - StatDate *gtime.Time `json:"stat_date" orm:"stat_date" description:"统计日期(按 created_at 的日期分桶)"` // 统计日期(按 created_at 的日期分桶) - TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID - ProjectId int64 `json:"project_id" orm:"project_id" description:"项目ID(0=无项目/个人Key)"` // 项目ID(0=无项目/个人Key) - ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称 - ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道) - Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled - RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*)) - InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计 - OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计 - TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) - AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) - SumLatencyMs int64 `json:"sum_latency_ms" orm:"sum_latency_ms" description:"总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值)"` // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) - SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) - CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` // - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` // + Id int64 `json:"id" orm:"id" description:""` // + StatDate *gtime.Time `json:"stat_date" orm:"stat_date" description:"统计日期(按 created_at 的日期分桶)"` // 统计日期(按 created_at 的日期分桶) + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID + ProjectId int64 `json:"project_id" orm:"project_id" description:"项目ID(0=无项目/个人Key)"` // 项目ID(0=无项目/个人Key) + ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称 + ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道) + Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled + RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*)) + InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计 + OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计 + TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost) + AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析) + SumLatencyMs int64 `json:"sum_latency_ms" orm:"sum_latency_ms" description:"总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值)"` // 总延迟合计(ms,源自 bil_usage_logs.latency_ms;视图按 SUM/COUNT 求均值) + SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` // + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` // + CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数(Claude cache_creation,SUM 聚合)"` // 缓存创建 token 数(Claude cache_creation,SUM 聚合) + CacheReadTokens int64 `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)"` // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合) + CacheHitRequestCount int64 `json:"cache_hit_request_count" orm:"cache_hit_request_count" description:"命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/entity/mdl_tenant_models.go b/internal/model/entity/mdl_tenant_models.go index ca7b682..4ef62d5 100644 --- a/internal/model/entity/mdl_tenant_models.go +++ b/internal/model/entity/mdl_tenant_models.go @@ -17,7 +17,7 @@ type MdlTenantModels struct { Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型) CustomInputPrice *decimal.Decimal `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价) CustomOutputPrice *decimal.Decimal `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价) - Multiplier decimal.Decimal `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率) + Multiplier decimal.Decimal `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier"` // 租户价格倍率(VIP 折扣)。作为 tenant_multiplier 参与最终价格计算:最终价格 = 基础价格 × 租户倍率。倍率来源优先级:discount_ratio > multiplier > 租户等级 price_multiplier CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认) diff --git a/migrations/000016_add_cache_columns_to_bil_usage_daily.sql b/migrations/000016_add_cache_columns_to_bil_usage_daily.sql new file mode 100644 index 0000000..3b3c60b --- /dev/null +++ b/migrations/000016_add_cache_columns_to_bil_usage_daily.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- 模型性能监控:bil_usage_daily 增加缓存聚合列(数据源自 bil_usage_logs 已有的缓存明细字段)。 +-- cache_creation_tokens 缓存创建 token 数(Claude cache_creation_input_tokens) +-- cache_read_tokens 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens) +-- cache_hit_request_count 命中缓存的请求数(明细行 cache_read_tokens > 0 计 1,与 request_count 同分母) +-- 上线后需用 aggregate-usage 命令回填历史区间(ON CONFLICT 幂等)。 + +ALTER TABLE bil_usage_daily ADD COLUMN cache_creation_tokens BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_creation_tokens IS '缓存创建 token 数(Claude cache_creation,SUM 聚合)'; + +ALTER TABLE bil_usage_daily ADD COLUMN cache_read_tokens BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_read_tokens IS '缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)'; + +ALTER TABLE bil_usage_daily ADD COLUMN cache_hit_request_count BIGINT DEFAULT 0 NOT NULL; +COMMENT ON COLUMN bil_usage_daily.cache_hit_request_count IS '命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)'; + +-- +goose Down +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_creation_tokens; +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_read_tokens; +ALTER TABLE bil_usage_daily DROP COLUMN IF EXISTS cache_hit_request_count; diff --git a/web/admin/src/views/monitor/ModelPerformancePage.vue b/web/admin/src/views/monitor/ModelPerformancePage.vue index c11169b..c2121fe 100644 --- a/web/admin/src/views/monitor/ModelPerformancePage.vue +++ b/web/admin/src/views/monitor/ModelPerformancePage.vue @@ -1,6 +1,7 @@