mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
批 1,不含 SDK 仓与目录改名。 - go.mod 第 1 行 module 路径 - 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换 (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5) - 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、 homed/waiter 可执行名、.hmap 后缀
156 lines
5.5 KiB
Go
156 lines
5.5 KiB
Go
package core
|
||
|
||
import (
|
||
"sync"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
)
|
||
|
||
// UsageTotals 是**跨调用累计**的用量账目。
|
||
//
|
||
// ── 为什么需要它 ──
|
||
//
|
||
// 单次调用的用量一直在(`StageCtx.TokenUsage`、LLM chain 事件),但没有任何地方
|
||
// 把多次调用加起来。于是「这一轮/这个会话花了多少、缓存省了多少」根本答不出来 ——
|
||
// 只能一条条翻日志自己加。这正是「无法统计缓存命中」的另一半:
|
||
// 数据流出来了,却没有落点。
|
||
//
|
||
// ── 关于命中率的口径(最容易被算错的地方)──
|
||
//
|
||
// 命中率的分母**必须只算「上游报了缓存」的调用**(`CacheReportedCalls`),
|
||
// 而不是全部调用。理由:没报缓存的提供商/版本不是「命中 0」而是「不知道」,
|
||
// 把它们算进分母会把命中率稀释成一个无意义的低值,
|
||
// 让人去优化一个本来就没开的功能。
|
||
//
|
||
// 同理 `CacheHitRate` 在没有任何调用报过缓存时返回 ok=false,
|
||
// 调用方应显示「—」而不是 0%。
|
||
type UsageTotals struct {
|
||
// 累计 token 数。
|
||
Prompt int64 `json:"prompt"`
|
||
Completion int64 `json:"completion"`
|
||
Total int64 `json:"total"`
|
||
// CacheRead 是累计命中缓存的输入 token(省下来的那部分计算)。
|
||
CacheRead int64 `json:"cache_read"`
|
||
// CacheMiss 是上游明确报告的未命中输入 token。
|
||
CacheMiss int64 `json:"cache_miss"`
|
||
// Reasoning 是累计的思考 token(计费输出里属于思考的部分)。
|
||
Reasoning int64 `json:"reasoning"`
|
||
|
||
// Calls 是累计的 LLM 调用次数(含用量为 0 的调用)。
|
||
Calls int64 `json:"calls"`
|
||
// CacheReportedCalls 是其中**上游报告了缓存字段**的调用数。
|
||
// 命中率的分母。
|
||
CacheReportedCalls int64 `json:"cache_reported_calls"`
|
||
}
|
||
|
||
// add 并入一次调用的用量。
|
||
func (t *UsageTotals) add(u agentAPI.TokenUsage) {
|
||
t.Calls++
|
||
t.Prompt += int64(u.Prompt)
|
||
t.Completion += int64(u.Completion)
|
||
t.Total += int64(u.Total)
|
||
t.CacheRead += int64(u.CacheRead)
|
||
t.CacheMiss += int64(u.CacheMiss)
|
||
t.Reasoning += int64(u.ReasoningTokens)
|
||
if u.CacheReported {
|
||
t.CacheReportedCalls++
|
||
}
|
||
// 上游没给 total 时按分量补,避免累计出现「total 小于 prompt+completion」。
|
||
if u.Total == 0 && (u.Prompt > 0 || u.Completion > 0) {
|
||
t.Total += int64(u.Prompt + u.Completion)
|
||
}
|
||
}
|
||
|
||
// CacheHitRate 返回缓存命中率与「是否可计算」。
|
||
//
|
||
// 分母是 `CacheRead + CacheMiss`(上游报告过缓存的那些调用里的输入侧),
|
||
// 而不是 `Prompt`:后者在未报告缓存的调用里也计入,会把命中率压低。
|
||
//
|
||
// 两者都为 0 时 ok=false —— 表示「没有任何调用报过缓存」,
|
||
// 而不是「命中率 0」。调用方必须区分,否则会把无数据画成 0%。
|
||
func (t UsageTotals) CacheHitRate() (float64, bool) {
|
||
denom := t.CacheRead + t.CacheMiss
|
||
if t.CacheReportedCalls == 0 || denom == 0 {
|
||
return 0, false
|
||
}
|
||
return float64(t.CacheRead) / float64(denom), true
|
||
}
|
||
|
||
// usageLedger 是 UsageTotals 的并发安全容器。
|
||
//
|
||
// 为何要锁:LLM 调用可能来自多个 goroutine ——
|
||
// · 主 agent 与驻留子 agent 各自的任务循环;
|
||
// · 同一轮里并行的工具执行虽不直接记账,但工具内部可能再发起 LLM 调用
|
||
//
|
||
// (子调用/摘要),与主循环并发。
|
||
//
|
||
// 计数丢失是**静默**的(少算一点没人看得出来),所以宁可用锁。
|
||
type usageLedger struct {
|
||
mu sync.Mutex
|
||
totals UsageTotals
|
||
}
|
||
|
||
// record 并入一次调用。
|
||
func (l *usageLedger) record(u agentAPI.TokenUsage) {
|
||
l.mu.Lock()
|
||
l.totals.add(u)
|
||
l.mu.Unlock()
|
||
}
|
||
|
||
// usageMapFromInts 把 SDK 插件的 TokenUsage(map[string]int) 归一成
|
||
// 回包用的 map[string]interface{}。
|
||
//
|
||
// 为何必须转而不能直接透传:Go 的类型断言对 map 是精确匹配,
|
||
// 消费方 handler_openai.go 写的是 .(map[string]interface{}),
|
||
// 直接塞 map[string]int 会断言失败 ⇒ usage 静默变 nil,两边都不报错。
|
||
// 注意这里**不**过滤零值:插件既然显式设了这份 map,就尊重它的全部内容。
|
||
func usageMapFromInts(in map[string]int) map[string]interface{} {
|
||
m := make(map[string]interface{}, len(in))
|
||
for k, v := range in {
|
||
m[k] = v
|
||
}
|
||
return m
|
||
}
|
||
|
||
// turnUsageMap 把**本次请求**的用量转成对外回包用的键值形式。
|
||
//
|
||
// 键名用 OpenAI 的 snake_case(prompt_tokens/…),且值类型为
|
||
// map[string]interface{} —— 消费方(handler_openai.go)用精确类型断言取值,
|
||
// 给 map[string]int 会静默变 nil(见 eventloop.go:emitResponse 的说明)。
|
||
func turnUsageMap(u agentAPI.TokenUsage) map[string]interface{} {
|
||
m := map[string]interface{}{
|
||
"prompt_tokens": u.Prompt,
|
||
"completion_tokens": u.Completion,
|
||
"total_tokens": u.Total,
|
||
}
|
||
// 缓存/推理字段仅在**有数据**时才带上:缺了它们消费方会画成 0,
|
||
// 而 0 与「上游没报」是两回事(与 UsageTotals.CacheHitRate 的 ok 同口径)。
|
||
if u.CacheRead > 0 {
|
||
m["cache_read_tokens"] = u.CacheRead
|
||
}
|
||
if u.CacheMiss > 0 {
|
||
m["cache_miss_tokens"] = u.CacheMiss
|
||
}
|
||
if u.ReasoningTokens > 0 {
|
||
m["reasoning_tokens"] = u.ReasoningTokens
|
||
}
|
||
if u.CacheReported {
|
||
m["cache_reported"] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
// snapshot 取当前累计值的副本。
|
||
func (l *usageLedger) snapshot() UsageTotals {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
return l.totals
|
||
}
|
||
|
||
// reset 清零(供测试与「重置统计」入口使用)。
|
||
func (l *usageLedger) reset() {
|
||
l.mu.Lock()
|
||
l.totals = UsageTotals{}
|
||
l.mu.Unlock()
|
||
}
|