Files
AskaEth be38651ba4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 侧在请求上打标,留列后接
2026-10-04 22:16:26 +08:00

240 lines
8.8 KiB
Go

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)
}