使用 Rust + simd-json 解析 2 GiB/s 的 AI 令牌日志

发布: (2026年1月31日 GMT+8 10:44)
6 分钟阅读
原文: Dev.to

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/**/*.jsonlJSONL,按消息使用情况记录
Codex CLI~/.codex/sessions/**/*.jsonlJSONL,累计计数器
Gemini CLI~/.gemini/tmp/*/chats/*.jsonJSON,包含 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-jsonsimdjson 的 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-jsonfrom_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):

MetricTime
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,并且想了解您的令牌去向——不妨试一试。

Back to Blog

相关文章

阅读更多 »