Token 统计链路
Claude / Codex / ZCode / Qwen Token 统计链路说明
本文档说明 Open ACE 如何为 claude、codex、zcode、qwen 这 4 个本地工具抓取 token、计算每日/消息级统计、写入数据库,并被 WebUI、配额、分析与报表模块消费。
目标读者:
- 用户:理解为什么 Open ACE 的 token 数与官方控制台可能不同
- 项目维护者:排查统计异常、修 fetcher、接入新工具
- 二次开发者:明确该改哪一层,避免重复累计或字段误用
核心概念
本开篇章节取代原 CONCEPTS.md,并按代码中的真实模型重新表述其四个核心概念。
1. Request(API 调用)
一次独立的模型调用,归属到某个计费单元。Open ACE 的 request_count 按工具定义:
- Claude / Qwen:按逻辑 assistant 消息 计数,有稳定 message id 时去重;token 为 0 的 assistant 消息也可能计为一次请求
- Codex:按
task_started事件计数(每个重建的 turn 一次),而不是按 assistant 行 - ZCode:按
turn_usage行归因(权威计费记录)
各工具的统计链路见第 3 节。
2. Message(消息)
daily_messages / session_messages 中的每一行都带一个归一化的 role,取值来自规范集合:
| Role | 含义 |
|---|---|
user | 用户发送的消息 |
assistant | AI 生成的回复 |
system | 系统/上下文消息 |
tool | 工具执行结果 |
规范值定义在 app/utils/roles.py(normalize_message_role),所有写入 daily_messages 的路径都在写边界经过它(app/repositories/message_repo.py 的文档注明接受的角色为 user, assistant, system, tool)。工具结果的变体拼写(toolResult、tool_result)会折叠为 tool;空值/None 变为 unknown。代码中不存在单独的 message_user / message_toolresult / message_error 分类。
示例:
user: "查 一下天气" → role=user +1
assistant: "我去查一下..." → role=assistant +1
tool: {天气数据} → role=tool +1
assistant: "今天晴天..." → role=assistant +1
3. Session(会话)
工具级会话 —— 以 agent_session_id 标识的进程级单元。会话归属于某个工具:claude、qwen、codex、zcode、openclaw 各有自己的 fetcher(scripts/fetch_*.py)和会话布局。一个会话包含多轮对话,并且可能跨多个本地日历日(因此按日分组依据的是 turn/消息时间戳,而不是会话创建时间)。
4. Conversation(对话轮次)
一轮对话:从用户发送消息到 AI 完成最终回复(包含其间的工具调用)。以 conversation_id 存储(app/repositories/message_repo.py 的 save_message 文档注明"一轮对话")。
概念关系
Session(工具进程:claude / qwen / codex / zcode / openclaw)
├── Conversation 1(一轮)
│ ├── user 消息 (role=user)
│ ├── assistant 回复 (role=assistant)
│ ├── tool 结果 (role=tool)
│ └── 最终 assistant 回复 (role=assistant)
├── Conversation 2
│ ├── user 消息 (role=user)
│ └── assistant 回复 (role=assistant)
└── Conversation N
统计示例
Conversation 1:
user: "查一下天气"
│
├── Request 1 ──> assistant: "我去查一下..." (role=assistant)
│
├── Request 2 ──> tool: {天气数据} (role=tool)
│
└── Request 3 ──> assistant: "今天晴天..." (role=assistant)
Conversation 2:
user: "明天呢?"
│
└── Request 4 ──> assistant: "明天..." (role=assistant)
结果:
- Requests:4 次模型调用(如何映射到
request_count取决于工具 —— 见上方 Request 概念) - Messages:6 行(2 user + 3 assistant + 1 tool)
- Conversations:2 轮
数据库字段
| 字段 | 说明 |
|---|---|
agent_session_id | 工具会话标识(进程级) |
conversation_id | 对话标识(一轮对话) |
feishu_conversation_id | 飞书会话标识 |
1. 总体链路
本地 JSONL / SQLite
-> scripts/fetch_*.py
-> scripts/shared/db.py
-> daily_usage
-> daily_messages
-> agent_sessions
-> session_messages
-> daily_stats / hourly_stats
-> user_daily_stats
-> app/repositories/* / app/services/*
-> Work / Manage 页面、报表、配额与分析接口
可以把这条链路理解为两层事实表、三层摘要:
daily_usage:按日、按工具、按主机的聚合事实表,适合总量和趋势daily_messages:消息级事实表,适合小时粒度、时间线、sender/project 归因agent_sessions/session_messages:Workspace 会话视图和 transcript 镜像daily_stats/hourly_stats/user_daily_stats:从事实表再派生的预聚合表
2. 统一口径
2.1 核心字段
| 字段 | 含义 | 主要存储位置 |
|---|---|---|
tokens_used | Open ACE 认为该记录的总 token | daily_usage、daily_messages、agent_sessions、session_messages |
input_tokens | 该记录的非 cache 输入 token | daily_usage、daily_messages、agent_sessions、session_messages |
output_tokens | 输出 token | daily_usage、daily_messages、agent_sessions、session_messages |
cache_tokens | cache token 总和 | 只在 daily_usage 中单独存列 |
request_count | Open ACE 定义下的请求数 | daily_usage、agent_sessions、user_daily_stats |
关键注意点:
daily_messages没有单独的cache_tokens列。- cache 只在
daily_usage.cache_tokens中单独保留,消息级只能看到tokens_used、input_tokens、output_tokens。 - 下游消费方如果已经拿到了
tokens_used,通常不应该再做tokens_used + cache_tokens,否则很容易 double count。
2.2 当前推荐理解
对这 4 个工具,Open ACE 的目标是尽量让:
tokens_used == 非 cache input_tokens + output_tokens + cache_tokens
但要注意 provider 原始语义并不完全相同:
- Claude:cache 单独给出,
tokens_used在 Open ACE 中显式把 cache 加进去 - Codex:provider
total_tokens已经包含 cached input,Open ACE 保留 provider total,并把input_tokens存成去 cache 后的输入 - Qwen:
totalTokenCount走 provider 口径,promptTokenCount里包含 cache,Open ACE 会把input_tokens改写为去 cache 后的输入 - ZCode:以
turn_usage为权威源,computed_total_tokens、input_tokens、output_tokens、cache_*一起使用
3. 各工具如何抓取与计算
3.1 Claude
源数据
- 路径:
~/.claude/projects/**.jsonl - 脚本:
scripts/fetch_claude.py
抓取方式
- Claude 本地日志按 JSONL 存储
- usage 主要从
entry["usage"]或entry["message"]["usage"]提取 - 入口函数:
extract_tokens_from_entry()process_jsonl_file()_merge_messages_by_id()
字段映射
input_tokens来自input_tokensoutput_tokens来自output_tokenscache_read_tokens来自cache_read_input_tokenscache_creation_tokens来自cache_creation_input_tokenstokens_used按input + output + cache_read + cache_creation计算
为什么要 merge message id
- Claude 同一个逻辑消息可能被拆成多行写入 JSONL
- 如果逐行直接入库,既会重复算 token,也会丢结构化内容
- 当前做法是先按逻辑
message_id合并,再归入daily_messages/session_messages
request_count
- 按 assistant 逻辑消息计数
- 有稳定 message id 时去重
- 即使某条 assistant 消息 token 为 0,也可能仍计为一次请求
3.2 Codex
源数据
- 路径:
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl - 脚本:
scripts/fetch_codex.py
关键事件
task_startedturn_contextresponse_itemtoken_counttask_complete
抓取方式
- Codex 不按“消息自带 usage”存账单数据,而是通过事件流重建 turn
task_started建 turnresponse_item收集 user / assistant messagetoken_count累加该 turn 的 token 使用task_complete结束该 turn
为什么按 turn,而不是按 session
- 一个 Codex session 可能跨多个小时甚至多天
- 如果把整场 session token 挂到第一条 assistant message,会扭曲每日/每小时统计
- 当前逻辑按 turn 重建,再把每个 turn 的 token 归给发起该 turn 的 user message
字段映射
tokens_used来自last_token_usage.total_tokens的逐事件累加值cache_tokens来自cached_input_tokensinput_tokens按input_tokens - cached_input_tokens计算output_tokens来自output_tokensthoughts_tokens只在抓取阶段参与日汇总,不单独入daily_messages
重要语义
- 当前本地 Codex 源日志中,
token_count.last_token_usage表现为事件级增量,应累加 cached_input_tokens是 provider total 的组成部分,不是额外再加的一层
request_count
- 按
task_started计数 - 不是按 assistant message 数量计数
重抓为什么会先删旧消息
- token 归因可能因为 parser 修复从 assistant message 挪到 user message
delete_messages_for_agent_sessions()会先按tool_name + host_name + agent_session_id清理旧行,再整体重写- 这样可以避免历史脏行残留造成双算
3.3 ZCode
源数据
- 路径:
~/.zcode/cli/db/db.sqlite - 脚本:
scripts/fetch_zcode.py - 原始表:
session、message、part、turn_usage
为什么和其他 3 个工具不同
- ZCode 原始源不是 JSONL,而是 SQLite 关系型结构
message/part更适合拿 transcriptturn_usage才是 token 统计的权威来源
抓取方式
- 用
remote-agent/session_sync.py里的ZcodeSession解析消息和项目路径 - 再单独查
turn_usage做 token 归因 - 关键函数:
_get_turn_usage_rows()_get_turn_usage_by_date()process_zcode_session()
字段映射
tokens_used来自computed_total_tokensinput_tokens来自turn_usage.input_tokensoutput_tokens来自turn_usage.output_tokenscache_tokens按cache_creation_input_tokens + cache_read_input_tokens计算
为什么按 turn_usage.started_at 分日期
- 一个 session 可能跨日
- ZCode 的 token 权威时间戳在
turn_usage.started_at - 所以
daily_usage的分日逻辑应基于 turn,而不是 session 的创建时间或“消息最多的那一天”
消息级归因
- 每个 turn 会优先归给
turn_usage.user_message_id对应的 user message - 这样
daily_messages/hourly_stats才能和真实 turn 发起时间对齐
匹配失败时怎么办
- 部分 turn 匹配失败:会打印告警
daily_usage仍然可信- 但
daily_messages/agent_sessions可能缺少部分 turn token
- 全部 turn 匹配失败:会退回旧逻辑,把整场 session token 注入第一条 assistant message,并打印 fallback 告警
3.4 Qwen
源数据
- 路径:
~/.qwen/projects/**/chats/*.jsonl - 兼容某些旧布局下的直接
*.jsonl - 脚本:
scripts/fetch_qwen.py
抓取方式
- usage 来自
usageMetadata - 入口函数:
extract_tokens_from_entry()process_jsonl_file()
字段映射
prompt_tokens来自promptTokenCountcandidates_tokens来自candidatesTokenCountthoughts_tokens来自thoughtsTokenCountcached_tokens来自cachedContentTokenCounttokens_used来自totalTokenCount
重要语义
promptTokenCount包含 cache- Open ACE 会额外计算
actual_input_tokens = promptTokenCount - cachedContentTokenCount - 最终写入
daily_messages.input_tokens的是actual_input_tokens tokens_used仍保留 providertotalTokenCount口径
为什么 thoughts 不再额外加进 total
- 当前逻辑把
thoughtsTokenCount视为额外观测维度,而不是一定要叠加进totalTokenCount - 否则会把 provider 已经给出的 total 再重复放大
request_count
- 按 assistant message 计数
- 有 message id 时做去重
4. 如何落库
4.1 daily_usage
职责
- 每日、每工具、每主机的聚合事实表
- 适合做趋势、总量、配额、ROI、成本估算
写入入口
scripts/shared/db.py的save_usage()
主要列
datetool_namehost_nametokens_usedinput_tokensoutput_tokenscache_tokensrequest_countmodels_used
4.2 daily_messages
职责
- 消息级分析事实表
- 适合做小时统计、时间线、sender 归属、conversation/project 维度分析
写入入口
scripts/shared/db.py的save_messages_batch()
重要限制
- 没有单独的
cache_tokens列 - 它不是 Workspace 运行时 transcript 权威表,更多是分析事实表
- 直接
SUM(tokens_used)时必须先确认工具归因语义,不要想当然地把它当成“官方账单逐条镜像”
4.3 agent_sessions
职责
- session 级摘要
- Work 模式 / 远程会话列表 / session 详情头部统计
更新入口
- 各 fetcher 的
update_agent_sessions_stats()
特点
- 聚合
message_count - 聚合
total_tokens - 聚合
request_count - 记录
model、project_path、updated_at
4.4 session_messages
职责
- session 详情页使用的 transcript 镜像
- 便于从 fetcher 导入后直接按 session 回放
更新入口
- 各 fetcher 的
update_agent_sessions_stats()在更新 session 摘要时顺带插入
4.5 daily_stats / hourly_stats
职责
- 从
daily_messages派生出的预聚合表
刷新机制
save_messages_batch()成功后会调用_refresh_daily_stats_for_messages(messages)- 它会按受影响日期重建:
daily_statshourly_stats
4.6 user_daily_stats
职责
- 面向用户维度的日聚合表
- 供配额、趋势和某些快速查询路径使用
刷新机制
save_messages_batch()完成后,会经scripts/shared/user_stats_helper.py- 调用
app/services/user_stats_aggregator.py - 把
daily_messages和agent_sessions汇总到user_daily_stats