CC Switch 的两种用量来源:会话回读与代理记账
用量看板上的一行记录,看上去只是「哪个模型、多少 token、多少钱」。但这一行怎么来的,其实有两种完全不同的可能:一种是请求真的从这个工具的本地网关走过去,token 数是它当场看见并落库的;另一种是请求压根没经过它,是事后去读 AI CLI 自己写在磁盘上的会话文件补记进来的。两类行躺在同一张表里,只靠一列 data_source 区分。
这两类数据的性质差得很远:前者是「亲眼所见」,后者是「事后转录」,会重复、会被外部改写、会分叉。理解这一列,基本就理解了这个看板上的数字能信到什么程度。
先说清本文的依据
下面所有说法都来自对仓库文件的静态阅读,对应快照 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31,另以 v3.19.2 快照作对比基线。我们没有安装、没有编译、也没有运行过这个桌面应用,所以凡是「跑起来才知道」的结论一律不写——本文只谈源码里写了什么、写在哪一行。
这一列叫 data_source,默认值就是「代理记账」
它是 proxy_request_logs 表的一列,DDL 在 src-tauri/src/database/schema.rs:210:
data_source TEXT NOT NULL DEFAULT 'proxy'
默认值本身就是一条历史线索:这张表最初就是代理请求日志表,会话导入是后来挤进来的租客。更硬的证据在迁移代码里——这一列是 v8 迁移才加上去的(schema.rs:1236-1241、:1290),迁移日志文案原文写的是「v7 -> v8 迁移完成:data_source 列、session_log_sync 表、修正 13 个模型定价」。
后加列的代价是历史行里这一列可能为 NULL。schema.rs:3171-3176 有一段注释把这笔账算得很清楚,大意是:查询层为了兼容历史 NULL 行会用 COALESCE(data_source, 'proxy'),而普通的 data_source 索引匹配不上这个表达式。于是索引建成了表达式索引:
ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens, ...)
一列加晚了,代价一路传到索引定义上,这种账在长期演进的项目里很常见。
前端这一侧倒是极简:整个前端只有一处消费这列,src/components/usage/RequestLogTable.tsx:313 取 log.dataSource || "proxy" 作为「来源」列的值——也就是说,代理记账行即使这一列是空的,也会被显示成 proxy。类型定义有两处,src/types/usage.ts:36 是可选、:49 是必填。后端另有一个聚合命令 get_usage_data_sources(src-tauri/src/commands/usage.rs:296-300),内部调 session_usage::get_data_source_breakdown,已在 lib.rs:1609 登记,但前端没有调用者。
两类来源的可信度和时效都不一样
代理记账(proxy):请求实际从本地网关流过,用量是当场记下的,实时落库。能产生这类行的只有那些「有本地网关、能被代理接管」的应用,这个集合由 PROXY_APP_IDS 框定(src/config/appConfig.tsx:60),Pi 不在其中。
会话回读(session_log 与各个 *_session):请求没走这个工具,是它事后去扫 CLI 写下的会话文件。会话文件是别人的地盘,随时可能被追加、被改写、被分叉,所以这条路径上的一切设计都在处理「同一条用量可能被读到两次」。
时效差别写在后台任务里:src-tauri/src/lib.rs:1266-1268 定义 SESSION_SYNC_INTERVAL_SECS = 60,启动时先跑一轮回填,之后每 60 秒一轮,并且 interval.set_missed_tick_behavior(MissedTickBehavior::Skip)(:1309)——错过的 tick 直接跳过而不是补跑。这是 v3.20.1 快照里的默认配置,可配、也会随版本变。
对应的开关是 session_auto_sync_enabled,默认 true(src-tauri/src/settings.rs:381-382)。有意思的是它在后台任务里判了两次(lib.rs:1274、:1286):非回填轮直接看开关,回填轮却仍然进入。理由写在 lib.rs:1272-1273 的注释里——费用回填只修补数据库里已有的行(包含代理记账行),不读会话文件。这句注释顺手证明了一件事:两类来源的行确实躺在同一张表里,后处理是不分来源统一做的。
前端那侧还有一个刻意的取舍:src/components/usage/UsageDashboard.tsx:498 的条件是 {!sessionAutoSyncEnabled && (…)},即「立即同步」按钮只在关掉自动扫描时才渲染。注释(:187-188)给的理由是,手动模式下这是唯一的补录途径,自动模式有后台定时扫描、不需要手动触发。
为什么新接一个应用就得多一个来源值
新增受管应用 Pi 之后,data_source 多了一个取值 pi_session。它是一个常量,写在 src-tauri/src/services/session_usage_pi.rs:24:
const DATA_SOURCE: &str = "pi_session";
紧挨着的 :25 还有一个 PROVIDER_PLACEHOLDER: &str = "_pi_session"。这是所有会话来源的共同做法:写入日志表时塞一个下划线开头的假 provider_id,好在 Provider 维度上和真实供应商行区分开。session_usage.rs:871 那一行的行尾注释直接写了用途:「provider_id: 标记为会话来源」。
那为什么不能让 Pi 的用量复用 proxy 这个来源?答案写在一段注释里。src/types/usage.ts:176-191 是这块代码里信息量最大的一段口径说明,v3.20.1 版本的原句是:
opencodeandpihave no proxy handler; their usage reaches this dashboard through session importers.
对比 v3.19.2 快照,同一段注释当时写的是 opencode / openclaw / hermes 完全没有代理处理器。也就是说,v3.19.2 时 OpenCode 的定位是「根本进不来」,v3.20.1 已改为「靠会话导入器进来」。一段注释被改写的痕迹,比发布说明的措辞更能说明这块能力是怎么长出来的。
来源值的数量当然会继续涨——每接一个没有本地网关的应用,就多一个 *_session 取值。v3.19.2 快照里是六种,v3.20.1 快照里是七种,这个数下个版本就可能不同,不必当成常量记。更值得记的是判据:一个应用能不能产生 proxy 行,取决于它有没有本地网关;没有网关的,用量只能靠回读会话文件进来,那就必须给它一个自己的来源值。
顺带一个文件级的证据:v3.19.2 快照的 src-tauri/src/services/ 目录下没有 session_usage_pi.rs 这个文件,v3.20.1 有。这是「新增一个来源」最硬的实物。
这条线对应的提交是 40d747c0(feat(pi): add session usage statistics,发布说明挂的编号是 #6463)。上面说的文件新增、types/usage.ts 那段注释从「没有代理处理器」改写成「靠会话导入器进来」,都能在这次提交的范围里找到出处;Pi 接管这条主线本身则挂在 84e75ad2(#6064)下。想复核的话,从这两个哈希入手比从发布说明的措辞入手快得多——措辞是写给人看的,diff 才是改了什么。
会话回读必须自带一本去重账本
既然会话文件会被改写和分叉,同一条请求就可能在两轮扫描里被读到两次。工具的处理方式是单独建一张持久去重表,DDL 在 src-tauri/src/database/schema.rs:321-337,上方 :319-320 的注释原文是:
-- Session detail rows are pruned after rollup, so request IDs needed
-- for fork/rewrite deduplication live in a compact durable ledger.
表本身长这样:
CREATE TABLE IF NOT EXISTS session_usage_dedup (
data_source TEXT NOT NULL,
request_id TEXT NOT NULL,
semantic_id TEXT NOT NULL,
has_entry_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (data_source, request_id)
);
CREATE INDEX IF NOT EXISTS idx_session_usage_dedup_semantic
ON session_usage_dedup(data_source, semantic_id, has_entry_id);
四个字段的意图基本能从名字读出来:request_id 是硬 id;semantic_id 是没有硬 id 时按内容算出来的等价键;has_entry_id 是个布尔标志,用来区分前面这两种情况。所以索引才建在 (data_source, semantic_id, has_entry_id) 上,而不是直接用主键——按内容找等价记录走的是另一条路。
注意主键的第一段是 data_source。去重是按来源分桶做的,不同来源之间的 request_id 不会互相干扰。配套查询在 src-tauri/src/services/session_usage_pi.rs:37,41,45(三条 SELECT 1 FROM ... 存在性检查)与 :785 的 INSERT OR IGNORE INTO session_usage_dedup。
这张账本还有一个不太直觉的性质:它比它去重的那些明细行活得更久。明细会被 rollup 汇总后剪掉,账本不剪——注释里那句「request IDs needed for fork/rewrite deduplication live in a compact durable ledger」说的就是这件事。
由此推出的一条防线写在 src-tauri/src/services/session_usage.rs:77-82:游标预取失败时必须中止本轮,不能当空表处理。注释里的因果链是完整的——rollup_and_prune 会把旧明细汇总后删除,而 request_id 去重只查明细表,对已剪的条目失明;空表回退意味着全量重导,被剪掉的旧条目会在下一次 rollup 时再被累加进汇总,统计就被永久放大了。一次读取失败,代价是一个永远回不去的错误总数,所以宁可这一轮什么都不做。
同一个文件 :220-227 还有一条更硬的处理:Claude 路径发现文件被外部改写时是永久跳过,不是下轮重试;并且这条会进 errors 而不是静默吞掉,注释给的理由是必须让手动同步的用户看见,不能显示成一次无事发生的成功。读取中断的文件则计入 deferred_files,并写一条「已入库部分保留、下轮从断点续读」的错误(:205-217)。
同一条请求被两边都记了怎么办
会话回读补记的行,有可能和代理记账行指向同一次请求。src-tauri/src/proxy/usage/logger.rs:135-152 就是处理这件事的仲裁段:写入前先查已存在的行,如果它的 data_source 是 session_log,走一条分支;如果是 proxy(含历史 NULL 行,用 unwrap_or("proxy") 兜底),走另一条。
这段代码的存在本身就说明了一件事:**同一条请求可能被两个来源各记一遍,所以必须按来源判定谁覆盖谁。**看板上的数字不是把两类行简单相加得来的,中间隔着这层仲裁和上一节那本去重账本。这也是为什么「来源」这一列不只是个展示字段——它是参与写入判定的。
混用两系 API 时,给的是口径标注而不是一个数字
来源不同,能拿到的字段也不同,最典型的是缓存写入。src/types/usage.ts:232 有一个只装了一个成员的集合:
const PARTIAL_CACHE_WRITE_APP_TYPES = new Set(["pi"]);
:229-231 的注释给了理由:一次 Pi 会话可能混用两套语义不同的 API 协议(注释里点名了具体是哪两系),所以按「部分覆盖」处理,但不改它的 fresh-input 语义。另一个集合 CACHE_INCLUSIVE_APP_TYPES(:223-227)装的是那些协议本身就不单独上报缓存写入的应用,它们的缓存写入恒为 0。
两个集合喂给同一个函数 getCacheWriteAvailability(appTypes)(:236-248),返回 "ok" | "partial" | "na" 三态。判定顺序是:空数组返回 ok;全部落在 inclusive 集合返回 na;既没有 inclusive 也没有 partial 返回 ok;其余情况返回 partial。消费方在 UsageHero.tsx:182-211,na 显示成字符串 "N/A" 并置灰,partial 挂一条「数值可能偏低」的提示文案,ok 什么都不加。
v3.20.0 发布说明对这个设计的表述是(docs/release-notes/v3.20.0-zh.md:74):由于一个 Pi 会话可能混用两系 API,含 Pi 的汇总带「缓存写不完整」的口径标注,而不是一个未加限定的数字。这句话值得单拎出来:当数据来源本身就不完整时,选择是标注不确定性,而不是硬凑一个看起来完整的数。这个三态函数是 v3.20.1 相对 v3.19.2 的新增——旧版是 UsageHero 组件内部的私有函数,新版提到 types/usage.ts 并加了 partial 分支,配套单测在 tests/types/usage.test.ts:4-5。关于 N/A 和 0 在看板上的区别,我们另有一篇专门讲。
你可以自己核一遍
不用信本文的转述,几条命令就能在仓库里把这条线走完:
grep -rn "const DATA_SOURCE" src-tauri/src/services/
ls src-tauri/src/services/ | grep -i usage
for f in session_usage session_usage_codex session_usage_gemini \
session_usage_grokbuild session_usage_opencode session_usage_pi; do
echo "--- $f"; grep -n '// data_source' src-tauri/src/services/$f.rs | head -3
done
第一条列出所有会话来源的常量定义,第二条看有几个导入器文件,第三条逐个导入器去找 // data_source 那条行尾注释,能看清每个来源值到底是在哪一句插入语句里落下的。Windows 上在 Git Bash 里这三条可以直接用;用 PowerShell 的话把 grep -rn 换成 Select-String -Path ... -Pattern ... 即可——这属于通用的本机操作习惯,不是项目文档里的内容。以上命令按仓库中的文件结构组合,未经实测,以你本机的实际输出为准。
看完这几行,你再回头看看板上那列「来源」,它就不再是一个装饰字段了:它标记着这条记录到底是被亲眼看见的,还是事后从别人的日志里转录来的。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、
路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,
核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。