- 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 侧在请求上打标,留列后接
112 lines
4.7 KiB
Go
112 lines
4.7 KiB
Go
package model
|
||
|
||
import (
|
||
"log/slog"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是**用量与缓存的可观测出口**(design/04 附录 A.3 第 4 条)。
|
||
//
|
||
// 背景(附录 A.2 第 3 条):在此之前输入侧用量从未被记录 —— 全仓只消费了
|
||
// PromptTokens / TotalTokens,`prompt_cache_hit_tokens` / `miss` 一律没解析、没落
|
||
// 日志,于是「大上下文到底贵不贵」根本无法实测。而提供商按命中/未命中两档计价,
|
||
// 命中价约为未命中的 1/50:看不见命中率,就等于看不见成本。
|
||
//
|
||
// 记录点选在**适配器内部**(每个适配器的 record 都调它一次),而不是循环里:
|
||
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
|
||
// 不会有「换个调用入口就没了成本日志」的缺口。
|
||
//
|
||
// 出口有**两条**,互不阻塞:
|
||
//
|
||
// 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,外加模型名、用途、适配器与耗时;随后把同一个
|
||
// 样本交给可选的 sink 落库。
|
||
//
|
||
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
|
||
// 缺字段容错、绝不 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
|
||
}
|
||
sink.RecordUsage(sample)
|
||
}
|