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

112 lines
4.7 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 (
"log/slog"
"time"
)
// 本文件是**用量与缓存的可观测出口**(design/04 附录 A.3 第 4 条)。
//
// 背景(附录 A.2 第 3 条):在此之前输入侧用量从未被记录 —— 全仓只消费了
// PromptTokens / TotalTokens,`prompt_cache_hit_tokens` / `miss` 一律没解析、没落
// 日志,于是「大上下文到底贵不贵」根本无法实测。而提供商按命中/未命中两档计价,
// 命中价约为未命中的 1/50:看不见命中率,就等于看不见成本。
//
// 记录点选在**适配器内部**(每个适配器的 record 都调它一次),而不是循环里:
// 这样流式、非流式、后台思考、子代理、管理面板试连等**所有**模型调用点都覆盖到,
// 不会有「换个调用入口就没了成本日志」的缺口。
//
// 出口有**两条**,互不阻塞:
//
// 1. 结构化日志(本条一直如此,T1);
// 2. 落库(切片 U1)—— 经可选的 UsageSink 交给组合根注入的异步写入器。
// sink 为 nil 时**只记日志、行为逐字不变**(既有测试因此零改动)。
//
// 红线:日志与落库样本里只出现模型名、用途、token 数与耗时 —— **凭据(apiKey /
// token)与消息正文绝不出现**,它们连参数都进不来。
// usageLogEvent 是每条用量日志的事件名,便于按事件过滤与统计。
const usageLogEvent = "model usage"
// UsageSample 是**一次成功模型调用的用量样本**:既是那条结构化日志的字段来源,
// 也是落库写入(UsageSink)的载荷。
//
// 它刻意只带非敏感计数与标签:凭据与正文不在字段集里,因此无论日志还是落库都
// 没有把它们写出去的可能。
type UsageSample struct {
// Adapter 是服务该次调用的适配器/提供商分组标识。
Adapter string
// Model 是产出该响应的模型名。
Model string
// Purpose 是调用用途标签(如 "chat" / "memory_extract" / "engage_judge")。
Purpose string
// PromptTokens / CompletionTokens / TotalTokens 是提供商上报的用量。
PromptTokens int
CompletionTokens int
TotalTokens int
// CacheHitTokens / CacheMissTokens 是**输入侧**的两档 token 数。
CacheHitTokens int
CacheMissTokens int
// ReasoningTokens 是完成侧里属于思维链的 token 数。
ReasoningTokens int
// TookMs 是端到端调用耗时(毫秒)。
TookMs int64
}
// UsageSink 接收每次成功调用的用量样本。实现方必须**非阻塞**:它在模型调用的
// 返回路径上被同步调用,任何等待都会把成本记到主人的每次对话上。
//
// 组合根注入的 model.UsageWriter 满足它(有界队列 + 单写者,满则丢弃并限流告警)。
// 为 nil = 不落库,只记日志 —— 这是「没接 sink」与「接了 sink」唯一的行为差别。
type UsageSink interface {
// RecordUsage 收下一次调用的用量样本。实现方绝不阻塞、绝不 panic。
RecordUsage(sample UsageSample)
}
// usageSampleOf 把一次调用归一化成 UsageSample。它是日志与落库**唯一**的字段来源,
// 两处口径因此不可能分叉。
func usageSampleOf(adapter string, req Request, resp Response, took time.Duration) UsageSample {
return UsageSample{
Adapter: adapter,
Model: resp.Model,
Purpose: req.Purpose,
PromptTokens: resp.PromptTokens,
CompletionTokens: resp.CompletionTokens,
TotalTokens: resp.TotalTokens,
CacheHitTokens: resp.CacheHitTokens,
CacheMissTokens: resp.CacheMissTokens,
ReasoningTokens: resp.ReasoningTokens,
TookMs: took.Milliseconds(),
}
}
// logUsage 记一条结构化用量日志:prompt / completion / total / reasoning /
// cacheHit / cacheMiss / hitRate,外加模型名、用途、适配器与耗时;随后把同一个
// 样本交给可选的 sink 落库。
//
// log 可以为 nil(适配器在未接日志时用的是丢弃 logger),这里仍做一次防御:
// 缺字段容错、绝不 panic。sink 为 nil 时**这一步是空操作** —— 逐字等于引入落库
// 之前的行为。
func logUsage(log *slog.Logger, sink UsageSink, adapter string, req Request, resp Response, took time.Duration) {
sample := usageSampleOf(adapter, req, resp, took)
if log != nil {
log.Info(usageLogEvent,
"adapter", sample.Adapter,
"model", sample.Model,
"purpose", sample.Purpose,
"prompt", sample.PromptTokens,
"completion", sample.CompletionTokens,
"total", sample.TotalTokens,
"reasoning", sample.ReasoningTokens,
"cacheHit", sample.CacheHitTokens,
"cacheMiss", sample.CacheMissTokens,
"hitRate", resp.CacheHitRate(),
"tookMs", sample.TookMs,
)
}
if sink == nil {
return
}
sink.RecordUsage(sample)
}