feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
- 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:
1 parent
18381f65fd
commit
be38651ba4
32 files changed
+2233
-44
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user