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
@@ -0,0 +1,239 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUsageUnavailable 表示用量汇总出口不可用(未接库 / 未构造读取器)。
|
||||
// 网关据此回 500 usage_unavailable,而不是回一份全 0 的假汇总。
|
||||
var ErrUsageUnavailable = errors.New("model: llm usage store unavailable")
|
||||
|
||||
// 本文件是**用量落库的读取侧**(切片 U1):把 `llm_usage` 表在给定时间窗与用途
|
||||
// 过滤下聚合成一份只读汇总,供管理端点 `GET /api/v1/admin/llm/usage` 与面板的
|
||||
// 用量小卡消费。
|
||||
//
|
||||
// 三条纪律:
|
||||
//
|
||||
// 1. **只读**:只有 SELECT(外加保留期清理的 DELETE,见 PurgeUsage),不建表、
|
||||
// 不迁移,复用组合根注入的活连接。
|
||||
// 2. **不含凭据**:查出来的字段只有 token 计数与模型/用途标签 —— 表里本来就没有
|
||||
// 凭据与正文,这条红线在写入侧就已经成立。
|
||||
// 3. **不编造**:读不到就返回错误,绝不用一份全 0 的假汇总假装成功(面板据此
|
||||
// 显示错误,而不是一个看着正常的假数字)。
|
||||
|
||||
const (
|
||||
// UsageDefaultWindow 是汇总的缺省时间窗(近 24 小时):不带 since/until 时
|
||||
// 「现在」往前推这么久。
|
||||
UsageDefaultWindow = 24 * time.Hour
|
||||
// UsageMaxWindow 是一次汇总查询允许的最大时间窗(90 天)。超过它的请求由
|
||||
// 网关以 400 明确拒绝,**不静默裁剪** —— 静默裁剪会让客户端以为拿到的是
|
||||
// 全量,把「我没查那么久」误读成「就那么少」。
|
||||
UsageMaxWindow = 90 * 24 * time.Hour
|
||||
// usageTimeLayout 与迁移 0017 的 `strftime('%Y-%m-%dT%H:%M:%fZ')` 逐字同形
|
||||
// (定宽 UTC ISO-8601,毫秒)。定宽使字典序比较等价于时间序,时间窗因此能
|
||||
// 直接用字符串比较、吃上 idx_llm_usage_created_at。
|
||||
usageTimeLayout = "2006-01-02T15:04:05.000Z"
|
||||
)
|
||||
|
||||
// UsageQuery 是一次汇总查询的条件。Since/Until 为零值时由调用方按缺省窗口补齐
|
||||
// (见 UsageDefaultWindow);Purpose 为空表示不过滤。
|
||||
type UsageQuery struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Purpose string
|
||||
}
|
||||
|
||||
// UsageTotals 是一个时间窗(或一个分组)内的用量合计。全部是**非敏感计数**。
|
||||
type UsageTotals struct {
|
||||
// Calls 是窗口内成功调用次数。
|
||||
Calls int64
|
||||
// Prompt / Completion 是输入侧与输出侧 token 合计。
|
||||
Prompt int64
|
||||
Completion int64
|
||||
// CacheHit / CacheMiss 是输入侧两档 token 合计。
|
||||
CacheHit int64
|
||||
CacheMiss int64
|
||||
}
|
||||
|
||||
// HitRate 返回缓存命中率 = cacheHit / (cacheHit + cacheMiss)。分母为 0(窗口内没有
|
||||
// 任何输入侧明细,或根本没有调用)时返回 0 —— 口径与 Response.CacheHitRate 同源,
|
||||
// 不编造 100% 或 0% 以外的假值。
|
||||
func (t UsageTotals) HitRate() float64 {
|
||||
total := t.CacheHit + t.CacheMiss
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(t.CacheHit) / float64(total)
|
||||
}
|
||||
|
||||
// UsageGroup 是按某个键(用途 / 模型)聚合的一行。
|
||||
type UsageGroup struct {
|
||||
// Key 是分组键(purpose 或 model 的取值)。
|
||||
Key string
|
||||
UsageTotals
|
||||
}
|
||||
|
||||
// UsageSummary 是一次汇总查询的结果。
|
||||
type UsageSummary struct {
|
||||
// Window 是本次实际生效的时间窗与过滤条件(回显给客户端,而不是让它猜
|
||||
// 缺省值)。
|
||||
Window UsageQuery
|
||||
// Totals 是整窗合计。
|
||||
Totals UsageTotals
|
||||
// ByPurpose / ByModel 是按用途与按模型的分解(按调用次数降序,同次数按键
|
||||
// 升序 —— 输出确定,客户端可安全 diff)。
|
||||
ByPurpose []UsageGroup
|
||||
ByModel []UsageGroup
|
||||
}
|
||||
|
||||
// UsageReader 是 `llm_usage` 表的只读出口。
|
||||
type UsageReader struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUsageReader 构造读取器;db 为 nil(未接库)时返回 nil。
|
||||
func NewUsageReader(db *sql.DB) *UsageReader {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return &UsageReader{db: db}
|
||||
}
|
||||
|
||||
// usageRowScanner 是 *sql.Row 与 *sql.Rows 共有的扫码能力,使合计与分组两条
|
||||
// 查询路径共用同一个扫码函数。
|
||||
type usageRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// usageAggregateCols 是合计列清单(顺序与 scanUsageTotals 的扫码顺序逐字一致)。
|
||||
const usageAggregateCols = `COUNT(*),
|
||||
COALESCE(SUM(prompt_tokens),0),
|
||||
COALESCE(SUM(completion_tokens),0),
|
||||
COALESCE(SUM(cache_hit_tokens),0),
|
||||
COALESCE(SUM(cache_miss_tokens),0)`
|
||||
|
||||
// Summary 聚合一个时间窗内的用量。q 的时间窗必须已由调用方校验并补齐(零值窗口
|
||||
// 会原样查库、什么都查不到)。
|
||||
func (r *UsageReader) Summary(ctx context.Context, q UsageQuery) (UsageSummary, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return UsageSummary{}, fmt.Errorf("model: %w", ErrUsageUnavailable)
|
||||
}
|
||||
totals, err := r.totalsOf(ctx, q)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
byPurpose, err := r.groupsOf(ctx, q, usageGroupByPurpose)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
byModel, err := r.groupsOf(ctx, q, usageGroupByModel)
|
||||
if err != nil {
|
||||
return UsageSummary{}, err
|
||||
}
|
||||
return UsageSummary{Window: q, Totals: totals, ByPurpose: byPurpose, ByModel: byModel}, nil
|
||||
}
|
||||
|
||||
// totalsOf 读整窗合计。空窗时 SUM 为 NULL,COALESCE 收敛成 0;表不存在(迁移没跑)
|
||||
// 则直接报错,不静默报 0。
|
||||
func (r *UsageReader) totalsOf(ctx context.Context, q UsageQuery) (UsageTotals, error) {
|
||||
query := `SELECT ` + usageAggregateCols + ` FROM llm_usage WHERE created_at >= ? AND created_at <= ?`
|
||||
args := []any{usageTime(q.Since), usageTime(q.Until)}
|
||||
if q.Purpose != "" {
|
||||
query += ` AND purpose = ?`
|
||||
args = append(args, q.Purpose)
|
||||
}
|
||||
totals, err := scanUsageTotals(r.db.QueryRowContext(ctx, query, args...))
|
||||
if err != nil {
|
||||
return UsageTotals{}, fmt.Errorf("model: read usage totals: %w", err)
|
||||
}
|
||||
return totals, nil
|
||||
}
|
||||
|
||||
// usageGroupBy* 是允许的分组列:它们是代码常量,**不是**用户输入(分组列无法参数化,
|
||||
// 因此这里以白名单常量而非拼接来保证不注入)。
|
||||
const (
|
||||
usageGroupByPurpose = "purpose"
|
||||
usageGroupByModel = "model"
|
||||
)
|
||||
|
||||
// groupsOf 按 groupBy 列聚合。groupBy 只接受 usageGroupBy* 常量,未知值一律报错
|
||||
// (绝不把任意字符串拼进 SQL)。
|
||||
func (r *UsageReader) groupsOf(ctx context.Context, q UsageQuery, groupBy string) ([]UsageGroup, error) {
|
||||
if groupBy != usageGroupByPurpose && groupBy != usageGroupByModel {
|
||||
return nil, fmt.Errorf("model: %w: unknown usage group %q", ErrBadRequest, groupBy)
|
||||
}
|
||||
query := `SELECT ` + groupBy + `, ` + usageAggregateCols +
|
||||
` FROM llm_usage WHERE created_at >= ? AND created_at <= ?`
|
||||
args := []any{usageTime(q.Since), usageTime(q.Until)}
|
||||
if q.Purpose != "" {
|
||||
query += ` AND purpose = ?`
|
||||
args = append(args, q.Purpose)
|
||||
}
|
||||
query += ` GROUP BY ` + groupBy + ` ORDER BY COUNT(*) DESC, ` + groupBy + ` ASC`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: read usage groups by %s: %w", groupBy, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]UsageGroup, 0, 8)
|
||||
for rows.Next() {
|
||||
group, err := scanUsageGroup(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: scan usage group by %s: %w", groupBy, err)
|
||||
}
|
||||
out = append(out, group)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("model: iterate usage groups by %s: %w", groupBy, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanUsageTotals 按 usageAggregateCols 的顺序扫码。
|
||||
func scanUsageTotals(sc usageRowScanner) (UsageTotals, error) {
|
||||
var t UsageTotals
|
||||
if err := sc.Scan(&t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil {
|
||||
return UsageTotals{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// scanUsageGroup 扫码「分组键 + 合计列」。
|
||||
func scanUsageGroup(sc usageRowScanner) (UsageGroup, error) {
|
||||
var key string
|
||||
var t UsageTotals
|
||||
if err := sc.Scan(&key, &t.Calls, &t.Prompt, &t.Completion, &t.CacheHit, &t.CacheMiss); err != nil {
|
||||
return UsageGroup{}, err
|
||||
}
|
||||
return UsageGroup{Key: key, UsageTotals: t}, nil
|
||||
}
|
||||
|
||||
// PurgeUsage 删除 `created_at` 早于 before 的用量行,返回删除条数。db 为 nil 时是
|
||||
// 空操作。它与保留期清理(组合根按 LLM_USAGE_RETENTION 定时调用)是唯一会写这张
|
||||
// 表、又不是追加样本的语句。
|
||||
func PurgeUsage(ctx context.Context, db *sql.DB, before time.Time) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `DELETE FROM llm_usage WHERE created_at < ?`, usageTime(before))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model: purge llm usage: %w", err)
|
||||
}
|
||||
removed, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model: purge llm usage rows affected: %w", err)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// usageTime 把时间点转成与迁移 0017 的 DEFAULT 逐字同形的 UTC 字符串(定宽毫秒),
|
||||
// 使时间窗比较是纯字符串比较、且能走 idx_llm_usage_created_at。
|
||||
func usageTime(t time.Time) string {
|
||||
return t.UTC().Format(usageTimeLayout)
|
||||
}
|
||||
Reference in new issue
Block a user