用量数字从哪来:代理请求日志 vs 本地会话文件
如果你只把 CC Switch 当成一个切供应商的工具,用量统计这块大概率会让你困惑一次:明明有些请求没走它的本地代理,数字里却也有;而走了代理的那些,看起来又没有被重复计两遍。
答案在一张表和一列上。以下全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2。我们只读源码文本,没有安装也没有运行过这个桌面应用,所以下面不会出现任何关于显示效果或操作过程的描述——只有文件、行号和字段。
反直觉的第一处:表叫 proxy_request_logs,里面装的不全是代理请求
所有用量明细,无论来自哪条路,都落到同一张表 proxy_request_logs。建表语句在 src-tauri/src/database/schema.rs:197-211,最后一列是:
data_source TEXT NOT NULL DEFAULT 'proxy'
这一列就是分水岭。默认值 'proxy' 意味着:不显式写这一列的插入,全部被当作代理请求。而从本机会话文件解析出来的记录,会显式写上别的值。
代码中出现的 data_source 取值一共 6 种:proxy、session_log、codex_session、gemini_session、opencode_session、grok_session(src-tauri/src/services/usage_stats.rs:231、usage_stats.rs:312,以及 session_usage_grokbuild.rs:558)。也就是说,一条「代理」加五条「会话」。
读侧不直接读这一列,而是统一走一个 helper data_source_expr(),把 NULL 归一成 'proxy'(src-tauri/src/services/usage_stats.rs:225-232)。注释写明这是在防御 schema v9 之前可能写入的 NULL 行。这就是为什么建表语句已经写了 NOT NULL DEFAULT 'proxy',查询侧还要再兜一层——老库升上来的行不保证符合新约束。
同一张表里还有三列容易混:model、request_model、pricing_model。建表注释写明 pricing_model 是写入时实际用于计价的模型名,NULL 表示 v11 之前的历史行,'' 表示未计价的错误行(src-tauri/src/database/schema.rs:194-200)。你如果要自己写 SQL 去查用量,这三列分不清就会算出三个不同的数。
会话来源的行还有一个特点:它们的 provider_id 是占位值,在 providers 表里根本不存在。SQL 里有一张权威映射把占位值翻成可读名字(src-tauri/src/services/usage_stats.rs:211-219):
占位 provider_id | 映射出的名字 |
|---|---|
_session | Claude (Session) |
_codex_session | Codex (Session) |
_gemini_session | Gemini (Session) |
_opencode_session | OpenCode (Session) |
_grok_session | Grok Build (Session) |
这张表只有五行,但它解释了一个很实际的现象:会话来源的记录挂不到任何一个你配置过的供应商上——因为它压根不是从供应商维度采集的,是从工具的本地会话文件里读出来的。
五个解析器,各自读本机哪个路径
第二条路由五个文件承担,都在 src-tauri/src/services/ 下,各读各的格式:
| 文件 | 行数 | 对应工具 | 读的位置 |
|---|---|---|---|
session_usage.rs | 895 | Claude Code | ~/.claude/projects/ 下的 JSONL 会话文件 |
session_usage_codex.rs | 3086 | Codex | ~/.codex/sessions/YYYY/MM/DD/*.jsonl |
session_usage_gemini.rs | 497 | Gemini CLI | ~/.gemini/tmp/<project_hash>/chats/session-*.json |
session_usage_opencode.rs | 580 | OpenCode | SQLite 文件 ~/.local/share/opencode/opencode.db |
session_usage_grokbuild.rs | 1234 | Grok Build | ~/.grok/{sessions,archived_sessions}/<enc-cwd>/<session-id>/updates.jsonl |
这里最该注意的是这些路径全是你本机的真实 CLI 数据目录。CC Switch 读的是你和这些工具的会话记录文件本身,不是它自己维护的一份副本。涉及本机敏感数据这件事,值得你在决定用不用这个应用之前先知道。
几个解析器的口径差异也写在文件头注释里,比较有信息量:
session_usage_gemini.rs:1-14 的注释列了它与 Claude / Codex 解析器的四点差异:读的是 JSON 而非 JSONL、无需 delta 计算、无需状态恢复、每条消息有唯一 id 天然去重。它的 request_id 形如 gemini_session:{session_id}:{message_id}(session_usage_gemini.rs:194)。
session_usage_codex.rs:1-14 则相反,它要从 event_msg 里 type=token_count 的累计 token 做 delta,还要解析 session_meta 取 thread_id、turn_context 取 model。注释写明该方案「替代原有的 state_5.sqlite 估算方案」。它的 request_id 前缀是常量 CODEX_THREAD_REQUEST_ID_PREFIX = "codex_session:thread-v1"(:44),批量插入批大小 CODEX_INSERT_BATCH_SIZE = 1000(:1130)。
session_usage_opencode.rs 读的干脆是另一个数据库:从 session 表取会话、message 表取 assistant 消息,再解析 data 字段里的 JSON 提取 tokens / cost / model(:1-12)。这个数据库路径支持环境变量 OPENCODE_DB 覆盖(src-tauri/src/opencode_config.rs:64-78)。
Grok Build 那份的注释最值得读,session_usage_grokbuild.rs:16-33 记了三条结论:turn_completed 的 usage 是每轮独立总量而非累计快照(注释里带 🔴 警告,明写「勿改回相邻事件差分」);reasoningTokens ⊂ outputTokens,不参与计费;costUsdTicks(1 tick = 1e-10 USD)是 CLI 自报成本,有自报且完整时以自报为准。它还有两个防炸的上限:单文件读取上限 MAX_GROK_FILE_BYTES = 50 * 1024 * 1024(:139),递归收集深度上限 MAX_COLLECT_DEPTH = 16(:141)。
五个解析器由 sync_all_unlocked() 串行调用,顺序固定为 Claude → Codex → Gemini → OpenCode → Grok Build(src-tauri/src/services/session_usage.rs:71-96)。
反直觉的第二处:去重不认 ID,认 token 数
同一次对话,如果它既走了本地代理、又被写进了工具的会话文件,就会有两条来源都能采到它。去重是怎么做的?
不是靠 request_id,也不是靠 session_id。看 effective_usage_log_filter()(src-tauri/src/services/usage_stats.rs:305-341),条件是:当一条会话行的 input / output / cache 各项 token 数与某条 2xx 的代理行相等、且创建时间落在 ±SESSION_PROXY_DEDUP_WINDOW_SECONDS 的窗口内、模型名匹配(忽略大小写,或任一侧为 unknown)时,这条会话行被排除。
那个窗口常量定义在 src-tauri/src/services/usage_stats.rs:223:
pub(crate) const SESSION_PROXY_DEDUP_WINDOW_SECONDS: i64 = 10 * 60;
十分钟。也就是说,这是一套内容指纹匹配:两条记录只要 token 数一模一样、模型名对得上、时间挨得够近,就被认定为同一次请求的两份记录,保留代理那份、丢掉会话那份。
这个设计有个直接推论值得你自己去核:被排除的条件里明确要求代理行的 status_code 落在 2xx。非 2xx 的代理行不参与这项匹配。
还有一处例外:Grok Build 的防双算不走这套指纹。它用的是「沉降窗 + 接管活动时间窗守卫」,只导入足够旧的事件,沉降窗常量直接复用了同一个值 SETTLE_WINDOW_SECONDS = SESSION_PROXY_DEDUP_WINDOW_SECONDS(src-tauri/src/services/session_usage_grokbuild.rs:58、:231)。两套机制解决的是同一个问题,实现路径不一样。
同步节奏与你能核查的几个点
后台同步的时间参数在 src-tauri/src/lib.rs:1226-1266:应用启动时先跑一次(带费用回填),之后按常量 SESSION_SYNC_INTERVAL_SECS = 60 每 60 秒跑一次。
增量解析的进度记在另一张表 session_log_sync,四列:file_path 作主键、last_modified、last_line_offset、last_synced_at(src-tauri/src/database/schema.rs:299-308)。这张表决定了下一轮从文件的哪个偏移继续读——它也是你排查「某个会话文件为什么没被采进来」时第一个该看的地方。
手动触发的命令是 sync_session_usage。另有一个 rebuild_codex_usage,流程是:备份数据库 → reset_codex_usage() → 重导 Codex,全程持同一把 session_sync_mutex(src-tauri/src/commands/usage.rs:248-291)。
如果你想知道自己库里这两条路各占多少,后端有一个专门的命令 get_usage_data_sources 返回来源分布(src-tauri/src/commands/usage.rs:294-300,返回类型 DataSourceSummary 定义在 src-tauri/src/services/session_usage.rs:105-111)。明细写入后会通过 usage_events::notify_log_recorded() 向前端 emit 事件 usage-log-recorded,带 200ms 防抖合并(src-tauri/src/usage_events.rs:20-24、:46-72)。
想自己复核上面这些说法,动作很直接:clone 仓库,打开 src-tauri/src/database/schema.rs 找 proxy_request_logs 的建表语句,确认最后一列的默认值;再打开 src-tauri/src/services/usage_stats.rs 搜 effective_usage_log_filter,把那段 SQL 从头读到尾——去重的全部条件都在那一个函数里,不需要跨文件拼。想确认取值集合,直接 grep session_log、codex_session 这几个字符串,看它们被写在哪几处。
什么情况说明问题不在这里
这一节是本文的边界,别越过去:
数字对不上,未必是去重的锅。 明细行不是永久保留的。Database::rollup_and_prune(retain_days) 会把 cutoff 之前的明细聚合进 usage_daily_rollups 再删掉,调用点两处、参数都是 30(天):启动时 src-tauri/src/database/mod.rs:155,周期任务 src-tauri/src/database/backup.rs:356。所以「三十天前的明细查不到了」和「去重把行吃掉了」是两回事,先看时间范围再看别的。
十分钟和六十秒都是代码里的默认配置,不是运行结果的保证。 我们没有运行过这个应用,不知道在你的机器上、你的用法下这两个数会带来什么效果。它们该不该改、改成多少合适,仓库没给通用值,我们也不给建议。
这套机制只覆盖上面列出的五个工具。 你用的 CLI 不在这五个之内,会话那条路就没有对应的解析器,用量里自然只会有走过代理的那部分。
读的是你本机的真实会话文件。 这几个路径下是你和这些工具的完整对话记录,属于本机敏感数据。要不要让一个桌面应用去解析它们,是你自己按环境评估的事——我们不写「这样就安全了」这种话。
最后回到开头那个困惑:数字里有没走代理的请求,是因为会话解析器直接读了工具自己的记录文件;走了代理的没被计两遍,是因为那条 token 指纹加十分钟窗的过滤把会话侧的重复行排除掉了。两句话,都能在上面标出的行号里找到出处。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、
src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。