feat(llm): 用量落库 + 只读汇总端点 + 面板用量小卡(契约 v0.64)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 22:16:26 +08:00
1 parent 18381f65fd
commit be38651ba4
32 files changed
+2233 -44

No files matched your search

+33 -9
View File
@@ -45,17 +45,41 @@ type RuntimeLLM struct {
path string
env config.LLMConfig
log *slog.Logger
sink UsageSink
current atomic.Value // 类型:*runtimeChainSnapshot
}
// RuntimeOption 用于配置 RuntimeLLM。
type RuntimeOption func(*RuntimeLLM)
// WithRuntimeUsageSink 装入用量落库出口(切片 U1):它透传到本栈构建的**每一条**
// 链、每一步适配器(含按角色跨协议的链与本地第三级),因此任何一条路径上的模型
// 调用都会被记入成本。nil 会被忽略,从而保留「只记日志」的默认行为。
//
// 为什么 sink 挂在栈上而不是适配器上:适配器由本栈在**每次热重载**时重建,sink
// 必须是那段生命周期之外的稳定引用 —— 否则面板每保存一次 models.json,落库就
// 静默断一次。
func WithRuntimeUsageSink(sink UsageSink) RuntimeOption {
return func(m *RuntimeLLM) {
if sink != nil {
m.sink = sink
}
}
}
// NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退
// 到 env。它会加载初始快照,因此运行时立即可用。
// log 可为 nil(此时使用丢弃式日志器)。
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger, opts ...RuntimeOption) *RuntimeLLM {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")}
for _, opt := range opts {
if opt != nil {
opt(m)
}
}
m.loadInitial()
return m
}
@@ -184,7 +208,7 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
if len(groups) == 0 {
groups = runtimeconfig.GroupFromEnv(m.env)
}
snap, err := buildSnapshot(groups, m.env, m.log)
snap, err := buildSnapshot(groups, m.env, m.log, m.sink)
if err != nil {
return err
}
@@ -213,11 +237,11 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由
// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了
// 那个决策(裁决 14.4.1/14.4.5)。
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) (*runtimeChainSnapshot, error) {
active := groups[0]
cfg := active.ToLLMConfig()
cfg.Local = env.Local
chain, err := NewChatChainWithLog(cfg, log)
chain, err := newChainWith(cfg, log, sink)
if err != nil && !errors.Is(err, ErrNoModel) {
return nil, err
}
@@ -233,7 +257,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
router: router,
group: active,
groups: groups,
providerChains: personaProviderChains(groups, active, env, log),
providerChains: personaProviderChains(groups, active, env, log, sink),
}, nil
}
@@ -248,7 +272,7 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
// 模型名经路由器到达它。personaProviders 中协议
// 没有对应组的条目会被上报并跳过——半构建的链只会
// 失败,该 persona 改为保留默认链。
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain {
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger, sink UsageSink) map[string]*Chain {
if len(active.PersonaProviders) == 0 {
return nil
}
@@ -272,7 +296,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
"persona", persona, "provider", provider)
continue
}
chain := personaChain(g, model, env, log)
chain := personaChain(g, model, env, log, sink)
if chain != nil {
out[provider] = chain
}
@@ -289,7 +313,7 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
// (reasoning.kind)随组的配置一起走,因此被路由到
// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因,
// 并在该组无法服务一条链时返回 nil。
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain {
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger, sink UsageSink) *Chain {
cfg := g.ToLLMConfig()
if cfg.Provider == ProviderAnthropic {
cfg.AnthropicModel = model
@@ -297,7 +321,7 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log
cfg.Model = model
}
cfg.Local = env.Local
chain, err := NewChatChainWithLog(cfg, log)
chain, err := newChainWith(cfg, log, sink)
if err != nil && !errors.Is(err, ErrNoModel) {
warn(log, "per-persona chain skipped", "group", g.ID, "model", model, "error", err)
return nil