feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- model 层新增可选 UsageSink(nil ⇒ 逐字不变,既有用例零改动)
- 组合根注入 UsageWriter:有界队列 1024 + 单写者 + 批量事务(128/批、2s 兜底),
  队列满丢弃 + 限流 WARN,绝不阻塞模型调用;sink 挂 RuntimeLLM(热重载不断)
- 迁移 0017:表 llm_usage(13 列,session_id 可空)+ idx_created_at + idx_session_id
- 新端点 GET /api/v1/admin/llm/usage(owner|admin 读;缺省近 24h、上限 90 天、
  超限 400 不静默裁剪;hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;
  绝不含凭据与正文;未接线/查库失败 ⇒ 500 usage_unavailable)
- LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理):启动清一次 + 每小时
- 面板 LLM 页「用量(近 24h)」小卡 + Docs 两份同步 v0.64
- 已知缺口(如实登记):session_id 恒 NULL,需 loop 侧在请求上打标,留列后接
This commit is contained in:
AskaEth committed 2026-10-04 22:16:26 +08:00
1 parent 18381f65fd
commit be38651ba4
32 files changed
+2233 -44

No files matched your search

+83 -19
View File
@@ -16,32 +16,96 @@ import (
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
// 不会有「换个调用入口就没了成本日志」的缺口。
//
// 红线:日志里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey / token)绝不
// 出现在这里**,它连参数都进不来。
// 出口有**两条**,互不阻塞:
//
// 1. 结构化日志(本条一直如此,T1);
// 2. 落库(切片 U1)—— 经可选的 UsageSink 交给组合根注入的异步写入器。
// sink 为 nil 时**只记日志、行为逐字不变**(既有测试因此零改动)。
//
// 红线:日志与落库样本里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey /
// token)与消息正文绝不出现**,它们连参数都进不来。
// usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。
const usageLogEvent = "model usage"
// UsageSample 是**一次成功模型调用的用量样本**:既是那条结构化日志的字段来源,
// 也是落库写入(UsageSink)的载荷。
//
// 它刻意只带非敏感计数与标签:凭据与正文不在字段集里,因此无论日志还是落库都
// 没有把它们写出去的可能。
type UsageSample struct {
// Adapter 是服务该次调用的适配器/提供商分组标识。
Adapter string
// Model 是产出该响应的模型名。
Model string
// Purpose 是调用用途标签(如 "chat" / "memory_extract" / "engage_judge")。
Purpose string
// PromptTokens / CompletionTokens / TotalTokens 是提供商上报的用量。
PromptTokens int
CompletionTokens int
TotalTokens int
// CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数。
CacheHitTokens int
CacheMissTokens int
// ReasoningTokens 是完成侧里属于思维链的 token 数。
ReasoningTokens int
// TookMs 是端到端调用耗时(毫秒)。
TookMs int64
}
// UsageSink 接收每次成功调用的用量样本。实现方必须**非阻塞**:它在模型调用的
// 返回路径上被同步调用,任何等待都会把成本记到主人的每次对话上。
//
// 组合根注入的 model.UsageWriter 满足它(有界队列 + 单写者,满则丢弃并限流告警)。
// 为 nil = 不落库,只记日志 —— 这是「没接 sink」与「接了 sink」唯一的行为差别。
type UsageSink interface {
// RecordUsage 收下一次调用的用量样本。实现方绝不阻塞、绝不 panic。
RecordUsage(sample UsageSample)
}
// usageSampleOf 把一次调用归一化成 UsageSample。它是日志与落库**唯一**的字段来源,
// 两处口径因此不可能分叉。
func usageSampleOf(adapter string, req Request, resp Response, took time.Duration) UsageSample {
return UsageSample{
Adapter: adapter,
Model: resp.Model,
Purpose: req.Purpose,
PromptTokens: resp.PromptTokens,
CompletionTokens: resp.CompletionTokens,
TotalTokens: resp.TotalTokens,
CacheHitTokens: resp.CacheHitTokens,
CacheMissTokens: resp.CacheMissTokens,
ReasoningTokens: resp.ReasoningTokens,
TookMs: took.Milliseconds(),
}
}
// logUsage 记一条结构化用量日志:prompt / completion / total / reasoning /
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时。
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时;随后把同一个
// 样本交给可选的 sink 落库。
//
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
// 缺字段容错、绝不 panic。
func logUsage(log *slog.Logger, adapter string, req Request, resp Response, took time.Duration) {
if log == nil {
// 缺字段容错、绝不 panic。sink 为 nil 时**这一步是空操作** —— 逐字等于引入落库
// 之前的行为。
func logUsage(log *slog.Logger, sink UsageSink, adapter string, req Request, resp Response, took time.Duration) {
sample := usageSampleOf(adapter, req, resp, took)
if log != nil {
log.Info(usageLogEvent,
"adapter", sample.Adapter,
"model", sample.Model,
"purpose", sample.Purpose,
"prompt", sample.PromptTokens,
"completion", sample.CompletionTokens,
"total", sample.TotalTokens,
"reasoning", sample.ReasoningTokens,
"cacheHit", sample.CacheHitTokens,
"cacheMiss", sample.CacheMissTokens,
"hitRate", resp.CacheHitRate(),
"tookMs", sample.TookMs,
)
}
if sink == nil {
return
}
log.Info(usageLogEvent,
"adapter", adapter,
"model", resp.Model,
"purpose", req.Purpose,
"prompt", resp.PromptTokens,
"completion", resp.CompletionTokens,
"total", resp.TotalTokens,
"reasoning", resp.ReasoningTokens,
"cacheHit", resp.CacheHitTokens,
"cacheMiss", resp.CacheMissTokens,
"hitRate", resp.CacheHitRate(),
"tookMs", took.Milliseconds(),
)
sink.RecordUsage(sample)
}