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 过滤,面板汇总口径不变。
This commit is contained in:
AskaEth committed 2026-10-05 08:04:49 +08:00
1 parent cb564fe981
commit 59a1ac4056
9 files changed
+359 -18

No files matched your search

+15 -5
View File
@@ -1,6 +1,7 @@
package model
import (
"context"
"log/slog"
"time"
)
@@ -36,6 +37,10 @@ const usageLogEvent = "model usage"
type UsageSample struct {
// Adapter 是服务该次调用的适配器/提供商分组标识。
Adapter string
// SessionID 是本次调用所属的会话(loop 经 ctx 播种,见 session_ctx.go)。
// 空串表示**没有会话归属**(管理面板试连、未接循环的后台流程)——落库时
// 写 NULL,绝不写 "",否则「无归属」与「属于某会话」在列里混成同一个值。
SessionID string
// Model 是产出该响应的模型名。
Model string
// Purpose 是调用用途标签(如 "chat" / "memory_extract" / "engage_judge")。
@@ -64,10 +69,12 @@ type UsageSink interface {
}
// usageSampleOf 把一次调用归一化成 UsageSample。它是日志与落库**唯一**的字段来源,
// 两处口径因此不可能分叉。
func usageSampleOf(adapter string, req Request, resp Response, took time.Duration) 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,
@@ -86,9 +93,12 @@ func usageSampleOf(adapter string, req Request, resp Response, took time.Duratio
//
// 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)
// 之前的行为(日志字段集也不含会话:会话只进落库面,见 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,