Files
Cyrene_v2/cmd/cyrene/wire_usage.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

127 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}