Files
Cyrene_v2/internal/runtime/model/usage_store.go
T
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

259 lines
8.2 KiB
Go

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
}