Files
smartmate/backend/service/agentsvc/token_meter.go
LoveLosita 96be3e2a02 Version: 0.6.6.dev.260317
 feat(task,agent): 新增任务完成接口,并打通聊天全链路 Token 记账

-  新增“标记任务为完成”接口,并补充幂等保护,避免重复完成导致状态污染
- 📊 为聊天链路补充 Token 统计能力:
  - 流式主对话链路直接读取模型 `usage`
  - Agent 链路通过 `Eino callback + ctx` 聚合 `Generate usage`
  - 在流式场景下补齐缺失的 `usage` 数据
- 🧾 按口径 B 完成 Token 落库:
  - 用户消息 `token` 记为 `0`
  - 助手消息记录本轮总 `token`
  - 持久化时同步更新 `chat_histories.tokens_consumed`、`agent_chats.tokens_total`、`users.token_usage`
- 🔄 异步标题生成产生的 Token 通过 Outbox 事件完成账本增量调整,保证统计口径一致
- 📝 同步更新 `AGENTS.md` 与 `.gitignore`
- 📚 小幅更新 README 说明文档
2026-03-17 18:23:07 +08:00

146 lines
4.3 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 agentsvc
import (
"context"
"sync"
einoCallbacks "github.com/cloudwego/eino/callbacks"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
templatecb "github.com/cloudwego/eino/utils/callbacks"
)
type requestTokenMeterCtxKey struct{}
// RequestTokenMeter 是“单次请求级”的 token 统计容器。
//
// 设计目标:
// 1. 聚合本次请求内所有模型调用 token路由/图节点/流式主对话);
// 2. 线程安全,允许在同一请求内被多个链路节点并发累加;
// 3. 最终由服务层一次性读取快照并写入持久化。
type RequestTokenMeter struct {
mu sync.Mutex
promptTokens int
completionTokens int
totalTokens int
}
// RequestTokenMeterSnapshot 是 RequestTokenMeter 的只读快照。
type RequestTokenMeterSnapshot struct {
PromptTokens int
CompletionTokens int
TotalTokens int
}
var registerTokenMeterCallbackOnce sync.Once
// ensureTokenMeterCallbackRegistered 注册一次全局 ChatModel callback。
//
// 说明:
// 1. callback 只负责“采集并累加 token”不做业务决策
// 2. 仅当 ctx 里存在 RequestTokenMeter 时才会生效;
// 3. 采用 once避免在测试/多次构造服务时重复注册。
func ensureTokenMeterCallbackRegistered() {
registerTokenMeterCallbackOnce.Do(func() {
handler := templatecb.NewHandlerHelper().
ChatModel(&templatecb.ModelCallbackHandler{
OnEnd: func(ctx context.Context, _ *einoCallbacks.RunInfo, output *einoModel.CallbackOutput) context.Context {
if output == nil || output.TokenUsage == nil {
return ctx
}
addModelUsageIntoRequest(ctx, output.TokenUsage)
return ctx
},
}).
Handler()
einoCallbacks.AppendGlobalHandlers(handler)
})
}
// withRequestTokenMeter 创建并挂载“请求级 token 统计器”。
func withRequestTokenMeter(ctx context.Context) (context.Context, *RequestTokenMeter) {
meter := &RequestTokenMeter{}
return context.WithValue(ctx, requestTokenMeterCtxKey{}, meter), meter
}
// getRequestTokenMeter 读取请求上下文中的 token 统计器。
func getRequestTokenMeter(ctx context.Context) *RequestTokenMeter {
if ctx == nil {
return nil
}
meter, _ := ctx.Value(requestTokenMeterCtxKey{}).(*RequestTokenMeter)
return meter
}
// addSchemaUsageIntoRequest 把 schema usage 累加到请求级统计器。
func addSchemaUsageIntoRequest(ctx context.Context, usage *schema.TokenUsage) {
if usage == nil {
return
}
addTokenUsageValues(ctx, usage.PromptTokens, usage.CompletionTokens, normalizeUsageTotal(usage.TotalTokens, usage.PromptTokens, usage.CompletionTokens))
}
// addModelUsageIntoRequest 把 Eino model callback usage 累加到请求级统计器。
func addModelUsageIntoRequest(ctx context.Context, usage *einoModel.TokenUsage) {
if usage == nil {
return
}
addTokenUsageValues(ctx, usage.PromptTokens, usage.CompletionTokens, normalizeUsageTotal(usage.TotalTokens, usage.PromptTokens, usage.CompletionTokens))
}
// addTokenUsageValues 统一累加 token 数值。
func addTokenUsageValues(ctx context.Context, promptTokens, completionTokens, totalTokens int) {
meter := getRequestTokenMeter(ctx)
if meter == nil {
return
}
if promptTokens < 0 {
promptTokens = 0
}
if completionTokens < 0 {
completionTokens = 0
}
if totalTokens < 0 {
totalTokens = 0
}
meter.mu.Lock()
defer meter.mu.Unlock()
meter.promptTokens += promptTokens
meter.completionTokens += completionTokens
meter.totalTokens += totalTokens
}
// snapshotRequestTokenMeter 获取请求级 token 统计快照。
func snapshotRequestTokenMeter(ctx context.Context) RequestTokenMeterSnapshot {
meter := getRequestTokenMeter(ctx)
if meter == nil {
return RequestTokenMeterSnapshot{}
}
meter.mu.Lock()
defer meter.mu.Unlock()
return RequestTokenMeterSnapshot{
PromptTokens: meter.promptTokens,
CompletionTokens: meter.completionTokens,
TotalTokens: meter.totalTokens,
}
}
// normalizeUsageTotal 统一 total token 口径。
//
// 规则:
// 1. 模型返回 total>0 时优先使用 total
// 2. total 缺失时使用 prompt+completion 回退。
func normalizeUsageTotal(totalTokens, promptTokens, completionTokens int) int {
if totalTokens > 0 {
return totalTokens
}
sum := promptTokens + completionTokens
if sum < 0 {
return 0
}
return sum
}