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) }