Files
Cyrene_v2/internal/runtime/model/usage.go
T
AskaEth 59a1ac4056 feat(model): llm_usage.session_id 真正落库(补完 U1 缺口)
通道:新增 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 过滤,面板汇总口径不变。
2026-10-05 08:04:49 +08:00

122 lines
5.5 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"
"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)
}