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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user