Files
Cyrene_v2/internal/runtime/model/runtime.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

363 lines
13 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 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
}