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,258 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是**用量落库的写入侧**(切片 U1,主人裁决「2.4 落」):一个有界队列 +
|
||||
// 单写者 + 批量事务的异步写入器。
|
||||
//
|
||||
// 为什么不是「在 logUsage 里直接 INSERT」:那条语句跑在**每一次模型调用的返回
|
||||
// 路径**上,库慢一点、锁一下,主人的每次对话就跟着慢一点 —— 记成本的开销绝不
|
||||
// 能加到被记成本的那次调用上。这里因此把两者彻底解耦:
|
||||
//
|
||||
// 1. `RecordUsage` 只往**有界** channel 里塞一个样本,塞不进就**丢弃并限流
|
||||
// WARN**(绝不阻塞、绝不 panic、绝不无界增长);
|
||||
// 2. 一个常驻单写者把队列攒成批次,在一个事务里落库;
|
||||
// 3. 整批失败只记一条带来源的 WARN(**不静默**),调用路径毫无感知。
|
||||
//
|
||||
// 写入样本里没有凭据、没有正文(见 UsageSample),这条红线在类型层面就成立。
|
||||
|
||||
// usageSource 是本文件每条日志的来源标签(跨模块可追溯定位,项目规范 §10)。
|
||||
const usageSource = "model/usage_store"
|
||||
|
||||
const (
|
||||
// usageQueueCapacity 是落库队列的容量。它是**有界**的:满了就丢弃,
|
||||
// 而不是把模型调用拖住。
|
||||
usageQueueCapacity = 1024
|
||||
// usageBatchMax 是单次批量事务最多写入的行数。
|
||||
usageBatchMax = 128
|
||||
// usageFlushInterval 是队列非空时的最长等待:低频调用不会因为没攒满一批
|
||||
// 就一直不落库(面板看得到的是近 24h,不该差这几十秒)。
|
||||
usageFlushInterval = 2 * time.Second
|
||||
// usageDropWarnInterval 是「队列满、已丢弃样本」告警的限流间隔:真实部署里
|
||||
// 库挂了会让每一次调用都丢弃,不限流就会把日志刷爆。
|
||||
usageDropWarnInterval = 30 * time.Second
|
||||
// usageFinalFlushTimeout 是停止时把队列剩余样本写完的期限。
|
||||
usageFinalFlushTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// insertUsageSQL 写入一条用量样本。**session_id 当前恒为 NULL**(模型适配器跑在
|
||||
// loop 之下、没有会话概念;见迁移 0017 的说明),列留着是为了接线时只改这一处。
|
||||
const insertUsageSQL = `INSERT INTO llm_usage
|
||||
(session_id, purpose, adapter, model, prompt_tokens, completion_tokens,
|
||||
cache_hit_tokens, cache_miss_tokens, reasoning_tokens, total_tokens, took_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
// UsageWriter 把用量样本异步落进 `llm_usage`。它实现 UsageSink,因此在模型适配器
|
||||
// 里就是一个「可选出口」:注入即有、不注入则只记日志。
|
||||
//
|
||||
// 构造后必须 Start 才会真的落库;未 Start 时队列照样收、塞满后照样丢弃并告警
|
||||
// (不静默失败)。Stop 必须在关库之前调用。
|
||||
type UsageWriter struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
|
||||
queue chan UsageSample
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
started bool
|
||||
dropped int64
|
||||
lastWarn time.Time
|
||||
}
|
||||
|
||||
// NewUsageWriter 构造写入器。db 为 nil(未接库)时返回 nil —— 调用方据此把
|
||||
// 「无 sink」原样传给适配器,行为等于这一版之前。
|
||||
func NewUsageWriter(db *sql.DB, log *slog.Logger) *UsageWriter {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
if log == nil {
|
||||
log = discardLogger(usageSource)
|
||||
}
|
||||
return &UsageWriter{
|
||||
db: db,
|
||||
log: logger.WithSource(log, usageSource),
|
||||
queue: make(chan UsageSample, usageQueueCapacity),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordUsage 实现 UsageSink:把样本塞进有界队列,塞不进就丢弃并(限流)告警。
|
||||
//
|
||||
// 它**绝不阻塞**:整个函数是一次非阻塞 channel 发送加一次可能的加锁自增,
|
||||
// 因此模型调用路径的额外开销与库的健康状况无关。
|
||||
func (w *UsageWriter) RecordUsage(sample UsageSample) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.queue <- sample:
|
||||
default:
|
||||
w.noteDrop()
|
||||
}
|
||||
}
|
||||
|
||||
// Start 拉起单写者循环。重复调用是空操作;ctx 取消(或 Stop)后循环退出。
|
||||
func (w *UsageWriter) Start(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.started {
|
||||
return
|
||||
}
|
||||
w.started = true
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
w.cancel = cancel
|
||||
go w.loop(runCtx)
|
||||
}
|
||||
|
||||
// Stop 停掉循环并等它退出,退出前尽力把队列里剩下的样本写完。未启动时是空操作。
|
||||
// 它必须在关库**之前**调用(收尾写入要用那条连接池)。
|
||||
func (w *UsageWriter) Stop() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
cancel := w.cancel
|
||||
started := w.started
|
||||
w.mu.Unlock()
|
||||
if !started || cancel == nil {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
<-w.done
|
||||
}
|
||||
|
||||
// loop 是单写者:攒批 → 批量事务落库,直到 ctx 取消。
|
||||
func (w *UsageWriter) loop(ctx context.Context) {
|
||||
defer close(w.done)
|
||||
ticker := time.NewTicker(usageFlushInterval)
|
||||
defer ticker.Stop()
|
||||
batch := make([]UsageSample, 0, usageBatchMax)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
w.writeBatch(ctx, batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// 收尾用**脱离取消**的上下文:ctx 已经取消,沿用它会让最后一批
|
||||
// 必然写失败,等于丢掉已经收下的样本。
|
||||
w.flushFinal(drainQueue(w.queue, batch))
|
||||
return
|
||||
case sample := <-w.queue:
|
||||
batch = append(batch, sample)
|
||||
if len(batch) >= usageBatchMax {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushFinal 在停机路径上把剩余样本写完(有期限,绝不无限等库)。
|
||||
func (w *UsageWriter) flushFinal(remaining []UsageSample) {
|
||||
if len(remaining) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), usageFinalFlushTimeout)
|
||||
defer cancel()
|
||||
w.writeBatch(ctx, remaining)
|
||||
}
|
||||
|
||||
// writeBatch 在一个事务里落一批样本。失败只记 WARN(带批次大小与原因),
|
||||
// 绝不上抛 —— 调用路径早已返回,没有任何人在等这个结果。
|
||||
func (w *UsageWriter) writeBatch(ctx context.Context, batch []UsageSample) {
|
||||
if err := insertUsageBatch(ctx, w.db, batch); err != nil {
|
||||
w.log.Warn("llm usage batch insert failed; batch dropped", "count", len(batch), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// noteDrop 记一次「队列满、样本被丢弃」,并按 usageDropWarnInterval 限流告警。
|
||||
// 丢弃计数是累计值,因此告警里能看到这一段到底丢了多少条。
|
||||
func (w *UsageWriter) noteDrop() {
|
||||
w.mu.Lock()
|
||||
w.dropped++
|
||||
dropped := w.dropped
|
||||
now := time.Now()
|
||||
shouldWarn := w.lastWarn.IsZero() || now.Sub(w.lastWarn) >= usageDropWarnInterval
|
||||
if shouldWarn {
|
||||
w.lastWarn = now
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if shouldWarn {
|
||||
w.log.Warn("llm usage queue full; samples dropped (never blocks the model call)",
|
||||
"dropped", dropped, "capacity", usageQueueCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
// drainQueue 把队列里当前排着的样本全部取出(不阻塞),追加到 dst 之后返回。
|
||||
func drainQueue(queue chan UsageSample, dst []UsageSample) []UsageSample {
|
||||
for {
|
||||
select {
|
||||
case sample := <-queue:
|
||||
dst = append(dst, sample)
|
||||
default:
|
||||
return dst
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// insertUsageBatch 在一个事务里写入一批样本:单条 prepared statement 循环执行,
|
||||
// 一次提交。用 prepared statement 而不是多值 INSERT,是为了避开各家 SQLite 对
|
||||
// 绑定参数个数的不同上限(11 列 × 128 行)。
|
||||
func insertUsageBatch(ctx context.Context, db *sql.DB, batch []UsageSample) error {
|
||||
if db == nil || len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin usage batch: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, insertUsageSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare usage insert: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for i, sample := range batch {
|
||||
// session_id 传 NULL:见 insertUsageSQL 的说明。
|
||||
if _, err := stmt.ExecContext(ctx,
|
||||
nil,
|
||||
sample.Purpose,
|
||||
sample.Adapter,
|
||||
sample.Model,
|
||||
sample.PromptTokens,
|
||||
sample.CompletionTokens,
|
||||
sample.CacheHitTokens,
|
||||
sample.CacheMissTokens,
|
||||
sample.ReasoningTokens,
|
||||
sample.TotalTokens,
|
||||
sample.TookMs,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert usage sample %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit usage batch: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user