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

@@ -0,0 +1,46 @@
-- 0017_llm_usage: 每次成功模型调用的用量落库(切片 U1,主人裁决「2.4 落」)。
--
-- 背景:T1 已在每次模型调用后记一条结构化日志(prompt / completion / total /
-- reasoning / cacheHit / cacheMiss / hitRate / tookMs),但**只落日志** ——
-- 看不见趋势、算不出按用途/模型的成本,面板也没有数据源。本表把同一条样本
-- 落进库里,供只读汇总端点(GET /api/v1/admin/llm/usage)聚合。
--
-- 口径:
-- * **无凭据、无正文**:本表只有 token 计数、模型名、用途标签与耗时 ——
-- apiKey / token / 消息正文一个字都不入库(写入方 model.UsageWriter 连
-- 参数都拿不到凭据)。
-- * cache_hit_tokens / cache_miss_tokens 是**输入侧两档**(提供商按命中/未命中
-- 两档计价,命中价约为未命中的 1/50),命中率 = hit / (hit + miss),分母为 0
-- 时按 0 处理(口径与 model.Response.CacheHitRate 同源)。
-- * session_id 可空。**当前尚无写入方**(恒为 NULL):模型适配器跑在 loop 之下、
-- 没有会话概念(见 internal/runtime/model/stream.go 的 StreamEvent.SessionID
-- 注释),把会话标识带到适配器边界需要 loop 侧在请求上打标 —— 那份文件本轮
-- 不在本切片改动范围内,故先留列、后接线。
-- * created_at 由 SQLite 以 UTC ISO-8601(毫秒)写入,与 0001 起的口径一致;
-- 汇总端点的时间窗按同一格式做**字典序**比较(定宽格式下等价于时间序)。
-- * 表名用复数,援引 AGENTS §10 已登记的例外(既有迁移口径一致)。
CREATE TABLE IF NOT EXISTS llm_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
purpose TEXT NOT NULL DEFAULT '',
adapter TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
cache_hit_tokens INTEGER NOT NULL DEFAULT 0,
cache_miss_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
took_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- 汇总端点与保留期清理都按时间窗扫表(`created_at >= ? AND created_at <= ?` /
-- `created_at < ?`),这条索引是两者的唯一依赖。
CREATE INDEX IF NOT EXISTS idx_llm_usage_created_at
ON llm_usage (created_at);
-- 按会话下钻(面板将来要「这个会话烧了多少」)。当前 session_id 恒为 NULL,
-- 索引仍然建好 —— 它不影响写入正确性,接线后立刻可用。
CREATE INDEX IF NOT EXISTS idx_llm_usage_session_id
ON llm_usage (session_id);