使用 Rust + simd-json 解析 2 GiB/s 的 AI 令牌日志
Source: Dev.to
问题
我每天使用 Claude Code、Codex CLI 和 Gemini CLI。一天,我查看了 API 账单——费用远高于预期,但我根本不知道 令牌 到底花到了哪里。
现有的跟踪工具太慢。扫描我 3 GB 的会话文件(跨三个 CLI 的 9,000 多个文件)耗时超过 40 秒。我想要即时的解决方案。
于是我构建了 toktrack —— 一个终端原生的令牌使用跟踪器,能够在本地以 2 GiB/s 的速度解析所有数据。
Source: …
数据概览
每个 AI CLI 的会话数据存储方式各不相同:
| CLI | 位置 | 格式 |
|---|---|---|
| Claude Code | ~/.claude/projects/**/*.jsonl | JSONL,按消息使用情况记录 |
| Codex CLI | ~/.codex/sessions/**/*.jsonl | JSONL,累计计数器 |
| Gemini CLI | ~/.gemini/tmp/*/chats/*.json | JSON,包含 thinking_tokens 字段 |
单个 Claude Code 会话文件可能如下所示:
{
"timestamp":"2026-01-15T10:00:00Z",
"message":{
"model":"claude-sonnet-4-20250514",
"usage":{
"input_tokens":12000,
"output_tokens":3500,
"cache_read_input_tokens":8000,
"cache_creation_input_tokens":2000
}
},
"costUSD":0.042
}
如果将其乘以数千个会话、跨越数月,你将面对数 GB 的 JSONL 需要解析。
Why simd-json
标准的 serde_json 已经很好,但在解析 3 GB 的逐行 JSON 时,每行的微秒级差异都会累计。
simd-json 是 simdjson 的 Rust 移植版,利用 SIMD 指令(AVX2、SSE4.2、NEON)显著加速 JSON 解析。关键技巧在于:就地解析并使用可变缓冲区。
#[derive(Deserialize)]
struct ClaudeJsonLine<'a> {
timestamp: &'a str, // borrowed, zero‑copy
#[serde(rename = "requestId")]
request_id: Option<&'a str>, // borrowed, zero‑copy
message: Option<&'a str>,
#[serde(rename = "costUSD")]
cost_usd: Option<f64>,
}
通过使用 &'a str 而不是 String,我们避免了每个字段的堆分配。simd-json 在可变字节缓冲区上就地解析 JSON,而我们的结构体只从该缓冲区借用切片。
唯一需要注意的点是:simd-json 的 from_slice 需要 &mut [u8],因此必须拥有每行的可变副本:
let reader = BufReader::new(File::open(path)?);
for line in reader.lines() {
let line = line?;
let mut bytes = line.into_bytes(); // owned, mutable
if let Ok(parsed) = simd_json::from_slice(&mut bytes) {
// extract what we need, bytes are consumed
}
}
在我的数据集上,这相较于标准 serde_json 提供了 17–25 % 的吞吐量提升。
使用 rayon 添加并行
单线程解析器的吞吐量约为 ~1 GiB/s。面对 9,000 多个文件,我们可以使用 rayon 在文件层面轻松实现并行:
use rayon::prelude::*;
let entries: Vec<_> = files
.par_iter()
.flat_map(|f| parser.parse_file(f).unwrap_or_default())
.collect();
Rayon 的 par_iter() 会自动将文件分配到各线程。结合 simd-json,吞吐量提升至 ~2 GiB/s —— 比顺序解析提升了 3.2 倍。
| 阶段 | 吞吐量 |
|---|---|
serde_json (baseline) | ~800 MiB/s |
simd-json (zero‑copy) | ~1.0 GiB/s |
simd-json + rayon | ~2.0 GiB/s |
困难之处:每个 CLI 都不同
实际的复杂性并非解析速度——而是要在同一个 trait 背后处理三种完全不同的数据格式:
pub trait CLIParser: Send + Sync {
fn name(&self) -> &str;
fn data_dir(&self) -> PathBuf;
fn file_pattern(&self) -> &str;
fn parse_file(&self, path: &Path) -> Result<Vec<TokenRecord>, Box<dyn Error>>;
}
Claude 代码
简单——每行包含 message.usage 字段的 JSONL 对应一次 API 调用。
Codex CLI
棘手。令牌计数是累计的——每个 token_count 事件报告的是当前总数,而不是增量。模型名称位于单独的 turn_context 行中,因此解析需要保持状态:
line 1: session_meta → extract session_id
line 2: turn_context → extract model name
line 3: event_msg → token_count (cumulative total)
line 4: event_msg → token_count (larger cumulative total)
你需要为每个会话仅保留最后的 token_count。
Gemini CLI
使用标准 JSON(而非 JSONL),并包含其他 CLI 没有的独特 thinking_tokens 字段。
使用 ratatui 的 TUI
对于仪表盘,我使用 ratatui 构建了四个视图:
- 概览 — 总 token/费用,配有 GitHub 风格的 52 周热图
- 模型 — 按模型划分的百分比条形图
- 每日 — 可滚动表格,带 sparkline 图表
- 统计 — 卡片网格中的关键指标
热图使用 2×2 Unicode 块字符,在紧凑空间内容纳 52 周的数据,并采用基于百分位的颜色强度。
结果
在我的机器上(Apple Silicon,9,000+ 文件,累计 3.4 GB):
| Metric | Time |
|---|---|
| Cold start (no cache) | ~1.2 s |
| Warm start (cached) | ~0.05 s |
缓存层将每日摘要存储在 ~/.toktrack/cache/ 中。过去的日期是不可变的——只有今天会重新计算。这意味着即使 Claude Code 在 30 天后删除会话文件,您的成本历史仍然会保留。
Try It
npx toktrack
# or
cargo install toktrack
GitHub:
如果您使用 Claude Code、Codex CLI 或 Gemini CLI,并且想了解您的令牌去向——不妨试一试。