通道:新增 model/session_ctx.go 的 WithSessionID / SessionIDFrom(与 tool.WithTurnPersona
同族的 ctx 范式,避开 model 反向 import loop 的环),loop 在 Run 顶部播种;适配器
record(ctx,...) 接会话;usageSessionArg 空串映射为 NULL。
兼容:sink 未注入或会话未播种时行为逐字不变;既有 model 测试零断言改动即全绿。
文件:model/{session_ctx.go(新), usage.go, usage_store.go, openai.go, anthropic.go,
usage_session_test.go(新)} + loop/{run.go, usage_session_test.go(新)}。
变异三处:去 loop 打标 / 写侧不绑会话 / 适配器不读 ctx ⇒ 各自红行,还原复绿。
契约与日志字段集未动;读侧不按 session 过滤,面板汇总口径不变。
122 lines
5.5 KiB
Go
122 lines
5.5 KiB
Go
package model
|
||
|
||
import (
|
||
"context"
|
||
"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
|
||
// SessionID 是本次调用所属的会话(loop 经 ctx 播种,见 session_ctx.go)。
|
||
// 空串表示**没有会话归属**(管理面板试连、未接循环的后台流程)——落库时
|
||
// 写 NULL,绝不写 "",否则「无归属」与「属于某会话」在列里混成同一个值。
|
||
SessionID 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。它是日志与落库**唯一**的字段来源,
|
||
// 两处口径因此不可能分叉。会话标识从 ctx 解析(session_ctx.go):适配器没有会话
|
||
// 概念,loop 是唯一把它播种进来的地方;未播种时为 "",落库写 NULL。
|
||
func usageSampleOf(ctx context.Context, adapter string, req Request, resp Response, took time.Duration) UsageSample {
|
||
return UsageSample{
|
||
Adapter: adapter,
|
||
SessionID: SessionIDFrom(ctx),
|
||
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 时**这一步是空操作** —— 逐字等于引入落库
|
||
// 之前的行为(日志字段集也不含会话:会话只进落库面,见 UsageSample.SessionID)。
|
||
//
|
||
// ctx 只为解析会话标识而来(session_ctx.go)—— 适配器的 record 拿不到会话,
|
||
// 循环在发起调用时把它播种在 ctx 上。
|
||
func logUsage(ctx context.Context, log *slog.Logger, sink UsageSink, adapter string, req Request, resp Response, took time.Duration) {
|
||
sample := usageSampleOf(ctx, 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)
|
||
}
|