- 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 侧在请求上打标,留列后接
363 lines
13 KiB
Go
363 lines
13 KiB
Go
package model
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"sync/atomic"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
// ModelRouter 是 agent(与 loop)依赖的路由契约。*Router
|
||
// 实现它;RuntimeLLM 也实现它,因此一个可热切换的提供商
|
||
// 可以同时充当适配器与路由器,无需另存一份 router 引用
|
||
// 去保持同步。
|
||
type ModelRouter interface {
|
||
RouteRequest(req Request) (model string, needsVision bool)
|
||
}
|
||
|
||
// runtimeChainSnapshot 是不可变、自包含的 LLM 栈快照。它
|
||
// 被原子存储与换入:已经捕获某个快照的请求
|
||
// 整次调用都用那条确切的链+路由器,所以一次重载绝不会
|
||
// 打断进行中的请求。
|
||
type runtimeChainSnapshot struct {
|
||
chain *Chain
|
||
router *Router
|
||
group runtimeconfig.Group
|
||
groups []runtimeconfig.Group
|
||
// providerChains 为生效组的 personaProviders 声明的每个
|
||
// *其它* 协议各存一条链(裁决 03.4.1 的 per-persona provider:跨协议换
|
||
// adapter)。提供商绑定解析到这些键之一的 persona 由
|
||
// 那条链服务;其余所有请求仍走上面的默认链。
|
||
providerChains map[string]*Chain
|
||
}
|
||
|
||
// RuntimeLLM 是运行时的可热切换 LLM 提供商栈。它
|
||
// 实现 Adapter(委托给当前链)与 ModelRouter
|
||
// (委托给当前路由器)。面板保存会重载 models.json、重建
|
||
// 一份新的链+路由器并原子换入:新调用使用新栈,
|
||
// 而进行中的调用在旧栈上跑完。
|
||
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, 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
|
||
}
|
||
|
||
// Path 返回本管理器读取并重载的 models.json 路径。
|
||
func (m *RuntimeLLM) Path() string { return m.path }
|
||
|
||
// Groups 返回当前已加载组的一份拷贝;生效组(新请求
|
||
// 默认使用的那个)是下标 0。
|
||
func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
|
||
src := m.load().groups
|
||
out := make([]runtimeconfig.Group, len(src))
|
||
copy(out, src)
|
||
return out
|
||
}
|
||
|
||
// Group 返回生效组(下标 0)。
|
||
func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group }
|
||
|
||
// Current 返回当前链与路由器。返回的链对进行中的
|
||
// 调用仍然有效;新调用会拿到最新的换入结果。
|
||
func (m *RuntimeLLM) Current() (*Chain, *Router) {
|
||
snap := m.load()
|
||
return snap.chain, snap.router
|
||
}
|
||
|
||
// Reload 重新读取 models.json(回退到 env)并原子重建
|
||
// 在用的链+路由器。进行中的调用保持原快照。
|
||
func (m *RuntimeLLM) Reload() error {
|
||
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
|
||
if warn != nil {
|
||
m.log.Warn("models.json reload fell back to env", "error", warn)
|
||
}
|
||
return m.swap(groups)
|
||
}
|
||
|
||
// Provider 返回生效组的提供商协议。
|
||
func (m *RuntimeLLM) Provider() string { return m.load().group.Provider }
|
||
|
||
// Model 返回生效组的主模型名。
|
||
func (m *RuntimeLLM) Model() string { return m.load().group.Model }
|
||
|
||
// Name 实现 Adapter,返回生效组 id。
|
||
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
|
||
|
||
// Chat 实现 Adapter,委托给请求的 persona 所解析到的
|
||
// 链。在重载完成前开始的请求,会在它已捕获的快照上
|
||
// 跑完。未配置模型(链为 nil)时返回 ErrNoModel,而不是
|
||
// panic。
|
||
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||
chain := m.chainFor(req.PersonaID)
|
||
if chain == nil {
|
||
return Response{}, ErrNoModel
|
||
}
|
||
return chain.Chat(ctx, req, opts)
|
||
}
|
||
|
||
// ChatStream 实现 Adapter。
|
||
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||
chain := m.chainFor(req.PersonaID)
|
||
if chain == nil {
|
||
return Response{}, ErrNoModel
|
||
}
|
||
return chain.ChatStream(ctx, req, onEvent)
|
||
}
|
||
|
||
// chainFor 解出服务该 persona 的链(裁决 03.4.1 的
|
||
// per-persona provider)。它消费 Router.RouteProviderForPersona ——
|
||
// 路由器已为该 persona 做出的提供商决策 —— 且只在
|
||
// 二者同时成立时才切换:
|
||
//
|
||
// - 该 persona 有自己的模型绑定(只写 personaProviders 一项
|
||
// 会把组的默认模型名发到另一厂商的端点),
|
||
// - 且该提供商存在一条链(声明了协议但 models.json 里没有
|
||
// 对应组的情况会在构建快照时上报,
|
||
// 该 persona 随后保留默认链,而不是让回合失败)。
|
||
//
|
||
// 其余情形 —— 无 persona、无绑定、与生效组同协议 ——
|
||
// 返回默认链,因此没有按 persona 提供商的部署
|
||
// 行为与之前完全一致。
|
||
func (m *RuntimeLLM) chainFor(persona string) *Chain {
|
||
snap := m.load()
|
||
if p := trimKey(persona); p != "" && snap.router != nil {
|
||
if _, _, ok := snap.group.PersonaRoute(p); ok {
|
||
if c := snap.providerChains[snap.router.RouteProviderForPersona(p)]; c != nil {
|
||
return c
|
||
}
|
||
}
|
||
}
|
||
return snap.chain
|
||
}
|
||
|
||
// RouteRequest 实现 ModelRouter,委托给当前路由器。
|
||
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
|
||
r := m.load().router
|
||
if r == nil {
|
||
return "", false
|
||
}
|
||
return r.RouteRequest(req)
|
||
}
|
||
|
||
// RouteRequestForPersona 实现 PersonaRouter(裁决 03.4.1):loop 以
|
||
// 本回合作答所依据的 persona 来路由,生效 models.json 组里的
|
||
// 按 persona 绑定经由这里到达请求。
|
||
func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) {
|
||
r := m.load().router
|
||
if r == nil {
|
||
return "", false
|
||
}
|
||
return r.RouteRequestForPersona(personaID, req)
|
||
}
|
||
|
||
// loadInitial 构建首个快照;构建失败会留下 nil 链,
|
||
// 使调用报告清晰的「无模型」错误而不是 panic。
|
||
func (m *RuntimeLLM) loadInitial() {
|
||
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
|
||
if warn != nil {
|
||
m.log.Warn("models.json load fell back to env", "error", warn)
|
||
}
|
||
_ = m.swap(groups)
|
||
}
|
||
|
||
// swap 为 groups 重建快照并原子存储。空切片会回
|
||
// 退到 env 合成的组,因此始终至少有一个组。
|
||
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, m.sink)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m.current.Store(snap)
|
||
return nil
|
||
}
|
||
|
||
// load 返回当前快照;管理器从未初始化时返回一个空
|
||
// (nil 链)快照。
|
||
func (m *RuntimeLLM) load() *runtimeChainSnapshot {
|
||
if v := m.current.Load(); v != nil {
|
||
return v.(*runtimeChainSnapshot)
|
||
}
|
||
return &runtimeChainSnapshot{}
|
||
}
|
||
|
||
// buildSnapshot 把生效组(下标 0)变成链+路由器。没有
|
||
// base URL/模型的组会产出 nil 链(未配置模型),由适配器
|
||
// 方法表现为 ErrNoModel。
|
||
//
|
||
// env 本地级会被复制到组的扁平配置上,因为 models.json
|
||
// 的组不携带它:没有这次复制,第三级降级
|
||
// (裁决 03.4.3)只要 models.json 一存在就会静默消失。
|
||
//
|
||
// 路由器以生效组的按 persona 绑定播种
|
||
// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由
|
||
// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了
|
||
// 那个决策(裁决 14.4.1/14.4.5)。
|
||
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 := newChainWith(cfg, log, sink)
|
||
if err != nil && !errors.Is(err, ErrNoModel) {
|
||
return nil, err
|
||
}
|
||
if chain != nil {
|
||
// 预算在链内部解出:生效组自己的
|
||
// maxTokens(models.json)优先,env.FallbackMaxTokens 只约束
|
||
// 确实有降级步骤的链的最后一步(design/04 §3.1)。
|
||
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
|
||
}
|
||
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
|
||
return &runtimeChainSnapshot{
|
||
chain: chain,
|
||
router: router,
|
||
group: active,
|
||
groups: groups,
|
||
providerChains: personaProviderChains(groups, active, env, log, sink),
|
||
}, nil
|
||
}
|
||
|
||
// personaProviderChains 为生效组 personaProviders 声明的、且
|
||
// models.json 确实提供了对应组的每种协议各构建一条链
|
||
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter)。persona 自己的
|
||
// 模型名被钉为该链的主模型,因此即便调用方打上的
|
||
// persona 没带被路由的模型名,也仍能到达正确端点。
|
||
//
|
||
// 与生效组同协议的 persona 不需要自己
|
||
// 的链:默认链本就说那个协议,而按 persona 的
|
||
// 模型名经路由器到达它。personaProviders 中协议
|
||
// 没有对应组的条目会被上报并跳过——半构建的链只会
|
||
// 失败,该 persona 改为保留默认链。
|
||
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
|
||
}
|
||
out := make(map[string]*Chain, len(active.PersonaProviders))
|
||
for persona := range active.PersonaProviders {
|
||
model, provider, ok := active.PersonaRoute(persona)
|
||
if !ok {
|
||
warn(log, "personaProviders entry ignored: the persona binds no model of its own",
|
||
"persona", persona, "field", "personaModels")
|
||
continue
|
||
}
|
||
if provider == active.Provider {
|
||
continue
|
||
}
|
||
if _, built := out[provider]; built {
|
||
continue
|
||
}
|
||
g, found := groupForProvider(groups, provider)
|
||
if !found {
|
||
warn(log, "per-persona provider skipped: no models.json group speaks it",
|
||
"persona", persona, "provider", provider)
|
||
continue
|
||
}
|
||
chain := personaChain(g, model, env, log, sink)
|
||
if chain != nil {
|
||
out[provider] = chain
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// personaChain 构建并通过另一厂商的组服务某个 persona 的链,并
|
||
// 为其定预算:组的扁平配置,persona 自己的模型名作为
|
||
// 主模型,加上共享的本地最后一级。推理形状
|
||
// (reasoning.kind)随组的配置一起走,因此被路由到
|
||
// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因,
|
||
// 并在该组无法服务一条链时返回 nil。
|
||
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
|
||
} else {
|
||
cfg.Model = model
|
||
}
|
||
cfg.Local = env.Local
|
||
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
|
||
}
|
||
if chain == nil {
|
||
warn(log, "per-persona chain skipped: the provider group has no base url or model",
|
||
"group", g.ID, "model", model)
|
||
return nil
|
||
}
|
||
return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens)
|
||
}
|
||
|
||
// groupForProvider 返回第一条说指定协议的组。models.json 里
|
||
// 组的顺序由运维者决定,所以「第一个匹配」是一条稳定、
|
||
// 可记录的规则,而不是一次静默挑选——重复协议会在
|
||
// 要紧的时候由构建器上报。
|
||
func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) {
|
||
for _, g := range groups {
|
||
if g.Provider == provider {
|
||
return g, true
|
||
}
|
||
}
|
||
return runtimeconfig.Group{}, false
|
||
}
|
||
|
||
// personaRouterOptions 把生效组的按 persona 模型/提供商
|
||
// 绑定转换成 router 选项(裁决 03.4.1)。它是一个辅助函数,好让 buildSnapshot
|
||
// 留在 80 行函数预算之内。
|
||
func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption {
|
||
opts := []RouterOption{WithDefaultProvider(cfg.Provider)}
|
||
for persona, model := range active.PersonaModels {
|
||
opts = append(opts, WithPersonaModel(persona, model))
|
||
}
|
||
for persona, provider := range active.PersonaProviders {
|
||
opts = append(opts, WithPersonaProvider(persona, provider))
|
||
}
|
||
return opts
|
||
}
|