agentsview 聚合用量缓存:让 30 天 Token 统计从全量扫描降到亚秒级
【免费下载链接】agentsviewLocal-first session search, analytics, insights, and token use statistics for coding agents, supporting Claude Code, Codex, and more than 20 other agents.项目地址: https://gitcode.com/GitHub_Trending/ag/agentsview
本文基于 agentsview 仓库的 Usage Aggregate Cache 设计文档 展开,并结合 internal/db 下的实际实现代码,完整讲解这套「一次性兄弟数据库 + 时区日粒度 rollup + 窄异常行」的缓存体系:它如何让 SQLite 后端的日用量、Top 会话、计费会话数等聚合查询不再逐条对每条带 token 的消息做排序、定价和传输,同时保证缓存失效、多进程退役、时区规则变更等边界场景下结果与实时路径逐字节一致。读完后你将理解:聚合缓存的两层派生数据如何划分、freshness 契约的完整请求流程、去重异常组的收敛条件,以及 2 秒 warm 30 天性能门禁背后的验证手段。
一、为什么需要一个「可随时删除」的兄弟数据库
agentsview 的 SQLite 后端此前对聚合类用量请求走的是「live path」:把窗口内每条带 token 的消息全部取出,在 Go 侧逐条做模型解析、费率档位选择、按行金额舍入和去重排序。会话历史越深,这个成本线性甚至超线性增长。
Usage Aggregate Cache 的方案核心是一句话:聚合读取改为查一个派生的、可随时删除的兄弟数据库(sibling database),归档库(archive)始终是权威数据源。由此得到几个设计约束(见 设计文档):
- 缓存只服务四类聚合:日用量(daily usage)、Top 会话、计费会话数、宽松匹配计数;单会话明细仍走有界 live path,PostgreSQL 后端保留 live 实现,并用完整结果
pgtest夹具与 SQLite 结果对齐。 - 删除任何被识别的缓存代次(generation)不丢失任何用户数据——因为归档库才是权威。
- 发布目标:在生产规模的受保护克隆(protected production-scale clone)上,warm 状态的 30 天 CLI 完整结果不超过 2 秒,且缓存构建前后的结果逐字节一致。
失败语义同样明确:聚合缓存读取失败就是错误,绝不回退到 live 路径(这一约束在 实施计划文档 中被再次强调),避免「缓存没算完就悄悄返回近似值」这类难以排查的静默降级。
二、两层派生数据:facts 是构建基座,rollups 是读取层
缓存内部严格分为两层,这一分层直接决定了 warm 读取的成本模型。
2.1 facts 层:窄事实,只含解析结果,不含转录内容
facts 层存储的是解析后的消息与 usage 事件字段,但不含任何转录正文。它的意义是让「换一个时区重建 rollup」这件事与被文档明确指出的 39 GB 归档库及其 JSON 列解耦——重建不再需要回读大字段。
从 usage_cache_schema.go 的usageCacheSchemaSQL可以看到 facts 层的真实形态:
usage_cached_sessions记录每个已缓存会话的来源指纹:source_sync_marker、source_transcript_rev、usage_event_fingerprint与install_revision,这是后续所有失效判断的锚点;usage_facts是主事实表(WITHOUT ROWID,主键(cached_session_id, fact_index)),保存 token 各类计数(input/output/reasoning/cache_creation/cache_read)、web_search_requests、上报成本(reported_cost_microdollars+cost_source)、request_scoped标记,以及去重身份字段(claude_message_id、claude_request_id、source_uuid、usage_dedup_key),并用三个部分索引(partial indexes)分别覆盖这三类去重键,服务跨会话身份检查;cursor_usage_facts单独存放 Cursor 用量事件,带charged_microdollars与is_headless。
设计文档中一句关键描述是:facts 层只是构建基座(build substrate),warm 聚合请求只读 rollups 和异常行,绝不回退 live 聚合路径。
2.2 rollups 层:时区日粒度聚合
rollups 层按「逻辑粒度(session_id, local_day, model)」保存每日贡献,外加活动行(activity rows)和窄去重异常。每条日行(daily row)保存:
- token 各类计数、web-search 请求数;
- 定价身份:
priced_model、matched_pattern、rate_ok、rate_hash、pricing_timestamp、band_threshold(费率档位阈值,无档位时为 -1,见 usage_rollup.go 的installUsageRollupRows); - 按行舍入后的估算成本
estimated_cost_microdollars、节省savings_microdollars、权威成本标记authoritative_cost_microdollars; - 请求计数(computed request/aggregate count、reported count、base request count)与被丢弃的快照输出
discarded_snapshot_output_tokens。
模型解析、费率档位选择、金额舍入与结果组装全部由 Go 负责,SQL 只做窄索引读取——这与 usage_rollup.go 中构建侧调用export包定价解析器(export.PricingResolver)的事实一致。
安装(install)粒度是「每个源会话一个 rollup install」,对应表usage_rollup_installs,其中每个 install 携带来源指纹、fact_install_revision、烘焙进行的baked_agent/baked_started_at、pricing_hash与install_revision。会话的其余元数据(项目、机器、自动化状态、终止状态、策展字段等过滤条件)保持为 live 归档元数据,不进入缓存;唯一烘焙进去的是agent与started_at:前者参与通用去重键,后者为没有自身时间戳的事实提供日期,且这两个精确值在每次读取时都会被重新校验(usage_rollup.go 的readUsageRollupInstalls中,baked[session] != [2]string{agent, startedAt}即判定 install 过期)。
2.3 去重异常:只有「不可约」的组才落盘
去重是这套缓存里语义最复杂的部分。构建某个会话 rollup 时按组分类,一组只有在其解析结果可证明不随查询窗口或 live 过滤器变化时,才允许收敛(finalize)进日行。收敛条件(设计文档逐条列出,全部是必要条件):
- 组内每个成员都属于正在构建的会话,且共享同一个本地日期——因为查询窗口以整天为单位,这样的组作为整体要么在窗口内、要么在窗口外;
- 对通用
source:/usage:组,每个成员还必须共享同一个模型和 headless 状态——因为 live 的模型过滤和自动化过滤在通用排序之前生效; - 组内没有任何成员同时连接 snapshot 去重与通用去重(携带 usage 键的快照幸存者会在读取时重新进入通用排序);
- 组内没有任何成员携带 Copilot 权威成本——它在整个窗口内是顺序依赖的按会话选择;
- 组身份不出现在任何其他已缓存会话中;对 usage 键,还不能出现在 Cursor 事实库中。
usage_facts上的部分身份索引就是为这个检查保守地服务的(例如source:身份忽略烘焙的 agent)。
收敛时应用与读取路径完全一致的排序规则:最大 output 的快照胜出、归属跟随最早一行、最大 web-search 计数带过去、落败快照的 output 记为 discarded。只有不可约的组(跨会话、跨天、跨模型的真实重复)才存为窄异常行(usage_rollup_exceptions,带group_kind为snapshot/general和group_key)。由此,异常量随真实重复组数量增长,而不是随带 token 的消息数量增长——这是 warm 读取能做到亚秒级的根本原因。Cursor 事实则完全保留在异常层:它们的键可能与会话 usage 键冲突,且过滤器依赖逐行 headless 状态,因此以一个按 Cursor high-water mark 键控的合成源 install(usage_rollup.go 中的usageRollupCursorSessionID = "\x00cursor")承载。
读取时的路径:查询只加载与请求窗口相交的、来自当前已校验候选会话的异常行,然后在 Go 中套用既有的窗口内排序、归属、过滤与定价规则;已收敛组和普通事实留在索引化日行路径上。
失效联动同样精巧:由于 rollup 按源会话安装,改一个转录就重建该会话;而当 fill、Cursor 批次或删除改变了某个会话贡献的去重身份集合时,同一个缓存事务会删除所有持有该身份的其他会话的时区 rollup install,且 rollup 安装事务内部会重新校验「没有已收敛身份新增了外部成员」,否则按「源已移动」重试。于是「已收敛日行绝不会在获得兄弟后存活」这一不变量成立:跨会话赢家变化会立即反映——下一次请求校验全部候选会话,并从所有成员的最新异常行重新解析该组。usage_rollup.go 中installUsageRollupBuilds在BEGIN IMMEDIATE事务里重新加载跨会话身份集合并断言currentCross.subsetOf(buildCross),构建与安装之间竞争的重分类最多重试usageRollupMaxBuildAttempts = 8次。
三、Freshness 契约:一次聚合请求的七步
设计文档给出的请求流程可以完整还原,usage_cache_consumers.go 的queryUsageRollups是其对应实现:
- 捕获快照:归档数据库 ID、候选会话、来源指纹、精确的
agent与started_at、live 过滤器元数据、定价行、Cursor high-water mark、请求时区; - 在获取缓存写锁之前关闭归档快照——避免跨数据库持锁窗口;
- 打开代次:由缓存格式版本 + 归档
database_id命名的那个代次; - 只填充缺失或过期的候选会话的规范化事实(
usageFillCoordinator.Ensure,usage_cache_fill.go); - 为请求时区构建缺失或过期的 rollups(
usageRollupCoordinator.Ensure); - 安装前重新检查来源指纹与烘焙元数据;
- 在一个钉住的缓存读事务内校验所有必需 install,然后才组装结果。
几个容易被忽视的细节:
- 定价失效不靠时间戳。定价代次使用规范的、与顺序无关的 effective-pricing 摘要;日行还保留按模型解析出的费率哈希。在 usage_rollup.go 中,
usagePricingIdentity组合export.EffectivePricingDigest(rows)与pricingpkg.BillingPolicyVersion()——后者很重要:定价策略本身的代码级变化(如摘要只能哈希目录行、哈希不到的解析规则变更)也能通过版本字符串触发重建。 sync_marker不是单调版本:它的触发器对一组可变时间戳字段取最大值,值可能变小。因此安装时比较的是提取后的完整来源指纹,而不是 marker 的顺序。- 被硬删除的会话按更新的态丢弃;其他「源在移动」或「归档忙」的竞态最多重试 3 次(
usageFillMaxAttempts = 3),然后失败而不是返回过期用量——宁可报错也不给错数。 - 请求取消只是把等待者从共享 fill 工作中摘除,fill 本身继续跑,让重试风暴收敛;
cached_at只作诊断,不参与任何新鲜度判断。
queryUsageRollups的重试循环与这套契约一一对应:capture → acquire generation → deletion sweep → fill → rollup ensure → cache read,任何阶段遇到errUsageCacheSourceChanged就重新捕获快照重试,超过usageFillMaxAttempts才把最后一个错误抛给调用方。
四、文件与代次:多进程安全地退役缓存
缓存文件放在归档库同目录,文件名即代次身份。usage_cache_schema.go 中usageCacheGenerationPath生成usage-cache-v{版本}-{databaseID 的 SHA-256 前 16 字节 hex}.db。格式或数据库 ID 变化会选中一个全新代次,而不是迁移旧文件。当前格式版本为 12,注释里完整保留了 v5→v12 每次重建的缘由(定价解析规则变化、计费身份保留、跨进程租约引入、特定模型费率修正等),是一份很好的「为什么缓存代次要整体重建」的案例记录。
退役协议(retirement protocol)保证了多进程共存时的安全删除:
- 删除或替换任何代次前,必须同时用 SQLite
application_id(0x41565543)和usage_cache_metadata.cache_kind(agentsview-usage-facts)确认它是 agentsview 用量缓存,文件名匹配不算数(probeUsageCache的 fail-closed 探测逻辑); - v8 及以后的代次携带退役协议标记,并在每个打开它的 SQLite 连接池的整个生命周期内持有一个跨进程共享租约(
flock,path + ".lease")。一个打开者只有在拿到独占租约、按精确文件名重新核对格式版本与源数据库 ID、并关闭自己的句柄之后,才能移除另一个被识别的代次;随后主库、WAL、共享内存文件一起删除,而极小的租约文件被保留,让竞争中的打开者不可能锁到被替换的 inode 上; - 协议之前的代次、身份不匹配的文件保持不动(旧进程可能还开着它们);比当前运行格式更新的代次也保留,避免降级后的二进制强迫新版代次重建。
- 若兄弟目录不可写,进程退化为在临时数据库中使用相同 schema 与读取路径,并告警「重启后会重建」——功能不中断,只是失去持久性(
openTemporaryUsageCache分支)。
时区身份:规则指纹防 zoneinfo 更新
时区身份在请求窗口之间保持稳定:命名时区用 IANA 名 +1970 至 2100 年时区规则的指纹组合成 key,任何 zoneinfo 更新只要在该范围内增删或移动了转换点,就会换一个 key,从而绝不会复用过期的日分桶(usage_rollup.go 的usageTimezoneRuleFingerprint逐条哈希每个规则区间及其精确结束时刻)。当进程本地时区只报告Local时,会先尝试解析/etc/localtime的 zoneinfo 符号链接(usageLocationName),解析不出就用同样的规则指纹作为 key——防止懒加载的time.Local初始化或不同请求范围把同一时区裂成多个代次。
五、后台维护:latest-first 回填与数据库卫生
HTTP 就绪之后,可写的 daemon 进程按「最新会话优先」回填:
- 每批最多 256 个会话(
usageFillInstallBatchSize = 256,usage_cache_fill.go;usage_cache_background.go 中回填批大小与其对齐,以便前台等待者随每个已提交批次尽早释放); - 构建进程本地 rollup 与最近请求过的最多 8 个命名时区;
- 变更工作只在归档提交之后入队,缓存绝不在归档写事务内填充;
- 每批之间执行
PRAGMA optimize,freelist 较大时做有界增量 vacuum(阈值为 4096 页、每次最多 256 页,见usageCacheVacuumFreelistThreshold/usageCacheVacuumPages),代次创建后与完整回填后执行全量ANALYZE; - 删除日志清扫只是卫生工作——聚合读取要求当前归档候选,本来就不可能暴露孤儿缓存行;
- 一轮过程中源数据若移动,worker 重新捕获整个快照重试,最多 3 次;已安装指纹让未变更的批次在多次尝试间可复用。
回填入口是 usage_cache_background.go 的StartUsageCacheBackfill(要求可写归档,且以已安装会话版本为准——重启一轮会自然跳过已完成的工作),WaitUsageCacheBackfill供 daemon 生命周期等待;restartUsageCacheBackfillIfEnabled的注释还点明了一个克制的设计:CLI resync 或 compaction 重开归档时不会自行拉起整库后台扫描,只有显式启用的 daemon 生命周期才会。
六、验证与性能门禁
正确性验证的策略是「拿永久 live 路径当 oracle」:
- 随机化对拍:公开 rollup 结果与 live oracle 在种子随机窗口、时区、过滤器、DST 边界、定价档位、上报成本、Cursor 行、空时间戳、跨会话去重组上逐一比对;
- 变更测试:覆盖转录替换、resync 指纹、定价、
agent、started_at、删除、schema 代次、数据库 ID 变化等每一类失效源; - 受保护克隆发布检查:7 天 / 30 天 / 全历史结果逐字节比对;性能报告把冷构建与 warm 读取分开,报 1 天、7 天、30 天与全历史;warm 路径不得扫描规范化事实。
成本模型的门禁表述非常明确:warm 读取必须随日行数量与真实重复组数量增长,而不是随带 token 的消息数量增长;发布检查会连同耗时一起报告异常基数(exception cardinality)。前台慢请求(超过 usage_cache_consumers.go 中usageRollupSlowRequestThreshold = 2s的阈值)会输出隐私安全的分阶段耗时与行计数日志(capture / deletion sweep / fact fill / rollup build / rollup install / cache read),warm 请求则保持静默。冷构建与未缓存候选会话的历史成正比,刻意放在后台、按最新优先消化。
实施计划文档 的「Measured result」一节记录了受保护克隆上的观测值:warm CLI 路径约0.19 秒(1 天)、0.35 秒(7 天)、0.85 秒(30 天),全部远低于 2 秒门禁;冷构建与全历史则作为后台规模工作单独报告。需要注意,设计文档同时声明受保护克隆的耗时会针对每次发布重新测量,而不是把某个记录值当长期承诺。
七、对读者的启示
这套缓存实现里可迁移的 engineering 决策有四点:
- 派生数据与权威数据物理分离,让「删除缓存零数据损失」成为架构承诺而非运维约定;
- 失效判断只信内容指纹(来源指纹、定价摘要、烘焙元数据精确值),不信任何可能回退的 marker 或时间戳;
- 把复杂语义(去重)收敛到「可证明与窗口无关」的子集,其余走窄异常行 + 读取期 Go 侧排序,使热路径成本与真实复杂度而非数据规模成正比;
- 竞态处理选择「重试到一致,失败到明确」,绝不静默返回过期结果。
进一步阅读可以从这几个入口开始:schema 与代次协议见 internal/db/usage_cache_schema.go,rollup 构建/安装与重试见 internal/db/usage_rollup.go 和 internal/db/usage_rollup_build.go,事实填充见 internal/db/usage_cache_fill.go,后台回填见 internal/db/usage_cache_background.go,消费端与慢请求日志见 internal/db/usage_cache_consumers.go;PostgreSQL 侧的完整结果对拍夹具位于 internal/postgres/usage_facts_parity_pgtest_test.go,设计全貌另有 实施计划 可对照阅读。
【免费下载链接】agentsviewLocal-first session search, analytics, insights, and token use statistics for coding agents, supporting Claude Code, Codex, and more than 20 other agents.项目地址: https://gitcode.com/GitHub_Trending/ag/agentsview
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考