- 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 侧在请求上打标,留列后接
127 lines
4.4 KiB
Go
127 lines
4.4 KiB
Go
package main
|
||
|
||
// 本文件把 **LLM 用量落库与保留期清理**接进组合根(切片 U1,主人裁决「2.4 落」):
|
||
// 构造异步写入器、把它注入模型栈,并在启动期清一次过期样本、此后按间隔定时清。
|
||
//
|
||
// 三条取舍写在这里(写入器实现见 `internal/runtime/model/usage_store.go`):
|
||
//
|
||
// 1. **写入绝不阻塞模型调用**:模型栈拿到的是一个有界队列 + 单写者的 sink,
|
||
// `RecordUsage` 不做任何等待(队列满就丢弃并限流 WARN)。因此库慢、库锁、
|
||
// 库挂了,主人的每次对话都不受影响。
|
||
// 2. **失败不退出进程**:整批落库失败只记一条带来源的 WARN;保留期清理失败
|
||
// 同样是 WARN 后等下一轮 —— 用量是观测数据,不该决定进程生死。
|
||
// 3. **关闭 = 显式配 ≤0**:`LLM_USAGE_RETENTION=0`(或负值)不构造清理器,
|
||
// 由下面的一条 INFO 写明「清理已关闭」(静默的「配了却不生效」不存在)。
|
||
//
|
||
// 清理间隔刻意不做成 env 键:保留期才是部署者的旋钮,清扫频率没有可调价值。
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// usagePurgeInterval 是用量保留期清理的定时间隔(启动期另有一次立即清扫)。
|
||
const usagePurgeInterval = time.Hour
|
||
|
||
// usageRetentionGC 是常驻的用量保留期清理任务:一个连接 + 一个可停的循环。
|
||
type usageRetentionGC struct {
|
||
db *sql.DB
|
||
retention time.Duration
|
||
log *slog.Logger
|
||
cancel context.CancelFunc
|
||
done chan struct{}
|
||
}
|
||
|
||
// newUsageSink 按当前配置构造用量写入器;库未接时返回 nil(= 只记日志,
|
||
// 与这一版之前逐字相同)。
|
||
func (a *app) newUsageSink() *model.UsageWriter {
|
||
return model.NewUsageWriter(a.db, a.log)
|
||
}
|
||
|
||
// newUsageRetentionGC 按当前配置构造保留期清理器;库未接或保留期 ≤0(关闭清理)
|
||
// 时返回 nil。关闭原因由 startUsage 记一条 INFO。
|
||
func (a *app) newUsageRetentionGC() *usageRetentionGC {
|
||
if a.db == nil || a.cfg.Server.LLMUsageRetention <= 0 {
|
||
return nil
|
||
}
|
||
return &usageRetentionGC{
|
||
db: a.db,
|
||
retention: a.cfg.Server.LLMUsageRetention,
|
||
log: logger.WithSource(a.log, "cmd/cyrene"),
|
||
}
|
||
}
|
||
|
||
// startUsage 拉起用量落库的单写者与保留期清理(各自关闭时是空操作)。
|
||
// 它不等清理跑完就去拉网关(与文档 GC 同一取舍:慢盘上的一次 DELETE 不该把
|
||
// 「昔涟能不能起来」绑在磁盘上)。
|
||
func (a *app) startUsage(ctx context.Context) {
|
||
if a.usage != nil {
|
||
a.usage.Start(ctx)
|
||
}
|
||
gc := a.newUsageRetentionGC()
|
||
if gc == nil {
|
||
a.log.Info("llm usage retention disabled",
|
||
"env", "LLM_USAGE_RETENTION", "value", a.cfg.Server.LLMUsageRetention.String())
|
||
return
|
||
}
|
||
runCtx, cancel := context.WithCancel(ctx)
|
||
gc.cancel = cancel
|
||
gc.done = make(chan struct{})
|
||
a.usageGC = gc
|
||
go gc.loop(runCtx)
|
||
}
|
||
|
||
// stopUsage 停掉用量落库与保留期清理(先让单写者把队列里剩下的样本写完,再停
|
||
// 清理)。两者都要用连接池,因此必须在关库**之前**调用;未启动时是空操作。
|
||
func (a *app) stopUsage() {
|
||
if a.usage != nil {
|
||
a.usage.Stop()
|
||
}
|
||
a.usageGC.stop()
|
||
}
|
||
|
||
// loop 是清理循环:启动期清扫一次 → 定时清扫。
|
||
func (g *usageRetentionGC) loop(ctx context.Context) {
|
||
defer close(g.done)
|
||
g.sweep(ctx)
|
||
ticker := time.NewTicker(usagePurgeInterval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
g.sweep(ctx)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sweep 跑一轮清理。删除条数**不是**每次都要记(正常运转时每轮都是 0,记满日志
|
||
// 只是噪声);真的删了才记一条 INFO,失败一律 WARN 并让循环继续。
|
||
func (g *usageRetentionGC) sweep(ctx context.Context) {
|
||
before := time.Now().Add(-g.retention)
|
||
removed, err := model.PurgeUsage(ctx, g.db, before)
|
||
if err != nil {
|
||
g.log.Warn("llm usage retention sweep failed", "err", err)
|
||
return
|
||
}
|
||
if removed > 0 {
|
||
g.log.Info("llm usage retention sweep removed rows",
|
||
"rows", removed, "retention", g.retention.String())
|
||
}
|
||
}
|
||
|
||
// stop 停掉清理循环并等它退出;未启动时是空操作。
|
||
func (g *usageRetentionGC) stop() {
|
||
if g == nil || g.cancel == nil {
|
||
return
|
||
}
|
||
g.cancel()
|
||
<-g.done
|
||
}
|